From 4bacdb0312e78673ae06c1947d0144861de91667 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 25 Aug 2026 17:40:44 -0400 Subject: [PATCH] [econ] friendly name for weekly --- apps/econ/src/challenge-rotation.ts | 50 ++++++++++++++++------ apps/econ/src/econ.app.ts | 3 ++ apps/econ/src/test/integration/api.test.ts | 41 +++++++++++++++++- 3 files changed, 79 insertions(+), 15 deletions(-) diff --git a/apps/econ/src/challenge-rotation.ts b/apps/econ/src/challenge-rotation.ts index 16757b4..364ec2d 100644 --- a/apps/econ/src/challenge-rotation.ts +++ b/apps/econ/src/challenge-rotation.ts @@ -325,6 +325,12 @@ export interface WeeklyChallengeRotation { Challenges: RotationChallenge[] Gift: ChallengeGiftBlock FallbackGiftName: string + /** + * What the week is themed on — the FriendlyName of the item its `Gift` hands over, set + * by {@link withWeeklyGift} once the catalog has named the roll. The static file's value + * is only a placeholder: a generated week's reward isn't known until it is rolled. A + * PINNED rotation keeps whatever string it ships. + */ ChallengeThemeString: string } @@ -334,6 +340,8 @@ export interface EquipmentGift { EquipmentPrefabName: string EquipmentModificationGuid: string Rarity: number + /** The catalog's display name for the item — what the week is themed on. */ + FriendlyName: string } /** Whether the shipped file pins the week, in which case nothing here is generated. */ @@ -564,22 +572,31 @@ function pickChallenges(random: () => number): RotationChallenge[] { * Null when the pool is empty (the catalog didn't load), and the caller keeps the static * file's block so the reward preview is still something rather than nothing. */ -function pickWeeklyGift(mapId: number, pool: EquipmentGift[]): ChallengeGiftBlock | null { +function pickWeeklyGift( + mapId: number, + pool: EquipmentGift[] +): { gift: ChallengeGiftBlock; friendlyName: string } | null { if (pool.length === 0) return null const random = mulberry32(seedFor(mapId, 0x9e3779b9)) const gift = pool[Math.floor(random() * pool.length)] as EquipmentGift + // The name comes back alongside rather than on the block: the block is the wire shape, + // whose display strings are optional and left unset here so `toChallengeGiftDrop` keeps + // resolving them from the catalog entry that sells the item. return { - GiftDropId: gift.GiftDropId, - AvatarItemDesc: '', - AvatarItemType: 0, - ConsumableItemDesc: '', - EquipmentPrefabName: gift.EquipmentPrefabName, - EquipmentModificationGuid: gift.EquipmentModificationGuid, - StorefrontType: 0, - Xp: 0, - Level: 0, - GiftContext: 0, - GiftRarity: gift.Rarity, + friendlyName: gift.FriendlyName, + gift: { + GiftDropId: gift.GiftDropId, + AvatarItemDesc: '', + AvatarItemType: 0, + ConsumableItemDesc: '', + EquipmentPrefabName: gift.EquipmentPrefabName, + EquipmentModificationGuid: gift.EquipmentModificationGuid, + StorefrontType: 0, + Xp: 0, + Level: 0, + GiftContext: 0, + GiftRarity: gift.Rarity, + }, } } @@ -631,6 +648,11 @@ export function withWeeklyGift( pool: EquipmentGift[] ): WeeklyChallengeRotation { if (pinnedRotation() !== null) return rotation - const gift = pickWeeklyGift(rotation.ChallengeMapId, pool) - return gift === null ? rotation : { ...rotation, Gift: gift } + const picked = pickWeeklyGift(rotation.ChallengeMapId, pool) + if (picked === null) return rotation + // The week is themed on its reward: `ChallengeThemeString` is the item's catalog name, + // which is the same string `toChallengeGiftDrop` resolves for the grant, so the heading + // and the thing handed over read as one. The static file's value is a placeholder — it + // can't name an item that is rolled per week. + return { ...rotation, Gift: picked.gift, ChallengeThemeString: picked.friendlyName } } diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index db9d7ee..c8b3b95 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -701,6 +701,9 @@ function toEquipmentGiftPool(catalog: StoreItem[]): EquipmentGift[] { EquipmentPrefabName: item.GiftDrop.EquipmentPrefabName, EquipmentModificationGuid: item.GiftDrop.EquipmentModificationGuid, Rarity: item.GiftDrop.Rarity, + // Carried so the rotation can theme the week on the item it rolled; the grant path + // resolves the same name from this entry when it hands the item over. + FriendlyName: item.GiftDrop.FriendlyName, })) } diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 38cfda3..47ea02e 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -31,7 +31,7 @@ import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../ch // The live weekly rotation, generated the same way the worker generates it, so the challenge // tests exercise whatever this week actually holds instead of ids from a rotation that has // since rolled over. -import { buildRotation, rotationIndex } from '../../challenge-rotation' +import { buildRotation, rotationIndex, withWeeklyGift } from '../../challenge-rotation' import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db' import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db' import { INVENTORY_SCHEMA_DDL } from '../../inventory-db' @@ -1912,6 +1912,45 @@ describe('econ endpoints', () => { expect(buildRotation(nextWeek).StartAt).toBe(buildRotation(at).EndAt) }) + test('the week is themed on the name of the item it rolls', async () => { + // `ChallengeThemeString` is the reward's catalog FriendlyName. The static file ships it + // empty on purpose — a generated week's gift isn't known until it is rolled — so the + // theming happens where the pick does. + const pool = [ + { + GiftDropId: 11, + EquipmentPrefabName: '[ShareCamera]', + EquipmentModificationGuid: 'guid-a', + Rarity: 30, + FriendlyName: 'Camera Skin (Comic)', + }, + { + GiftDropId: 12, + EquipmentPrefabName: '[Boombox]', + EquipmentModificationGuid: 'guid-b', + Rarity: 20, + FriendlyName: 'Boombox (Neon)', + }, + ] + const at = new Date('2026-08-25T12:00:00Z') + const themed = withWeeklyGift(buildRotation(at), pool) + const rolled = pool.find((p) => p.GiftDropId === themed.Gift.GiftDropId) + expect(rolled).toBeDefined() + expect(themed.ChallengeThemeString).toBe(rolled!.FriendlyName) + + // An empty pool (the catalog didn't load) leaves the rotation as it was rather than + // theming the week on nothing. + expect(withWeeklyGift(buildRotation(at), []).ChallengeThemeString).toBe( + buildRotation(at).ChallengeThemeString + ) + + // And over the live catalog the route serves a real name, not the placeholder. + const served = (await ( + await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`) + ).json()) as { ChallengeThemeString: string } + expect(served.ChallengeThemeString).not.toBe('') + }) + test('every generated week is five valid, distinct challenges', async () => { // Walk two years of rotations: the pool, the constraints and the tree builders all have // to hold for every week, not just this one.