[econ] mostly working challenges

This commit is contained in:
Devin Zuczek
2026-08-10 22:55:13 -04:00
parent 0b33e0b46f
commit 208c1fe772
9 changed files with 859 additions and 82 deletions
+51 -2
View File
@@ -16,8 +16,12 @@
* unique within a rotation, so a challenge that returns in a later week would otherwise
* start out already complete on the old week's row.
*
* The `econ` worker owns this table and its migration
* (apps/econ/migrations/0009_challenge_status.sql).
* Finishing every challenge in a rotation earns the rotation's `Gift`, which is handed out
* from the same `updateProgress` call that completes the set. That payout is gated by a
* second table here, `challenge_gift` — one row per (account, rotation), claimed once.
*
* The `econ` worker owns both tables and their migrations
* (apps/econ/migrations/0009_challenge_status.sql, 0011_challenge_gift.sql).
*/
/** Schema DDL (mirror of migrations 0009_challenge_status.sql) — also builds the table in tests. */
@@ -83,6 +87,9 @@ export async function recordChallengeProgress(
* The ids of the challenges a player has finished in one rotation. Scoped to the rotation
* so a stale row from an earlier week — same challenge id, different `challenge_map_id` —
* doesn't show up pre-completed before the client has reported anything against it.
*
* Also what "the whole set is finished" is decided from: the rotation's `Gift` is due once
* every challenge in static/weekly-challenge.json appears here.
*/
export async function getCompletedChallengeIds(
db: D1Database,
@@ -98,3 +105,45 @@ export async function getCompletedChallengeIds(
.all<{ challenge_id: number }>()
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<boolean> {
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
}
+429 -45
View File
@@ -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<App>,
accountId: number,
gift: GrantedGift,
message: string,
fromPlayerId: number
): Promise<void> {
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 840 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<App>): Promise<StoreItem[]> {
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<boolean> {
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<App>,
accountId: number,
rarity: number,
rollCatalog?: StoreItem[]
): Promise<StoreGiftDrop | null> {
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<App>,
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<GrantedGift> {
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<App>, accountId: number): Promise<void> {
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<App>({ 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<App>({ 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,
+195 -10
View File
@@ -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<string, number> }>
Array<{ accountId: number; notificationType: number; payload: Record<string, unknown> }>
> =>
(
env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') as unknown as {
drainFrames(): Promise<
Array<{ accountId: number; notificationType: number; payload: Record<string, number> }>
Array<{ accountId: number; notificationType: number; payload: Record<string, unknown> }>
>
}
).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 rotations 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 buyers 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')),