mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
[econ] mostly working challenges
This commit is contained in:
@@ -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')),
|
||||
|
||||
Reference in New Issue
Block a user