mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[econ] mostly working challenges
This commit is contained in:
@@ -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<App>, account: Account): Promise<voi
|
||||
try {
|
||||
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
|
||||
const publicDto = toAccountDto(account)
|
||||
await hub.notifyPlayer(account.accountId, 'SelfAccountUpdate', toSelfAccountDto(account))
|
||||
await hub.notifyPlayer(account.accountId, 'AccountUpdate', publicDto)
|
||||
await hub.broadcast('AccountUpdate', publicDto)
|
||||
await hub.notifyPlayer(
|
||||
account.accountId,
|
||||
NotificationType.SubscriptionUpdateSelfProfile,
|
||||
toSelfAccountDto(account)
|
||||
)
|
||||
await hub.notifyPlayer(account.accountId, NotificationType.SubscriptionUpdateProfile, publicDto)
|
||||
await hub.broadcast(NotificationType.SubscriptionUpdateProfile, publicDto)
|
||||
} catch (err) {
|
||||
logger.error('failed to push account update notifications', {
|
||||
accountId: account.accountId,
|
||||
|
||||
+114
-9
@@ -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
|
||||
price the catalog no longer offers;
|
||||
3. debits the buyer **atomically** (`400` on insufficient balance);
|
||||
4. grants the drop — an avatar item into the `inventory` table (own-once), a consumable
|
||||
into the `consumable` table (each buy stacks a new instance); currency/xp drops
|
||||
aren't granted yet;
|
||||
4. grants the drop — an avatar item into the `inventory` table (own-once), equipment into
|
||||
`equipment`, a consumable into `consumable` (each buy stacks a new instance), or, for a
|
||||
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.
|
||||
|
||||
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 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 8–40 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
|
||||
|
||||
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` →
|
||||
`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
|
||||
`sf3.json` as `2121` ("Camera Skin (Comic)"). Nothing grants it — the reward is preview
|
||||
only (see Known gaps).
|
||||
`sf3.json` as `2121` ("Camera Skin (Comic)").
|
||||
|
||||
**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`)
|
||||
|
||||
@@ -293,11 +390,19 @@ Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no cod
|
||||
|
||||
## Known gaps
|
||||
|
||||
- Gifting to another player grants the item and box but does not notify the recipient.
|
||||
- `buyItem` grants avatar-item and consumable drops; currency/xp drops aren't granted.
|
||||
- Gifting to another player grants the item and box but does not notify the recipient — the
|
||||
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.
|
||||
- Several routes (room keys, wishlist, equipment, room consumables/currencies) are
|
||||
empty-list stubs pending their own stores.
|
||||
- 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 —
|
||||
nothing watches for the last challenge finishing, and there is no claim endpoint.
|
||||
- The weekly-challenge gift is granted but not announced: the box appears in the gifts list
|
||||
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)
|
||||
);
|
||||
@@ -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
@@ -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 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<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,
|
||||
|
||||
@@ -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 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')),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user