import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'

import {
	addXp,
	consumeGift,
	createGift,
	getGift,
	getOutfits,
	getPendingGifts,
	grantInvention,
	levelReward,
	levelsReached,
	ownsInvention,
	setOutfit,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'

// Invention storage (owned by the `api` worker, on this same `recflare` database).
// Imported directly rather than copied: these are plain D1 helpers with no bindings of
// their own, and buyInvention has to read the very rows `api` writes.
// Custom avatar items likewise live in an `api`-owned table; the UGC-purchasable bulk
// lookup is the store's view of those rows.
import {
	getCustomAvatarItems,
	toUgcPurchasable,
	UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
} from '../../api/src/custom-avatar-items-db'
import { getInventionById, toSaveResult } from '../../api/src/inventions-db'
// The notification-type ids the hub carries, and the payload shapes recovered from the
// client's own decoder (both owned by the `notify` worker). Imported rather than copied so
// the frames this worker builds are typed by the shapes the client actually parses — a
// wrong or renamed key (see the `Platform`/`BalanceType` trap) fails the build here.
import { BalanceAddType } from '../../notify/src/notification-payloads'
import { NotificationType } from '../../notify/src/notification-types'
import adCarouselItems from '../static/ad-carousel-items.json'
import defaultAvatarItems from '../static/default-avatar-items.json'
import defaultAvatar from '../static/default-avatar.json'
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
import myProgress from '../static/my-progress.json'
import { getAvatar, setAvatar } from './avatar-db'
import {
	ALL_PLATFORMS,
	creditCurrency,
	CurrencyType,
	DEFAULT_STARTING_TOKENS,
	ensureStartingBalances,
	getBalance,
	isSpendable,
	spendCurrency,
} from './balance-db'
import { claimChallengeGift, getChallengeStatuses, recordChallengeProgress } from './challenge-db'
import { buildRotation, rotationMapId, withWeeklyGift } from './challenge-rotation'
import {
	consumeConsumable,
	countConsumable,
	getConsumables,
	grantConsumable,
} from './consumables-db'
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
import { getInventory, grantItem, toAvatarItemV4 } from './inventory-db'
import {
	AUTHED,
	AvatarItemV4Dto,
	AvatarV2Dto,
	BalanceEntry,
	BulkPurchaseRequest,
	BulkPurchaseResponse,
	BuyInventionResponse,
	BuyItemRequest,
	BuyItemResponse,
	ChallengeProgressRequest,
	ChallengeProgressResponse,
	ChecklistCompleteResponse,
	ChecklistEntry,
	CompleteChecklistRequest,
	ConsumeConsumableRequest,
	ConsumeEnvelope,
	ConsumeGiftRequest,
	CustomAvatarItemsResponse,
	EquipmentUpdateRequest,
	ErrorResponse,
	form,
	GameRewardRequest,
	InfluencerIdsResponse,
	InfluencerTierResponse,
	json,
	JsonArray,
	jsonBody,
	JsonObject,
	MakerAiFreeTrialEligibilityResponse,
	OpaqueJsonBody,
	OPTIONAL_AUTHED,
	ReferralProgressResponse,
	RoomEconConfig,
	RRPlusSignUpBonus,
	SaveOutfitRequest,
	SaveOutfitV4Response,
	SubscriptionResponse,
	UgcPurchasableBulkRequest,
	UgcPurchasableItemList,
	UNAUTHORIZED_RESPONSE,
	UpdateObjectiveRequest,
	UpdateObjectiveResponse,
} from './openapi'
import { claimReward } from './reward-db'

import type { Context } from 'hono'
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
import type {
	BalanceResponsePayload,
	PurchaseBalanceModificationPayload,
} from '../../notify/src/notification-payloads'
import type { Avatar } from './avatar-db'
import type {
	ChallengeGiftBlock,
	EquipmentGift,
	WeeklyChallengeRotation,
} from './challenge-rotation'
import type { ConsumeResult } from './consumables-db'
import type { App } from './context'
import type { Equipment } from './equipment-db'
import type { AvatarItem } from './inventory-db'

/**
 * Economy Worker. Hosts the avatar/economy endpoints the game client calls on
 * the `econ` service (these are separate from the main `api` worker). Balances,
 * inventory (avatar items, equipment, bought inventions), consumables, saved outfits,
 * avatars, gift boxes, weekly-challenge progress and game-reward eligibility are D1-backed;
 * storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS
 * binding. Some routes are still empty-list stubs (room keys, wishlist, …).
 *
 * Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
 */

/**
 * Resolve the account id from a Bearer token. Returns `null` when the header is
 * missing, the token is invalid, or the `sub` claim isn't an integer.
 */
async function authedId(c: Context<App>): Promise<number | null> {
	return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}

/**
 * The `role` claim from a Bearer token — the operator-granted roles the auth worker stamps
 * from the account's flags, so a plain player's token is just `['gameClient']`. `null` when
 * the request carries no valid token; an empty array means a valid token with no roles.
 * Shaped to mirror {@link authedId}.
 */
async function authedRoles(c: Context<App>): Promise<string[] | null> {
	return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
}

/** Results.Unauthorized() equivalent — 401 with empty body. */
function unauthorized(c: Context<App>) {
	return c.body(null, 401)
}

/**
 * A boolean the client may send either as a JSON `true` or as .NET's `bool.ToString()`
 * output — `"True"`/`"False"`, capitalized. `Boolean(value)` is a trap here: the string
 * `"False"` is truthy, so a client reporting "not complete" would read as complete.
 * Anything unrecognised (missing, `null`, `""`) is false.
 */
function parseBool(value: string | boolean | undefined): boolean {
	return typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true'
}

/**
 * Shared parse/validate/store for the save-outfit routes (v3 and v4). Persists the
 * posted outfit into its `Slot` verbatim and returns the stored `Outfit`; on the
 * unauth or bad-body path it returns the Response to send directly (401, or 400 for a
 * non-object body or missing/non-integer `Slot`). Callers format the success body — v3
 * echoes the whole outfit, v4 answers a lean `{ Success, Slot }` ack.
 */
async function persistPostedOutfit(c: Context<App>): Promise<Outfit | Response> {
	const id = await authedId(c)
	if (id === null) return unauthorized(c)
	const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
	if (body === null || typeof body !== 'object' || Array.isArray(body)) {
		return c.body(null, 400)
	}
	if (!Number.isInteger(body.Slot)) return c.body(null, 400)
	const outfit = body as Outfit
	await setOutfit(c.env.DB, id, outfit)
	return outfit
}

/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'

/**
 * Push a ConsumableMappingRemoved notification to a player after they consume a
 * consumable, mirroring the reference's
 * `HubSendToPlayer(accountID, NotifFrame(ConsumableMappingRemoved, {...}))` — the
 * client uses it to update/remove the item from inventory. Best-effort: a hub failure
 * is logged and swallowed, since the consume has already committed.
 */
async function pushConsumableRemoved(
	c: Context<App>,
	accountId: number,
	consumed: ConsumeResult
): Promise<void> {
	try {
		await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
			accountId,
			NotificationType.ConsumableMappingRemoved,
			{
				Id: consumed.id,
				ConsumableItemDesc: consumed.consumableItemDesc,
				CreatedAt: consumed.createdAt,
				Count: consumed.remaining,
				InitialCount: consumed.previousCount,
				IsActive: false,
				ActiveDurationMinutes: 0,
				IsTransferable: false,
			}
		)
	} catch (err) {
		logger.error('failed to push ConsumableMappingRemoved notification', {
			accountId,
			error: err instanceof Error ? err.message : String(err),
		})
	}
}

/**
 * Push a ConsumableMappingAdded notification to a player after they open a gift box
 * that carried a consumable, mirroring the reference's
 * `HubSendToPlayer(accountID, NotifFrame(ConsumableMappingAdded, {...}))` — the client
 * uses it to show the newly-unlocked consumable. The mapping id and pre-existing count
 * were stamped onto the box at purchase (see toGiftContent). Best-effort like the
 * removed push.
 */
async function pushConsumableAdded(
	c: Context<App>,
	accountId: number,
	gift: StoredGift
): Promise<void> {
	try {
		await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
			accountId,
			NotificationType.ConsumableMappingAdded,
			{
				Id: gift.ConsumableMappingId ?? 0,
				ConsumableItemDesc: gift.ConsumableItemDesc,
				CreatedAt: new Date().toISOString(),
				Count: gift.ConsumableCount,
				InitialCount: gift.ConsumablePreExistingCount ?? 0,
				IsActive: false,
				ActiveDurationMinutes: 0,
				IsTransferable: false,
			}
		)
	} catch (err) {
		logger.error('failed to push ConsumableMappingAdded notification', {
			accountId,
			error: err instanceof Error ? err.message : String(err),
		})
	}
}

/**
 * THE BALANCE-FRAME RULE, which both balance bugs came from getting wrong.
 *
 * The client holds a balance PER `(CurrencyType, Platform)` bucket and shows the SUM of the
 * buckets. Every `StorefrontBalance*` frame is an absolute SET of the one bucket it names —
 * not a change to apply — so:
 *
 *  1. `Balance` is the RESULTING TOTAL. Sending the change sets the bucket TO that change.
 *  2. The bucket key on the wire is `Platform`. The client's property is called
 *     `BalanceType` but carries a `[DataMember]` rename, and its decoder drops unknown
 *     members in silence — so a frame that says `BalanceType` lands in `Platform` 0,
 *     `SteamPurchased`, and creates a SECOND bucket that is added to the real one forever.
 *  3. That bucket must be the same one `GET /api/storefronts/v4/balance/:type` reports,
 *     `ALL_PLATFORMS`. One account-wide bucket per currency is the whole model here; a
 *     frame naming any other Platform is a phantom balance, not a per-store nicety.
 *
 * Both live bugs were rule 2 or 3, and both looked like the frame being "additive":
 *  - A player who earned 250 on 10,000 read 20,250 — `BalanceType: -2` was dropped, so the
 *    total landed in a phantom Steam bucket beside the real one.
 *  - A player who spent 900 of 17,500 read 34,100, then 33,200 once the purchase response's
 *    -900 reached the real bucket — same phantom bucket, this time from `Platform: RecNet`.
 * Neither was additivity: the totals were right, the bucket was wrong. Frames as specified
 * here are idempotent, so re-sending one or racing a `GET /balance` cannot drift the total.
 *
 * See apps/notify/src/notification-payloads.ts for the payload shapes this is recovered
 * from — the interfaces there type these calls, so a wrong key is now a build error.
 */

/**
 * Push a StorefrontBalanceUpdate (61) — "your balance in this bucket is now X" — after a
 * player's balance changes for a reason that is not their own purchase. `balance` is their
 * resulting TOTAL in that currency, per the rule above.
 *
 * A player who is reading the HTTP response for the same change gets this too: it sets the
 * bucket to the same total the body reports, so the two agree rather than compound. Pushing
 * it is what saves them a `GET /balance` re-fetch.
 *
 * Best-effort: a hub failure is logged and swallowed, since the change has already committed.
 */
async function pushBalanceUpdate(
	c: Context<App>,
	accountId: number,
	currencyType: number,
	balance: number
): Promise<void> {
	// `satisfies` rather than a type annotation: the hub takes a Record<string, unknown>, and
	// an interface (unlike an inferred object type) has no implicit index signature to match
	// it. This still checks every key against the shape the client's decoder parses.
	const payload = {
		Balance: balance,
		CurrencyType: currencyType,
		Platform: ALL_PLATFORMS,
	} satisfies BalanceResponsePayload
	try {
		await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
			accountId,
			NotificationType.StorefrontBalanceUpdate,
			payload
		)
	} catch (err) {
		logger.error('failed to push StorefrontBalanceUpdate notification', {
			accountId,
			error: err instanceof Error ? err.message : String(err),
		})
	}
}

/**
 * Push a StorefrontBalancePurchase (62) — the frame the reference sends when the balance
 * moved because the player BOUGHT something, as opposed to the plain update above. Same
 * absolute-set semantics: `balance` is the resulting total.
 *
 * `Delta` (the negated price) and `BalanceAddType` are display/telemetry only — the client
 * logs them and then stores `Balance` outright, so a correct `Delta` beside a stale
 * `Balance` still leaves the player's balance wrong. `Platform` is `ALL_PLATFORMS`, NOT
 * `RecNetPurchased`: it has to name the bucket `GET /balance` reports, and sending RecNet
 * here is exactly what doubled a buyer's tokens on screen. Best-effort, as above.
 */
async function pushBalancePurchase(
	c: Context<App>,
	accountId: number,
	currencyType: number,
	delta: number,
	balance: number
): Promise<void> {
	const payload = {
		BalanceAddType: BalanceAddType.CommercePurchase,
		Delta: delta,
		Balance: balance,
		Platform: ALL_PLATFORMS,
		CurrencyType: currencyType,
	} satisfies PurchaseBalanceModificationPayload
	try {
		await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
			accountId,
			NotificationType.StorefrontBalancePurchase,
			payload
		)
	} catch (err) {
		logger.error('failed to push StorefrontBalancePurchase notification', {
			accountId,
			error: err instanceof Error ? err.message : String(err),
		})
	}
}

/**
 * The influencer partner tier every account has here — the "not an influencer" one. It is
 * the whole body of both `/api/influencerpartnerprogram/influencer` and `…/myinfluencer`,
 * served as a bare number rather than wrapped in anything.
 */
const NOT_AN_INFLUENCER = 0

/** The operator-granted role that comes with a complimentary subscription. */
const DEVELOPER_ROLE = 'developer'

/**
 * Whether the caller currently holds a Rec Room Plus subscription — the ONE definition,
 * shared by `UpdateAndGetSubscription` (which reports it) and the storefront buys (which
 * price off it via `SubscriberPrices`). Nothing sells subscriptions here, so holding the
 * `developer` role IS the subscription; if a real subscription store ever lands, this is
 * the only place that has to learn about it. Read from the token's `role` claim, never the
 * body; no or an invalid token is "not subscribed".
 */
async function isSubscriber(c: Context<App>): Promise<boolean> {
	const roles = await authedRoles(c)
	return roles?.includes(DEVELOPER_ROLE) ?? false
}

/** `SubscriptionLevel.Gold`. 1 is Platinum. */
const SUBSCRIPTION_LEVEL_GOLD = 0

/** `SubscriptionPeriod.Year`. 0 is Month, 2 ThreeMonth, 3 SixMonth. */
const SUBSCRIPTION_PERIOD_YEAR = 1

/**
 * `PlatformType.All` (-1) — the subscription belongs to no single store, which is the honest
 * answer when no store sold it. The rest of the enum: 0 Steam, 1 Oculus, 2 PlayStation,
 * 3 Xbox, 4 RecNet, 5 IOS, 6 GooglePlay, 7 Standalone, 8 Pico.
 */
const SUBSCRIPTION_PLATFORM_ALL = -1

/** The id every reported subscription carries — a placeholder, since none is stored. */
const STUB_SUBSCRIPTION_ID = 1

/**
 * The complimentary subscription a `developer` account reports — Rec Room Plus, which the
 * client's API calls a `CampusCard`.
 *
 * Nothing here sells subscriptions, so holding the role IS the subscription: it's how the
 * paid-tier surfaces get exercised without a store. Every field is computed per call and
 * none of it is persisted, so this is not a record of anything — revoking the role revokes
 * the subscription, and no expiry sweep or renewal exists.
 *
 * `ExpirationDate` is a year out from THIS call rather than a fixed date: a hard-coded one
 * lapses on a day nobody is expecting, and the client would start showing an expired
 * subscription with no way to renew it. `IsAutoRenewing` tells the client the same thing.
 * The dates are milliseconds-precision ISO like the rest of this worker's timestamps.
 */
