[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
+17 -4
View File
@@ -11,9 +11,18 @@ import {
searchAccounts, searchAccounts,
updateAccount, updateAccount,
} from '@repo/domain' } 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' 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 { import {
AccountDto, AccountDto,
BioRequest, BioRequest,
@@ -138,9 +147,13 @@ async function pushAccountUpdate(c: Context<App>, account: Account): Promise<voi
try { try {
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE) const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
const publicDto = toAccountDto(account) const publicDto = toAccountDto(account)
await hub.notifyPlayer(account.accountId, 'SelfAccountUpdate', toSelfAccountDto(account)) await hub.notifyPlayer(
await hub.notifyPlayer(account.accountId, 'AccountUpdate', publicDto) account.accountId,
await hub.broadcast('AccountUpdate', publicDto) NotificationType.SubscriptionUpdateSelfProfile,
toSelfAccountDto(account)
)
await hub.notifyPlayer(account.accountId, NotificationType.SubscriptionUpdateProfile, publicDto)
await hub.broadcast(NotificationType.SubscriptionUpdateProfile, publicDto)
} catch (err) { } catch (err) {
logger.error('failed to push account update notifications', { logger.error('failed to push account update notifications', {
accountId: account.accountId, accountId: account.accountId,
+114 -9
View File
@@ -71,9 +71,9 @@ The core flow. The client posts the storefront/item ids, the currency, and the
2. rejects a stale price (`409`) — this stops a stale or tampered client buying at a 2. rejects a stale price (`409`) — this stops a stale or tampered client buying at a
price the catalog no longer offers; price the catalog no longer offers;
3. debits the buyer **atomically** (`400` on insufficient balance); 3. debits the buyer **atomically** (`400` on insufficient balance);
4. grants the drop — an avatar item into the `inventory` table (own-once), a consumable 4. grants the drop — an avatar item into the `inventory` table (own-once), equipment into
into the `consumable` table (each buy stacks a new instance); currency/xp drops `equipment`, a consumable into `consumable` (each buy stacks a new instance), or, for a
aren't granted yet; query drop, whatever the roll lands on (below); currency/xp drops aren't granted yet;
5. returns a **gift box** and pushes a `StorefrontBalanceUpdate` over the socket. 5. returns a **gift box** and pushes a `StorefrontBalanceUpdate` over the socket.
Two things are easy to get wrong: Two things are easy to get wrong:
@@ -87,6 +87,46 @@ Two things are easy to get wrong:
A `Gift` block routes the item (and box) to another player, but the caller always pays. A `Gift` block routes the item (and box) to another player, but the caller always pays.
A self-buy or anonymous gift is attributed to the "Coach" system account (id 1). A self-buy or anonymous gift is attributed to the "Coach" system account (id 1).
## Query drops — the loot boxes (`IsQuery`)
A gift-drop with `IsQuery: true` is not an item, it is a **roll**: all of its item fields
(`AvatarItemDesc`, `EquipmentModificationGuid`, `ConsumableItemDesc`) are empty on purpose,
and what the player gets is picked at grant time. sf2's tooltip states the rule outright —
_"A random 4-star item that you don't have."_ Eight ship in the catalogs, two families of
the same ladder:
| sf2 "Star Boxes" (`ItemSetId` 44, `Unique`) | Rarity | sf3 "Random box" family |
| ------------------------------------------- | ------ | ----------------------- |
| — | 0 | Common Random box |
| 2-Star Unique Box | 10 | Uncommon Random box |
| 3-Star Unique Box | 20 | Rare Random box |
| 4-Star Unique Box | 30 | Epic Random box |
| — | 50 | Legendary Random box |
That table is the **star ↔ rarity ladder** (`STAR_RARITY` in `econ.app.ts`): sf2's three
boxes pin 2/3/4 → 10/20/30 by carrying both their name and their `QueryRedirectRarity`, and
sf3's five-name ladder fills in the ends. It's the same tier list twice, so read a rarity
number in either dialect.
`rollQueryDrop` resolves one inside `grantGiftDrop`, so both faucets — a purchase and the
weekly gift — hand over a real item rather than an unopenable box:
- **The pool is sf3**, the general store (`ROLL_STOREFRONT_TYPE`). It's the only catalog
with a real pool at every tier (1161 items against 840 in the themed ones), it's where
the Random box family itself sells, and "a random 4-star item" means the item universe,
not whichever seasonal shelf the box came off.
- **Filtered to what the player doesn't own**, which is the `Unique` promise and the only
reading of "an item you don't have" that means anything.
- **Avatar items and equipment only.** Other query drops are excluded (a box that rolls a
box), and so are consumables: they stack, so "don't have" never becomes false and they'd
crowd out the real prizes.
- **`QueryRedirectRarity` wins over `Rarity`** when present — sf2 carries both and they
agree; sf3's boxes carry only `Rarity`.
- **An empty pool grants nothing** (logged `query gift-drop rolled nothing`) — an owner of
every 4-star item still gets the box, just nothing in it. The `buyItem` response still
echoes the drop the player _bought_, i.e. the box; the rolled item shows up in the box
itself, via `GET /api/avatar/v2/gifts`.
## Consume envelopes ## Consume envelopes
Both consume routes (`/gifts/consume`, `/consumables/consume`) always answer HTTP 200 Both consume routes (`/gifts/consume`, `/consumables/consume`) always answer HTTP 200
@@ -215,8 +255,65 @@ feed one shape to the other's reader.
guid bytes in .NET little-endian order, padding stripped (`g5u0weNLmkCLeUXFUVn74Q` guid bytes in .NET little-endian order, padding stripped (`g5u0weNLmkCLeUXFUVn74Q`
`c1b49b83-4be3-409a-8b79-45c55159fbe1`). The reward is identified by prefab + that guid, `c1b49b83-4be3-409a-8b79-45c55159fbe1`). The reward is identified by prefab + that guid,
_not_ by `GiftDropId`: this block's `GiftDropId` is `3994`, while the same skin sells in _not_ by `GiftDropId`: this block's `GiftDropId` is `3994`, while the same skin sells in
`sf3.json` as `2121` ("Camera Skin (Comic)"). Nothing grants it — the reward is preview `sf3.json` as `2121` ("Camera Skin (Comic)").
only (see Known gaps).
**Granted when the set is finished** — see below. The grant path is `buyItem`'s, so the
block is translated into a storefront gift-drop first (`toChallengeGiftDrop`); the renamed
`GiftContext`/`GiftRarity` are exactly what that translation is for.
The block carries no display strings and a `GiftRarity` of `0` for an item that sells at
rarity `5`, so both are taken from the catalog entry selling the same item (matched on
equipment guid / avatar desc) — the reward reads as "Camera Skin (Comic)", not as the box it
might have arrived in. An explicit `FriendlyName`/`Tooltip` on the block wins over the
catalog if a rotation we publish sets them; neither is present in the captured one.
**`FallbackGiftName` is the other half of the reward, not just a label.** "4-Star Box" is
what the player gets _instead_ when they already own the item — the real game phrased it
"…or a 4-Star Box!" — so it is granted as a query drop (a roll) at the tier its star count
names, via the ladder in the query-drop section. Renaming it to `3-Star Box` retunes the
consolation tier with no code change; a name that doesn't parse falls back to 4 stars.
### Winning the gift (`challenge_gift`)
There is no claim endpoint and the client never asks: the reward is handed out from the
`updateProgress` call that completes the set. Every completing report on the **live**
rotation re-reads the caller's completions and, if every challenge in
`weekly-challenge.json` is there, grants the `Gift` the way a purchase grants a drop — the
item into `inventory`/`equipment`/`consumable`, plus a gift box (message
`Weekly challenge complete!`) the player finds in `GET /api/avatar/v2/gifts`.
**The item, or a roll.** If the player already owns the `Gift`'s item — likely, since the
rotation's reward is one fixed item that sells in the store — they get the
`FallbackGiftName` box instead, rolled at its star tier. Finishing the week can't be worth
nothing. A `Gift` block carrying no ownable item at all (no avatar desc, no equipment guid)
counts as "already owned", so a rotation whose reward is _only_ a box is written by leaving
the block empty and naming the tier.
- **`challenge_gift` makes it happen once.** One row per (account, rotation); the row's
existence _is_ the grant. The client keeps reporting after the set is finished, so the
insert is the gate: `ON CONFLICT … DO NOTHING … RETURNING` claims it in one statement, and
a second report returns no row and grants nothing.
- **Claim first, grant second** — at-most-once. If the grant then fails the reward is lost
rather than doubled; it's logged (`failed to grant weekly challenge gift`) and re-granted
by hand if it ever happens. A faucet that sticks is easier to spot than one that leaks.
- **The response is unchanged; the socket carries the news.** `updateProgress` answers the
same four fields whether or not a gift was won, and a `GiftPackageReceivedImmediate` (31)
frame goes out over the hub with the box — that's what pops the reward panel the moment
the set is finished, instead of the player finding it on the next read of the gifts list.
The payload is the reference server's field-for-field (`Id`, `FromGiftDropId: 0`,
`FromPlayerId`, the item fields, `Platform`/`PlatformsToSpawnOn: -1`, `BalanceType: -2`,
`Message`), and it names the **rolled** item when the fallback box is what was granted.
`Immediate` (31) rather than `GiftPackageReceived` (30) is what the reference sends for a
box the server hands over unasked; the sender is Coach (1). Best-effort — a hub failure is
logged and swallowed, since the gift is already granted and stored.
- **`CompletedRequired` is not consulted.** Its meaning is inferred, and the only reading
under which the gift is due _before_ the set is done would pay out on the first challenge.
- **`Xp`/`Level` on the block are ignored**, as on a purchase — same gap, and both are `0`
in the captured rotation.
- **A report against an old rotation never wins anything**, and an empty `Challenges` array
is not a finished set (without that guard "every challenge complete" is vacuously true).
- **Players who finished the set before this shipped still get it**: the client re-reports
completed challenges, and the first such report is a completing report.
### Progress (`challenge_status`) ### Progress (`challenge_status`)
@@ -293,11 +390,19 @@ Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no cod
## Known gaps ## Known gaps
- Gifting to another player grants the item and box but does not notify the recipient. - Gifting to another player grants the item and box but does not notify the recipient — the
- `buyItem` grants avatar-item and consumable drops; currency/xp drops aren't granted. reference sends `GiftPackageReceivedImmediate` there too (`buy.go`, when the body carries
a `Gift`), and `pushGiftReceived` is now sitting right there to do it.
- `buyItem` grants avatar-item, equipment, consumable and query (box) drops; currency/xp
drops aren't granted.
- A query drop rolls uniformly across the tier and can't run at a rarity sf3 doesn't
publish; per-item weighting and a multi-catalog pool would both need a manifest of the
storefronts, which the ASSETS binding can't enumerate.
- Consumables are granted and listed but never spent by gameplay, so `Count` only grows. - Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
- Several routes (room keys, wishlist, equipment, room consumables/currencies) are - Several routes (room keys, wishlist, equipment, room consumables/currencies) are
empty-list stubs pending their own stores. empty-list stubs pending their own stores.
- Game rewards gate correctly but pay nothing out — see the `reward_status` section. - Game rewards gate correctly but pay nothing out — see the `reward_status` section.
- Weekly-challenge completion is persisted, but the rotation's `Gift` is never granted — - The weekly-challenge gift is granted but not announced: the box appears in the gifts list
nothing watches for the last challenge finishing, and there is no claim endpoint. with no `GiftPackageReceived` notification, so the player sees it the next time the client
reads that list rather than the moment they finish the set. Same gap as gifting to another
player, and the same reason — the frame's payload shape hasn't been captured.
@@ -0,0 +1,20 @@
-- Weekly-challenge gift grants, owned by the `econ` worker. One row per (account,
-- rotation), written when the last challenge of a rotation is reported complete on
-- `/api/challenge/v2/updateProgress` and the rotation's `Gift` is handed out.
--
-- The table exists only to make that grant happen ONCE. The client reports progress
-- repeatedly, so every report that arrives with the set already finished would otherwise
-- mint another copy of the reward; the insert is the gate, and it conflicts on the second
-- report instead of paying out again.
--
-- Keyed by rotation as well as account so a new week's set can be finished and rewarded on
-- its own — `challenge_map_id` is the rotation, matching `challenge_status`. There is no
-- `granted` flag: the row's existence IS the grant. Kept in sync with
-- CHALLENGE_GIFT_SCHEMA_DDL in src/challenge-db.ts.
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)
);
+51 -2
View File
@@ -16,8 +16,12 @@
* unique within a rotation, so a challenge that returns in a later week would otherwise * 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. * start out already complete on the old week's row.
* *
* The `econ` worker owns this table and its migration * Finishing every challenge in a rotation earns the rotation's `Gift`, which is handed out
* (apps/econ/migrations/0009_challenge_status.sql). * 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. */ /** 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 * 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` — * 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. * 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( export async function getCompletedChallengeIds(
db: D1Database, db: D1Database,
@@ -98,3 +105,45 @@ export async function getCompletedChallengeIds(
.all<{ challenge_id: number }>() .all<{ challenge_id: number }>()
return new Set(results.map((r) => r.challenge_id)) 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, isSpendable,
spendCurrency, spendCurrency,
} from './balance-db' } from './balance-db'
import { getCompletedChallengeIds, recordChallengeProgress } from './challenge-db' import {
claimChallengeGift,
getCompletedChallengeIds,
recordChallengeProgress,
} from './challenge-db'
import { import {
consumeConsumable, consumeConsumable,
countConsumable, countConsumable,
@@ -288,6 +292,20 @@ interface StoreGiftDrop {
Context: number Context: number
Currency: number Currency: number
CurrencyType: 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 { interface StorePrice {
CurrencyType: number 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 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 * 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) if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
// Grant the item to the recipient. A gift-drop carries an avatar item, a consumable, // Grant the item to the recipient, with the gift box that renders it. A box (an
// an equipment skin, or none of these (currency/xp drops aren't granted yet); grant // `IsQuery` drop, e.g. sf2's "4-Star Unique Box") rolls its prize in here.
// whichever it actually has. const { id: giftId } = await grantGiftDrop(c, receiverId, item.GiftDrop, message)
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
)
)
// Push the debit over the socket so the buyer's client updates the shown total // 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 // 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, challengeId,
complete: parseBool(body.Complete), 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({ return c.json({
ChallengeMapId: challengeMapId, ChallengeMapId: challengeMapId,
ChallengeId: challengeId, 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 // 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). // 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' 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 // 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. // instead of hard-coded ids from a rotation that has since been replaced.
import weeklyChallenge from '../../../static/weekly-challenge.json' import weeklyChallenge from '../../../static/weekly-challenge.json'
@@ -24,9 +27,9 @@ import {
getBalance, getBalance,
spendCurrency, spendCurrency,
} from '../../balance-db' } 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 { 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 { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
import { OUTFIT_SCHEMA_DDL } from '../../outfit-db' import { OUTFIT_SCHEMA_DDL } from '../../outfit-db'
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-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 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 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_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 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 INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of CONSUMABLE_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([ expect(await drainFrames()).toEqual([
{ {
accountId: 20, accountId: 20,
notificationType: STOREFRONT_BALANCE_UPDATE, notificationType: NotificationType.StorefrontBalanceUpdate,
payload: { Balance: -450, CurrencyType: 2, BalanceType: -2 }, payload: { Balance: -450, CurrencyType: 2, BalanceType: -2 },
}, },
]) ])
@@ -1051,19 +1055,16 @@ describe('econ endpoints', () => {
* test sees what was actually pushed. * test sees what was actually pushed.
*/ */
const drainFrames = async (): Promise< 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 { env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') as unknown as {
drainFrames(): Promise< drainFrames(): Promise<
Array<{ accountId: number; notificationType: number; payload: Record<string, number> }> Array<{ accountId: number; notificationType: number; payload: Record<string, unknown> }>
> >
} }
).drainFrames() ).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. // buyInvention is a GET with query params — that is how the client sends it.
const buyInvention = async (sub: string, inventionId: number, requestedPrice = 0) => const buyInvention = async (sub: string, inventionId: number, requestedPrice = 0) =>
exports.default.fetch( exports.default.fetch(
@@ -1136,12 +1137,12 @@ describe('econ endpoints', () => {
expect(await drainFrames()).toEqual([ expect(await drainFrames()).toEqual([
{ {
accountId: 999, accountId: 999,
notificationType: STOREFRONT_BALANCE_UPDATE, notificationType: NotificationType.StorefrontBalanceUpdate,
payload: { Balance: 250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 }, payload: { Balance: 250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 },
}, },
{ {
accountId: 51, accountId: 51,
notificationType: STOREFRONT_BALANCE_UPDATE, notificationType: NotificationType.StorefrontBalanceUpdate,
payload: { Balance: -250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 }, payload: { Balance: -250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 },
}, },
]) ])
@@ -1442,6 +1443,190 @@ describe('econ endpoints', () => {
expect(await completeOf(await post('18', 'True'))).toBe(true) 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 () => { test('POST /api/gamerewards/v1/request claims once an hour per reward type', async () => {
const headers = { const headers = {
...(await bearer('80')), ...(await bearer('80')),
+1 -1
View File
@@ -8,7 +8,7 @@
{ {
"ChallengeId": 37, "ChallengeId": 37,
"Name": "CompleteJT", "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", "Description": "Complete ^TheRiseOfJumbotron quest",
"Tooltip": "Vanquish Jumbotron in the Rise of Jumbotron quest!", "Tooltip": "Vanquish Jumbotron in the Rise of Jumbotron quest!",
"Complete": false "Complete": false
+31 -10
View File
@@ -1,8 +1,14 @@
/** /**
* The client's `NotificationType` enum — the integer `Id` carried on a hub * The client's `NotificationType` enum — the `Id` carried on a hub notification frame
* notification frame (`{ Id, Msg }`, see {@link NotificationsHub}). The reference * (`{ Id, Msg }`, see {@link NotificationsHub}). The reference server sends these as the
* server sends these as the notification type so the client's dispatcher can route * notification type so the client's dispatcher can route each frame (e.g. remove a consumed
* each frame (e.g. remove a consumed item from inventory on ConsumableMappingRemoved). * 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 * 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 * typed notification instead of a magic number. No runtime dependencies, so it's safe
@@ -15,16 +21,31 @@ export enum NotificationType {
PresenceHeartbeatResponse = 4, PresenceHeartbeatResponse = 4,
RefreshLogin = 5, RefreshLogin = 5,
Logout = 6, Logout = 6,
SubscriptionUpdateProfile = "AccountUpdate", SubscriptionUpdateProfile = 'AccountUpdate',
SubscriptionUpdatePresence = "PresenceUpdate", /**
SubscriptionUpdateGameSession = "RoomInstanceUpdate", * The owner-only twin of {@link SubscriptionUpdateProfile}: the same account, rendered
SubscriptionUpdateRoom = 15, * 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, SubscriptionUpdateRoomPlaylist = 16,
ModerationQuitGame = 20, ModerationQuitGame = 20,
ModerationUpdateRequired = 21, ModerationUpdateRequired = 21,
ModerationKick = 22, ModerationKick = 22,
ModerationKickAttemptFailed = 23, ModerationKickAttemptFailed = 23,
ModerationRoomBan = "ModerationRoomBan", ModerationRoomBan = 'ModerationRoomBan',
ServerMaintenance = 25, ServerMaintenance = 25,
GiftPackageReceived = 30, GiftPackageReceived = 30,
GiftPackageReceivedImmediate = 31, GiftPackageReceivedImmediate = 31,
@@ -42,7 +63,7 @@ export enum NotificationType {
PlayerEventResponseChanged = 83, PlayerEventResponseChanged = 83,
PlayerEventResponseDeleted = 84, PlayerEventResponseDeleted = 84,
PlayerEventStateChanged = 85, PlayerEventStateChanged = 85,
ChatMessageReceived = "ChatMessageReceived", ChatMessageReceived = 'ChatMessageReceived',
CommunityBoardUpdate = 95, CommunityBoardUpdate = 95,
CommunityBoardAnnouncementUpdate = 96, CommunityBoardAnnouncementUpdate = 96,
InventionModerationStateChanged = 100, InventionModerationStateChanged = 100,
+1 -1
View File
@@ -402,7 +402,7 @@ async function pushRoomUpdate(
try { try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId, playerId,
'RoomUpdate', NotificationType.SubscriptionUpdateRoom,
room room
) )
} catch (err) { } catch (err) {