diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index 0a195c3..8100f2c 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -11,9 +11,18 @@ import { searchAccounts, updateAccount, } from '@repo/domain' -import { logger, withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers' +import { + logger, + withCleanSpec, + withDefaultCors, + withNotFound, + withOnError, +} from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' +// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a +// value — the enum has no runtime dependencies. +import { NotificationType } from '../../notify/src/notification-types' import { AccountDto, BioRequest, @@ -138,9 +147,13 @@ async function pushAccountUpdate(c: Context, account: Account): Promise() return new Set(results.map((r) => r.challenge_id)) } + +/** Schema DDL (mirror of migrations 0011_challenge_gift.sql) — also builds the table in tests. */ +export const CHALLENGE_GIFT_SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS challenge_gift ( + account_id INTEGER NOT NULL, + challenge_map_id INTEGER NOT NULL, + granted_at TEXT NOT NULL, + PRIMARY KEY (account_id, challenge_map_id) + )`, +] + +/** + * Take the one gift a rotation owes a player, returning whether this call is the one that + * got it — `false` means it was already handed out and the caller must grant nothing. + * + * The client keeps reporting progress after the set is finished, so "has this been paid?" + * has to be asked and answered in ONE statement: a read-then-insert would let two reports + * that land together both see no row and both pay out. `ON CONFLICT … DO NOTHING` with + * `RETURNING` gives us that — the second insert matches the existing row, writes nothing + * and returns nothing. + * + * The gate is deliberately at-most-once: the row is claimed BEFORE the items are granted, + * so a failure mid-grant loses the reward rather than risking a second one. It is a faucet, + * and a stuck one is easier to notice and re-grant by hand than a leaking one. + */ +export async function claimChallengeGift( + db: D1Database, + accountId: number, + challengeMapId: number, + now: Date = new Date() +): Promise { + const row = await db + .prepare( + `INSERT INTO challenge_gift (account_id, challenge_map_id, granted_at) + VALUES (?1, ?2, ?3) + ON CONFLICT (account_id, challenge_map_id) DO NOTHING + RETURNING granted_at` + ) + .bind(accountId, challengeMapId, now.toISOString()) + .first<{ granted_at: string }>() + return row !== null +} diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 415375c..833f92c 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -36,7 +36,11 @@ import { isSpendable, spendCurrency, } from './balance-db' -import { getCompletedChallengeIds, recordChallengeProgress } from './challenge-db' +import { + claimChallengeGift, + getCompletedChallengeIds, + recordChallengeProgress, +} from './challenge-db' import { consumeConsumable, countConsumable, @@ -288,6 +292,20 @@ interface StoreGiftDrop { Context: number Currency: number CurrencyType: number + /** + * A QUERY drop — a loot box rather than an item. Its item fields are all empty on + * purpose: what the player gets is rolled at grant time from everything of the target + * rarity they don't already own (see {@link rollQueryDrop}). sf2's "Star Boxes" set and + * sf3's "Random box" family are the two that ship; sf2's tooltip says it outright — "A + * random 4-star item that you don't have." + */ + IsQuery?: boolean + /** + * The rarity a query drop rolls at, when it differs from the box's own `Rarity`. The + * sf2 boxes carry both and they agree; sf3's don't carry it at all, hence the fallback + * to `Rarity`. + */ + QueryRedirectRarity?: number } interface StorePrice { CurrencyType: number @@ -386,6 +404,404 @@ function toGiftContent( } } +/** + * Push a GiftPackageReceivedImmediate notification for a gift box the player didn't ask + * for, mirroring the reference's + * `HubSendToPlayer(accountID, NotifFrame(GiftPackageReceivedImmediate, {...}))` — the + * client pops the "you got something" panel from it instead of waiting for the next read of + * `GET /api/avatar/v2/gifts`. + * + * The payload is the reference's field-for-field: the stored box's contents plus its `Id`, + * a `FromGiftDropId` of 0 (the reference never populates it either) and the + * platform/balance constants. `Xp` and `Level` are 0 — the drop shape doesn't carry them + * and nothing grants them yet. + * + * "Immediate" (31) rather than GiftPackageReceived (30) is what the reference sends for a + * box handed over by the server: a purchase gifted to another player, an admin token grant, + * a report reward. This is the same case — the player is being handed a box they never + * clicked for. Best-effort: a hub failure is logged and swallowed, since the gift itself is + * already granted and stored. + */ +async function pushGiftReceived( + c: Context, + accountId: number, + gift: GrantedGift, + message: string, + fromPlayerId: number +): Promise { + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( + accountId, + NotificationType.GiftPackageReceivedImmediate, + { + Id: gift.id, + FromGiftDropId: 0, + FromPlayerId: fromPlayerId, + ConsumableItemDesc: gift.drop.ConsumableItemDesc, + AvatarItemDesc: gift.drop.AvatarItemDesc, + AvatarItemType: gift.drop.AvatarItemType ?? 0, + EquipmentPrefabName: gift.drop.EquipmentPrefabName, + EquipmentModificationGuid: gift.drop.EquipmentModificationGuid, + CurrencyType: gift.drop.CurrencyType, + Currency: gift.drop.Currency, + Xp: 0, + Level: 0, + Platform: -1, + PlatformsToSpawnOn: -1, + BalanceType: ALL_PLATFORMS, + GiftContext: gift.drop.Context, + GiftRarity: gift.drop.Rarity, + Message: message, + } + ) + } catch (err) { + logger.error('failed to push GiftPackageReceivedImmediate notification', { + accountId, + giftId: gift.id, + error: err instanceof Error ? err.message : String(err), + }) + } +} + +/** + * The catalog a query drop rolls from: sf3, the general store. It is the only catalog with + * a real pool at every rarity (1161 items against 8–40 in the themed ones), it's where the + * "Random box" family itself sells, and a box promising "a random 4-star item" plainly + * means the whole item universe rather than whichever seasonal shelf it was bought from. + */ +const ROLL_STOREFRONT_TYPE = 3 + +/** Every item in the roll catalog, or `[]` if it can't be read (a roll then yields nothing). */ +async function loadRollCatalog(c: Context): Promise { + const res = await c.env.ASSETS.fetch(new URL(`/sf${ROLL_STOREFRONT_TYPE}.json`, c.req.url)) + if (!res.ok) return [] + const storefront = (await res.json()) as Storefront + return storefront.StoreItems +} + +/** + * Whether the player already owns what a drop carries — the question a query drop's "an + * item that you don't have" turns on, and the one that decides whether the weekly gift + * hands over its item or rolls the fallback box instead. + * + * Ownership is boolean for avatar items and equipment, which is what makes "already have + * it" meaningful. A drop carrying neither (a consumable, a currency drop, an empty query + * box) counts as owned: there is nothing ownable to hand over, so callers offering a + * fallback should take it. + */ +async function ownsGiftDrop( + db: D1Database, + accountId: number, + giftDrop: StoreGiftDrop +): Promise { + if (typeof giftDrop.AvatarItemDesc === 'string' && giftDrop.AvatarItemDesc !== '') { + const owned = await getInventory(db, accountId) + return owned.some((item) => item.AvatarItemDesc === giftDrop.AvatarItemDesc) + } + if ( + typeof giftDrop.EquipmentModificationGuid === 'string' && + giftDrop.EquipmentModificationGuid !== '' + ) { + const owned = await getEquipment(db, accountId) + return owned.some((eq) => eq.ModificationGuid === giftDrop.EquipmentModificationGuid) + } + return true +} + +/** + * Roll a query drop: pick, uniformly at random, one item of `rarity` from the roll catalog + * that the player doesn't already own. Returns null when the pool is empty — an unreadable + * catalog, a rarity nothing is published at, or a player who owns every item of that tier. + * + * The pool is deliberately narrow. Other query drops are excluded (a box that rolls a box + * would either loop or hand over an unopenable one), and so is everything that isn't an + * avatar item or a piece of equipment: "an item you don't have" only means anything for + * things owned once, and consumables stack, so a consumable would be rollable forever and + * would crowd out the real prizes. + */ +async function rollQueryDrop( + c: Context, + accountId: number, + rarity: number, + rollCatalog?: StoreItem[] +): Promise { + const [catalog, ownedItems, ownedEquipment] = await Promise.all([ + rollCatalog ?? loadRollCatalog(c), + getInventory(c.env.DB, accountId), + getEquipment(c.env.DB, accountId), + ]) + const haveItem = new Set(ownedItems.map((item) => item.AvatarItemDesc)) + const haveEquipment = new Set(ownedEquipment.map((eq) => eq.ModificationGuid)) + const pool = catalog.filter(({ GiftDrop: drop }) => { + if (drop.IsQuery === true || drop.Rarity !== rarity) return false + if (typeof drop.AvatarItemDesc === 'string' && drop.AvatarItemDesc !== '') { + return !haveItem.has(drop.AvatarItemDesc) + } + if ( + typeof drop.EquipmentModificationGuid === 'string' && + drop.EquipmentModificationGuid !== '' + ) { + return !haveEquipment.has(drop.EquipmentModificationGuid) + } + return false + }) + const rolled = pool[Math.floor(Math.random() * pool.length)] + return rolled?.GiftDrop ?? null +} + +/** + * A gift box that was just created, and the drop it ended up holding. The drop is the + * RESOLVED one — what a query drop rolled, not the box that promised it — so a caller + * announcing the gift names the item the player actually won. + */ +interface GrantedGift { + id: number + drop: StoreGiftDrop +} + +/** + * Hand a gift-drop to a player: grant whatever it turns out to carry (an avatar item, an + * equipment skin, a consumable, or none of these — currency/xp drops aren't granted yet) + * and create the gift box that renders it. + * + * A query drop is ROLLED here first, so what gets granted — and what the box shows — is the + * item the player actually won, not the box that promised it. A roll with nothing left to + * give falls through with the box itself, which grants nothing: no worse than not rolling, + * and the warning says which rarity ran dry. + * + * Both faucets share this — a storefront purchase and the weekly-challenge reward — so a + * drop lands in a player's inventory the same way whichever one it came from. The item is + * granted here, not when the box is opened: consuming a box only deletes the row. + */ +async function grantGiftDrop( + c: Context, + accountId: number, + drop: StoreGiftDrop, + message: string, + // The roll catalog, when the caller has already read it — it's the big one (sf3), and + // the weekly gift has to consult it before it knows whether it's rolling at all. + rollCatalog?: StoreItem[] +): Promise { + let giftDrop = drop + if (drop.IsQuery === true) { + const rarity = drop.QueryRedirectRarity ?? drop.Rarity + const rolled = await rollQueryDrop(c, accountId, rarity, rollCatalog) + if (rolled === null) { + logger.warn('query gift-drop rolled nothing', { + accountId, + rarity, + friendlyName: drop.FriendlyName, + }) + } else { + giftDrop = rolled + } + } + const db = c.env.DB + if (typeof giftDrop.AvatarItemDesc === 'string' && giftDrop.AvatarItemDesc !== '') { + await grantItem(db, accountId, toAvatarItem(giftDrop)) + } + if ( + typeof giftDrop.EquipmentModificationGuid === 'string' && + giftDrop.EquipmentModificationGuid !== '' + ) { + await grantEquipment(db, accountId, toEquipment(giftDrop)) + } + const isConsumable = + typeof giftDrop.ConsumableItemDesc === 'string' && giftDrop.ConsumableItemDesc !== '' + const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0 + // Capture the granted consumable's row id and the player's pre-existing count so the + // gift box can carry them — gift-consume fires ConsumableMappingAdded from these. + let consumableMappingId = 0 + let consumablePreExisting = 0 + if (isConsumable) { + consumablePreExisting = await countConsumable(db, accountId, giftDrop.ConsumableItemDesc) + consumableMappingId = await grantConsumable( + db, + accountId, + giftDrop.ConsumableItemDesc, + consumableCount + ) + } + const { id } = await createGift( + db, + accountId, + toGiftContent(giftDrop, message, consumableCount, consumableMappingId, consumablePreExisting) + ) + return { id, drop: giftDrop } +} + +/** + * The rotation's reward, as static/weekly-challenge.json writes it. Same item vocabulary as + * a storefront `GiftDrop` but with `Context`/`Rarity` spelled `GiftContext`/`GiftRarity`, + * so it has to be translated before the grant path can read it (see + * {@link toChallengeGiftDrop}). + * + * `FriendlyName`/`Tooltip` are OPTIONAL because the captured rotation has neither — the + * client resolves the reward's name from the item itself, falling back to + * `FallbackGiftName`. A rotation we publish can carry them to name the granted item + * properly without a code change. + */ +interface ChallengeGift { + AvatarItemDesc: string + AvatarItemType: number + ConsumableItemDesc: string + EquipmentPrefabName: string + EquipmentModificationGuid: string + GiftContext: number + GiftRarity: number + Xp: number + FriendlyName?: string + Tooltip?: string +} + +/** The message on the gift box the weekly reward arrives in. */ +const CHALLENGE_GIFT_MESSAGE = 'Weekly challenge complete!' + +/** + * The star rating → `Rarity` ladder, indexed by stars - 1. Pinned by sf2's "Star Boxes" + * item set, whose three members name their own tier and carry the rarity they roll at: + * 2-Star → 10, 3-Star → 20, 4-Star → 30. The ends are extrapolated from sf3's parallel + * "Random box" family (Common 0, Uncommon 10, Rare 20, Epic 30, Legendary 50), which is the + * same ladder under the other naming. + */ +const STAR_RARITY = [0, 10, 20, 30, 50] + +/** The tier a "4-Star Box" rolls at, used when a rotation's fallback name doesn't parse. */ +const DEFAULT_FALLBACK_STARS = 4 + +/** + * The rarity the rotation's `FallbackGiftName` promises, read off the leading star count + * ("4-Star Box" → 30). That string is the whole specification of the consolation prize — + * it is what the client renders when the gift resolves to a box rather than a named item — + * so a rotation can retune the tier by renaming it, with no code change. + */ +function fallbackGiftRarity(): number { + const stars = Number(/^(\d+)-star/i.exec(weeklyChallenge.FallbackGiftName)?.[1]) + return STAR_RARITY[stars - 1] ?? STAR_RARITY[DEFAULT_FALLBACK_STARS - 1] ?? 0 +} + +/** + * Translate the rotation's `Gift` block into the storefront gift-drop shape the grant path + * reads. The renamed fields are the whole point — feeding one shape to the other's reader + * silently drops the rarity and context. + * + * The reward carries no price, so `Currency`/`CurrencyType` are zero: the box shows an + * item, not a payout. Display strings come from the block when it carries them; a block + * that doesn't (the captured rotation names neither) borrows them from the catalog entry + * selling the same item, so the granted item reads as itself — "Camera Skin (Comic)" rather + * than the name of the box it might have arrived in. + */ +function toChallengeGiftDrop(catalog: StoreItem[]): StoreGiftDrop { + const gift = weeklyChallenge.Gift as ChallengeGift + const sold = catalog.find( + ({ GiftDrop: drop }) => + (gift.EquipmentModificationGuid !== '' && + drop.EquipmentModificationGuid === gift.EquipmentModificationGuid) || + (gift.AvatarItemDesc !== '' && drop.AvatarItemDesc === gift.AvatarItemDesc) + )?.GiftDrop + return { + FriendlyName: gift.FriendlyName ?? sold?.FriendlyName ?? weeklyChallenge.FallbackGiftName, + Tooltip: gift.Tooltip ?? sold?.Tooltip ?? '', + ConsumableItemDesc: gift.ConsumableItemDesc, + AvatarItemDesc: gift.AvatarItemDesc, + AvatarItemType: gift.AvatarItemType, + EquipmentPrefabName: gift.EquipmentPrefabName, + EquipmentModificationGuid: gift.EquipmentModificationGuid, + // The block's own `GiftRarity` is 0 in the captured rotation even though the item it + // names sells at rarity 5, so the catalog's rarity wins where there is one. + Rarity: sold?.Rarity ?? gift.GiftRarity, + Context: gift.GiftContext, + Currency: 0, + CurrencyType: 0, + } +} + +/** + * The consolation box: a query drop at the rarity `FallbackGiftName` promises, named after + * it. Handed over instead of the rotation's item when that item would be a duplicate, which + * is what the fallback name is for — the reward reads "the Camera Skin, or a 4-Star Box". + */ +function toChallengeFallbackDrop(): StoreGiftDrop { + return { + FriendlyName: weeklyChallenge.FallbackGiftName, + Tooltip: '', + ConsumableItemDesc: '', + AvatarItemDesc: '', + AvatarItemType: null, + EquipmentPrefabName: '', + EquipmentModificationGuid: '', + Rarity: fallbackGiftRarity(), + Context: (weeklyChallenge.Gift as ChallengeGift).GiftContext, + Currency: 0, + CurrencyType: 0, + IsQuery: true, + } +} + +/** + * Award the rotation's `Gift` if this player has just finished the whole set, doing nothing + * otherwise. Called after each completing progress report, since `updateProgress` is the + * only place a challenge is ever finished — there is no separate claim endpoint, and the + * client never asks for this reward. + * + * "The whole set" is every challenge in the current rotation, read back from + * `challenge_status`. The rotation's `CompletedRequired` flag is NOT consulted: what it + * means is inferred, and the only reading under which the gift is due before the set is + * finished would pay out on the first challenge, which no rotation can have intended. + * + * What lands is the `Gift` block's item — or, if the player already owns it, the box named + * by `FallbackGiftName`, which rolls something they don't have at that tier. Finishing the + * week can't be worth nothing, and the rotation's reward is one fixed item that plenty of + * players will have bought already. + * + * A grant that throws is swallowed: the client is reporting gameplay progress, and failing + * that report (which it would then retry with the same completion) is worse than missing + * the reward — the claim row is already taken, so the miss is permanent but visible in the + * logs. An empty rotation is not "all complete"; without the guard, `every` on it is + * vacuously true and every report would win a gift. + */ +async function awardChallengeGift(c: Context, accountId: number): Promise { + try { + if (weeklyChallenge.Challenges.length === 0) return + const complete = await getCompletedChallengeIds( + c.env.DB, + accountId, + weeklyChallenge.ChallengeMapId + ) + if (!weeklyChallenge.Challenges.every((ch) => complete.has(ch.ChallengeId))) return + // Claim first: this is what stops the next report paying out a second time. + const claimed = await claimChallengeGift(c.env.DB, accountId, weeklyChallenge.ChallengeMapId) + if (!claimed) return + const catalog = await loadRollCatalog(c) + const reward = toChallengeGiftDrop(catalog) + const duplicate = await ownsGiftDrop(c.env.DB, accountId, reward) + const granted = await grantGiftDrop( + c, + accountId, + duplicate ? toChallengeFallbackDrop() : reward, + CHALLENGE_GIFT_MESSAGE, + catalog + ) + // Nobody asked for this box, so the client has no reason to re-read the gifts list: + // the notification is what makes the reward show up at the moment the set is finished. + // From "Coach", the same system sender a self-buy is attributed to — the rotation is + // the server handing something over, not another player. + await pushGiftReceived(c, accountId, granted, CHALLENGE_GIFT_MESSAGE, COACH_ACCOUNT_ID) + logger.info('weekly challenge gift granted', { + accountId, + challengeMapId: weeklyChallenge.ChallengeMapId, + giftId: granted.id, + fallbackRoll: duplicate, + }) + } catch (err) { + logger.error('failed to grant weekly challenge gift', { + accountId, + challengeMapId: weeklyChallenge.ChallengeMapId, + error: err instanceof Error ? err.message : String(err), + }) + } +} + /** * A concise `describeRoute` spec for a route that serves an opaque JSON array — either * a static catalog served verbatim or an empty-list stub. `auth` adds the bearer @@ -1143,50 +1559,9 @@ const app = new Hono({ strict: false }) ) if (!paid) return c.json({ error: 'Insufficient balance' }, 400) - // Grant the item to the recipient. A gift-drop carries an avatar item, a consumable, - // an equipment skin, or none of these (currency/xp drops aren't granted yet); grant - // whichever it actually has. - if (typeof item.GiftDrop.AvatarItemDesc === 'string' && item.GiftDrop.AvatarItemDesc !== '') { - await grantItem(c.env.DB, receiverId, toAvatarItem(item.GiftDrop)) - } - if ( - typeof item.GiftDrop.EquipmentModificationGuid === 'string' && - item.GiftDrop.EquipmentModificationGuid !== '' - ) { - await grantEquipment(c.env.DB, receiverId, toEquipment(item.GiftDrop)) - } - const isConsumable = - typeof item.GiftDrop.ConsumableItemDesc === 'string' && - item.GiftDrop.ConsumableItemDesc !== '' - const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0 - // Capture the granted consumable's row id and the player's pre-existing count so - // the gift box can carry them — gift-consume fires ConsumableMappingAdded from these. - let consumableMappingId = 0 - let consumablePreExisting = 0 - if (isConsumable) { - consumablePreExisting = await countConsumable( - c.env.DB, - receiverId, - item.GiftDrop.ConsumableItemDesc - ) - consumableMappingId = await grantConsumable( - c.env.DB, - receiverId, - item.GiftDrop.ConsumableItemDesc, - consumableCount - ) - } - const { id: giftId } = await createGift( - c.env.DB, - receiverId, - toGiftContent( - item.GiftDrop, - message, - consumableCount, - consumableMappingId, - consumablePreExisting - ) - ) + // Grant the item to the recipient, with the gift box that renders it. A box (an + // `IsQuery` drop, e.g. sf2's "4-Star Unique Box") rolls its prize in here. + const { id: giftId } = await grantGiftDrop(c, receiverId, item.GiftDrop, message) // Push the debit over the socket so the buyer's client updates the shown total // immediately — the buyer (`id`) is who was charged, in the currency they spent. The @@ -1479,6 +1854,15 @@ const app = new Hono({ strict: false }) challengeId, complete: parseBool(body.Complete), }) + // This report may have been the last one of the set. Only a completing report on + // the LIVE rotation can be — an old rotation's set can no longer be finished, and + // an unfinished challenge means the set isn't either, so neither is worth a read. + // The response is unchanged whether or not a gift was won: the client learns about + // the box from `GET /api/avatar/v2/gifts`, and adding a field here would be + // inventing response shape the client never sent us. + if (complete && challengeId !== 0 && challengeMapId === weeklyChallenge.ChallengeMapId) { + await awardChallengeGift(c, id) + } return c.json({ ChallengeMapId: challengeMapId, ChallengeId: challengeId, diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index dec6394..c94d87c 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -13,6 +13,9 @@ import { // The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL // is built here too (see the same cross-worker import in econ.app.ts). import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db' +// The notification-type ids the hub carries, from the worker that owns them — asserting +// against the enum rather than a copied number is what keeps these frames honest. +import { NotificationType } from '../../../../notify/src/notification-types' // The live weekly rotation, so the challenge tests exercise whatever it currently holds // instead of hard-coded ids from a rotation that has since been replaced. import weeklyChallenge from '../../../static/weekly-challenge.json' @@ -24,9 +27,9 @@ import { getBalance, spendCurrency, } from '../../balance-db' -import { CHALLENGE_STATUS_SCHEMA_DDL } from '../../challenge-db' +import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../challenge-db' import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db' -import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-db' +import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db' import { INVENTORY_SCHEMA_DDL } from '../../inventory-db' import { OUTFIT_SCHEMA_DDL } from '../../outfit-db' import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db' @@ -51,6 +54,7 @@ beforeAll(async () => { for (const stmt of BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of CHALLENGE_STATUS_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of CHALLENGE_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of REWARD_STATUS_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run() @@ -775,7 +779,7 @@ describe('econ endpoints', () => { expect(await drainFrames()).toEqual([ { accountId: 20, - notificationType: STOREFRONT_BALANCE_UPDATE, + notificationType: NotificationType.StorefrontBalanceUpdate, payload: { Balance: -450, CurrencyType: 2, BalanceType: -2 }, }, ]) @@ -1051,19 +1055,16 @@ describe('econ endpoints', () => { * test sees what was actually pushed. */ const drainFrames = async (): Promise< - Array<{ accountId: number; notificationType: number; payload: Record }> + Array<{ accountId: number; notificationType: number; payload: Record }> > => ( env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') as unknown as { drainFrames(): Promise< - Array<{ accountId: number; notificationType: number; payload: Record }> + Array<{ accountId: number; notificationType: number; payload: Record }> > } ).drainFrames() - /** `NotificationType.StorefrontBalanceUpdate` in the notify worker's enum. */ - const STOREFRONT_BALANCE_UPDATE = 61 - // buyInvention is a GET with query params — that is how the client sends it. const buyInvention = async (sub: string, inventionId: number, requestedPrice = 0) => exports.default.fetch( @@ -1136,12 +1137,12 @@ describe('econ endpoints', () => { expect(await drainFrames()).toEqual([ { accountId: 999, - notificationType: STOREFRONT_BALANCE_UPDATE, + notificationType: NotificationType.StorefrontBalanceUpdate, payload: { Balance: 250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 }, }, { accountId: 51, - notificationType: STOREFRONT_BALANCE_UPDATE, + notificationType: NotificationType.StorefrontBalanceUpdate, payload: { Balance: -250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 }, }, ]) @@ -1442,6 +1443,190 @@ describe('econ endpoints', () => { expect(await completeOf(await post('18', 'True'))).toBe(true) }) + /** Report every challenge of the live rotation complete, for one player. */ + async function finishTheRotation(sub: string) { + const headers = { ...(await bearer(sub)), 'Content-Type': 'application/json' } + const ids = weeklyChallenge.Challenges.map((challenge) => challenge.ChallengeId) + const report = (challengeId: number) => + exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, { + method: 'POST', + headers, + body: JSON.stringify({ + ChallengeMapId: String(weeklyChallenge.ChallengeMapId), + ChallengeId: String(challengeId), + Complete: 'True', + }), + }) + return { ids, report } + } + + /** A player's unopened gift boxes, as the client reads them back. */ + async function giftBoxes(sub: string) { + const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, { + headers: await bearer(sub), + }) + return (await res.json()) as Array<{ + Id: number + Message: string + EquipmentModificationGuid: string + AvatarItemDesc: string + GiftRarity: number + }> + } + + test('finishing every challenge in the rotation grants its gift, once', async () => { + // The whole live rotation, so this follows whatever static/weekly-challenge.json holds. + const { ids, report } = await finishTheRotation('74') + for (const id of ids.slice(0, -1)) expect((await report(id)).status).toBe(200) + // One challenge short of the set — the gift isn't due yet. + expect(await giftBoxes('74')).toEqual([]) + await drainFrames() + + expect((await report(ids[ids.length - 1] ?? 0)).status).toBe(200) + const won = await giftBoxes('74') + expect(won).toHaveLength(1) + expect(won[0]?.Message).toBe('Weekly challenge complete!') + expect(won[0]?.EquipmentModificationGuid).toBe(weeklyChallenge.Gift.EquipmentModificationGuid) + + // The client is told the moment the set is finished, rather than finding the box the + // next time it reads the gifts list. `Immediate` (31), from Coach (1). + const frames = await drainFrames() + expect(frames).toHaveLength(1) + expect(frames[0]?.accountId).toBe(74) + expect(frames[0]?.notificationType).toBe(NotificationType.GiftPackageReceivedImmediate) + expect(frames[0]?.payload).toEqual({ + Id: won[0]?.Id, + FromGiftDropId: 0, + FromPlayerId: 1, + ConsumableItemDesc: '', + AvatarItemDesc: weeklyChallenge.Gift.AvatarItemDesc, + AvatarItemType: weeklyChallenge.Gift.AvatarItemType, + EquipmentPrefabName: weeklyChallenge.Gift.EquipmentPrefabName, + EquipmentModificationGuid: weeklyChallenge.Gift.EquipmentModificationGuid, + CurrencyType: 0, + Currency: 0, + Xp: 0, + Level: 0, + Platform: -1, + PlatformsToSpawnOn: -1, + BalanceType: -2, + GiftContext: weeklyChallenge.Gift.GiftContext, + // The catalog's rarity for the item, not the block's `GiftRarity` of 0. + GiftRarity: 5, + Message: 'Weekly challenge complete!', + }) + + // The reward is the item, not the box: it lands in the inventory unopened. + const unlocked = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, { + headers: await bearer('74'), + }) + const owned = (await unlocked.json()) as Array<{ ModificationGuid: string }> + expect(owned.map((e) => e.ModificationGuid)).toContain( + weeklyChallenge.Gift.EquipmentModificationGuid + ) + + // The client keeps reporting progress after the set is finished; a second pass over + // the same completions must not mint a second reward. + for (const id of ids) expect((await report(id)).status).toBe(200) + expect(await giftBoxes('74')).toHaveLength(1) + }) + + test('a player who already owns the rotation’s gift rolls the fallback box instead', async () => { + // Own the reward up front — the case the rotation's `FallbackGiftName` exists for. + await grantEquipment(env.DB, 75, { + ModificationGuid: weeklyChallenge.Gift.EquipmentModificationGuid, + PrefabName: weeklyChallenge.Gift.EquipmentPrefabName, + FriendlyName: 'Camera Skin (Comic)', + Tooltip: '', + Rarity: 5, + PlatformMask: -1, + Favorited: false, + }) + + const { ids, report } = await finishTheRotation('75') + for (const id of ids.slice(0, -1)) expect((await report(id)).status).toBe(200) + await drainFrames() + expect((await report(ids[ids.length - 1] ?? 0)).status).toBe(200) + + const won = await giftBoxes('75') + expect(won).toHaveLength(1) + // Something they don't have, at the tier `FallbackGiftName` names ("4-Star Box" → 30), + // rather than a second copy of the gift. + const rolled = won[0] + expect(rolled?.EquipmentModificationGuid).not.toBe( + weeklyChallenge.Gift.EquipmentModificationGuid + ) + expect(rolled?.GiftRarity).toBe(30) + expect( + (rolled?.AvatarItemDesc ?? '') !== '' || (rolled?.EquipmentModificationGuid ?? '') !== '' + ).toBe(true) + + // The frame announces what was ROLLED, not the box that promised it — so the client + // pops the item they actually won. + const frames = await drainFrames() + expect(frames).toHaveLength(1) + expect(frames[0]?.notificationType).toBe(NotificationType.GiftPackageReceivedImmediate) + expect(frames[0]?.payload).toMatchObject({ + Id: rolled?.Id, + FromPlayerId: 1, + GiftRarity: 30, + AvatarItemDesc: rolled?.AvatarItemDesc, + EquipmentModificationGuid: rolled?.EquipmentModificationGuid, + Message: 'Weekly challenge complete!', + }) + }) + + test('buying a query drop rolls a real item into the buyer’s inventory', async () => { + // sf2's "4-Star Unique Box" (539) — an `IsQuery` drop with no item fields of its own, + // which before the roll existed debited the buyer and granted nothing. + const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { + method: 'POST', + headers: { ...(await bearer('76')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + StorefrontType: 2, + PurchasableItemId: 539, + CurrencyType: CurrencyType.RecCenterTokens, + RequestedPrice: 800, + }), + }) + expect(res.status).toBe(200) + + const boxes = await giftBoxes('76') + expect(boxes).toHaveLength(1) + // The box shows what was rolled — a real 4-star item, not the empty box drop. + expect(boxes[0]?.GiftRarity).toBe(30) + const key = (box?: { AvatarItemDesc: string; EquipmentModificationGuid: string }) => + `${box?.AvatarItemDesc ?? ''}|${box?.EquipmentModificationGuid ?? ''}` + expect(key(boxes[0])).not.toBe('|') + + // …and it is already in their inventory, unopened box or not. + const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, { + headers: await bearer('76'), + }) + const owned = (await items.json()) as Array<{ AvatarItemDesc: string }> + if ((boxes[0]?.AvatarItemDesc ?? '') !== '') { + expect(owned.map((i) => i.AvatarItemDesc)).toContain(boxes[0]?.AvatarItemDesc) + } + + // A second box can't roll the same prize: "an item that you don't have" excludes what + // the first roll just granted. Two draws from a 244-item pool could collide by chance, + // so this only holds because the pool is filtered by ownership. + const second = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { + method: 'POST', + headers: { ...(await bearer('76')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + StorefrontType: 2, + PurchasableItemId: 539, + CurrencyType: CurrencyType.RecCenterTokens, + RequestedPrice: 800, + }), + }) + expect(second.status).toBe(200) + const after = await giftBoxes('76') + expect(after).toHaveLength(2) + expect(key(after[0])).not.toBe(key(after[1])) + }) + test('POST /api/gamerewards/v1/request claims once an hour per reward type', async () => { const headers = { ...(await bearer('80')), diff --git a/apps/econ/static/weekly-challenge.json b/apps/econ/static/weekly-challenge.json index 228082b..1357f1c 100644 --- a/apps/econ/static/weekly-challenge.json +++ b/apps/econ/static/weekly-challenge.json @@ -8,7 +8,7 @@ { "ChallengeId": 37, "Name": "CompleteJT", - "Config": "{\"ct\":0,\"ipc\":false,\"wc\":[{\"ct\":6,\"vs\":[2]},{\"ct\":9,\"vs\":[true],\"v\":\"won\"},{\"ct\":7,\"vs\":[{\"l\":\"acc06e66-c2d0-4361-b0cd-46246a4c455c\"}]}]}", + "Config": "{\"ct\":1,\"c\":true,\"ipc\":false,\"ctc\":[{\"ct\":0,\"ipc\":false,\"wc\":[{\"ct\":6,\"vs\":[2]},{\"ct\":7,\"vs\":[{\"l\":\"6d5eea4b-f069-4ed0-9916-0e2f07df0d03\"},{\"l\":\"4078dfed-24bb-4db7-863f-578ba48d726b\"}]}]}],\"t\":1,\"cc\":1}", "Description": "Complete ^TheRiseOfJumbotron quest", "Tooltip": "Vanquish Jumbotron in the Rise of Jumbotron quest!", "Complete": false diff --git a/apps/notify/src/notification-types.ts b/apps/notify/src/notification-types.ts index 9cff255..4fcede3 100644 --- a/apps/notify/src/notification-types.ts +++ b/apps/notify/src/notification-types.ts @@ -1,8 +1,14 @@ /** - * The client's `NotificationType` enum — the integer `Id` carried on a hub - * notification frame (`{ Id, Msg }`, see {@link NotificationsHub}). The reference - * server sends these as the notification type so the client's dispatcher can route - * each frame (e.g. remove a consumed item from inventory on ConsumableMappingRemoved). + * The client's `NotificationType` enum — the `Id` carried on a hub notification frame + * (`{ Id, Msg }`, see {@link NotificationsHub}). The reference server sends these as the + * notification type so the client's dispatcher can route each frame (e.g. remove a consumed + * item from inventory on ConsumableMappingRemoved). + * + * Mostly integers, but some members are STRINGS, and that is not an inconsistency to tidy + * up: the reference's hub sends a wire name for those frames (`"AccountUpdate"`, + * `"RoomUpdate"`, …) even where its own Go enum has a number for them, and the frame's `Id` + * is stringified as-is — so a member's value is whatever that frame is actually addressed + * by. Where the two disagree, the wire wins; the number is noted in the member's comment. * * Lives in the `notify` worker (the hub owner); other workers import it to send a * typed notification instead of a magic number. No runtime dependencies, so it's safe @@ -15,16 +21,31 @@ export enum NotificationType { PresenceHeartbeatResponse = 4, RefreshLogin = 5, Logout = 6, - SubscriptionUpdateProfile = "AccountUpdate", - SubscriptionUpdatePresence = "PresenceUpdate", - SubscriptionUpdateGameSession = "RoomInstanceUpdate", - SubscriptionUpdateRoom = 15, + SubscriptionUpdateProfile = 'AccountUpdate', + /** + * The owner-only twin of {@link SubscriptionUpdateProfile}: the same account, rendered + * with the private fields (email, birthday, remaining username changes). The reference + * sends both on connect and after a profile mutation — everyone gets the public frame, + * the owner additionally gets this one. Named after its twin rather than after a client + * enum member, since the client's enum doesn't list it; the WIRE name is what matters. + */ + SubscriptionUpdateSelfProfile = 'SelfAccountUpdate', + SubscriptionUpdatePresence = 'PresenceUpdate', + SubscriptionUpdateGameSession = 'RoomInstanceUpdate', + /** + * A room the player is subscribed to changed. STRING-valued like its neighbours even + * though the reference's own enum numbers it `15`: its hub sends the wire name + * (`NotifFrame("RoomUpdate", room)`) and never the number, and the payload builder + * stringifies whatever it is given, so `15` would go out as the unrelated `"15"`. + */ + SubscriptionUpdateRoom = 'RoomUpdate', + /** Unverified: nothing sends it here, and the reference's hub never puts it on the wire. */ SubscriptionUpdateRoomPlaylist = 16, ModerationQuitGame = 20, ModerationUpdateRequired = 21, ModerationKick = 22, ModerationKickAttemptFailed = 23, - ModerationRoomBan = "ModerationRoomBan", + ModerationRoomBan = 'ModerationRoomBan', ServerMaintenance = 25, GiftPackageReceived = 30, GiftPackageReceivedImmediate = 31, @@ -42,7 +63,7 @@ export enum NotificationType { PlayerEventResponseChanged = 83, PlayerEventResponseDeleted = 84, PlayerEventStateChanged = 85, - ChatMessageReceived = "ChatMessageReceived", + ChatMessageReceived = 'ChatMessageReceived', CommunityBoardUpdate = 95, CommunityBoardAnnouncementUpdate = 96, InventionModerationStateChanged = 100, diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index ebc58ec..f453a9c 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -402,7 +402,7 @@ async function pushRoomUpdate( try { await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( playerId, - 'RoomUpdate', + NotificationType.SubscriptionUpdateRoom, room ) } catch (err) {