function developerSubscription(accountId: number) {
	const now = new Date()
	// Calendar arithmetic, not now + 365 days: setUTCFullYear lands on the same date next
	// year whether or not a leap day falls in between.
	const expires = new Date(now)
	expires.setUTCFullYear(expires.getUTCFullYear() + 1)
	return {
		SubscriptionId: STUB_SUBSCRIPTION_ID,
		RecNetPlayerId: accountId,
		PlatformType: SUBSCRIPTION_PLATFORM_ALL,
		PlatformId: '',
		PlatformPurchaseId: '',
		Level: SUBSCRIPTION_LEVEL_GOLD,
		Period: SUBSCRIPTION_PERIOD_YEAR,
		ExpirationDate: expires.toISOString(),
		IsAutoRenewing: true,
		CreatedAt: now.toISOString(),
		ModifiedAt: now.toISOString(),
	}
}

/**
 * Project a stored avatar into the public render subset returned by
 * `GET /api/avatar/v2/:id` — the fields needed to draw another player's avatar
 * (the full blob also holds `OutfitSelectionsV2`/`CustomAvatarItems`, which this
 * view omits).
 */
function toAvatarV2Dto(avatar: Avatar) {
	return {
		OutfitSelections: avatar.OutfitSelections,
		FaceFeatures: avatar.FaceFeatures,
		SkinColor: avatar.SkinColor,
		HairColor: avatar.HairColor,
	}
}

/**
 * The subset of a storefront catalog (`static/storefronts/sf{N}.json`) that `buyItem`
 * reads: each store item carries the `GiftDrop` describing what you get, a list of
 * `Prices` per currency, and optionally `SubscriberPrices` — the discounted list a Rec Room
 * Plus subscriber is shown and pays. The catalogs hold more fields (IsFeatured, …) that
 * the purchase path doesn't need.
 */
interface StoreGiftDrop {
	FriendlyName: string
	Tooltip: string
	ConsumableItemDesc: string
	AvatarItemDesc: string
	AvatarItemType: number | null
	EquipmentPrefabName: string
	EquipmentModificationGuid: string
	Rarity: number
	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
	/**
	 * XP the drop pays out. No storefront catalog sets it — a bought item is an item — but a
	 * game reward is XP in a gift box, so the box and its notification carry the amount from
	 * here. The XP itself is banked in `progression`, not read back off the box.
	 */
	Xp?: number
}
interface StorePrice {
	CurrencyType: number
	Price: number
}
interface StoreItem {
	GiftDrop: StoreGiftDrop
	Prices: StorePrice[]
	/**
	 * The subscriber price list, where the catalog has one (sf300's item 2263 lists 95 tokens
	 * in `Prices` and 85 in here). The client renders — and posts as `RequestedPrice` — the
	 * subscriber price to a subscriber, so the price check has to read the same list or a
	 * subscriber's every buy 409s as "Price has changed".
	 */
	SubscriberPrices?: StorePrice[] | null
	PurchasableItemId: number
}

/**
 * The Rec Room Plus discount, in percent off the regular price, floored to whole tokens.
 *
 * The client applies this ITSELF: a subscriber's client posts `floor(Price * 0.9)` as
 * `RequestedPrice` (95 → 85, 75 → 67, 30 → 27) whatever the catalog says — the captured
 * catalogs carry a `SubscriberPrices` list, but for 1238 of 1382 items it is a verbatim copy
 * of `Prices` (they were captured through a non-subscriber's view), and every entry that does
 * differ is exactly this formula. So the catalog list can't be the source of truth for the
 * check; the formula is, and the list is only honoured where it agrees with it (i.e. is lower).
 */
const SUBSCRIBER_DISCOUNT_PERCENT = 10

/**
 * The price of an item in one currency for one buyer. A non-subscriber pays the `Prices`
 * entry. A subscriber pays the `SubscriberPrices` entry when the catalog has a genuinely
 * discounted one for that currency, else {@link SUBSCRIBER_DISCOUNT_PERCENT} off the regular
 * price — what their client rendered and posted. `undefined` when the item isn't sold in
 * that currency at all.
 */
function priceFor(
	item: StoreItem,
	currencyType: number,
	subscriber: boolean
): StorePrice | undefined {
	const regular = item.Prices.find((p) => p.CurrencyType === currencyType)
	if (regular === undefined || !subscriber) return regular
	const listed = item.SubscriberPrices?.find((p) => p.CurrencyType === currencyType)
	if (listed !== undefined && listed.Price < regular.Price) return listed
	return {
		CurrencyType: currencyType,
		Price: Math.floor((regular.Price * (100 - SUBSCRIBER_DISCOUNT_PERCENT)) / 100),
	}
}
interface Storefront {
	StoreItems: StoreItem[]
}

/** The `Gift` block of a buyItem body — present when buying an item for another player. */
interface GiftRequest {
	ToPlayerId?: number
	Anonymous?: boolean
	Message?: string
	GiftContext?: number
}

/**
 * Read a storefront catalog (`sf{type}.json`) from the ASSETS binding. Null when there is
 * no such storefront.
 *
 * Separate from {@link findStoreItem} so a caller resolving SEVERAL items from one
 * storefront reads (and parses) it once: sf3 alone is over a thousand items, and a bulk
 * purchase carries up to `BULK_PURCHASE_CAP` lines.
 */
async function loadStorefront(c: Context<App>, storefrontType: number): Promise<Storefront | null> {
	const res = await c.env.ASSETS.fetch(new URL(`/sf${storefrontType}.json`, c.req.url))
	if (!res.ok) return null
	return (await res.json()) as Storefront
}

/**
 * Look up a store item by (storefront type, purchasable item id), reading the catalog
 * from the ASSETS binding (`sf{type}.json`). Returns null when there is no such
 * storefront or no item with that id in it.
 */
async function findStoreItem(
	c: Context<App>,
	storefrontType: number,
	purchasableItemId: number
): Promise<StoreItem | null> {
	const storefront = await loadStorefront(c, storefrontType)
	if (storefront === null) return null
	return storefront.StoreItems.find((it) => it.PurchasableItemId === purchasableItemId) ?? null
}

/** Build the owned avatar-item DTO granted into the buyer's inventory from a gift-drop. */
function toAvatarItem(giftDrop: StoreGiftDrop): AvatarItem {
	return {
		AvatarItemType: giftDrop.AvatarItemType,
		AvatarItemDesc: giftDrop.AvatarItemDesc,
		PlatformMask: -1,
		FriendlyName: giftDrop.FriendlyName,
		Tooltip: giftDrop.Tooltip,
		Rarity: giftDrop.Rarity,
	}
}

/** Build the owned equipment DTO granted into the buyer's inventory from a gift-drop. */
function toEquipment(giftDrop: StoreGiftDrop): Equipment {
	return {
		ModificationGuid: giftDrop.EquipmentModificationGuid,
		PrefabName: giftDrop.EquipmentPrefabName,
		FriendlyName: giftDrop.FriendlyName,
		Tooltip: giftDrop.Tooltip,
		Rarity: giftDrop.Rarity,
		PlatformMask: -1,
		Favorited: false,
	}
}

/** Quantity of a consumable granted per purchase — our storefront catalogs don't specify one. */
const CONSUMABLE_GRANT_COUNT = 1

/** The "Coach" system account — the sender a self-buy or anonymous gift is attributed to. */
const COACH_ACCOUNT_ID = 1

/** Build the stored gift-box content (the client's rendered "gift box") from a gift-drop. */
function toGiftContent(
	giftDrop: StoreGiftDrop,
	message: string,
	consumableCount: number,
	consumableMappingId = 0,
	consumablePreExistingCount = 0
): GiftContent {
	return {
		ConsumableItemDesc: giftDrop.ConsumableItemDesc,
		ConsumableCount: consumableCount,
		ConsumableMappingId: consumableMappingId,
		ConsumablePreExistingCount: consumablePreExistingCount,
		AvatarItemDesc: giftDrop.AvatarItemDesc,
		AvatarItemType: giftDrop.AvatarItemType,
		CurrencyType: giftDrop.CurrencyType,
		Currency: giftDrop.Currency,
		Xp: giftDrop.Xp ?? 0,
		PackageType: 0,
		Message: message,
		EquipmentPrefabName: giftDrop.EquipmentPrefabName,
		EquipmentModificationGuid: giftDrop.EquipmentModificationGuid,
		GiftRarity: giftDrop.Rarity,
		Platform: -1,
		PlatformsToSpawnOn: -1,
		BalanceType: null,
	}
}

/**
 * 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` is the drop's, so a game reward's box announces the XP it
 * paid; `Level` is 0, since nothing levels a player up 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: gift.drop.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),
		})
	}
}

/**
 * Push a PlayerProgressionLevelUpdate so the client's level bar moves when XP lands, instead
 * of waiting for its next progression read. `XP` is the progress into the current level (the
 * ladder spends the rest on the level-ups), which is what the bar draws against the
 * `LevelProgressionMaps` the client is served.
 *
 * Best-effort: the XP is already banked, so a hub failure costs a bar animation, not the
 * reward.
 */
async function pushProgressionUpdate(
	c: Context<App>,
	accountId: number,
	progression: Progression
): Promise<void> {
	try {
		await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
			accountId,
			NotificationType.PlayerProgressionLevelUpdate,
			{ PlayerId: progression.PlayerId, Level: progression.Level, XP: progression.XP }
		)
	} catch (err) {
		logger.error('failed to push PlayerProgressionLevelUpdate notification', {
			accountId,
			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 storefront = await loadStorefront(c, ROLL_STOREFRONT_TYPE)
	return storefront?.StoreItems ?? []
}

/**
 * The equipment a weekly challenge gift can be drawn from: every roll-catalog item carrying
 * an `EquipmentModificationGuid`. Weekly rewards are equipment — the captured rotation's is a
 * camera skin — and in sf3 that guid is exactly what marks an item as equipment (187 of its
 * 1161, all with a prefab, none with an avatar item or consumable attached).
 *
 * `GiftDropId` comes off `PurchasableItemId`, which every sf3 equipment entry agrees with.
 */
function toEquipmentGiftPool(catalog: StoreItem[]): EquipmentGift[] {
	return catalog
		.filter((item) => item.GiftDrop.EquipmentModificationGuid !== '')
		.map((item) => ({
			GiftDropId: item.PurchasableItemId,
			EquipmentPrefabName: item.GiftDrop.EquipmentPrefabName,
			EquipmentModificationGuid: item.GiftDrop.EquipmentModificationGuid,
			Rarity: item.GiftDrop.Rarity,
			// Carried so the rotation can theme the week on the item it rolled; the grant path
			// resolves the same name from this entry when it hands the item over.
			FriendlyName: item.GiftDrop.FriendlyName,
		}))
}

/**
 * The same pool, memoised for the life of the isolate. `getCurrent` needs it on every call
 * just to show the week's reward, and sf3 is a megabyte and a half of JSON to fetch and parse
 * — but it is a bundled asset, so it cannot change under a running isolate and a deploy
 * builds new ones. A failed read is deliberately NOT cached: it would pin an empty pool (and
 * so the static fallback gift) until the next deploy.
 */
let cachedGiftPool: EquipmentGift[] | null = null
async function loadEquipmentGiftPool(c: Context<App>): Promise<EquipmentGift[]> {
	if (cachedGiftPool !== null) return cachedGiftPool
	const pool = toEquipmentGiftPool(await loadRollCatalog(c))
	if (pool.length > 0) cachedGiftPool = pool
	return pool
}

/**
 * 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
}

/** How a query drop is rolled — what it may land on, and whose catalog copy to use. */
interface RollOptions {
	/**
	 * Restrict the roll to avatar items, leaving equipment skins out of the pool. Off by
	 * default: a bought box says "a random item", and the catalog's own boxes mean both.
	 */
	avatarItemsOnly?: boolean
	/**
	 * The roll catalog, when the caller has already read it — it's the big one (sf3), and a
	 * caller granting several boxes at once shouldn't re-read it per box.
	 */
	rollCatalog?: StoreItem[]
}

/**
 * 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.
 *
 * `avatarItemsOnly` narrows it further to things worn on the avatar, leaving equipment
 * skins out — a level-up prize should be something the player can see on themselves, not a
 * skin for a weapon they may not own. It also skips the equipment read entirely, since
 * nothing in the pool can match it.
 */
async function rollQueryDrop(
	c: Context<App>,
	accountId: number,
	rarity: number,
	options: RollOptions = {}
): Promise<StoreGiftDrop | null> {
	const [catalog, ownedItems, ownedEquipment] = await Promise.all([
		options.rollCatalog ?? loadRollCatalog(c),
		getInventory(c.env.DB, accountId),
		options.avatarItemsOnly === true ? [] : 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 (options.avatarItemsOnly === true) return false
		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.
 *
 * `id` is 0 when no box was created (`skipGiftBox`) — the drop was still granted.
 */
interface GrantedGift {
	id: number
	drop: StoreGiftDrop
}

/** How a drop is handed over: how a query one rolls, plus how it is wrapped. */
interface GrantOptions extends RollOptions {
	/**
	 * How many of the drop to hand over in ONE box — a bulk line's `DuplicateItemCount`.
	 * Only a consumable stacks, so this multiplies the consumable count and nothing else;
	 * callers must refuse a count above 1 for anything owned once. Defaults to 1.
	 */
	copies?: number
	/**
	 * Grant the drop without creating the gift box that renders it — what a bulk purchase's
	 * `BypassGiftPackages` asks for. Ownership never depended on the box (it is granted here,
	 * not when the box is opened), so this only skips the "open it" moment; a caller setting
	 * it is saying its own UI announces the items.
	 */
	skipGiftBox?: boolean
}

/**
 * Pick a random consumable from the roll catalog — the reward the published level table
 * hands out for the early levels.
 *
 * Unlike a clothing roll this one has no rarity and no ownership filter: the table names no
 * star tier for a consumable, and consumables STACK, so "one you don't have" is meaningless
 * (a second Confetti Cannon is a fine prize). Returns a concrete drop rather than a query
 * one, so the grant path just grants it.
 */
function rollConsumableDrop(catalog: StoreItem[]): StoreGiftDrop | null {
	const pool = catalog.filter(
		({ GiftDrop: drop }) =>
			drop.IsQuery !== true &&
			typeof drop.ConsumableItemDesc === 'string' &&
			drop.ConsumableItemDesc !== ''
	)
	return pool[Math.floor(Math.random() * pool.length)]?.GiftDrop ?? null
}

/**
 * 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,
	options: GrantOptions = {}
): Promise<GrantedGift> {
	let giftDrop = drop
	if (drop.IsQuery === true) {
		const rarity = drop.QueryRedirectRarity ?? drop.Rarity
		const rolled = await rollQueryDrop(c, accountId, rarity, options)
		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 * (options.copies ?? 1) : 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
		)
	}
	if (options.skipGiftBox === true) return { id: 0, drop: giftDrop }
	const { id } = await createGift(
		db,
		accountId,
		toGiftContent(giftDrop, message, consumableCount, consumableMappingId, consumablePreExisting)
	)
	return { id, drop: giftDrop }
}

/**
 * One `BalanceUpdates[].Data` entry: the gift-drop a player RECEIVED, as both purchase
 * endpoints report it. `granted.drop` is the resolved drop — the rolled prize for a query
 * box, not the box that promised it — or a query purchase answers with every item field
 * empty and the client draws an empty box.
 *
 * It carries no FriendlyName or consumable count (the count is a getUnlocked concept; each
 * box is one instance). `giftContext` is the requesting `Gift` block's, when it named one;
 * otherwise the drop's own.
 */
function toBalanceUpdateData(
	granted: GrantedGift,
	fromPlayerId: number,
	message: string,
	giftContext: number | null
): Record<string, unknown> {
	const drop = granted.drop
	return {
		Id: granted.id,
		FromPlayerId: fromPlayerId,
		ConsumableItemDesc: drop.ConsumableItemDesc,
		AvatarItemDesc: drop.AvatarItemDesc,
		AvatarItemType: drop.AvatarItemType ?? 0,
		EquipmentPrefabName: drop.EquipmentPrefabName,
		EquipmentModificationGuid: drop.EquipmentModificationGuid,
		CurrencyType: drop.CurrencyType,
		Currency: drop.Currency,
		Xp: drop.Xp ?? 0,
		Level: 0,
		Platform: -1,
		PlatformsToSpawnOn: -1,
		BalanceType: ALL_PLATFORMS,
		GiftContext: giftContext ?? drop.Context,
		GiftRarity: drop.Rarity,
		Message: message,
	}
}

/**
 * `Econ.BulkPurchaseCap` — the most copies one bulk purchase may carry. The client reads
 * the same 200 out of its game config (apps/api/static/gameconfigs-v1-all.json) and caps
 * the bag with it, so this is the server side of a limit the client already knows; a
 * request over it is a client that ignored its own config, not a bigger shopping trip.
 */
const BULK_PURCHASE_CAP = 200

/**
 * `UpdateResponse` — the outcome of ONE `BalanceUpdates` entry, from the client's own enum.
 * This is where a bulk purchase reports per line: the bag answers one entry per REQUESTED
 * item, and `AllowPartialSuccess` is what lets some of them come back non-OK while the
 * envelope's `Success` stays true. (buyItem's single `UpdateResponse: 0` is this same OK.)
 *
 * The members this server can produce are the ones a catalog purchase can fail on;
 * `TooManyRequests`, `PlayerNotEligible`, `RequestCannotBeRefunded` and `PlayerNotApproved`
 * belong to rate limiting, entitlements and refunds, none of which exist here. `AlreadyOwned`
 * is deliberately unused too: buyItem lets a player re-buy an item they own (the grant
 * upserts), and one purchase path refusing what the other allows would be worse than either.
 */
const UpdateResponse = {
	OK: 0,
	TooManyRequests: 1,
	NotEnoughCredit: 2,
	AlreadyOwned: 3,
	NoItemAvailable: 4,
	CouponNotApplicable: 5,
	RequestedPriceDoesNotMatch: 6,
	RequestedAmountNotAllowed: 7,
	PlayerNotEligible: 8,
	RequestCannotBeRefunded: 9,
	PlayerNotApproved: 10,
} as const

/** The discriminated item id a bulk-purchase line names its item by. */
interface PurchaseMethodId {
	Type: number
	NumberId: number | null
	Guid: string | null
}

/** One line of a `POST /api/items/bulkpurchase` body. */
interface PurchaseItemRequest {
	ItemPurchaseMethodId?: Partial<PurchaseMethodId> | null
	RequestedPrice?: number
	Gift?: GiftRequest | null
	CouponConsumablePlayerMappingId?: number | null
	DuplicateItemCount?: number
}

/**
 * A line that could not be bought: the `UpdateResponse` its entry carries, and the message
 * that fills the envelope's single `Error` when the bag as a whole is refused.
 */
interface BulkLineFailure {
	method: PurchaseMethodId
	code: number
	error: string
}

/** A line that resolved to something buyable, with the catalog's own price. */
interface BulkPurchaseLine {
	method: PurchaseMethodId
	item: StoreItem
	/** The UNIT price from the catalog — `count` copies cost `price * count`. */
	price: number
	count: number
	gift: GiftRequest | null
}

/**
 * One `BalanceUpdates[].Data` — what a single requested item turned into. Unlike buyItem's,
 * it NAMES the purchase rather than describing the drop: the client already has the catalog
 * entry for `PurchasableItemId`, so the only thing it can't reconstruct is the box.
 *
 * `CustomAvatarItem` is the UGC counterpart of `PurchasableItemId`, and both it and
 * `GiftPackage` are null on a line that didn't sell. Nothing here fills `CustomAvatarItem`:
 * no catalog we serve sells guid-keyed items.
 */
interface BulkPurchaseData {
	GiftPackage: Record<string, unknown> | null
	PurchasableItemId: number | null
	CustomAvatarItem: null
}

/** `ItemPurchaseMethodId.Type` for a numeric (storefront `PurchasableItemId`) id. */
const PURCHASE_METHOD_NUMBER_ID = 0

/**
 * The gift box as `GiftPackage` carries it — the same DTO family as buyItem's
 * `BalanceUpdates[].Data` entry, but with the four keys that shape doesn't carry
 * (`PlayerId`, `CustomAvatarItemId`, `Signature`, `IsSignatureValid`) and without its
 * `Level`. Twenty keys, in the order the client's own member list names them.
 *
 * `Platform`/`PlatformsToSpawnOn` are the platform MASK (-1, all) — the balance bucket is
 * the separate `BalanceType` beside them, unlike the envelope's `Value.Platform`, which IS
 * a renamed `BalanceType`. `Signature` is null and `IsSignatureValid` false: a box the
 * server minted was never signed for peer-to-peer transfer.
 */
function toGiftPackage(
	granted: GrantedGift,
	playerId: number,
	fromPlayerId: number,
	message: string,
	giftContext: number | null
): Record<string, unknown> {
	const drop = granted.drop
	return {
		Id: granted.id,
		PlayerId: playerId,
		FromPlayerId: fromPlayerId,
		ConsumableItemDesc: drop.ConsumableItemDesc,
		AvatarItemType: drop.AvatarItemType ?? 0,
		AvatarItemDesc: drop.AvatarItemDesc,
		CustomAvatarItemId: null,
		EquipmentPrefabName: drop.EquipmentPrefabName,
		EquipmentModificationGuid: drop.EquipmentModificationGuid,
		CurrencyType: drop.CurrencyType,
		Currency: drop.Currency,
		Xp: drop.Xp ?? 0,
		GiftContext: giftContext ?? drop.Context,
		GiftRarity: drop.Rarity,
		Message: message,
		Signature: null,
		IsSignatureValid: false,
		Platform: -1,
		PlatformsToSpawnOn: -1,
		BalanceType: ALL_PLATFORMS,
	}
}

/**
 * Normalise the id a line named its item by. A line that sent no id at all still resolves
 * to something, so this never returns null — the checks in {@link resolveBulkLine} are what
 * reject it.
 */
function toPurchaseMethodId(raw: Partial<PurchaseMethodId> | null | undefined): PurchaseMethodId {
	const id = typeof raw === 'object' && raw !== null ? raw : {}
	return {
		Type: Number.isInteger(id.Type) ? (id.Type as number) : PURCHASE_METHOD_NUMBER_ID,
		NumberId: Number.isInteger(id.NumberId) ? (id.NumberId as number) : null,
		Guid: typeof id.Guid === 'string' ? id.Guid : null,
	}
}

/**
 * Resolve one line against the bag's catalog: what it wants, how many, and at what price.
 * Returns the failure — with the `UpdateResponse` its entry will carry — instead when the
 * line can't be bought.
 *
 * Pure — the catalog and the buyer's subscriber status are passed in — so the whole bag
 * resolves from ONE storefront read and ONE token read. The price check is buyItem's, per
 * line: `RequestedPrice` is the UNIT price the client rendered (the subscriber price, for a
 * subscriber — see {@link priceFor}), and a mismatch means the catalog moved under a stale
 * client rather than that the player agreed to today's price.
 */
function resolveBulkLine(
	line: PurchaseItemRequest,
	storefront: Storefront | null,
	currencyType: number,
	subscriber: boolean
): BulkPurchaseLine | BulkLineFailure {
	const method = toPurchaseMethodId(line.ItemPurchaseMethodId)
	// Guid-keyed ids name UGC / custom avatar items, which no catalog here sells. Failing the
	// line (rather than the request) is what lets a bag of ordinary items still go through.
	if (method.Type !== PURCHASE_METHOD_NUMBER_ID || method.NumberId === null) {
		return {
			method,
			code: UpdateResponse.NoItemAvailable,
			error: 'Only numeric storefront item ids can be bought',
		}
	}
	// Nothing issues coupons, so a line claiming one would otherwise be charged full price
	// for a discount it thinks it applied.
	if (
		line.CouponConsumablePlayerMappingId !== null &&
		line.CouponConsumablePlayerMappingId !== undefined
	) {
		return {
			method,
			code: UpdateResponse.CouponNotApplicable,
			error: 'Coupons are not supported',
		}
	}
	const count = line.DuplicateItemCount ?? 1
	if (!Number.isInteger(count) || count < 1) {
		return {
			method,
			code: UpdateResponse.RequestedAmountNotAllowed,
			error: 'DuplicateItemCount must be a positive integer',
		}
	}
	if (storefront === null) {
		return { method, code: UpdateResponse.NoItemAvailable, error: 'No such storefront' }
	}
	const item = storefront.StoreItems.find((it) => it.PurchasableItemId === method.NumberId)
	if (item === undefined) {
		return { method, code: UpdateResponse.NoItemAvailable, error: 'Item not found' }
	}
	// Only a consumable stacks. An avatar item or an equipment skin is owned once, so a
	// second copy would grant nothing while charging for it — and the bag answers ONE entry
	// (one box) per requested item, which is the same statement from the wire's side.
	if (count > 1 && item.GiftDrop.ConsumableItemDesc === '') {
		return {
			method,
			code: UpdateResponse.RequestedAmountNotAllowed,
			error: 'This item can only be bought once per line',
		}
	}
	const price = priceFor(item, currencyType, subscriber)
	if (price === undefined) {
		return {
			method,
			code: UpdateResponse.NoItemAvailable,
			error: 'Currency type not available for this item',
		}
	}
	if (!Number.isInteger(line.RequestedPrice)) {
		return {
			method,
			code: UpdateResponse.RequestedPriceDoesNotMatch,
			error: 'RequestedPrice is required',
		}
	}
	if (line.RequestedPrice !== price.Price) {
		return {
			method,
			code: UpdateResponse.RequestedPriceDoesNotMatch,
			error: 'Price has changed',
		}
	}
	const gift = typeof line.Gift === 'object' && line.Gift !== null ? line.Gift : null
	return { method, item, price: price.Price, count, gift }
}

/** Whether a resolved line is buyable or is already a failure. */
function isBulkLine(resolved: BulkPurchaseLine | BulkLineFailure): resolved is BulkPurchaseLine {
	return 'item' in resolved
}

/**
 * XP paid for a claimed game reward. One flat amount for every reward type, matching the
 * one flat cooldown they share — "First Game of the Day" and "Activity completed!" are the
 * same size of pat on the back until there's reason to price them apart.
 *
 * Deliberately smaller than the 10 XP the first level costs: a single action shouldn't be a
 * level-up, let alone two of them. At 5 it takes two rewards to reach level 2, and the early
 * levels are paced by the hourly cooldown rather than cleared in one match.
 */
const GAME_REWARD_XP = 5

/**
 * `GiftContext.GameRewards` — what the box says it came from, so the client files it under
 * gameplay rewards rather than a purchase or a player's gift. (`51` is the tokens variant,
 * for when a reward pays currency instead of XP.)
 */
const GIFT_CONTEXT_GAME_REWARDS = 50

/** Shown on the box when the client asks for a reward without saying what to call it. */
const DEFAULT_GAME_REWARD_MESSAGE = 'Reward earned!'

/**
 * The gift-drop a claimed game reward hands over: XP in a box, no item. Every item field is
 * empty on purpose — this is not a purchase and not a roll, so `grantGiftDrop` grants
 * nothing into the inventory and only creates the box. The XP is banked in `progression`;
 * the copy here is what the box and its notification display.
 */
function toGameRewardDrop(): StoreGiftDrop {
	return {
		FriendlyName: '',
		Tooltip: '',
		ConsumableItemDesc: '',
		AvatarItemDesc: '',
		AvatarItemType: null,
		EquipmentPrefabName: '',
		EquipmentModificationGuid: '',
		Rarity: 0,
		Context: GIFT_CONTEXT_GAME_REWARDS,
		Currency: 0,
		CurrencyType: 0,
		Xp: GAME_REWARD_XP,
	}
}

/**
 * The box a CLOTHING level-up hands over: a query drop at the level's own tier, rolled from
 * AVATAR ITEMS only. The published table calls these levels "N-Star Clothing", so the prize
 * has to be something the player can wear and be seen in — never an equipment skin for a
 * weapon they may not own. This is the one roll that narrows the pool that far.
 */
function toLevelUpDrop(rarity: number): StoreGiftDrop {
	return {
		FriendlyName: '',
		Tooltip: '',
		ConsumableItemDesc: '',
		AvatarItemDesc: '',
		AvatarItemType: null,
		EquipmentPrefabName: '',
		EquipmentModificationGuid: '',
		Rarity: rarity,
		Context: GIFT_CONTEXT_GAME_REWARDS,
		Currency: 0,
		CurrencyType: 0,
		IsQuery: true,
	}
}

/**
 * Hand over the rewards a run of level-ups earned — ONE PER LEVEL crossed, since the
 * published table names a reward for every level and a single grant can cross several (a
 * large enough grant could clear the first three levels at 10 XP each). Each arrives as a
 * gift box, announced like any other unasked-for gift.
 *
 * Which reward is per level, not per tier: the early levels pay CONSUMABLES and the rest pay
 * clothing at a rising star rating. The catalog is read once and shared across the boxes.
 * Best-effort as a whole: the XP is banked and the levels are already stored, so a failed
 * roll costs a prize, not the level.
 */
async function grantLevelUpGifts(
	c: Context<App>,
	accountId: number,
	grant: XpGrant
): Promise<void> {
	const levels = levelsReached(grant)
	if (levels.length === 0) return
	try {
		const rollCatalog = await loadRollCatalog(c)
		for (const level of levels) {
			const reward = levelReward(level)
			if (reward === null) continue
			const message = `Level ${level}!`
			// A consumable is rolled to a concrete drop up front; clothing rides the query path,
			// which rolls it against what the player already owns.
			const drop =
				reward.kind === 'consumable'
					? rollConsumableDrop(rollCatalog)
					: toLevelUpDrop(reward.rarity)
			if (drop === null) {
				logger.warn('level up reward rolled nothing', { accountId, level, kind: reward.kind })
				continue
			}
			const granted = await grantGiftDrop(c, accountId, drop, message, {
				avatarItemsOnly: reward.kind === 'clothing',
				rollCatalog,
			})
			await pushGiftReceived(c, accountId, granted, message, COACH_ACCOUNT_ID)
			logger.info('level up gift granted', {
				accountId,
				level,
				kind: reward.kind,
				rarity: reward.kind === 'clothing' ? reward.rarity : null,
				giftId: granted.id,
				avatarItemDesc: granted.drop.AvatarItemDesc,
				consumableItemDesc: granted.drop.ConsumableItemDesc,
			})
		}
	} catch (err) {
		logger.error('failed to grant level up gift', {
			accountId,
			levels,
			error: err instanceof Error ? err.message : String(err),
		})
	}
}

/** 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(rotation: WeeklyChallengeRotation): number {
	const stars = Number(/^(\d+)-star/i.exec(rotation.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(
	rotation: WeeklyChallengeRotation,
	catalog: StoreItem[]
): StoreGiftDrop {
	const gift: ChallengeGiftBlock = rotation.Gift
	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 ?? rotation.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(rotation: WeeklyChallengeRotation): StoreGiftDrop {
	return {
		FriendlyName: rotation.FallbackGiftName,
		Tooltip: '',
		ConsumableItemDesc: '',
		AvatarItemDesc: '',
		AvatarItemType: null,
		EquipmentPrefabName: '',
		EquipmentModificationGuid: '',
		Rarity: fallbackGiftRarity(rotation),
		Context: rotation.Gift.GiftContext,
		Currency: 0,
		CurrencyType: 0,
		IsQuery: true,
	}
}

/**
 * How many of a rotation's challenges earn its gift. A week presents five and asks for
 * three: the reward is for playing most of the week's set, not for clearing all of it, so
 * the two a player can't reach (a quest they don't own, a mode they don't like) don't sink
 * the whole week.
 */
const CHALLENGES_REQUIRED_FOR_GIFT = 3

/**
 * How many completions this rotation's gift needs. `CompletedRequired` makes the set
 * all-or-nothing when it's true — the reading its name and the partial default suggest —
 * and a rotation shorter than the threshold can only ever ask for what it publishes.
 */
function challengesRequiredForGift(rotation: WeeklyChallengeRotation): number {
	const published = rotation.Challenges.length
	return rotation.CompletedRequired ? published : Math.min(CHALLENGES_REQUIRED_FOR_GIFT, published)
}

/**
 * Award the rotation's `Gift` if this player has just earned it, 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.
 *
 * Earning it takes {@link challengesRequiredForGift} of the rotation's challenges, counted
 * from `challenge_status`. Only challenges the rotation still publishes count: a report can
 * carry an id this week's set no longer lists (an edited rotation under a live client), and
 * three of those shouldn't buy a gift the player never worked for.
 *
 * 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 earns nothing: its threshold clamps to zero, which every player
 * would otherwise meet without playing.
 */
async function awardChallengeGift(c: Context<App>, accountId: number): Promise<void> {
	const rotation = buildRotation(new Date())
	try {
		if (rotation.Challenges.length === 0) return
		const statuses = await getChallengeStatuses(c.env.DB, accountId, rotation.ChallengeMapId)
		const done = rotation.Challenges.filter(
			(ch) => statuses.get(ch.ChallengeId)?.complete === true
		).length
		if (done < challengesRequiredForGift(rotation)) return
		// Claim first: this is what stops the next report paying out a second time.
		const claimed = await claimChallengeGift(c.env.DB, accountId, rotation.ChallengeMapId)
		if (!claimed) return
		// Only now is the catalog worth reading: it names the week's reward and is what the
		// grant path rolls a duplicate's replacement from.
		const catalog = await loadRollCatalog(c)
		const week = withWeeklyGift(rotation, toEquipmentGiftPool(catalog))
		const reward = toChallengeGiftDrop(week, catalog)
		const duplicate = await ownsGiftDrop(c.env.DB, accountId, reward)
		const granted = await grantGiftDrop(
			c,
			accountId,
			duplicate ? toChallengeFallbackDrop(week) : reward,
			CHALLENGE_GIFT_MESSAGE,
			{ rollCatalog: 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: rotation.ChallengeMapId,
			giftId: granted.id,
			fallbackRoll: duplicate,
			challengesComplete: done,
		})
	} catch (err) {
		logger.error('failed to grant weekly challenge gift', {
			accountId,
			challengeMapId: rotation.ChallengeMapId,
			error: err instanceof Error ? err.message : String(err),
		})
	}
}

/**
 * The default NUX checklist for a brand-new account. `Objective` is an `ObjectiveType`
 * ordinal (from the client's `ProgressionManager`) that the client matches its own
 * progress events against — the names below are what those ordinals mean.
 */
const DEFAULT_CHECKLIST = [
	{ Order: 0, Objective: 38, Count: 1, CreditAmount: 25 }, // SaveOutfitSlot
	{ Order: 1, Objective: 32, Count: 1, CreditAmount: 25 }, // VisitACustomRoom
	{ Order: 2, Objective: 2, Count: 1, CreditAmount: 25 }, // AddAFriend
	{ Order: 3, Objective: 30, Count: 1, CreditAmount: 25 }, // GoToRecCenter
	{ Order: 4, Objective: 6, Count: 1, CreditAmount: 25 }, // CheerAPlayer
]

/** The `UpdateResponse` context a checklist reward is reported under. */
const CHECKLIST_REWARD_CONTEXT = 303

/**
 * 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
 * requirement + a 401 response.
 */
function listRoute(summary: string, description: string, auth = false) {
	return describeRoute({
		tags: ['Econ'],
		summary,
		description,
		...(auth ? { security: AUTHED } : {}),
		responses: {
			200: json(JsonArray, description),
			...(auth ? { 401: UNAUTHORIZED_RESPONSE } : {}),
		},
	})
}

// strict: false so trailing-slash routes (e.g. `/gifts/consume/`, which the client
// posts with a trailing slash) match either form. Mirrors the `api` worker.
const app = new Hono<App>({ strict: false })
	.use(
		'*',
		// middleware
		(c, next) =>
			useWorkersLogger(c.env.NAME, {
				environment: c.env.ENVIRONMENT,
				release: c.env.SENTRY_RELEASE,
			})(c, next)
	)

	.onError(withOnError())
	.notFound(withNotFound())

	// Default-unlocked avatar items, served from the bundled static JSON.
	.get(
		'/api/avatar/v1/defaultunlocked',
		listRoute('Default-unlocked avatar items', 'The bundled default avatar-item catalog'),
		(c) => c.json(defaultAvatarItems)
	)

	// The base items UGC clothing is built on top of — served from bundled static JSON,
	// separate from the `defaultunlocked` catalog. No auth.
	.get(
		'/api/avatar/v1/defaultbaseavataritems',
		listRoute('Default base avatar items', 'The bundled base items UGC clothing builds on'),
		(c) => c.json(defaultBaseAvatarItems)
	)

	// The player's avatar items — the items they've bought (from `buyItem`, stored in
	// the inventory table) prepended to the default catalog. A player who has bought
	// nothing gets just the catalog.
	.get(
		'/api/avatar/v4/items',
		describeRoute({
			tags: ['Avatar'],
			summary: 'The player’s avatar items',
			description: [
				'The items the player has bought (from buyItem, in the inventory table) prepended',
				'to the default catalog. A player who has bought nothing gets just the catalog.',
				'Both sources are projected into the camelCase v4 DTO — the sibling item endpoints',
				'(`defaultunlocked`, `defaultbaseavataritems`) serve their records raw instead.',
			].join(' '),
			security: AUTHED,
			responses: {
				200: json(AvatarItemV4Dto.array(), 'Owned items followed by the default catalog'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			const owned = await getInventory(c.env.DB, id)
			return c.json([...owned, ...defaultAvatarItems].map(toAvatarItemV4))
		}
	)

	// The player's owned custom avatar items. [Authorize]; paginated. Empty stub for
	// now (no DB binding). The client downloads these when custom-item creation is
	// allowed; a 404 here surfaces as "Failed to download unlocked avatar items".
	.get(
		'/econ/customAvatarItems/v1/owned',
		describeRoute({
			tags: ['Avatar'],
			summary: 'Owned custom avatar items',
			description: [
				'Paginated owned custom items. Empty stub for now. The client requests this when',
				'custom-item creation is allowed; a 404 shows as “Failed to download unlocked',
				'avatar items”.',
			].join(' '),
			security: AUTHED,
			responses: {
				200: json(CustomAvatarItemsResponse, 'Paginated results (empty for now)'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			return c.json({ Results: [], TotalResults: 0 })
		}
	)

	// The player's objectives progress. Serves a static JSON file verbatim with
	// no auth — same default for everyone until there's a DB binding to track
	// per-player progress.
	.get(
		'/api/objectives/v1/myprogress',
		describeRoute({
			tags: ['Econ'],
			summary: 'Objectives progress',
			description:
				'Serves the bundled static progress verbatim (no per-player store yet). No auth.',
			responses: { 200: json(JsonObject, 'The bundled objectives-progress default') },
		}),
		(c) => c.json(myProgress)
	)

	// Clears a group of objectives. No per-player progress to clear yet, so this
	// is a no-op that returns an empty array (a 404 here breaks the client). Accepts
	// GET or POST since the client may use either.
	.on(
		['GET', 'POST'],
		'/api/objectives/v1/cleargroup',
		describeRoute({
			tags: ['Econ'],
			summary: 'Clear an objectives group (no-op)',
			description: 'No per-player progress to clear yet → []. Accepts GET or POST.',
			responses: { 200: json(JsonArray, 'Always empty for now') },
		}),
		(c) => c.json([])
	)

	// Report one objective's progress. The client posts the whole objective as it now
	// sees it (Index/Group identify it within `myprogress`) and reads back the state of
	// the GROUP that objective belongs to — camelCase here, unlike the PascalCase body it
	// posted. Stubbed: with no objectives store yet we persist nothing, echo the group
	// back and never complete it, so the reward-claim flow isn't triggered. `clearedAt`
	// is the clear time, which for a group we didn't clear is just now.
	.post(
		'/api/objectives/v1/updateobjective',
		describeRoute({
			tags: ['Econ'],
			summary: 'Report objective progress',
			description: [
				'Stubbed: with no objectives store we persist nothing and never complete a group.',
				'Echoes `Group` back as camelCase `group` with `isCompleted: false` so the client',
				'gets a well-formed body.',
			].join(' '),
			requestBody: jsonBody(UpdateObjectiveRequest, 'The objective as the client now sees it'),
			responses: { 200: json(UpdateObjectiveResponse, 'The echoed group, never completed') },
		}),
		async (c) => {
			const body = await c.req
				.json<{ Group?: string | number }>()
				.catch(() => ({}) as Record<string, never>)
			return c.json({
				group: Number(body.Group) || 0,
				isCompleted: false,
				clearedAt: new Date().toISOString(),
			})
		}
	)

	// The player's avatar, stored as a JSON blob on their account row. Falls back
	// to the default outfit when they haven't saved one — the client's parser NREs
	// on an empty OutfitSelections (real RecNet never returns one).
	.get(
		'/api/avatar/v2',
		describeRoute({
			tags: ['Avatar'],
			summary: 'The player’s own avatar',
			description: [
				'The avatar JSON blob stored on the account row, or the default outfit when none is',
				'saved (the client NREs on an empty OutfitSelections).',
			].join(' '),
			security: AUTHED,
			responses: {
				200: json(JsonObject, 'The stored avatar blob (or the default)'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			return c.json((await getAvatar(c.env.DB, id)) ?? defaultAvatar)
		}
	)

	// Save the player's avatar. [Authorize]. Stores the posted JSON payload verbatim
	// on the account row and echoes it back. 400 on a non-object body; 404 when the
	// caller has no account row to attach it to.
	.post(
		'/api/avatar/v2/set',
		describeRoute({
			tags: ['Avatar'],
			summary: 'Save the player’s avatar',
			description: 'Stores the posted JSON blob verbatim on the account row and echoes it back.',
			security: AUTHED,
			requestBody: jsonBody(OpaqueJsonBody, 'The avatar blob'),
			responses: {
				200: json(JsonObject, 'The saved avatar (echoed back)'),
				400: { description: 'Body was not a JSON object (empty body)' },
				401: UNAUTHORIZED_RESPONSE,
				404: { description: 'No account row to attach it to (empty body)' },
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			const avatar = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
			if (avatar === null || typeof avatar !== 'object' || Array.isArray(avatar)) {
				return c.body(null, 400)
			}
			if (!(await setAvatar(c.env.DB, id, avatar))) return c.body(null, 404)
			return c.json(avatar)
		}
	)

	// NUX checklist — the client fetches this on the econ host during load, on either
	// version path. A 404 here can abort the load orchestration before matchmake. We
	// serve the default brand-new-account list to everyone: nothing records per-player
	// checklist progress yet, so it never shrinks as steps are done.
	.on(
		'GET',
		['/api/checklist/v1/current', '/api/checklist/v2/current'],
		describeRoute({
			tags: ['Econ'],
			summary: 'NUX checklist',
			description:
				'The new-user checklist, as the default brand-new-account list — nothing records ' +
				'per-player progress yet, so the same rows come back however much the player has ' +
				'done. `Objective` is an `ObjectiveType` ordinal the client matches its own ' +
				'progress events against. v1 and v2 serve the same list.',
			security: AUTHED,
			responses: {
				200: json(ChecklistEntry.array(), 'The checklist rows, in `Order`'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			return c.json(DEFAULT_CHECKLIST)
		}
	)

	// Mark a checklist row done. [Authorize]. Stubbed: there is no objective-progress
	// table to record the completion in, and no reward ledger to make the 25-token grant
	// once-only — without one, re-posting the same row would mint tokens indefinitely, so
	// we grant nothing and report a change of 0. The envelope is still the balance-update
	// shape the client parses, so the flow completes instead of erroring.
	.on(
		'POST',
		['/api/checklist/v1/complete', '/api/checklist/v2/complete'],
		describeRoute({
			tags: ['Econ'],
			summary: 'Complete a checklist row (stub)',
			description:
				'Marks a NUX checklist row done. Stubbed: nothing records the completion (no ' +
				'objective-progress table) and nothing is granted — a reward is worth 25 XP and 25 ' +
				'tokens, but making that once-only needs a ledger we do not have, and without one ' +
				're-posting the same row would mint tokens indefinitely. The response is still the ' +
				'balance-update envelope, with `Balance` (the change) 0. v1 and v2 behave alike.',
			security: AUTHED,
			requestBody: jsonBody(CompleteChecklistRequest, 'Which row was completed — `{ ItemIndex }`'),
			responses: {
				200: json(ChecklistCompleteResponse, 'The balance-update envelope, granting nothing'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			// The body names the row (`{ ItemIndex: 1 }`, or `Id` as a fallback) — read only
			// once there is somewhere to record it.
			return c.json({
				BalanceUpdates: [{ UpdateResponse: CHECKLIST_REWARD_CONTEXT, Data: [] }],
				Balance: 0,
				CurrencyType: CurrencyType.RecCenterTokens,
				BalanceType: -2,
			})
		}
	)

	// The caller's item wishlist. [Authorize]; empty — nothing stores wishlists yet.
	.get(
		'/api/itemWishlists/v1/wishlist/me',
		listRoute('The player’s item wishlist', 'Empty for now', true),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			return c.json([])
		}
	)

	// Another player's item wishlist, by account id — what the client reads to show what
	// somebody else is hoping for (and to mark items in the store as already wished for).
	// Empty like `/me`: nothing stores wishlists, so there is nothing to show for anyone.
	//
	// Registered AFTER `/me` so that path stays its own route rather than being read as an
	// account id — the pattern here is digits-only, so it could not swallow `me`, but the
	// order also says which is the special case.
	.get(
		'/api/itemWishlists/v1/wishlist/:accountId{[0-9]+}',
		describeRoute({
			tags: ['Econ'],
			summary: 'Another player’s item wishlist',
			description: [
				'The wishlist of the account named in the path, as a bare array. Empty for now —',
				'nothing on this server stores wishlists, so every player’s is empty, and an empty',
				'list is what the client renders as “nothing wished for” where a 404 would read as a',
				'failed load.',
			].join(' '),
			security: AUTHED,
			parameters: [
				{
					name: 'accountId',
					in: 'path',
					required: true,
					description: 'The account whose wishlist to read',
					schema: { type: 'string', pattern: '^[0-9]+$' },
				},
			],
			responses: {
				200: json(JsonArray, 'That player’s wishlist — empty for now'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			return c.json([])
		}
	)

	// The player's saved outfits. [Authorize]. Served back as the client posted them
	// (see /saved/set); a player who has saved none gets [].
	.get(
		'/api/avatar/v3/saved',
		describeRoute({
			tags: ['Avatar'],
			summary: 'The player’s saved outfits',
			description: 'Served back as the client posted them (see /saved/set); [] when none.',
			security: AUTHED,
			responses: {
				200: json(JsonArray, 'Saved outfits (empty when none)'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			return c.json(await getOutfits(c.env.DB, id))
		}
	)

	// Save an outfit into one of the player's slots. [Authorize]. The posted `Slot` is
	// the slot to write, and re-saving a slot overwrites it — that's the avatar screen's
	// "save over this outfit". The payload is stored verbatim and echoed back: its inner
	// fields (OutfitSelectionsV2, FaceFeatures, …) are JSON-in-a-string from the client's
	// own serializer, so re-encoding them risks handing back something it can't parse.
	//
	// A missing/non-integer `Slot` is a 400 rather than a default slot — guessing would
	// silently overwrite an outfit the player didn't mean to touch.
	//
	// v3 and v4 share this handler: newer clients POST to /v4/saved/set with the same
	// payload shape (Slot, PreviewImageName, OutfitSelections(V2), FaceFeatures, Skin/HairColor,
	// CustomAvatarItems) and expect the same slot-overwrite semantics, so they store into the
	// same outfit table and read back through /api/avatar/v3/saved.
	.post(
		'/api/avatar/v3/saved/set',
		describeRoute({
			tags: ['Avatar'],
			summary: 'Save an outfit into a slot',
			description: [
				'Writes the posted outfit into the given `Slot` (overwriting it) and echoes it back.',
				'The payload is stored verbatim — its inner fields are JSON-in-a-string from the',
				'client’s own serializer. A missing/non-integer `Slot` is a 400 (guessing would',
				'silently overwrite another outfit).',
			].join(' '),
			security: AUTHED,
			requestBody: jsonBody(SaveOutfitRequest, 'The outfit, with a target Slot'),
			responses: {
				200: json(JsonObject, 'The saved outfit (echoed back)'),
				400: { description: 'Non-object body or missing/non-integer Slot (empty body)' },
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const result = await persistPostedOutfit(c)
			if (result instanceof Response) return result
			return c.json(result)
		}
	)

	// v4 of the save-outfit route. Same payload, table and slot-overwrite semantics as v3
	// (see above) — newer clients moved to /v4/saved/set. The one difference is the response:
	// v4 answers a lean `{ Success, Slot }` acknowledgement rather than echoing the whole
	// outfit back. The outfit is read back through /api/avatar/v3/saved either way.
	.post(
		'/api/avatar/v4/saved/set',
		describeRoute({
			tags: ['Avatar'],
			summary: 'Save an outfit into a slot (v4)',
			description: [
				'Writes the posted outfit into the given `Slot` (overwriting it), same as',
				'`POST /api/avatar/v3/saved/set`, but answers a lean `{ Success, Slot }` ack instead',
				'of echoing the outfit. A missing/non-integer `Slot` is a 400.',
			].join(' '),
			security: AUTHED,
			requestBody: jsonBody(SaveOutfitRequest, 'The outfit, with a target Slot'),
			responses: {
				200: json(SaveOutfitV4Response, 'Save acknowledgement'),
				400: { description: 'Non-object body or missing/non-integer Slot (empty body)' },
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const result = await persistPostedOutfit(c)
			if (result instanceof Response) return result
			return c.json({ Success: true, Slot: result.Slot })
		}
	)

	// Pending avatar gifts for the player — the unopened gift boxes from their purchases
	// (and, once gifting lands, from other players). [Authorize]. The client opens each
	// box and consumes it via the consume route below; the item itself was already
	// granted at purchase, so an unopened box is cosmetic.
	.get(
		'/api/avatar/v2/gifts',
		describeRoute({
			tags: ['Gifts'],
			summary: 'Pending gift boxes',
			description: [
				'The player’s unopened gift boxes from their purchases (and, later, from other',
				'players). The item was already granted at purchase, so an unopened box is cosmetic.',
			].join(' '),
			security: AUTHED,
			responses: {
				200: json(JsonArray, 'Unopened gift boxes (empty when none)'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			return c.json(await getPendingGifts(c.env.DB, id))
		}
	)

	// Open (consume) a gift box. [Authorize]. The client posts this on the econ host after
	// the box animation, form-encoded as `Id=<giftId>&UnlockedLevel=<n>`. Opening just
	// deletes the box — the item was granted into the inventory at purchase, so there's
	// nothing to grant here — an avatar-item drop was granted into the inventory table and a
	// consumable drop into the consumable table, both at purchase. (`UnlockedLevel`, a
	// consumable-level hint, is unused.)
	//
	// Always answers 200 with the `{ error, success, value }` envelope — even with no token,
	// a zero id, or a box that is already gone. A captured real consume returns this envelope,
	// not an empty body: the client parses it to finish opening the box, so a bare 200 reads
	// as a failure and the consumable never finishes unlocking. The delete is scoped to the
	// caller's account, so an unauthenticated or mismatched call is simply a no-op. Mirrors
	// the same route on the `api` worker (the client may call either host).
	.post(
		'/api/avatar/v2/gifts/consume',
		describeRoute({
			tags: ['Gifts'],
			summary: 'Open (consume) a gift box',
			description: [
				'Deletes the box (the item was already granted at purchase). Always answers the',
				'`{ error, success, value }` envelope with HTTP 200 — even with no token, a zero id,',
				'or a box already gone — because the client parses it to finish opening the box. The',
				'delete is scoped to the caller; opening someone else’s box is 403. Also served by',
				'the `api` worker.',
			].join(' '),
			requestBody: form(ConsumeGiftRequest, 'The gift-box id'),
			responses: {
				200: json(ConsumeEnvelope, 'Success envelope'),
				403: { description: 'The box belongs to another player (empty body)' },
			},
		}),
		async (c) => {
			const id = await authedId(c)
			const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
			const giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0
			if (id !== null && giftId !== 0) {
				// Scoped delete: only the box's owner deletes it. A returned box means it was
				// theirs and is now consumed.
				const gift = await consumeGift(c.env.DB, id, giftId)
				if (gift !== null) {
					// If the box carried a consumable, tell the client it now has it (so it shows
					// up in inventory without a refetch). Avatar-item boxes carry no ConsumableItemDesc.
					if (gift.ConsumableItemDesc !== '') await pushConsumableAdded(c, id, gift)
				} else {
					// Nothing was consumed: either the box is already gone (a harmless no-op —
					// re-opening your own consumed box still succeeds) or it belongs to another
					// player, which is forbidden.
					const other = await getGift(c.env.DB, giftId)
					if (other !== null && other.accountId !== id) return c.body(null, 403)
				}
			}
			return c.json({ error: '', success: true, value: null })
		}
	)

	// A player's avatar by account id, projected to the public render subset (used
	// to draw other players' avatars). No auth — like the accounts `/account/:id`
	// lookup. Falls back to the default outfit when the player hasn't saved one.
	// Registered after the static `/api/avatar/v2/*` routes so `:id` can't shadow them.
	.get(
		'/api/avatar/v2/:id',
		describeRoute({
			tags: ['Avatar'],
			summary: 'Another player’s avatar (render subset)',
			description: [
				'The public render subset used to draw another player’s avatar. No auth. Falls back',
				'to the default outfit when the player hasn’t saved one.',
			].join(' '),
			parameters: [
				{
					name: 'id',
					in: 'path',
					required: true,
					description: 'Account id; non-numeric is 400',
					schema: { type: 'string' },
				},
			],
			responses: {
				200: json(AvatarV2Dto, 'The render subset'),
				400: { description: 'Non-numeric id (empty body)' },
			},
		}),
		async (c) => {
			const accountId = Number.parseInt(c.req.param('id'), 10)
			if (Number.isNaN(accountId)) return c.body(null, 400)
			return c.json(toAvatarV2Dto((await getAvatar(c.env.DB, accountId)) ?? defaultAvatar))
		}
	)

	// Unlocked equipment. [Authorize]. The equipment skins the player has bought (from
	// `buyItem`, stored in the `equipment` table). A player who has bought none gets an
	// empty list.
	.get(
		'/api/equipment/v2/getUnlocked',
		listRoute('Unlocked equipment', 'The equipment skins the player has bought', true),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			return c.json(await getEquipment(c.env.DB, id))
		}
	)

	// Favourite/un-favourite owned equipment. [Authorize]. The client sends the entries it
	// wants changed (one request can carry several) and reads nothing back. Only
	// `Favorited` is written — the rest of each entry is the client echoing what it was
	// served, and a guid the caller doesn't own matches no row and is dropped.
	//
	// PUT or POST: the client uses both spellings for this one call, with an identical body
	// either way, so they are the same route rather than two handlers. A 404 on the POST
	// leaves the star drawn on the item the client already redrew, and the favourite
	// silently doesn't stick.
	.on(
		['PUT', 'POST'],
		'/api/equipment/v1/update',
		describeRoute({
			tags: ['Equipment'],
			summary: 'Update owned equipment',
			description: [
				'Applies the posted `Favorited` flags to the caller’s owned equipment, matched by',
				'`ModificationGuid`. Everything else in each entry is ignored, and a guid the caller',
				'doesn’t own is silently skipped. Empty body on success. Accepts PUT or POST — the',
				'client uses both, with the same body.',
			].join(' '),
			security: AUTHED,
			requestBody: jsonBody(EquipmentUpdateRequest, 'The entries to update'),
			responses: {
				200: { description: 'Applied (empty body)' },
				400: { description: 'Body isn’t a JSON array (empty body)' },
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			const body = (await c.req.json().catch(() => null)) as unknown
			if (!Array.isArray(body)) return c.body(null, 400)
			const updates = body
				.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null)
				.filter((e) => typeof e.ModificationGuid === 'string' && e.ModificationGuid !== '')
				.map((e) => ({
					ModificationGuid: e.ModificationGuid as string,
					Favorited: e.Favorited === true,
				}))
			await setEquipmentFavorited(c.env.DB, id, updates)
			return c.body(null, 200)
		}
	)

	// Room consumables/currencies for a given room. Stubbed as empty lists so the
	// client doesn't 404.
	.get(
		'/api/roomconsumables/v1/roomConsumable/room/:roomId',
		listRoute('Room consumables', 'Empty stub so the client doesn’t 404'),
		(c) => c.json([])
	)
	.get(
		'/api/roomconsumables/v1/roomConsumable/room/:roomId/me',
		listRoute('The caller’s room consumables', 'Empty stub'),
		(c) => c.json([])
	)
	.get('/api/roomcurrencies/v1/currencies', listRoute('Room currencies', 'Empty stub'), (c) =>
		c.json([])
	)
	.get('/api/roomcurrencies/v1/getAllBalances', listRoute('Room balances', 'Empty stub'), (c) =>
		c.json([])
	)

	// The room-economy surface the client asks for on entering a room: the room's own
	// inventory/offers/gift-drop shops and the caller's slice of them. Nothing here is
	// stored yet, so every one is an empty list — the client reads that as "this room
	// sells nothing" and renders no shop, where a 404 stalls the room load instead.
	//
	// The `/player` and `purchaseCounts` variants are caller-scoped but deliberately
	// unauthed, matching the `roomConsumable/.../me` stub above: an empty list is the
	// same answer for every caller, so there's nothing to protect until something
	// writes here. Gate them when they start returning real data.
	.get(
		'/econ/roomInventory/room/:roomId',
		listRoute('A room’s inventory', 'Empty stub so the client doesn’t 404'),
		(c) => c.json([])
	)
	.get(
		'/econ/roomInventory/room/:roomId/player',
		listRoute('The caller’s inventory in a room', 'Empty stub'),
		(c) => c.json([])
	)
	.get(
		'/econ/roomInventoryItemTags/room/:roomId',
		listRoute('A room’s inventory item tags', 'Empty stub'),
		(c) => c.json([])
	)
	.get('/econ/roomOffer/room/:roomId', listRoute('A room’s offers', 'Empty stub'), (c) =>
		c.json([])
	)
	.get(
		'/econ/roomOffer/room/:roomId/purchaseCounts',
		listRoute('Per-offer purchase counts for a room', 'Empty stub'),
		(c) => c.json([])
	)
	.get(
		'/econ/roomGiftDropShops/room/:roomId',
		listRoute('A room’s gift-drop shops', 'Empty stub'),
		(c) => c.json([])
	)

	// A room's economy config, asked for alongside the room-economy lists above. The only
	// setting is whether the room's shop UI groups its offers into sorting tabs; nothing
	// stores per-room config yet, so every room answers false and the client renders one
	// flat list. [Authorize] — unlike the empty-list stubs above this is a real answer the
	// client acts on, so it takes the same token the rest of the econ surface does.
	.get(
		'/econ/roomEconConfig/:roomId',
		describeRoute({
			tags: ['Econ'],
			summary: 'A room’s economy config',
			description: [
				'Whether the room’s shop groups offers into sorting tabs. No per-room config is',
				'stored, so this is always false; the `RoomId` is echoed from the path.',
			].join(' '),
			security: AUTHED,
			responses: {
				200: json(RoomEconConfig, 'The room’s economy config'),
				400: { description: 'Non-numeric roomId (empty body)' },
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			if (Number.isNaN(roomId)) return c.body(null, 400)
			return c.json({ RoomId: roomId, EnableSortingTabs: false })
		}
	)

	// The UGC items a room sells (the creator-made things on sale inside it). Same empty
	// stub as the room-economy routes above and asked for on the same room load: nothing
	// stores room UGC purchasables yet, and an empty list reads as "this room sells
	// nothing" where a 404 stalls the load.
	.get(
		'/api/ugcPurchasables/v1/items/room/:roomId',
		listRoute('A room’s UGC purchasables', 'Empty stub so the client doesn’t 404'),
		(c) => c.json([])
	)

	// Bulk lookup of UGC purchasables by `{ itemType, itemId }`. Only custom avatar items
	// (type 3) exist to resolve; they come off the api-owned `custom_avatar_item` table.
	.post(
		'/api/ugcPurchasables/v1/items/bulk',
		describeRoute({
			tags: ['Rooms'],
			summary: 'Look up UGC purchasables by id',
			description:
				'Resolves `Ids[]` (`{ itemType, itemId }`) against the `custom_avatar_item` table and ' +
				'answers the store-facing `UgcPurchasableItem` view of each, in request order. ' +
				'Only `itemType` 3 (custom avatar item) is served; other types and unknown ids are ' +
				'dropped. `RoomId` is echoed onto every item — what the client wants it for is ' +
				'not yet known. `PurchaseCurrencyId` is null until a currency exists.',
			security: AUTHED,
			requestBody: jsonBody(UgcPurchasableBulkRequest, 'The room and the ids to resolve'),
			responses: {
				200: json(UgcPurchasableItemList, 'The resolved items (unknown ids omitted)'),
				400: json(ErrorResponse, 'Malformed body'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)

			const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
			if (!body || !Array.isArray(body.Ids)) return c.json({ error: 'Ids is required' }, 400)
			const roomId = typeof body.RoomId === 'number' ? body.RoomId : 0
			const ids = (body.Ids as unknown[]).flatMap((ref) => {
				if (!ref || typeof ref !== 'object') return []
				const { itemType, itemId } = ref as Record<string, unknown>
				return itemType === UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM && typeof itemId === 'string'
					? [itemId]
					: []
			})
			const items = await getCustomAvatarItems(c.env.DB, ids)
			return c.json(items.map((item) => toUgcPurchasable(item, roomId)))
		}
	)

	// Unlocked consumables. [Authorize]. The consumables the player has bought (from
	// `buyItem`, stored in the `consumable` table), grouped by item into the client's
	// unlocked-consumable DTO. A player who has bought none gets an empty list.
	.get(
		'/api/consumables/v2/getUnlocked',
		describeRoute({
			tags: ['Consumables'],
			summary: 'Unlocked consumables',
			description: [
				'The consumables the player has bought (from buyItem, in the consumable table),',
				'grouped by item into the unlocked-consumable DTO (Ids/CreatedAts per instance,',
				'Count their sum). [] when they’ve bought none.',
			].join(' '),
			security: AUTHED,
			responses: {
				200: json(JsonArray, 'Grouped unlocked consumables (empty when none)'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			return c.json(await getConsumables(c.env.DB, id))
		}
	)

	// Consume a quantity of an owned consumable instance. [Authorize]. Body is JSON
	// `{ Id, DeltaCount }` where `Id` is the consumable row id. Reduces that instance's
	// count by DeltaCount, deleting the row once it hits zero. Scoped to the caller so
	// they can only consume their own. Envelope mirrors the gift-consume ack.
	.post(
		'/api/consumables/v1/consume',
		describeRoute({
			tags: ['Consumables'],
			summary: 'Consume a quantity of an owned consumable',
			description: [
				'Reduces the given consumable instance’s count by `DeltaCount` (default 1), deleting',
				'the row at zero. Scoped to the caller. Pushes a ConsumableMappingRemoved socket',
				'notification. Envelope mirrors the gift-consume ack.',
			].join(' '),
			security: AUTHED,
			requestBody: jsonBody(ConsumeConsumableRequest, 'The consumable id and delta'),
			responses: {
				200: json(ConsumeEnvelope, 'Success envelope'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			const body = await c.req
				.json<{ Id?: unknown; DeltaCount?: unknown }>()
				.catch(() => ({}) as { Id?: unknown; DeltaCount?: unknown })
			const consumableId = typeof body.Id === 'number' ? body.Id : Number.NaN
			const delta = typeof body.DeltaCount === 'number' ? body.DeltaCount : 1
			if (!Number.isNaN(consumableId) && delta > 0) {
				const consumed = await consumeConsumable(c.env.DB, id, consumableId, delta)
				// Notify the player so their client removes/updates the item in inventory.
				if (consumed !== null) await pushConsumableRemoved(c, id, consumed)
			}
			return c.json({ error: '', success: true, value: null })
		}
	)

	// Currency balance. [Authorize]. The trailing int is a CurrencyType — the client
	// fetches `/balance/2` (RecCenterTokens) on load. Backed by the `balance` table; a
	// player who has never been granted gets their starting balance on this first read.
	//
	// An unknown or non-account-scoped currency (a room currency, ProgressionEvent,
	// Invalid) returns a 0 balance rather than 404: the client treats a failed balance
	// fetch as a load error, and "you have none of that" is the honest answer anyway.
	.get(
		'/api/storefronts/v4/balance/:currencyType',
		describeRoute({
			tags: ['Storefront'],
			summary: 'Currency balance',
			description: [
				'The player’s balance in a CurrencyType (the client fetches `/balance/2`,',
				'RecCenterTokens, on load). A first read seeds their starting balance. An unknown or',
				'non-account currency returns a 0 balance rather than 404.',
			].join(' '),
			security: AUTHED,
			parameters: [
				{
					name: 'currencyType',
					in: 'path',
					required: true,
					description: 'CurrencyType integer; non-numeric is 400',
					schema: { type: 'string' },
				},
			],
			responses: {
				200: json(BalanceEntry.array(), 'A single-entry balance array'),
				400: { description: 'Non-numeric currencyType (empty body)' },
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			const currencyType = Number.parseInt(c.req.param('currencyType'), 10)
			if (Number.isNaN(currencyType)) return c.body(null, 400)
			const amount = isSpendable(currencyType)
				? await getBalance(
						c.env.DB,
						id,
						currencyType,
						intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
					)
				: 0
			return c.json([{ CurrencyType: currencyType, Platform: ALL_PLATFORMS, Balance: amount }])
		}
	)

	// Gift-drop storefront. Serves `static/storefronts/sf{id}.json` for the requested
	// storefront id via the ASSETS binding; 404s when no such catalog exists.
	.get(
		'/api/storefronts/v3/giftdropstore/:id',
		describeRoute({
			tags: ['Storefront'],
			summary: 'Gift-drop storefront catalog',
			description: 'Serves the `sf{id}.json` catalog via the ASSETS binding. 404 when none exists.',
			parameters: [
				{
					name: 'id',
					in: 'path',
					required: true,
					description: 'Storefront id (selects sf{id}.json)',
					schema: { type: 'string' },
				},
			],
			responses: {
				200: json(JsonObject, 'The storefront catalog'),
				404: { description: 'No such storefront catalog' },
			},
		}),
		async (c) => {
			const id = c.req.param('id')
			const res = await c.env.ASSETS.fetch(new URL(`/sf${id}.json`, c.req.url))
			if (!res.ok) return c.notFound()
			return c.json(await res.json())
		}
	)

	// Buy a storefront item. [Authorize]. The client posts the storefront/item ids, the
	// currency and the price it sees; we look the item up in that storefront's catalog,
	// confirm the price the client sent still matches, debit the buyer atomically, grant
	// the item into the recipient's inventory, and hand back a gift box.
	//
	// The buyer is always the caller; a `Gift` block routes the item (and box) to another
	// player, but the caller pays. Ownership is persisted at purchase — the gift box is
	// only the cosmetic "open it" moment, so the grant does not wait for the box to be
	// opened (see /api/avatar/v2/gifts/consume on the `api` worker, which just deletes it).
	//
	// `RequestedPrice` is the price the client rendered; rejecting a mismatch stops a stale
	// client (or a tampered request) from buying at a price the catalog no longer offers.
	.post(
		'/api/storefronts/v2/buyItem',
		describeRoute({
			tags: ['Storefront'],
			summary: 'Buy a storefront item',
			description: [
				'Looks the item up in its storefront catalog, confirms the client’s `RequestedPrice`',
				'still matches (the `SubscriberPrices` entry for a Rec Room Plus subscriber — the same',
				'check as `UpdateAndGetSubscription` — else the `Prices` one), debits the buyer atomically, grants the item (into the inventory or',
				'consumable table), and returns a gift box. A `Gift` block routes the item to another',
				'player, but the caller always pays. `Balance` in the response is the CHANGE (negated',
				'price), not the new total. Pushes a StorefrontBalancePurchase socket frame that SETS the',
				'buyer’s account-wide bucket to the RESULTING total, so the frame, this body and a',
				'`GET /balance` re-fetch all agree (`Delta` there is display-only).',
			].join(' '),
			security: AUTHED,
			requestBody: jsonBody(BuyItemRequest, 'The item, currency, price, and optional Gift'),
			responses: {
				200: json(BuyItemResponse, 'The purchase result (gift box + balance change)'),
				400: json(ErrorResponse, 'Invalid body, unavailable currency, or insufficient balance'),
				401: UNAUTHORIZED_RESPONSE,
				404: json(ErrorResponse, 'No such item'),
				409: json(ErrorResponse, 'The price has changed since the client rendered it'),
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)

			const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
			if (body === null || typeof body !== 'object' || Array.isArray(body)) {
				return c.json({ error: 'Invalid request body' }, 400)
			}
			const storefrontType = body.StorefrontType
			const purchasableItemId = body.PurchasableItemId
			const currencyType = body.CurrencyType
			const requestedPrice = body.RequestedPrice
			if (
				!Number.isInteger(storefrontType) ||
				!Number.isInteger(purchasableItemId) ||
				!Number.isInteger(currencyType) ||
				!Number.isInteger(requestedPrice)
			) {
				return c.json(
					{
						error:
							'StorefrontType, PurchasableItemId, CurrencyType and RequestedPrice are required',
					},
					400
				)
			}

			const item = await findStoreItem(c, storefrontType as number, purchasableItemId as number)
			if (item === null) return c.json({ error: 'Item not found' }, 404)

			// A subscriber is shown, and posts, the item's `SubscriberPrices` entry; checking the
			// regular price against it 409'd every subscriber buy of a discounted item.
			const price = priceFor(item, currencyType as number, await isSubscriber(c))
			if (price === undefined) {
				return c.json({ error: 'Currency type not available for this item' }, 400)
			}
			if (price.Price !== requestedPrice) {
				return c.json({ error: 'Price has changed' }, 409)
			}
			// The item's currency must be an account balance we can debit (RecCenterTokens et al),
			// not a room-scoped or non-spendable currency.
			if (!isSpendable(currencyType as number)) {
				return c.json({ error: 'Currency type is not spendable' }, 400)
			}

			const gift = (
				typeof body.Gift === 'object' && body.Gift !== null ? body.Gift : null
			) as GiftRequest | null
			const receiverId = Number.isInteger(gift?.ToPlayerId) ? (gift?.ToPlayerId as number) : id
			// A named (non-anonymous) gift shows the sender; a self-purchase or an anonymous gift
			// is attributed to the "Coach" system account (id 1), never a null/0 sender.
			const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
			const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3'

			const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
			// Debit the buyer atomically; a false return means they couldn't afford it and
			// nothing changed, so no item is granted.
			const paid = await spendCurrency(
				c.env.DB,
				id,
				currencyType as number,
				price.Price,
				startingTokens
			)
			if (!paid) return c.json({ error: 'Insufficient balance' }, 400)

			// 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, and
			// `granted.drop` is what the roll landed on — the response has to describe THAT, not
			// the box, or a query purchase answers with every item field empty and the client
			// draws an empty box.
			const granted = await grantGiftDrop(c, receiverId, item.GiftDrop, message)

			// Push the spend to the buyer (`id` — the caller is who was charged) so their client
			// updates without waiting for a `GET /balance` re-fetch. StorefrontBalancePurchase
			// SETS the account-wide bucket to the resulting total read back from D1, so it agrees
			// with both the response body below and any re-fetch instead of compounding with them
			// — see the frame rule above pushBalanceUpdate. Best-effort.
			const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
			await pushBalancePurchase(c, id, currencyType as number, -price.Price, newBalance)

			// The response mirrors a captured real buyItem: `Balance` is the change applied (the
			// negated price), not the resulting balance (the client reads its new total from
			// `GET /balance/:type`); `BalanceType` is -2 (account-wide, all platforms).
			return c.json({
				BalanceUpdates: [
					{
						UpdateResponse: 0,
						Data: [
							toBalanceUpdateData(
								granted,
								fromPlayerId,
								message,
								Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null
							),
						],
					},
				],
				Balance: -price.Price,
				CurrencyType: currencyType,
				BalanceType: ALL_PLATFORMS,
			})
		}
	)

	// Check out a whole shopping bag. [Authorize]. The client posts every line it has in the
	// bag — an item id, `DuplicateItemCount` copies, the unit price it rendered and an
	// optional Gift — plus the ONE storefront and the ONE currency they all share.
	//
	// The whole bag is debited in ONE `spendCurrency` call. Charging line by line would let a
	// bag half-succeed on a race with another spend, and would push a balance frame per line.
	//
	// The response is NOT buyItem's envelope. It is `{ Success, Error, error_id, Value }`
	// (`error_id` lowercase — the client renames that one member; the other three are
	// PascalCase), and `Value` is a BalanceUpdateResponse: the RESULTING `{ Balance,
	// CurrencyType, Platform }` — `Platform` there being a renamed `BalanceType`, i.e. the
	// bucket, not a store — plus ONE `BalanceUpdates` entry per REQUESTED item.
	//
	// Per-line reporting is that entry's `UpdateResponse`: a line that didn't sell comes back
	// non-OK with a null `GiftPackage`, and `AllowPartialSuccess` is what lets those sit
	// beside successful ones while `Success` stays true. Without it, one bad line refuses the
	// whole bag — `Success: false`, the reason in `Error`, a null `Value`, nothing charged.
	.post(
		'/api/items/bulkpurchase',
		describeRoute({
			tags: ['Storefront'],
			summary: 'Buy a bag of storefront items',
			description: [
				'Resolves every line against the bag’s storefront catalog (one read for the whole',
				'bag), confirms each line’s `RequestedPrice` still matches, debits the total in ONE',
				'atomic spend, grants what sold, and answers the `{ Success, Error, error_id, Value }`',
				'envelope. `Value.Balance` is the RESULTING total (not buyItem’s change) in the',
				'`Platform` bucket named beside it, and `BalanceUpdates` carries one entry per',
				'REQUESTED item, each with its own `UpdateResponse`. `AllowPartialSuccess` lets some',
				'of those be non-OK while `Success` stays true; without it a single bad line refuses',
				'the bag and nothing is charged.',
			].join(' '),
			security: AUTHED,
			requestBody: jsonBody(BulkPurchaseRequest, 'The bag: its lines, storefront and currency'),
			responses: {
				200: json(BulkPurchaseResponse, 'The bag’s result, or `Success: false` if nothing sold'),
				400: json(BulkPurchaseResponse, 'A request that could not be evaluated at all'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)

			// Every refusal answers the same envelope, so a client that only knows how to parse
			// this shape never has to special-case one. A null `Value` is legal here (the client's
			// validator only cascades into a non-null one), and it is the honest answer: nothing
			// was bought, so there is no balance to report and nothing to render.
			const refuse = (error: string, status: 200 | 400 = 200) =>
				c.json({ Success: false, Error: error, error_id: null, Value: null }, status)

			const body = (await c.req.json().catch(() => null)) as {
				PurchaseItemRequests?: PurchaseItemRequest[]
				StorefrontType?: number
				CurrencyType?: number
				BypassGiftPackages?: boolean
				AllowPartialSuccess?: boolean
				ShoppingBagId?: string | number | null
			} | null
			if (body === null || typeof body !== 'object' || Array.isArray(body)) {
				return refuse('Invalid request body', 400)
			}
			const lines = body.PurchaseItemRequests
			if (!Array.isArray(lines) || lines.length === 0) {
				return refuse('PurchaseItemRequests must be a non-empty array', 400)
			}
			const storefrontType = body.StorefrontType
			const currencyType = body.CurrencyType
			if (!Number.isInteger(storefrontType) || !Number.isInteger(currencyType)) {
				return refuse('StorefrontType and CurrencyType are required', 400)
			}
			// The bag's currency must be an account balance we can debit, exactly as buyItem's.
			if (!isSpendable(currencyType as number)) {
				return refuse('Currency type is not spendable', 400)
			}
			const allowPartial = body.AllowPartialSuccess === true
			const skipGiftBox = body.BypassGiftPackages === true

			// One catalog read for the bag; every line resolves against it in memory.
			const storefront = await loadStorefront(c, storefrontType as number)
			const subscriber = await isSubscriber(c)
			const resolved = lines.map((line) =>
				resolveBulkLine(line, storefront, currencyType as number, subscriber)
			)
			const buyable = resolved.filter(isBulkLine)

			const copies = buyable.reduce((n, line) => n + line.count, 0)
			if (copies > BULK_PURCHASE_CAP) {
				return refuse(`A bulk purchase is capped at ${BULK_PURCHASE_CAP} items`, 400)
			}
			// All-or-nothing: one unbuyable line stops the bag before anything is charged, and the
			// client is told why by the first thing that was wrong with it.
			const firstFailure = resolved.find((line): line is BulkLineFailure => !isBulkLine(line))
			if (!allowPartial && firstFailure !== undefined) return refuse(firstFailure.error)

			// Decide what the balance covers BEFORE spending: lines are taken in request order
			// while they fit, so a bag that overruns still buys the items the player put in first.
			// The read is only for choosing; the single spend below is what actually settles, and
			// its `amount >= ?` guard is what makes that safe against a concurrent spend.
			const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
			const balance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
			const affordable: BulkPurchaseLine[] = []
			let total = 0
			for (const line of buyable) {
				const cost = line.price * line.count
				if (total + cost > balance) continue
				total += cost
				affordable.push(line)
			}
			const bought = new Set(affordable)
			// Without partial success an unaffordable line fails the whole bag — including the
			// lines that would have fitted, since the player asked for all of it or none.
			if (!allowPartial && affordable.length !== buyable.length) {
				return refuse('Insufficient balance')
			}
			// Nothing sold at all: there is no purchase to report, so this is a refusal rather
			// than a `Success: true` bag full of non-OK entries.
			if (affordable.length === 0) {
				return refuse(firstFailure?.error ?? 'Insufficient balance')
			}

			// One atomic debit for the whole bag. A false return means another request spent the
			// tokens between the read above and here, so nothing is granted and nothing changed.
			if (
				total > 0 &&
				!(await spendCurrency(c.env.DB, id, currencyType as number, total, startingTokens))
			) {
				return refuse('Insufficient balance')
			}

			// A query drop (a loot box) rolls against sf3, the big catalog. Read it ONCE for the
			// whole bag and only when a line actually holds one — a bag of ordinary items should
			// not pull a thousand-item catalog in to grant them.
			const rollCatalog = affordable.some((line) => line.item.GiftDrop.IsQuery === true)
				? await loadRollCatalog(c)
				: undefined

			// Grant what sold, keeping each line's box so the entry built below can carry it.
			const packages = new Map<BulkPurchaseLine, Record<string, unknown> | null>()
			for (const line of affordable) {
				// Same routing as buyItem: a Gift block sends the item (and its box) to another
				// player while the caller pays, a named gift shows the sender, and a self-buy or an
				// anonymous gift is attributed to the "Coach" system account.
				const gift = line.gift
				const receiverId = Number.isInteger(gift?.ToPlayerId) ? (gift?.ToPlayerId as number) : id
				const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
				const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3'
				// One box per requested item, holding all `count` copies — the wire has one
				// `GiftPackage` per entry, and only a consumable can be asked for more than once
				// (`resolveBulkLine` refuses a bigger count on anything owned once).
				const granted = await grantGiftDrop(c, receiverId, line.item.GiftDrop, message, {
					rollCatalog,
					skipGiftBox,
					copies: line.count,
				})
				packages.set(
					line,
					// Null under `BypassGiftPackages`, which is the flag asking for exactly that —
					// the item is granted either way.
					skipGiftBox
						? null
						: toGiftPackage(
								granted,
								receiverId,
								fromPlayerId,
								message,
								Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null
							)
				)
			}

			// One entry per REQUESTED item, in request order — the failures included, which is
			// where a partial bag says what it left behind.
			const updates = resolved.map((line) => {
				if (!isBulkLine(line)) {
					return {
						UpdateResponse: line.code,
						Data: {
							GiftPackage: null,
							PurchasableItemId: line.method.NumberId,
							CustomAvatarItem: null,
						} satisfies BulkPurchaseData,
					}
				}
				return {
					UpdateResponse: bought.has(line) ? UpdateResponse.OK : UpdateResponse.NotEnoughCredit,
					Data: {
						GiftPackage: packages.get(line) ?? null,
						PurchasableItemId: line.method.NumberId,
						CustomAvatarItem: null,
					} satisfies BulkPurchaseData,
				}
			})

			// One frame for the whole bag, not one per line: it SETS the account-wide bucket to the
			// resulting total read back from D1, so it agrees with the `Value.Balance` below and
			// with a `GET /balance` re-fetch instead of compounding — see the frame rule above
			// pushBalanceUpdate. Nothing moved on a free bag, so nothing is sent and the balance
			// read for the affordability check above still stands.
			let newBalance = balance
			if (total > 0) {
				newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
				await pushBalancePurchase(c, id, currencyType as number, -total, newBalance)
			}
			return c.json({
				Success: true,
				Error: null,
				error_id: null,
				Value: {
					// The RESULTING total, unlike buyItem's change — and the bucket it belongs to.
					// `Platform` here is the client's `BalanceType` under a [DataMember] rename. A
					// capture from the reference server says 4 (RecNetPurchased) because it kept a
					// wallet per store; this server keeps ONE account-wide bucket, and the client SUMS
					// its buckets, so naming any other platform invents a second balance beside the
					// real one. See the frame rule above pushBalanceUpdate.
					Balance: newBalance,
					CurrencyType: currencyType,
					Platform: ALL_PLATFORMS,
					BalanceUpdates: updates,
				},
			})
		}
	)

	// Buy an invention. [Authorize]. A GET, despite being a purchase — the client sends
	// `?inventionId=…&requestedPrice=…` with no body, so that's what we answer.
	//
	// A priced invention is settled player-to-player: the buyer is debited its `Price` in
	// RecCenterTokens and the CREATOR is credited the same amount — no house cut, so the
	// tokens are moved rather than minted or burned. A free invention (`Price` 0) skips the
	// money entirely: nothing is debited and nobody is paid. The stored price is confirmed
	// against the price the client rendered first, so a stale or tampered client can't buy
	// at a price the creator no longer offers (409), and an unaffordable one is a 400 —
	// the same "Insufficient balance" buyItem answers with.
	//
	// Ownership is recorded in `inventory_invention`; the creator is not sold their own
	// invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather
	// than a second row. The invention's `NumDownloads` counter is deliberately NOT
	// bumped: that column lives on the `invention` table the `api` worker owns, and this
	// worker only reads it.
	.get(
		'/api/storefronts/v2/buyInvention',
		describeRoute({
			tags: ['Storefront'],
			summary: 'Buy an invention',
			description: [
				'Looks the invention up by id, confirms the client’s `requestedPrice` still matches',
				'its stored `Price`, debits the buyer and pays the creator that price in',
				'RecCenterTokens (a free invention moves nothing), records ownership in',
				'`inventory_invention`, and returns the invention alongside the buyer’s resulting',
				'balance. When tokens moved, both players get a socket push carrying their RESULTING',
				'total — the buyer a StorefrontBalancePurchase, the CREATOR a StorefrontBalanceUpdate —',
				'which sets the account-wide bucket their client shows, agreeing with this body.',
				'A GET because that is how the client sends it.',
			].join(' '),
			security: AUTHED,
			parameters: [
				{
					name: 'inventionId',
					in: 'query',
					required: true,
					description: 'Invention id; missing or non-numeric is 400',
					schema: { type: 'integer' },
				},
				{
					name: 'requestedPrice',
					in: 'query',
					required: false,
					description: 'The price the client rendered; a mismatch is 409. Defaults to 0',
					schema: { type: 'integer' },
				},
			],
			responses: {
				200: json(BuyInventionResponse, 'The purchase result (invention + balance)'),
				400: json(
					ErrorResponse,
					'Missing/non-numeric inventionId, buying your own, or insufficient balance'
				),
				401: UNAUTHORIZED_RESPONSE,
				403: json(ErrorResponse, 'The invention is not published, so it is not for sale'),
				404: json(ErrorResponse, 'No such invention'),
				409: json(ErrorResponse, 'Already owned, or the price has changed'),
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)

			const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
			if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
			// Absent/non-numeric requestedPrice reads as 0, which only matches a free invention —
			// a priced one then fails the confirmation below rather than selling for nothing.
			const requestedPrice = Number.parseInt(c.req.query('requestedPrice') ?? '0', 10) || 0

			const invention = await getInventionById(c.env.DB, inventionId)
			if (invention === null) return c.json({ error: 'Invention not found' }, 404)
			// An unpublished invention is a draft: it isn't on sale, not even for free.
			if (!invention.IsPublished) return c.json({ error: 'Invention is not for sale' }, 403)
			if (invention.CreatorPlayerId === id) {
				return c.json({ error: 'Cannot buy your own invention' }, 400)
			}
			if (await ownsInvention(c.env.DB, id, inventionId)) {
				return c.json({ error: 'Already owned' }, 409)
			}

			// The price the client rendered must still be the stored one: a mismatch is a stale
			// catalog or a tampered request, never a sale.
			if (invention.Price !== requestedPrice) {
				return c.json({ error: 'Price has changed' }, 409)
			}

			const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
			// Inventions are priced in RecCenterTokens only — the store shows no other currency
			// for them, and `Price` carries no currency of its own to pick a different one from.
			const price = invention.Price
			if (price > 0) {
				// Debit the buyer atomically; false means they couldn't afford it and nothing
				// changed, so no ownership is recorded and the creator is not paid.
				const paid = await spendCurrency(
					c.env.DB,
					id,
					CurrencyType.RecCenterTokens,
					price,
					startingTokens
				)
				if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
			}

			// Grant before paying out: these are three separate D1 writes with no transaction
			// around them, so order them by what a failure costs. A buyer who paid and got the
			// invention but left the creator unpaid is recoverable; a buyer charged for nothing
			// is not.
			await grantInvention(c.env.DB, id, inventionId)

			if (price > 0) {
				// Seed the creator's signup grant BEFORE crediting them: `creditCurrency` upserts
				// the balance row, and `ensureStartingBalances` is an INSERT OR IGNORE, so a
				// creator who had never touched their balance would otherwise have the row created
				// here and lose their starting tokens forever.
				await ensureStartingBalances(c.env.DB, invention.CreatorPlayerId, startingTokens)
				const creatorBalance = await creditCurrency(
					c.env.DB,
					invention.CreatorPlayerId,
					CurrencyType.RecCenterTokens,
					price,
					startingTokens
				)
				// The creator is a different, probably-online player with no response to read:
				// push the sale so it lands on their shown balance without a re-fetch. The frame
				// carries their resulting TOTAL (what `creditCurrency` returns), not the payout —
				// sending the payout would set their whole balance to it. A plain update rather
				// than a purchase frame: they sold, they didn't buy. Best-effort, as everywhere.
				await pushBalanceUpdate(
					c,
					invention.CreatorPlayerId,
					CurrencyType.RecCenterTokens,
					creatorBalance
				)
			}

			// Unlike buyItem — whose `Balance` is the change applied — the reference server
			// answers this one with the RESULTING total (a first read seeds the buyer's starting
			// grant, as everywhere else). The buyer's frame carries that same total, so the body
			// and the push land the client on one number.
			const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
			// A free invention moved nothing, so there is no purchase to report.
			if (price > 0) {
				await pushBalancePurchase(c, id, CurrencyType.RecCenterTokens, -price, balance)
			}
			return c.json({
				BalanceUpdateResponse: {
					Balance: balance,
					BalanceType: ALL_PLATFORMS,
					CurrencyType: CurrencyType.RecCenterTokens,
					BalanceUpdates: [{ UpdateResponse: 0, Data: invention }],
				},
				// The same `{ Status, Invention, InventionVersion }` envelope the invention
				// save/read endpoints serve — the client re-renders the invention from it.
				InventionResponse: toSaveResult(invention),
			})
		}
	)

	// Storefront ad-carousel items. Served from the bundled static JSON — one
	// placeholder banner with no purchasable items until real promo data exists.
	.get(
		'/api/storefronts/v1/adcarouselitems',
		listRoute('Storefront ad-carousel items', 'The bundled carousel (one placeholder banner)'),
		(c) => c.json(adCarouselItems)
	)

	// Current weekly challenge. The rotation is GENERATED from the calendar week (see
	// challenge-rotation.ts — the same five challenges, window and gift for everyone, derived
	// from the week index; static/weekly-challenge.json pins it instead when it carries
	// challenges), but each challenge's state is per-player, so the caller's rows from
	// `challenge_status` are stamped over the week's: `Complete` over the published `false`,
	// and `Config` over the published rule tree — the client evaluates that tree locally and
	// reports it back with its running counts written into it (`cc`/`c`), so serving the
	// pristine tree back is what makes partial progress reset every session.
	// Auth is OPTIONAL: without a valid bearer the week is served unstamped rather than 401,
	// since the rotation is public information and a 404/401 on this route can stall the
	// client's load orchestration.
	.get(
		'/api/challenge/v2/getCurrent',
		describeRoute({
			tags: ['Econ'],
			summary: 'Current weekly challenge',
			description: [
				'This week’s rotation — generated from the calendar week — with each challenge’s',
				'`Complete` and `Config` stamped from the caller’s progress rows, the stored `Config`',
				'carrying the client’s running counts. Auth is optional: unauthenticated callers get',
				'the week unstamped, every `Complete` false and every `Config` as published.',
			].join(' '),
			security: OPTIONAL_AUTHED,
			responses: { 200: json(JsonObject, 'The current weekly challenge') },
		}),
		async (c) => {
			const rotation = withWeeklyGift(buildRotation(new Date()), await loadEquipmentGiftPool(c))
			const id = await authedId(c)
			if (id === null) return c.json(rotation)
			const statuses = await getChallengeStatuses(c.env.DB, id, rotation.ChallengeMapId)
			if (statuses.size === 0) return c.json(rotation)
			// Rebuild rather than mutate: the generated rotation is cached module state shared
			// by every request this isolate serves, so stamping it in place would leak one
			// player's progress to the next caller.
			return c.json({
				...rotation,
				Challenges: rotation.Challenges.map((challenge) => {
					const status = statuses.get(challenge.ChallengeId)
					if (status === undefined) return challenge
					// A row with no stored tree (never reported one) keeps the authored `Config`;
					// overwriting it with null would hand the client a challenge it can't evaluate.
					return {
						...challenge,
						Complete: status.complete,
						Config: status.config ?? challenge.Config,
					}
				}),
			})
		}
	)

	// Report progress on a weekly challenge. [Authorize]. The client evaluates the
	// challenge's rule tree locally and posts ChallengeMapId/ChallengeId, that tree in
	// `Config`, and whether it now considers the challenge `Complete`. Both are persisted
	// (keyed by account + challenge): the posted tree is the catalog's definition with the
	// client's running counts written into it, so it is this player's progress, and
	// `getCurrent` serves it back in place of the authored tree. Echoes the identifying
	// fields back with the state the row now holds — which is not always what was posted,
	// since completion latches within a rotation and a report with no `Config` keeps the
	// stored tree.
	.post(
		'/api/challenge/v2/updateProgress',
		describeRoute({
			tags: ['Econ'],
			summary: 'Report weekly-challenge progress',
			description: [
				'Persists the reported completion and rule tree into `challenge_status`, keyed by',
				'account + challenge, so `getCurrent` can serve the player’s own progress back.',
				'Completion latches within a rotation and a report carrying no `Config` keeps the',
				'stored tree, so the echoed fields are the stored values, not the posted ones.',
			].join(' '),
			security: AUTHED,
			requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'),
			responses: {
				200: json(ChallengeProgressResponse, 'Echoed fields with the stored completion'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			const body = await c.req
				.json<{
					ChallengeMapId?: string | number
					ChallengeId?: string | number
					Config?: string
					Complete?: string | boolean
				}>()
				.catch(() => ({}) as Record<string, never>)
			const challengeMapId = Number(body.ChallengeMapId) || 0
			const challengeId = Number(body.ChallengeId) || 0
			const config = typeof body.Config === 'string' ? body.Config : null
			// Nothing to key a row on — echo the body back rather than writing a (0, 0) row.
			const stored =
				challengeId === 0
					? { complete: parseBool(body.Complete), config }
					: await recordChallengeProgress(c.env.DB, id, {
							challengeMapId,
							challengeId,
							complete: parseBool(body.Complete),
							config,
						})
			// 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 (stored.complete && challengeId !== 0 && challengeMapId === rotationMapId(new Date())) {
				await awardChallengeGift(c, id)
			}
			return c.json({
				ChallengeMapId: challengeMapId,
				ChallengeId: challengeId,
				Config: stored.config ?? '',
				Complete: stored.complete,
			})
		}
	)

	// Pending game rewards. Returns "[]".
	.get('/api/gamerewards/v1/pending', listRoute('Pending game rewards', 'Empty for now'), (c) =>
		c.json([])
	)

	// Request a game reward. [Authorize]. The client asks whenever it thinks one is due,
	// posting the type and the message to show for it (`rewardType=FirstActivityOfDay&
	// Message=First Game of the Day`, or `rewardType=PostGameActivity&Message=Activity
	// completed!&giftContext=Soccer`) — so whether a reward is actually OWED is decided
	// here, from `reward_status`: one claim per type per activity per hour, atomically.
	//
	// A claim pays GAME_REWARD_XP into `progression` and hands over a gift box carrying that
	// XP, announced with the same GiftPackageReceivedImmediate frame the weekly gift uses —
	// the client posted the message to show, so the box wears it. An on-cooldown ask changes
	// nothing and pays nothing.
	//
	// The response stays `[]` either way. It is what the client already accepts, and the box
	// is how a reward is delivered, so there is no captured shape to put the payout in — the
	// reference answers its own (different, selection-based) flow with a success envelope,
	// not a list of rewards.
	//
	// `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown key: the first
	// activity of the day is per ACTIVITY, so a player who moves from Soccer to Paintball is
	// owed another reward while a second Soccer match inside the hour is not. An ask that
	// sends no context keys on `''`.
	.post(
		'/api/gamerewards/v1/request',
		describeRoute({
			tags: ['Econ'],
			summary: 'Request a game reward',
			description: [
				'Claims one reward of `rewardType` in `giftContext` per hour per player, recorded in',
				'`reward_status`. The cooldown is per (type, activity), so a different activity is',
				'owed another reward while the same one is not; an ask with no `giftContext` keys on',
				'the empty context. The reward rides in a gift box, so a claim and a rejected',
				'(on-cooldown) ask both answer `[]`.',
			].join(' '),
			security: AUTHED,
			requestBody: form(GameRewardRequest, 'The reward type and its display message'),
			responses: {
				200: json(JsonArray, 'The rewards granted — always [] while the payload is stubbed'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
			const rewardType = typeof body.rewardType === 'string' ? body.rewardType : ''
			// No type, nothing to gate: don't write a row keyed on an empty string.
			if (rewardType === '') return c.json([])
			const giftContext = typeof body.giftContext === 'string' ? body.giftContext : ''
			const claimed = await claimReward(c.env.DB, id, rewardType, giftContext)
			// On cooldown: nothing was claimed, so nothing is paid and nothing is announced.
			if (claimed === null) return c.json([])
			const message =
				typeof body.Message === 'string' && body.Message !== ''
					? body.Message
					: DEFAULT_GAME_REWARD_MESSAGE
			// Bank the XP first: it is the reward, and the box is the wrapper the client shows.
			// A failure here must not leave a box promising XP that was never credited.
			const { progression, levelsGained } = await addXp(c.env.DB, id, GAME_REWARD_XP)
			const granted = await grantGiftDrop(c, id, toGameRewardDrop(), message)
			await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
			// Every grant moves the bar, whether or not it crossed a level.
			await pushProgressionUpdate(c, id, progression)
			// …and every level crossed is worth a box of its own tier.
			await grantLevelUpGifts(c, id, { progression, levelsGained })
			logger.info('game reward claimed', {
				accountId: id,
				rewardType,
				giftContext,
				grantCount: claimed,
				message,
				xp: GAME_REWARD_XP,
				level: progression.Level,
				levelsGained,
				levelXp: progression.XP,
				giftId: granted.id,
			})
			return c.json([])
		}
	)

	// The player's room keys. Returns "[]".
	.get('/api/roomkeys/v1/mine', listRoute('The player’s room keys', 'Empty for now'), (c) =>
		c.json([])
	)
	// Room keys for a given room (client calls this on the econ host). [] with no DB.
	.get('/api/roomkeys/v1/room', listRoute('Room keys for a room', 'Empty for now'), (c) =>
		c.json([])
	)

	// The Rec Room Plus sign-up bonus: which bonus is running and the token price window
	// the free items are drawn from. Fixed numbers, the same for every caller.
	//
	// Unauthenticated, like the subscription lookup below and for the same reason: the
	// client reads this while putting the RR+ page together, nothing in the answer is
	// per-account, and a 401 would only be a way for that load to stall. (The `api` copy of
	// this path does validate a token, mirroring the reference server.)
	.get(
		'/api/CampusCard/v1/SignUpBonus',
		describeRoute({
			tags: ['Econ'],
			summary: 'Rec Room Plus sign-up bonus',
			description: [
				'The bonus a player gets for taking out Rec Room Plus: which bonus is running',
				'(`RRPlusSignUpBonusId`) and the token price window the free items are picked from.',
				'Fixed values — nothing here is per-account or stored, so no auth is required and',
				'every caller gets the same three numbers.',
			].join(' '),
			responses: { 200: json(RRPlusSignUpBonus, 'The running sign-up bonus') },
		}),
		(c) =>
			c.json({
				RRPlusSignUpBonusId: 3,
				MinFreeItemsPrice: 6000,
				MaxFreeItemsPrice: 10000,
			})
	)

	// Subscription lookup (Rec Room Plus, the client's `CampusCard`). There is no store to
	// buy one from, so the `developer` role stands in for a paid subscription: a developer
	// reports an active Gold year, everyone else reports none. Nothing is stored — see
	// `developerSubscription`.
	//
	// Auth is OPTIONAL, and a missing or invalid token answers "no subscription" rather than
	// 401: the client posts this while loading, so an error here can stall its load
	// orchestration, and "you aren't subscribed" is the truthful answer for an anonymous
	// caller anyway. The role is read from the token's `role` claim, never from the body.
	.post(
		'/api/CampusCard/v1/UpdateAndGetSubscription',
		describeRoute({
			tags: ['Econ'],
			summary: 'Subscription lookup',
			description: [
				'The caller’s Rec Room Plus subscription. Nothing sells subscriptions here, so the',
				'operator-granted `developer` role stands in for one: a developer’s token reports an',
				'active Gold (`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All),',
				'expiring a year from the call, and every other caller gets `{}`. Auth is optional —',
				'a missing or invalid token reads as “not subscribed”, not 401. Nothing is persisted:',
				'the role IS the subscription, so revoking it revokes this.',
			].join(' '),
			responses: {
				200: json(SubscriptionResponse, 'The subscription, or `{}` for no subscription'),
			},
		}),
		async (c) => {
			if (!(await isSubscriber(c))) return c.json({})
			const id = await authedId(c)
			if (id === null) return c.json({})
			return c.json({
				Subscription: developerSubscription(id),
				PlatformAccountSubscribedPlayerId: null,
			})
		}
	)

	// The subscription seasons running right now (the RR+ seasonal reward tracks). Nothing
	// here runs a season, so this is an empty-list stub — the client reads it as "no season
	// in progress" and skips the seasonal UI, where a 404 stalls the RR+ page load.
	.get(
		'/api/subscriptionseasons/v1/seasons/current',
		listRoute('Current subscription seasons', 'Empty stub — no RR+ season is running'),
		(c) => c.json([])
	)

	// Whether the caller can start a Maker AI free trial. Always false, mirroring the
	// reference server: nothing here runs trials, and false is the answer that leaves the
	// client's creation UI in its normal state rather than offering a trial that can't
	// start. The body is a BARE JSON `false` — not an envelope, not `{ value: false }`.
	//
	// Auth-gated (401 on a missing or invalid token) even though the answer is the same for
	// everyone, because the reference validates the token before answering and eligibility
	// is a per-account question the moment anything does run trials.
	.get(
		'/api/makerai/checkfreetrialeligibility',
		describeRoute({
			tags: ['Econ'],
			summary: 'Maker AI free-trial eligibility',
			description: [
				'Whether the caller can start a Maker AI free trial. Always `false` — nothing here',
				'runs trials. The body is a bare JSON boolean, not an envelope.',
			].join(' '),
			security: AUTHED,
			responses: {
				200: json(MakerAiFreeTrialEligibilityResponse, 'Always `false`'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			return c.json(false)
		}
	)

	// The caller's progress through the refer-a-friend rewards: how many of their referrals
	// have been verified, and which rewards they have taken from that track.
	//
	// Nothing here runs a referral programme, so nobody has referred anybody: the count is 0
	// and the reward list is empty. That is a real answer rather than a stub — it is what a
	// player who has referred nobody sees — so the client draws an untouched track, which is
	// exactly the state this server is in.
	//
	// The payload is NESTED under `value`, unlike econ's flat balance bodies.
	.get(
		'/api/incentivizedreferrals/progress',
		describeRoute({
			tags: ['Econ'],
			summary: 'The caller’s referral-reward progress',
			description: [
				'How many of the caller’s referrals have been verified and which referral rewards they',
				'have claimed, under a `{ success, value }` envelope. Always 0 and empty — no referral',
				'programme runs here — which the client renders as an untouched reward track.',
			].join(' '),
			security: AUTHED,
			responses: {
				200: json(ReferralProgressResponse, 'The caller’s progress — always zero'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			return c.json({
				success: true,
				value: { ReferralsVerifiedCount: 0, PlayerReferralRewards: [] },
			})
		}
	)

	// Everyone in the influencer partner program, by account id — the list the client keeps
	// so it can badge an influencer wherever they turn up, rather than asking per player.
	//
	// Empty: no programme runs here, so there is nobody to list. Note this is the LIST
	// counterpart of the single-account check below, and the two answer very differently —
	// that one 404s to say "not an influencer", this one is a 200 carrying an empty list,
	// because "nobody is" is a complete answer to "who is?".
	//
	// `take` is accepted and ignored; there is nothing to page through.
	.get(
		'/api/influencerpartnerprogram/influencers',
		describeRoute({
			tags: ['Econ'],
			summary: 'Every influencer in the partner program',
			description: [
				'The account ids in the influencer partner program, as `{ InfluencerIds }` — an object',
				'around the list, not a bare array. Always empty here: no programme runs on this',
				'server. `take` is accepted and ignored, there being nothing to page.',
			].join(' '),
			security: AUTHED,
			parameters: [
				{
					name: 'take',
					in: 'query',
					required: false,
					description: 'How many ids to return. Accepted and ignored.',
					schema: { type: 'integer' },
				},
			],
			responses: {
				200: json(InfluencerIdsResponse, 'The influencer ids — always empty'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			return c.json({ InfluencerIds: [] })
		}
	)

	// One account's standing in the influencer partner program. NOBODY here has one: this
	// server runs no such program, so the answer is the literal `0` — the "not an influencer"
	// tier — for every account.
	//
	// A BARE NUMBER is the whole body, like `…/makerai/checkfreetrialeligibility`'s bare
	// `false`, not a number wrapped in an object. This used to answer 404 with an empty body;
	// the tier is what the client actually reads.
	//
	// `accountId` names the account being asked about. It makes no difference to the answer
	// while nobody is an influencer, but it is read rather than ignored so this stays the
	// question it looks like — the caller's own standing is `…/myinfluencer` below.
	.get(
		'/api/influencerpartnerprogram/influencer',
		describeRoute({
			tags: ['Econ'],
			summary: 'An account’s influencer partner program tier',
			description: [
				'The partner tier of the account named by `accountId`, as a BARE NUMBER — the whole',
				'body is `0`, not an object around it. Always 0: this server runs no partner program,',
				'so no account is an influencer. Auth-gated; a missing or invalid token is a 401.',
			].join(' '),
			security: AUTHED,
			parameters: [
				{
					name: 'accountId',
					in: 'query',
					required: false,
					description: 'The account being asked about. Every account answers 0.',
					schema: { type: 'integer' },
				},
			],
			responses: {
				200: json(InfluencerTierResponse, 'The account’s tier — always 0'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			return c.json(NOT_AN_INFLUENCER)
		}
	)

	// The same question about the CALLER — the `my` form, which names no account because the
	// token already does. Same bare `0`, for the same reason: nobody here is an influencer.
	//
	// Its own route rather than an alias of the one above, because the two differ in who they
	// are about; they agree today only because the answer is currently the same for everyone.
	.get(
		'/api/influencerpartnerprogram/myinfluencer',
		describeRoute({
			tags: ['Econ'],
			summary: 'The caller’s influencer partner program tier',
			description: [
				'The caller’s own partner tier — the `my` form of the route above, taking the account',
				'from the token rather than a query parameter. A BARE NUMBER, always `0`: this server',
				'runs no partner program. Auth-gated; a missing or invalid token is a 401.',
			].join(' '),
			security: AUTHED,
			responses: {
				200: json(InfluencerTierResponse, 'The caller’s tier — always 0'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return unauthorized(c)
			return c.json(NOT_AN_INFLUENCER)
		}
	)

// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
	'/openapi.json',
	describeRoute({ hide: true }),
	withCleanSpec(
		openAPIRouteHandler(app, {
			documentation: {
				info: {
					title: 'recflare econ',
					version: '1.0.0',
					description: [
						'Avatar and economy endpoints for recflare, a private-server reimplementation of the',
						'Rec Room backend. The client calls these on the `econ` host; many are also served by',
						'the `api` worker. Storefront catalogs are static assets (`sf{N}.json`); balances,',
						'inventory, consumables, saved outfits and gift boxes are D1-backed.',
					].join('\n'),
				},
				servers: [{ url: 'https://econ.recflare.net', description: 'Production' }],
				components: {
					securitySchemes: {
						bearerAuth: {
							type: 'http',
							scheme: 'bearer',
							bearerFormat: 'JWT',
							description: 'An `access_token` from the auth worker’s `POST /connect/token`.',
						},
					},
				},
			},
		})
	)
)

export default app
