mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
econ docs
This commit is contained in:
+109
-70
@@ -1,77 +1,116 @@
|
|||||||
# econ
|
# econ
|
||||||
|
|
||||||
Economy Worker served on the `econ` subdomain. Hosts the avatar/economy
|
Economy Worker served on the `econ` subdomain (`econ.recflare.net`). Hosts the
|
||||||
endpoints the game client calls on the `econ` service (distinct from the main
|
avatar/economy endpoints the game client calls on the `econ` service (distinct from the
|
||||||
`api` worker). DB-backed data is stubbed for now — no bindings yet.
|
main `api` worker, which also serves many of them — the client may call either host).
|
||||||
|
|
||||||
## Endpoints
|
Balances, inventory, consumables, saved outfits, avatars and gift boxes are D1-backed;
|
||||||
|
storefront catalogs are static assets (`static/storefronts/sf{N}.json`) served via the
|
||||||
|
ASSETS binding. Several routes are still empty-list stubs.
|
||||||
|
|
||||||
- `GET /api/avatar/v1/defaultunlocked` — default-unlocked avatar items, served
|
## Routes
|
||||||
from the bundled `static/default-avatar-items.json` catalog.
|
|
||||||
- `GET /api/avatar/v1/defaultbaseavataritems` — default base avatar items. Reads
|
|
||||||
the same source file as `defaultunlocked`, so it returns the identical
|
|
||||||
catalog.
|
|
||||||
- `GET /api/avatar/v4/items` — `[Authorize]`. The player's avatar items: the
|
|
||||||
items they've bought (from `buyItem`, in the `inventory` table) prepended to
|
|
||||||
the default catalog. A player who has bought nothing gets just the catalog.
|
|
||||||
- `GET /api/avatar/v2` — `[Authorize]`. The player's avatar. No DB binding yet,
|
|
||||||
so it returns the default `{ OutfitSelections, FaceFeatures, SkinColor,
|
|
||||||
HairColor }` seeded for a new player.
|
|
||||||
- `GET /econ/customAvatarItems/v1/owned` — the player's owned custom avatar
|
|
||||||
items. No auth; returns `{ items: [] }` with no DB binding.
|
|
||||||
The client requests this when custom-item creation is allowed, so a missing
|
|
||||||
route here shows up as "Failed to download unlocked avatar items".
|
|
||||||
- `GET /api/objectives/v1/myprogress` — objectives progress. No auth (serves a
|
|
||||||
static JSON file verbatim); returns the bundled
|
|
||||||
`static/my-progress.json` default for all players until a DB binding exists.
|
|
||||||
- `GET /api/avatar/v3/saved` — `[Authorize]`. Saved outfits; `[]` without a DB.
|
|
||||||
- `GET /api/avatar/v2/gifts` — `[Authorize]`. The player's unopened gift boxes
|
|
||||||
(from their purchases), out of the shared `received_gift` table; `[]` when
|
|
||||||
they have none.
|
|
||||||
- `POST /api/avatar/v2/gifts/consume` — open a box (form body `Id=<n>&UnlockedLevel=<n>`,
|
|
||||||
posted with a trailing slash). Deletes the box scoped to the caller; the item was
|
|
||||||
already granted at purchase, so this is cosmetic. Always answers the success envelope
|
|
||||||
`{ error: "", success: true, value: null }` (a captured real consume returns this, not
|
|
||||||
an empty body — the client parses it to finish opening the box), even for a
|
|
||||||
missing/already-opened box, so a fire-and-forget re-open never errors. Also served by
|
|
||||||
the `api` worker (the client may call either host).
|
|
||||||
- `POST /api/storefronts/v2/buyItem` — `[Authorize]`. Buy a storefront item.
|
|
||||||
Looks the item up in `static/storefronts/sf{StorefrontType}.json`, confirms the
|
|
||||||
client's `RequestedPrice` still matches, debits the buyer atomically, grants the
|
|
||||||
item, and returns a gift box. An avatar-item drop goes into the `inventory` table
|
|
||||||
(own-once); a consumable drop goes into the `consumable` table (each buy stacks a
|
|
||||||
new instance). The response's `Balance` is the change applied (the negated price),
|
|
||||||
not the resulting total — the client reads its new total from `GET /balance/:type`.
|
|
||||||
`409` on a stale price, `404` on an unknown item, `400` on insufficient balance.
|
|
||||||
- `GET /api/equipment/v2/getUnlocked` — unlocked equipment; `[]` (no auth).
|
|
||||||
- `POST /api/settings/v2/set` — `[Authorize]`. Persist settings; 200 ack only.
|
|
||||||
- `GET /api/consumables/v2/getUnlocked` — `[Authorize]`. 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.
|
|
||||||
- `GET /api/storefronts/v4/balance/2` — `[Authorize]`. Token balance; `[]`.
|
|
||||||
- `GET /api/storefronts/v3/giftdropstore/3` — gift-drop storefront, served from
|
|
||||||
the bundled `static/storefronts-v3-giftdropstore-3.json`.
|
|
||||||
- `GET /api/storefronts/v1/adcarouselitems` — storefront ad-carousel items,
|
|
||||||
served from the bundled `static/ad-carousel-items.json` (one placeholder
|
|
||||||
banner until real promo data exists).
|
|
||||||
- `GET /api/challenge/v2/getCurrent` — current weekly challenge, served from the
|
|
||||||
bundled `static/weekly-challenge.json`.
|
|
||||||
- `GET /api/gamerewards/v1/pending` — pending rewards; `[]`.
|
|
||||||
- `GET /api/roomkeys/v1/mine` — the player's room keys; `[]`.
|
|
||||||
- `POST /api/CampusCard/v1/UpdateAndGetSubscription` — subscription lookup;
|
|
||||||
`{ subscription: null, platformAccountSubscribedPlayerId: null }`.
|
|
||||||
- Stubbed: `GET /api/roomconsumables/v1/roomConsumable/room/:id`
|
|
||||||
and `GET /api/roomcurrencies/v1/currencies` both return `[]`.
|
|
||||||
|
|
||||||
These economy routes are also served by the `api` worker; they're
|
`✓` = auth-gated (validates the Bearer JWT from the `auth` worker; empty-body 401 when
|
||||||
duplicated here because the client calls them on the `econ` host.
|
missing/invalid).
|
||||||
|
|
||||||
## TODO before production
|
| Method | Path | Auth | Description |
|
||||||
|
| -------- | ---------------------------------------------------- | ---- | --------------------------------------- |
|
||||||
|
| GET | `/api/avatar/v1/defaultunlocked` | | Default-unlocked avatar items (static) |
|
||||||
|
| GET | `/api/avatar/v1/defaultbaseavataritems` | | Default base avatar items (stub `[]`) |
|
||||||
|
| GET | `/api/avatar/v4/items` | ✓ | Owned items + the default catalog |
|
||||||
|
| GET | `/econ/customAvatarItems/v1/owned` | ✓ | Owned custom avatar items (stub) |
|
||||||
|
| GET | `/api/objectives/v1/myprogress` | | Objectives progress (static) |
|
||||||
|
| GET/POST | `/api/objectives/v1/cleargroup` | | Clear an objectives group (no-op `[]`) |
|
||||||
|
| GET | `/api/avatar/v2` | ✓ | The player's own avatar |
|
||||||
|
| POST | `/api/avatar/v2/set` | ✓ | Save the player's avatar |
|
||||||
|
| GET | `/api/checklist/v1/current` | ✓ | NUX checklist (stub `[]`) |
|
||||||
|
| GET | `/api/itemWishlists/v1/wishlist/me` | ✓ | Item wishlist (stub `[]`) |
|
||||||
|
| GET | `/api/avatar/v3/saved` | ✓ | Saved outfits |
|
||||||
|
| POST | `/api/avatar/v3/saved/set` | ✓ | Save an outfit into a slot |
|
||||||
|
| GET | `/api/avatar/v2/gifts` | ✓ | Pending (unopened) gift boxes |
|
||||||
|
| POST | `/api/avatar/v2/gifts/consume` | | Open a gift box → success envelope |
|
||||||
|
| GET | `/api/avatar/v2/:id` | | Another player's avatar (render subset) |
|
||||||
|
| GET | `/api/equipment/v2/getUnlocked` | | Unlocked equipment (stub `[]`) |
|
||||||
|
| GET | `/api/roomconsumables/v1/roomConsumable/room/:id` | | Room consumables (stub `[]`) |
|
||||||
|
| GET | `/api/roomconsumables/v1/roomConsumable/room/:id/me` | | Caller's room consumables (stub `[]`) |
|
||||||
|
| GET | `/api/roomcurrencies/v1/currencies` | | Room currencies (stub `[]`) |
|
||||||
|
| GET | `/api/roomcurrencies/v1/getAllBalances` | | Room balances (stub `[]`) |
|
||||||
|
| POST | `/api/settings/v2/set` | ✓ | Persist settings (accept-and-ack) |
|
||||||
|
| GET | `/api/consumables/v2/getUnlocked` | ✓ | Unlocked consumables |
|
||||||
|
| POST | `/api/consumables/v1/consume` | ✓ | Consume an owned consumable |
|
||||||
|
| GET | `/api/storefronts/v4/balance/:currencyType` | ✓ | Currency balance |
|
||||||
|
| GET | `/api/storefronts/v3/giftdropstore/:id` | | Gift-drop storefront catalog |
|
||||||
|
| POST | `/api/storefronts/v2/buyItem` | ✓ | Buy a storefront item |
|
||||||
|
| GET | `/api/storefronts/v1/adcarouselitems` | | Ad-carousel items (static) |
|
||||||
|
| GET | `/api/challenge/v2/getCurrent` | | Current weekly challenge (static) |
|
||||||
|
| POST | `/api/challenge/v2/updateProgress` | | Report challenge progress (stub) |
|
||||||
|
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) |
|
||||||
|
| POST | `/api/gamerewards/v1/request` | | Request a game reward (stub `[]`) |
|
||||||
|
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
|
||||||
|
| GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) |
|
||||||
|
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | | Subscription lookup (both null) |
|
||||||
|
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
|
||||||
|
|
||||||
- Gifting to another player (`buyItem` with a `Gift` block) grants the item and
|
The app runs with `strict: false`, so trailing-slash variants match (the client posts
|
||||||
box to the recipient, but there's no notification. `buyItem` grants avatar-item
|
`/gifts/consume/` with a trailing slash).
|
||||||
and consumable drops; currency/xp drops aren't granted yet.
|
|
||||||
- Consumables are granted and listed but never spent — nothing consumes them, so
|
## API documentation
|
||||||
`Count` only ever grows (each purchase grants `1`; catalogs don't specify a
|
|
||||||
per-item quantity).
|
`GET /openapi.json` serves a spec generated from `describeRoute` blocks alongside each
|
||||||
|
handler, with the schemas in `src/openapi.ts`. **Descriptive, not enforced** — same
|
||||||
|
rationale as the `auth`/`accounts`/`match` workers. A test asserts every route appears
|
||||||
|
in the spec, so adding one without documenting it fails.
|
||||||
|
|
||||||
|
## Purchases (`buyItem`)
|
||||||
|
|
||||||
|
The core flow. The client posts the storefront/item ids, the currency, and the
|
||||||
|
`RequestedPrice` it rendered; the handler:
|
||||||
|
|
||||||
|
1. looks the item up in `static/storefronts/sf{StorefrontType}.json`;
|
||||||
|
2. rejects a stale price (`409`) — this stops a stale or tampered client buying at a
|
||||||
|
price the catalog no longer offers;
|
||||||
|
3. debits the buyer **atomically** (`400` on insufficient balance);
|
||||||
|
4. grants the drop — an avatar item into the `inventory` table (own-once), a consumable
|
||||||
|
into the `consumable` table (each buy stacks a new instance); currency/xp drops
|
||||||
|
aren't granted yet;
|
||||||
|
5. returns a **gift box** and pushes a `StorefrontBalanceUpdate` over the socket.
|
||||||
|
|
||||||
|
Two things are easy to get wrong:
|
||||||
|
|
||||||
|
- **`Balance` in the response is the _change_ applied** (the negated price), not the
|
||||||
|
resulting total. The client reads its new total from `GET /balance/:type`.
|
||||||
|
- **Ownership is persisted at purchase**, not when the box is opened. Opening a box
|
||||||
|
(`/gifts/consume`) just deletes it — the item was already granted. So the grant never
|
||||||
|
waits on the cosmetic "open it" moment.
|
||||||
|
|
||||||
|
A `Gift` block routes the item (and box) to another player, but the caller always pays.
|
||||||
|
A self-buy or anonymous gift is attributed to the "Coach" system account (id 1).
|
||||||
|
|
||||||
|
## Consume envelopes
|
||||||
|
|
||||||
|
Both consume routes (`/gifts/consume`, `/consumables/consume`) always answer HTTP 200
|
||||||
|
with `{ error: "", success: true, value: null }` — even for a missing or already-gone
|
||||||
|
target. A captured real consume returns this envelope, not an empty body: the client
|
||||||
|
parses it to finish the action, so a bare 200 reads as a failure and the item never
|
||||||
|
finishes unlocking. Deletes are scoped to the caller, so an unauthenticated or
|
||||||
|
mismatched call is a harmless no-op (opening _another_ player's box is a 403).
|
||||||
|
|
||||||
|
## Bindings
|
||||||
|
|
||||||
|
| Binding | Type | Notes |
|
||||||
|
| ---------------------------- | -------------- | -------------------------------------------------------- |
|
||||||
|
| `DB` | D1 | Shared `recflare` database — balances, inventory, etc. |
|
||||||
|
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
|
||||||
|
| `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs |
|
||||||
|
| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub |
|
||||||
|
| `STARTING_TOKENS` | var | Optional; new-player token grant (default in balance-db) |
|
||||||
|
|
||||||
|
Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no code change.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- Gifting to another player grants the item and box but does not notify the recipient.
|
||||||
|
- `buyItem` grants avatar-item and consumable drops; currency/xp drops aren't granted.
|
||||||
|
- Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
|
||||||
|
- Several routes (room keys, wishlist, equipment, room consumables/currencies, game
|
||||||
|
rewards) are empty-list stubs pending their own stores.
|
||||||
|
|||||||
@@ -19,8 +19,13 @@
|
|||||||
"@repo/domain": "workspace:*",
|
"@repo/domain": "workspace:*",
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
|
"@standard-community/standard-json": "0.3.5",
|
||||||
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.12.27",
|
||||||
"workers-tagged-logger": "1.0.1"
|
"hono-openapi": "1.3.1",
|
||||||
|
"openapi-types": "12.1.3",
|
||||||
|
"workers-tagged-logger": "1.0.1",
|
||||||
|
"zod": "4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vitest-pool-workers": "0.16.20",
|
"@cloudflare/vitest-pool-workers": "0.16.20",
|
||||||
|
|||||||
+754
-298
@@ -1,4 +1,5 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import { consumeGift, createGift, getGift, getPendingGifts } from '@repo/domain'
|
import { consumeGift, createGift, getGift, getPendingGifts } from '@repo/domain'
|
||||||
@@ -8,7 +9,6 @@ import { validateAndGetAccountId } from '@repo/jwt'
|
|||||||
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
|
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
|
||||||
// as a value — the enum has no runtime dependencies.
|
// as a value — the enum has no runtime dependencies.
|
||||||
import { NotificationType } from '../../notify/src/notification-types'
|
import { NotificationType } from '../../notify/src/notification-types'
|
||||||
|
|
||||||
import adCarouselItems from '../static/ad-carousel-items.json'
|
import adCarouselItems from '../static/ad-carousel-items.json'
|
||||||
import defaultAvatarItems from '../static/default-avatar-items.json'
|
import defaultAvatarItems from '../static/default-avatar-items.json'
|
||||||
import defaultAvatar from '../static/default-avatar.json'
|
import defaultAvatar from '../static/default-avatar.json'
|
||||||
@@ -22,8 +22,36 @@ import {
|
|||||||
isSpendable,
|
isSpendable,
|
||||||
spendCurrency,
|
spendCurrency,
|
||||||
} from './balance-db'
|
} from './balance-db'
|
||||||
import { consumeConsumable, countConsumable, getConsumables, grantConsumable } from './consumables-db'
|
import {
|
||||||
|
consumeConsumable,
|
||||||
|
countConsumable,
|
||||||
|
getConsumables,
|
||||||
|
grantConsumable,
|
||||||
|
} from './consumables-db'
|
||||||
import { getInventory, grantItem } from './inventory-db'
|
import { getInventory, grantItem } from './inventory-db'
|
||||||
|
import {
|
||||||
|
AUTHED,
|
||||||
|
AvatarV2Dto,
|
||||||
|
BalanceEntry,
|
||||||
|
BuyItemRequest,
|
||||||
|
BuyItemResponse,
|
||||||
|
ChallengeProgressRequest,
|
||||||
|
ChallengeProgressResponse,
|
||||||
|
ConsumeConsumableRequest,
|
||||||
|
ConsumeEnvelope,
|
||||||
|
ConsumeGiftRequest,
|
||||||
|
CustomAvatarItemsResponse,
|
||||||
|
ErrorResponse,
|
||||||
|
form,
|
||||||
|
json,
|
||||||
|
JsonArray,
|
||||||
|
jsonBody,
|
||||||
|
JsonObject,
|
||||||
|
OpaqueJsonBody,
|
||||||
|
SaveOutfitRequest,
|
||||||
|
SubscriptionResponse,
|
||||||
|
UNAUTHORIZED_RESPONSE,
|
||||||
|
} from './openapi'
|
||||||
import { getOutfits, setOutfit } from './outfit-db'
|
import { getOutfits, setOutfit } from './outfit-db'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
@@ -36,10 +64,12 @@ import type { Outfit } from './outfit-db'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
|
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
|
||||||
* the `econ` service (these are separate from the main `api` worker). DB-backed
|
* the `econ` service (these are separate from the main `api` worker). Balances,
|
||||||
* data is stubbed for now — no bindings yet.
|
* inventory, consumables, saved outfits, avatars and gift boxes 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 still validate the Bearer JWT issued by the `auth` worker.
|
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -280,6 +310,24 @@ function toGiftContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
// 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.
|
// posts with a trailing slash) match either form. Mirrors the `api` worker.
|
||||||
const app = new Hono<App>({ strict: false })
|
const app = new Hono<App>({ strict: false })
|
||||||
@@ -297,85 +345,196 @@ const app = new Hono<App>({ strict: false })
|
|||||||
.notFound(withNotFound())
|
.notFound(withNotFound())
|
||||||
|
|
||||||
// Default-unlocked avatar items, served from the bundled static JSON.
|
// Default-unlocked avatar items, served from the bundled static JSON.
|
||||||
.get('/api/avatar/v1/defaultunlocked', (c) => c.json(defaultAvatarItems))
|
.get(
|
||||||
|
'/api/avatar/v1/defaultunlocked',
|
||||||
|
listRoute('Default-unlocked avatar items', 'The bundled default avatar-item catalog'),
|
||||||
|
(c) => c.json(defaultAvatarItems)
|
||||||
|
)
|
||||||
|
|
||||||
// Default base avatar items — empty stub for now. No auth.
|
// Default base avatar items — empty stub for now. No auth.
|
||||||
.get('/api/avatar/v1/defaultbaseavataritems', (c) => c.json([]))
|
.get(
|
||||||
|
'/api/avatar/v1/defaultbaseavataritems',
|
||||||
|
listRoute('Default base avatar items', 'Empty stub for now'),
|
||||||
|
(c) => c.json([])
|
||||||
|
)
|
||||||
|
|
||||||
// The player's avatar items — the items they've bought (from `buyItem`, stored in
|
// 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
|
// the inventory table) prepended to the default catalog. A player who has bought
|
||||||
// nothing gets just the catalog.
|
// nothing gets just the catalog.
|
||||||
.get('/api/avatar/v4/items', async (c) => {
|
.get(
|
||||||
const id = await authedId(c)
|
'/api/avatar/v4/items',
|
||||||
if (id === null) return unauthorized(c)
|
describeRoute({
|
||||||
const owned = await getInventory(c.env.DB, id)
|
tags: ['Avatar'],
|
||||||
return c.json([...owned, ...defaultAvatarItems])
|
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.',
|
||||||
|
security: AUTHED,
|
||||||
|
responses: {
|
||||||
|
200: json(JsonArray, '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])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// The player's owned custom avatar items. [Authorize]; paginated. Empty stub for
|
// 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
|
// 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".
|
// allowed; a 404 here surfaces as "Failed to download unlocked avatar items".
|
||||||
.get('/econ/customAvatarItems/v1/owned', async (c) => {
|
.get(
|
||||||
const id = await authedId(c)
|
'/econ/customAvatarItems/v1/owned',
|
||||||
if (id === null) return unauthorized(c)
|
describeRoute({
|
||||||
return c.json({ Results: [], TotalResults: 0 })
|
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”.',
|
||||||
|
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
|
// 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
|
// no auth — same default for everyone until there's a DB binding to track
|
||||||
// per-player progress.
|
// per-player progress.
|
||||||
.get('/api/objectives/v1/myprogress', (c) => c.json(myProgress))
|
.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
|
// 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
|
// 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.
|
// GET or POST since the client may use either.
|
||||||
.on(['GET', 'POST'], '/api/objectives/v1/cleargroup', (c) => c.json([]))
|
.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([])
|
||||||
|
)
|
||||||
|
|
||||||
// The player's avatar, stored as a JSON blob on their account row. Falls back
|
// 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
|
// to the default outfit when they haven't saved one — the client's parser NREs
|
||||||
// on an empty OutfitSelections (real RecNet never returns one).
|
// on an empty OutfitSelections (real RecNet never returns one).
|
||||||
.get('/api/avatar/v2', async (c) => {
|
.get(
|
||||||
const id = await authedId(c)
|
'/api/avatar/v2',
|
||||||
if (id === null) return unauthorized(c)
|
describeRoute({
|
||||||
return c.json((await getAvatar(c.env.DB, id)) ?? defaultAvatar)
|
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).',
|
||||||
|
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
|
// 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
|
// 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.
|
// caller has no account row to attach it to.
|
||||||
.post('/api/avatar/v2/set', async (c) => {
|
.post(
|
||||||
const id = await authedId(c)
|
'/api/avatar/v2/set',
|
||||||
if (id === null) return unauthorized(c)
|
describeRoute({
|
||||||
const avatar = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
tags: ['Avatar'],
|
||||||
if (avatar === null || typeof avatar !== 'object' || Array.isArray(avatar)) {
|
summary: 'Save the player’s avatar',
|
||||||
return c.body(null, 400)
|
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)
|
||||||
}
|
}
|
||||||
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. []
|
// NUX checklist — the client fetches this on the econ host during load. []
|
||||||
// with no DB. A 404 here can abort the load orchestration before matchmake.
|
// with no DB. A 404 here can abort the load orchestration before matchmake.
|
||||||
.get('/api/checklist/v1/current', async (c) => {
|
.get(
|
||||||
const id = await authedId(c)
|
'/api/checklist/v1/current',
|
||||||
if (id === null) return unauthorized(c)
|
listRoute('NUX checklist', 'The new-user checklist; [] for now. A 404 can abort load.', true),
|
||||||
return c.json([])
|
async (c) => {
|
||||||
})
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
return c.json([])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// The player's item wishlist. [Authorize]; empty without a DB binding.
|
// The player's item wishlist. [Authorize]; empty without a DB binding.
|
||||||
.get('/api/itemWishlists/v1/wishlist/me', async (c) => {
|
.get(
|
||||||
const id = await authedId(c)
|
'/api/itemWishlists/v1/wishlist/me',
|
||||||
if (id === null) return unauthorized(c)
|
listRoute('The player’s item wishlist', 'Empty for now', true),
|
||||||
return c.json([])
|
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
|
// The player's saved outfits. [Authorize]. Served back as the client posted them
|
||||||
// (see /saved/set); a player who has saved none gets [].
|
// (see /saved/set); a player who has saved none gets [].
|
||||||
.get('/api/avatar/v3/saved', async (c) => {
|
.get(
|
||||||
const id = await authedId(c)
|
'/api/avatar/v3/saved',
|
||||||
if (id === null) return unauthorized(c)
|
describeRoute({
|
||||||
return c.json(await getOutfits(c.env.DB, id))
|
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
|
// 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
|
// the slot to write, and re-saving a slot overwrites it — that's the avatar screen's
|
||||||
@@ -385,28 +544,62 @@ const app = new Hono<App>({ strict: false })
|
|||||||
//
|
//
|
||||||
// A missing/non-integer `Slot` is a 400 rather than a default slot — guessing would
|
// 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.
|
// silently overwrite an outfit the player didn't mean to touch.
|
||||||
.post('/api/avatar/v3/saved/set', async (c) => {
|
.post(
|
||||||
const id = await authedId(c)
|
'/api/avatar/v3/saved/set',
|
||||||
if (id === null) return unauthorized(c)
|
describeRoute({
|
||||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
tags: ['Avatar'],
|
||||||
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
summary: 'Save an outfit into a slot',
|
||||||
return c.body(null, 400)
|
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).',
|
||||||
|
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 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 c.json(outfit)
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(body.Slot)) return c.body(null, 400)
|
)
|
||||||
const outfit = body as Outfit
|
|
||||||
await setOutfit(c.env.DB, id, outfit)
|
|
||||||
return c.json(outfit)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Pending avatar gifts for the player — the unopened gift boxes from their purchases
|
// 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
|
// (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
|
// box and consumes it via the consume route below; the item itself was already
|
||||||
// granted at purchase, so an unopened box is cosmetic.
|
// granted at purchase, so an unopened box is cosmetic.
|
||||||
.get('/api/avatar/v2/gifts', async (c) => {
|
.get(
|
||||||
const id = await authedId(c)
|
'/api/avatar/v2/gifts',
|
||||||
if (id === null) return unauthorized(c)
|
describeRoute({
|
||||||
return c.json(await getPendingGifts(c.env.DB, id))
|
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.',
|
||||||
|
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
|
// 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
|
// the box animation, form-encoded as `Id=<giftId>&UnlockedLevel=<n>`. Opening just
|
||||||
@@ -421,86 +614,188 @@ const app = new Hono<App>({ strict: false })
|
|||||||
// as a failure and the consumable never finishes unlocking. The delete is scoped to the
|
// 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
|
// 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).
|
// the same route on the `api` worker (the client may call either host).
|
||||||
.post('/api/avatar/v2/gifts/consume', async (c) => {
|
.post(
|
||||||
const id = await authedId(c)
|
'/api/avatar/v2/gifts/consume',
|
||||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
describeRoute({
|
||||||
const giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0
|
tags: ['Gifts'],
|
||||||
if (id !== null && giftId !== 0) {
|
summary: 'Open (consume) a gift box',
|
||||||
// Scoped delete: only the box's owner deletes it. A returned box means it was
|
description:
|
||||||
// theirs and is now consumed.
|
'Deletes the box (the item was already granted at purchase). Always answers the ' +
|
||||||
const gift = await consumeGift(c.env.DB, id, giftId)
|
'`{ error, success, value }` envelope with HTTP 200 — even with no token, a zero id, ' +
|
||||||
if (gift !== null) {
|
'or a box already gone — because the client parses it to finish opening the box. The ' +
|
||||||
// If the box carried a consumable, tell the client it now has it (so it shows
|
'delete is scoped to the caller; opening someone else’s box is 403. Also served by ' +
|
||||||
// up in inventory without a refetch). Avatar-item boxes carry no ConsumableItemDesc.
|
'the `api` worker.',
|
||||||
if (gift.ConsumableItemDesc !== '') await pushConsumableAdded(c, id, gift)
|
requestBody: form(ConsumeGiftRequest, 'The gift-box id'),
|
||||||
} else {
|
responses: {
|
||||||
// Nothing was consumed: either the box is already gone (a harmless no-op —
|
200: json(ConsumeEnvelope, 'Success envelope'),
|
||||||
// re-opening your own consumed box still succeeds) or it belongs to another
|
403: { description: 'The box belongs to another player (empty body)' },
|
||||||
// player, which is forbidden.
|
},
|
||||||
const other = await getGift(c.env.DB, giftId)
|
}),
|
||||||
if (other !== null && other.accountId !== id) return c.body(null, 403)
|
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 })
|
||||||
}
|
}
|
||||||
return c.json({ error: '', success: true, value: null })
|
)
|
||||||
})
|
|
||||||
|
|
||||||
// A player's avatar by account id, projected to the public render subset (used
|
// 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`
|
// 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.
|
// 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.
|
// Registered after the static `/api/avatar/v2/*` routes so `:id` can't shadow them.
|
||||||
.get('/api/avatar/v2/:id', async (c) => {
|
.get(
|
||||||
const accountId = Number.parseInt(c.req.param('id'), 10)
|
'/api/avatar/v2/:id',
|
||||||
if (Number.isNaN(accountId)) return c.body(null, 400)
|
describeRoute({
|
||||||
return c.json(toAvatarV2Dto((await getAvatar(c.env.DB, accountId)) ?? defaultAvatar))
|
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.',
|
||||||
|
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. Returns "[]" with no auth.
|
// Unlocked equipment. Returns "[]" with no auth.
|
||||||
.get('/api/equipment/v2/getUnlocked', (c) => c.json([]))
|
.get('/api/equipment/v2/getUnlocked', listRoute('Unlocked equipment', 'Empty for now'), (c) =>
|
||||||
|
c.json([])
|
||||||
|
)
|
||||||
|
|
||||||
// Room consumables/currencies for a given room. Stubbed as empty lists so the
|
// Room consumables/currencies for a given room. Stubbed as empty lists so the
|
||||||
// client doesn't 404.
|
// client doesn't 404.
|
||||||
.get('/api/roomconsumables/v1/roomConsumable/room/:roomId', (c) => c.json([]))
|
.get(
|
||||||
.get('/api/roomconsumables/v1/roomConsumable/room/:roomId/me', (c) => c.json([]))
|
'/api/roomconsumables/v1/roomConsumable/room/:roomId',
|
||||||
.get('/api/roomcurrencies/v1/currencies', (c) => c.json([]))
|
listRoute('Room consumables', 'Empty stub so the client doesn’t 404'),
|
||||||
.get('/api/roomcurrencies/v1/getAllBalances', (c) => c.json([]))
|
(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([])
|
||||||
|
)
|
||||||
|
|
||||||
// Persist player settings. [Authorize]; would replace the player's settings.
|
// Persist player settings. [Authorize]; would replace the player's settings.
|
||||||
// No DB binding yet, so accept-and-ack.
|
// No DB binding yet, so accept-and-ack.
|
||||||
.post('/api/settings/v2/set', async (c) => {
|
.post(
|
||||||
const id = await authedId(c)
|
'/api/settings/v2/set',
|
||||||
if (id === null) return unauthorized(c)
|
describeRoute({
|
||||||
// TODO: replace stored settings for `id` once a DB binding exists.
|
tags: ['Econ'],
|
||||||
return c.body(null, 200)
|
summary: 'Persist player settings',
|
||||||
})
|
description: 'Accept-and-ack — no settings store yet. Empty 200.',
|
||||||
|
security: AUTHED,
|
||||||
|
requestBody: jsonBody(OpaqueJsonBody, 'The settings payload (currently ignored)'),
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Acknowledged (empty body)' },
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
// TODO: replace stored settings for `id` once a DB binding exists.
|
||||||
|
return c.body(null, 200)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Unlocked consumables. [Authorize]. The consumables the player has bought (from
|
// Unlocked consumables. [Authorize]. The consumables the player has bought (from
|
||||||
// `buyItem`, stored in the `consumable` table), grouped by item into the client's
|
// `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.
|
// unlocked-consumable DTO. A player who has bought none gets an empty list.
|
||||||
.get('/api/consumables/v2/getUnlocked', async (c) => {
|
.get(
|
||||||
const id = await authedId(c)
|
'/api/consumables/v2/getUnlocked',
|
||||||
if (id === null) return unauthorized(c)
|
describeRoute({
|
||||||
return c.json(await getConsumables(c.env.DB, id))
|
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.',
|
||||||
|
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
|
// 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
|
// `{ 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
|
// 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.
|
// they can only consume their own. Envelope mirrors the gift-consume ack.
|
||||||
.post('/api/consumables/v1/consume', async (c) => {
|
.post(
|
||||||
const id = await authedId(c)
|
'/api/consumables/v1/consume',
|
||||||
if (id === null) return unauthorized(c)
|
describeRoute({
|
||||||
const body = await c.req
|
tags: ['Consumables'],
|
||||||
.json<{ Id?: unknown; DeltaCount?: unknown }>()
|
summary: 'Consume a quantity of an owned consumable',
|
||||||
.catch(() => ({}) as { Id?: unknown; DeltaCount?: unknown })
|
description:
|
||||||
const consumableId = typeof body.Id === 'number' ? body.Id : Number.NaN
|
'Reduces the given consumable instance’s count by `DeltaCount` (default 1), deleting ' +
|
||||||
const delta = typeof body.DeltaCount === 'number' ? body.DeltaCount : 1
|
'the row at zero. Scoped to the caller. Pushes a ConsumableMappingRemoved socket ' +
|
||||||
if (!Number.isNaN(consumableId) && delta > 0) {
|
'notification. Envelope mirrors the gift-consume ack.',
|
||||||
const consumed = await consumeConsumable(c.env.DB, id, consumableId, delta)
|
security: AUTHED,
|
||||||
// Notify the player so their client removes/updates the item in inventory.
|
requestBody: jsonBody(ConsumeConsumableRequest, 'The consumable id and delta'),
|
||||||
if (consumed !== null) await pushConsumableRemoved(c, id, consumed)
|
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 })
|
||||||
}
|
}
|
||||||
return c.json({ error: '', success: true, value: null })
|
)
|
||||||
})
|
|
||||||
|
|
||||||
// Currency balance. [Authorize]. The trailing int is a CurrencyType — the client
|
// Currency balance. [Authorize]. The trailing int is a CurrencyType — the client
|
||||||
// fetches `/balance/2` (RecCenterTokens) on load. Backed by the `balance` table; a
|
// fetches `/balance/2` (RecCenterTokens) on load. Backed by the `balance` table; a
|
||||||
@@ -509,30 +804,77 @@ const app = new Hono<App>({ strict: false })
|
|||||||
// An unknown or non-account-scoped currency (a room currency, ProgressionEvent,
|
// 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
|
// 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.
|
// fetch as a load error, and "you have none of that" is the honest answer anyway.
|
||||||
.get('/api/storefronts/v4/balance/:currencyType', async (c) => {
|
.get(
|
||||||
const id = await authedId(c)
|
'/api/storefronts/v4/balance/:currencyType',
|
||||||
if (id === null) return unauthorized(c)
|
describeRoute({
|
||||||
const currencyType = Number.parseInt(c.req.param('currencyType'), 10)
|
tags: ['Storefront'],
|
||||||
if (Number.isNaN(currencyType)) return c.body(null, 400)
|
summary: 'Currency balance',
|
||||||
const amount = isSpendable(currencyType)
|
description:
|
||||||
? await getBalance(
|
'The player’s balance in a CurrencyType (the client fetches `/balance/2`, ' +
|
||||||
c.env.DB,
|
'RecCenterTokens, on load). A first read seeds their starting balance. An unknown or ' +
|
||||||
id,
|
'non-account currency returns a 0 balance rather than 404.',
|
||||||
currencyType,
|
security: AUTHED,
|
||||||
intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
parameters: [
|
||||||
)
|
{
|
||||||
: 0
|
name: 'currencyType',
|
||||||
return c.json([{ CurrencyType: currencyType, Platform: ALL_PLATFORMS, Balance: amount }])
|
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
|
// Gift-drop storefront. Serves `static/storefronts/sf{id}.json` for the requested
|
||||||
// storefront id via the ASSETS binding; 404s when no such catalog exists.
|
// storefront id via the ASSETS binding; 404s when no such catalog exists.
|
||||||
.get('/api/storefronts/v3/giftdropstore/:id', async (c) => {
|
.get(
|
||||||
const id = c.req.param('id')
|
'/api/storefronts/v3/giftdropstore/:id',
|
||||||
const res = await c.env.ASSETS.fetch(new URL(`/sf${id}.json`, c.req.url))
|
describeRoute({
|
||||||
if (!res.ok) return c.notFound()
|
tags: ['Storefront'],
|
||||||
return c.json(await res.json())
|
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
|
// 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,
|
// currency and the price it sees; we look the item up in that storefront's catalog,
|
||||||
@@ -546,154 +888,196 @@ const app = new Hono<App>({ strict: false })
|
|||||||
//
|
//
|
||||||
// `RequestedPrice` is the price the client rendered; rejecting a mismatch stops a stale
|
// `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.
|
// client (or a tampered request) from buying at a price the catalog no longer offers.
|
||||||
.post('/api/storefronts/v2/buyItem', async (c) => {
|
.post(
|
||||||
const id = await authedId(c)
|
'/api/storefronts/v2/buyItem',
|
||||||
if (id === null) return unauthorized(c)
|
describeRoute({
|
||||||
|
tags: ['Storefront'],
|
||||||
|
summary: 'Buy a storefront item',
|
||||||
|
description:
|
||||||
|
'Looks the item up in its storefront catalog, confirms the client’s `RequestedPrice` ' +
|
||||||
|
'still matches, 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 StorefrontBalanceUpdate socket notification.',
|
||||||
|
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
|
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||||
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
||||||
return c.json({ error: 'Invalid request body' }, 400)
|
return c.json({ error: 'Invalid request body' }, 400)
|
||||||
}
|
}
|
||||||
const storefrontType = body.StorefrontType
|
const storefrontType = body.StorefrontType
|
||||||
const purchasableItemId = body.PurchasableItemId
|
const purchasableItemId = body.PurchasableItemId
|
||||||
const currencyType = body.CurrencyType
|
const currencyType = body.CurrencyType
|
||||||
const requestedPrice = body.RequestedPrice
|
const requestedPrice = body.RequestedPrice
|
||||||
if (
|
if (
|
||||||
!Number.isInteger(storefrontType) ||
|
!Number.isInteger(storefrontType) ||
|
||||||
!Number.isInteger(purchasableItemId) ||
|
!Number.isInteger(purchasableItemId) ||
|
||||||
!Number.isInteger(currencyType) ||
|
!Number.isInteger(currencyType) ||
|
||||||
!Number.isInteger(requestedPrice)
|
!Number.isInteger(requestedPrice)
|
||||||
) {
|
) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: 'StorefrontType, PurchasableItemId, CurrencyType and RequestedPrice are required',
|
error:
|
||||||
},
|
'StorefrontType, PurchasableItemId, CurrencyType and RequestedPrice are required',
|
||||||
400
|
},
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await findStoreItem(c, storefrontType as number, purchasableItemId as number)
|
||||||
|
if (item === null) return c.json({ error: 'Item not found' }, 404)
|
||||||
|
|
||||||
|
const price = item.Prices.find((p) => p.CurrencyType === currencyType)
|
||||||
|
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)
|
||||||
|
|
||||||
const item = await findStoreItem(c, storefrontType as number, purchasableItemId as number)
|
// Grant the item to the recipient. A gift-drop carries an avatar item, a consumable,
|
||||||
if (item === null) return c.json({ error: 'Item not found' }, 404)
|
// or neither (currency/xp drops aren't granted yet); grant whichever it actually has.
|
||||||
|
if (typeof item.GiftDrop.AvatarItemDesc === 'string' && item.GiftDrop.AvatarItemDesc !== '') {
|
||||||
const price = item.Prices.find((p) => p.CurrencyType === currencyType)
|
await grantItem(c.env.DB, receiverId, toAvatarItem(item.GiftDrop))
|
||||||
if (price === undefined) {
|
}
|
||||||
return c.json({ error: 'Currency type not available for this item' }, 400)
|
const isConsumable =
|
||||||
}
|
typeof item.GiftDrop.ConsumableItemDesc === 'string' &&
|
||||||
if (price.Price !== requestedPrice) {
|
item.GiftDrop.ConsumableItemDesc !== ''
|
||||||
return c.json({ error: 'Price has changed' }, 409)
|
const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0
|
||||||
}
|
// Capture the granted consumable's row id and the player's pre-existing count so
|
||||||
// The item's currency must be an account balance we can debit (RecCenterTokens et al),
|
// the gift box can carry them — gift-consume fires ConsumableMappingAdded from these.
|
||||||
// not a room-scoped or non-spendable currency.
|
let consumableMappingId = 0
|
||||||
if (!isSpendable(currencyType as number)) {
|
let consumablePreExisting = 0
|
||||||
return c.json({ error: 'Currency type is not spendable' }, 400)
|
if (isConsumable) {
|
||||||
}
|
consumablePreExisting = await countConsumable(
|
||||||
|
c.env.DB,
|
||||||
const gift = (
|
receiverId,
|
||||||
typeof body.Gift === 'object' && body.Gift !== null ? body.Gift : null
|
item.GiftDrop.ConsumableItemDesc
|
||||||
) as GiftRequest | null
|
)
|
||||||
const receiverId = Number.isInteger(gift?.ToPlayerId) ? (gift?.ToPlayerId as number) : id
|
consumableMappingId = await grantConsumable(
|
||||||
// A named (non-anonymous) gift shows the sender; a self-purchase or an anonymous gift
|
c.env.DB,
|
||||||
// is attributed to the "Coach" system account (id 1), never a null/0 sender.
|
receiverId,
|
||||||
const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
|
item.GiftDrop.ConsumableItemDesc,
|
||||||
const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3'
|
consumableCount
|
||||||
|
)
|
||||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
}
|
||||||
// Debit the buyer atomically; a false return means they couldn't afford it and
|
const { id: giftId } = await createGift(
|
||||||
// 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. A gift-drop carries an avatar item, a consumable,
|
|
||||||
// or neither (currency/xp drops aren't granted yet); grant whichever it actually has.
|
|
||||||
if (typeof item.GiftDrop.AvatarItemDesc === 'string' && item.GiftDrop.AvatarItemDesc !== '') {
|
|
||||||
await grantItem(c.env.DB, receiverId, toAvatarItem(item.GiftDrop))
|
|
||||||
}
|
|
||||||
const isConsumable =
|
|
||||||
typeof item.GiftDrop.ConsumableItemDesc === 'string' &&
|
|
||||||
item.GiftDrop.ConsumableItemDesc !== ''
|
|
||||||
const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0
|
|
||||||
// Capture the granted consumable's row id and the player's pre-existing count so
|
|
||||||
// the gift box can carry them — gift-consume fires ConsumableMappingAdded from these.
|
|
||||||
let consumableMappingId = 0
|
|
||||||
let consumablePreExisting = 0
|
|
||||||
if (isConsumable) {
|
|
||||||
consumablePreExisting = await countConsumable(
|
|
||||||
c.env.DB,
|
c.env.DB,
|
||||||
receiverId,
|
receiverId,
|
||||||
item.GiftDrop.ConsumableItemDesc
|
toGiftContent(
|
||||||
)
|
item.GiftDrop,
|
||||||
consumableMappingId = await grantConsumable(
|
message,
|
||||||
c.env.DB,
|
consumableCount,
|
||||||
receiverId,
|
consumableMappingId,
|
||||||
item.GiftDrop.ConsumableItemDesc,
|
consumablePreExisting
|
||||||
consumableCount
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Push the buyer's new (reduced) balance over the socket so their client updates the
|
||||||
|
// shown total immediately — the buyer (`id`) is who was debited, in the currency they
|
||||||
|
// spent. Best-effort; the HTTP response still carries the change either way.
|
||||||
|
const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
|
||||||
|
await pushBalanceUpdate(c, id, currencyType as number, 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). The Data
|
||||||
|
// entry is the gift-drop the client received — it carries no FriendlyName or
|
||||||
|
// consumable count (the count is a getUnlocked concept; each box is one instance).
|
||||||
|
return c.json({
|
||||||
|
BalanceUpdates: [
|
||||||
|
{
|
||||||
|
UpdateResponse: 0,
|
||||||
|
Data: [
|
||||||
|
{
|
||||||
|
Id: giftId,
|
||||||
|
FromPlayerId: fromPlayerId,
|
||||||
|
ConsumableItemDesc: item.GiftDrop.ConsumableItemDesc,
|
||||||
|
AvatarItemDesc: item.GiftDrop.AvatarItemDesc,
|
||||||
|
AvatarItemType: item.GiftDrop.AvatarItemType ?? 0,
|
||||||
|
EquipmentPrefabName: item.GiftDrop.EquipmentPrefabName,
|
||||||
|
EquipmentModificationGuid: item.GiftDrop.EquipmentModificationGuid,
|
||||||
|
CurrencyType: item.GiftDrop.CurrencyType,
|
||||||
|
Currency: item.GiftDrop.Currency,
|
||||||
|
Xp: 0,
|
||||||
|
Level: 0,
|
||||||
|
Platform: -1,
|
||||||
|
PlatformsToSpawnOn: -1,
|
||||||
|
BalanceType: ALL_PLATFORMS,
|
||||||
|
GiftContext: Number.isInteger(gift?.GiftContext)
|
||||||
|
? (gift?.GiftContext as number)
|
||||||
|
: item.GiftDrop.Context,
|
||||||
|
GiftRarity: item.GiftDrop.Rarity,
|
||||||
|
Message: message,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Balance: -price.Price,
|
||||||
|
CurrencyType: currencyType,
|
||||||
|
BalanceType: ALL_PLATFORMS,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const { id: giftId } = await createGift(
|
)
|
||||||
c.env.DB,
|
|
||||||
receiverId,
|
|
||||||
toGiftContent(item.GiftDrop, message, consumableCount, consumableMappingId, consumablePreExisting)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Push the buyer's new (reduced) balance over the socket so their client updates the
|
|
||||||
// shown total immediately — the buyer (`id`) is who was debited, in the currency they
|
|
||||||
// spent. Best-effort; the HTTP response still carries the change either way.
|
|
||||||
const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
|
|
||||||
await pushBalanceUpdate(c, id, currencyType as number, 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). The Data
|
|
||||||
// entry is the gift-drop the client received — it carries no FriendlyName or
|
|
||||||
// consumable count (the count is a getUnlocked concept; each box is one instance).
|
|
||||||
return c.json({
|
|
||||||
BalanceUpdates: [
|
|
||||||
{
|
|
||||||
UpdateResponse: 0,
|
|
||||||
Data: [
|
|
||||||
{
|
|
||||||
Id: giftId,
|
|
||||||
FromPlayerId: fromPlayerId,
|
|
||||||
ConsumableItemDesc: item.GiftDrop.ConsumableItemDesc,
|
|
||||||
AvatarItemDesc: item.GiftDrop.AvatarItemDesc,
|
|
||||||
AvatarItemType: item.GiftDrop.AvatarItemType ?? 0,
|
|
||||||
EquipmentPrefabName: item.GiftDrop.EquipmentPrefabName,
|
|
||||||
EquipmentModificationGuid: item.GiftDrop.EquipmentModificationGuid,
|
|
||||||
CurrencyType: item.GiftDrop.CurrencyType,
|
|
||||||
Currency: item.GiftDrop.Currency,
|
|
||||||
Xp: 0,
|
|
||||||
Level: 0,
|
|
||||||
Platform: -1,
|
|
||||||
PlatformsToSpawnOn: -1,
|
|
||||||
BalanceType: ALL_PLATFORMS,
|
|
||||||
GiftContext: Number.isInteger(gift?.GiftContext)
|
|
||||||
? (gift?.GiftContext as number)
|
|
||||||
: item.GiftDrop.Context,
|
|
||||||
GiftRarity: item.GiftDrop.Rarity,
|
|
||||||
Message: message,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
Balance: -price.Price,
|
|
||||||
CurrencyType: currencyType,
|
|
||||||
BalanceType: ALL_PLATFORMS,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Storefront ad-carousel items. Served from the bundled static JSON — one
|
// Storefront ad-carousel items. Served from the bundled static JSON — one
|
||||||
// placeholder banner with no purchasable items until real promo data exists.
|
// placeholder banner with no purchasable items until real promo data exists.
|
||||||
.get('/api/storefronts/v1/adcarouselitems', (c) => c.json(adCarouselItems))
|
.get(
|
||||||
|
'/api/storefronts/v1/adcarouselitems',
|
||||||
|
listRoute('Storefront ad-carousel items', 'The bundled carousel (one placeholder banner)'),
|
||||||
|
(c) => c.json(adCarouselItems)
|
||||||
|
)
|
||||||
|
|
||||||
// Current weekly challenge. Served from the bundled static JSON until
|
// Current weekly challenge. Served from the bundled static JSON until
|
||||||
// per-rotation challenge data is wired up.
|
// per-rotation challenge data is wired up.
|
||||||
.get('/api/challenge/v2/getCurrent', (c) => c.json(weeklyChallenge))
|
.get(
|
||||||
|
'/api/challenge/v2/getCurrent',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Econ'],
|
||||||
|
summary: 'Current weekly challenge',
|
||||||
|
description: 'Served from the bundled static challenge until per-rotation data is wired up.',
|
||||||
|
responses: { 200: json(JsonObject, 'The current weekly challenge') },
|
||||||
|
}),
|
||||||
|
(c) => c.json(weeklyChallenge)
|
||||||
|
)
|
||||||
|
|
||||||
// Report progress on a weekly challenge. The client evaluates the challenge's rule
|
// Report progress on a weekly challenge. The client evaluates the challenge's rule
|
||||||
// tree locally and posts ChallengeMapId/ChallengeId, that tree in `Config`, and
|
// tree locally and posts ChallengeMapId/ChallengeId, that tree in `Config`, and
|
||||||
@@ -701,34 +1085,106 @@ const app = new Hono<App>({ strict: false })
|
|||||||
// progress DB yet we persist nothing and never mark a challenge complete (so the
|
// progress DB yet we persist nothing and never mark a challenge complete (so the
|
||||||
// gift flow isn't triggered). Echo the identifying fields back with Complete=false
|
// gift flow isn't triggered). Echo the identifying fields back with Complete=false
|
||||||
// so the client gets a well-formed, non-null body to deserialize.
|
// so the client gets a well-formed, non-null body to deserialize.
|
||||||
.post('/api/challenge/v2/updateProgress', async (c) => {
|
.post(
|
||||||
const body = await c.req
|
'/api/challenge/v2/updateProgress',
|
||||||
.json<{ ChallengeMapId?: string | number; ChallengeId?: string | number; Config?: string }>()
|
describeRoute({
|
||||||
.catch(() => ({}) as Record<string, never>)
|
tags: ['Econ'],
|
||||||
return c.json({
|
summary: 'Report weekly-challenge progress',
|
||||||
ChallengeMapId: Number(body.ChallengeMapId) || 0,
|
description:
|
||||||
ChallengeId: Number(body.ChallengeId) || 0,
|
'Stubbed: with no challenge-progress store we persist nothing and never mark a ' +
|
||||||
Config: typeof body.Config === 'string' ? body.Config : '',
|
'challenge complete. Echoes the identifying fields back with `Complete: false` so the ' +
|
||||||
Complete: false,
|
'client gets a well-formed body.',
|
||||||
})
|
requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'),
|
||||||
})
|
responses: { 200: json(ChallengeProgressResponse, 'Echoed fields, Complete false') },
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const body = await c.req
|
||||||
|
.json<{
|
||||||
|
ChallengeMapId?: string | number
|
||||||
|
ChallengeId?: string | number
|
||||||
|
Config?: string
|
||||||
|
}>()
|
||||||
|
.catch(() => ({}) as Record<string, never>)
|
||||||
|
return c.json({
|
||||||
|
ChallengeMapId: Number(body.ChallengeMapId) || 0,
|
||||||
|
ChallengeId: Number(body.ChallengeId) || 0,
|
||||||
|
Config: typeof body.Config === 'string' ? body.Config : '',
|
||||||
|
Complete: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Pending game rewards. Returns "[]".
|
// Pending game rewards. Returns "[]".
|
||||||
.get('/api/gamerewards/v1/pending', (c) => c.json([]))
|
.get('/api/gamerewards/v1/pending', listRoute('Pending game rewards', 'Empty for now'), (c) =>
|
||||||
|
c.json([])
|
||||||
|
)
|
||||||
|
|
||||||
// Request a game reward (client posts `rewardType`/`Message`, e.g.
|
// Request a game reward (client posts `rewardType`/`Message`, e.g.
|
||||||
// FirstActivityOfDay). Stubbed: with no reward DB yet we grant nothing and return an
|
// FirstActivityOfDay). Stubbed: with no reward DB yet we grant nothing and return an
|
||||||
// empty list of rewards — matching the `pending` shape so the client deserializes it.
|
// empty list of rewards — matching the `pending` shape so the client deserializes it.
|
||||||
.post('/api/gamerewards/v1/request', (c) => c.json([]))
|
.post(
|
||||||
|
'/api/gamerewards/v1/request',
|
||||||
// The player's room keys. Returns "[]".
|
listRoute('Request a game reward', 'Stubbed — grants nothing, returns []'),
|
||||||
.get('/api/roomkeys/v1/mine', (c) => c.json([]))
|
(c) => c.json([])
|
||||||
// Room keys for a given room (client calls this on the econ host). [] with no DB.
|
|
||||||
.get('/api/roomkeys/v1/room', (c) => c.json([]))
|
|
||||||
|
|
||||||
// Subscription lookup. Returns both fields null with no auth.
|
|
||||||
.post('/api/CampusCard/v1/UpdateAndGetSubscription', (c) =>
|
|
||||||
c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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([])
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscription lookup. Returns both fields null with no auth.
|
||||||
|
.post(
|
||||||
|
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Econ'],
|
||||||
|
summary: 'Subscription lookup',
|
||||||
|
description: 'No subscriptions yet — both fields null. No auth.',
|
||||||
|
responses: { 200: json(SubscriptionResponse, 'Both fields null') },
|
||||||
|
}),
|
||||||
|
(c) => c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 }),
|
||||||
|
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.',
|
||||||
|
'',
|
||||||
|
'The shapes here are **reverse-engineered from the game client**, which is the only',
|
||||||
|
'real consumer. They record observed behaviour, not a designed contract; the handlers',
|
||||||
|
'are lenient and parse bodies defensively. Nothing in this spec is enforced at',
|
||||||
|
'runtime — treat a field marked required as "the client always sends it", not "the',
|
||||||
|
'server rejects it if absent".',
|
||||||
|
].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
|
export default app
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { resolver } from 'hono-openapi'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI schemas for the econ worker.
|
||||||
|
*
|
||||||
|
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
|
||||||
|
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
|
||||||
|
* rationale as the auth/accounts/match workers: a reverse-engineered protocol, lenient
|
||||||
|
* handlers, no runtime validation.
|
||||||
|
*
|
||||||
|
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
|
||||||
|
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
|
||||||
|
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
|
||||||
|
* schema inline, which renders correctly in any tool.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Emit a zod schema as an `application/json` response body. */
|
||||||
|
export function json(schema: z.ZodType, description: string) {
|
||||||
|
return { description, content: { 'application/json': { schema: resolver(schema) } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
|
||||||
|
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
|
||||||
|
return jsonSchema as OpenAPIV3_1.SchemaObject
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A form-urlencoded / multipart request body (the client posts both). */
|
||||||
|
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||||
|
const s = toOpenApiSchema(schema)
|
||||||
|
return {
|
||||||
|
description,
|
||||||
|
content: {
|
||||||
|
'application/x-www-form-urlencoded': { schema: s },
|
||||||
|
'multipart/form-data': { schema: s },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An `application/json` request body. */
|
||||||
|
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||||
|
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The empty-body 401 the auth-gated routes return. */
|
||||||
|
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
|
||||||
|
|
||||||
|
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||||
|
export const AUTHED = [{ bearerAuth: [] }]
|
||||||
|
|
||||||
|
// ---- Loose shapes ----------------------------------------------------------
|
||||||
|
// Several routes serve opaque static catalogs (avatar items, the weekly challenge) or
|
||||||
|
// empty-list stubs. Modelling every catalog field adds noise without value, so these
|
||||||
|
// use deliberately loose schemas.
|
||||||
|
|
||||||
|
/** An opaque JSON object (a catalog entry, an avatar blob, …). */
|
||||||
|
export const JsonObject = z.record(z.string(), z.unknown())
|
||||||
|
/** An opaque JSON array (a static catalog served verbatim). */
|
||||||
|
export const JsonArray = z.array(z.unknown())
|
||||||
|
|
||||||
|
// ---- Response schemas ------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public avatar render subset (`GET /api/avatar/v2/:id`) — the fields needed to
|
||||||
|
* draw another player's avatar. The stored blob also holds OutfitSelectionsV2 /
|
||||||
|
* CustomAvatarItems, which this view omits.
|
||||||
|
*/
|
||||||
|
export const AvatarV2Dto = z.object({
|
||||||
|
OutfitSelections: z.unknown(),
|
||||||
|
FaceFeatures: z.unknown(),
|
||||||
|
SkinColor: z.unknown(),
|
||||||
|
HairColor: z.unknown(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `{ error, success, value }` envelope both consume routes return. Always HTTP 200,
|
||||||
|
* even for a missing/already-gone target — the client parses this to finish the action,
|
||||||
|
* so a bare 200 reads as a failure.
|
||||||
|
*/
|
||||||
|
export const ConsumeEnvelope = z.object({
|
||||||
|
error: z.string(),
|
||||||
|
success: z.boolean(),
|
||||||
|
value: z.null(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** One currency balance entry (`GET /api/storefronts/v4/balance/:currencyType`). */
|
||||||
|
export const BalanceEntry = z.object({
|
||||||
|
CurrencyType: z.int(),
|
||||||
|
Platform: z.int().describe('-2 = all platforms (account-wide)'),
|
||||||
|
Balance: z.int(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `GET /econ/customAvatarItems/v1/owned` — paginated owned custom items. */
|
||||||
|
export const CustomAvatarItemsResponse = z.object({
|
||||||
|
Results: JsonArray,
|
||||||
|
TotalResults: z.int(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both fields null (no subs yet). */
|
||||||
|
export const SubscriptionResponse = z.object({
|
||||||
|
subscription: z.null(),
|
||||||
|
platformAccountSubscribedPlayerId: z.null(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `POST /api/challenge/v2/updateProgress` — the identifying fields echoed back. */
|
||||||
|
export const ChallengeProgressResponse = z.object({
|
||||||
|
ChallengeMapId: z.int(),
|
||||||
|
ChallengeId: z.int(),
|
||||||
|
Config: z.string(),
|
||||||
|
Complete: z.boolean().describe('Always false — no challenge-progress store yet'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /api/storefronts/v2/buyItem` — the purchase result. `Balance` is the CHANGE
|
||||||
|
* applied (the negated price), not the resulting total; the client reads its new total
|
||||||
|
* from `GET /balance/:type`. `BalanceType` -2 is account-wide. Each `Data` entry is the
|
||||||
|
* gift-drop the recipient received.
|
||||||
|
*/
|
||||||
|
export const BuyItemResponse = z.object({
|
||||||
|
BalanceUpdates: z.array(
|
||||||
|
z.object({
|
||||||
|
UpdateResponse: z.int(),
|
||||||
|
Data: z.array(JsonObject).describe('The gift-drop(s) granted'),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
Balance: z.int().describe('The change applied (negated price), not the new total'),
|
||||||
|
CurrencyType: z.int(),
|
||||||
|
BalanceType: z.int().describe('-2 = account-wide'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** buyItem error body (`{ error }`), returned on 400/404/409. */
|
||||||
|
export const ErrorResponse = z.object({ error: z.string() })
|
||||||
|
|
||||||
|
// ---- Request schemas -------------------------------------------------------
|
||||||
|
|
||||||
|
/** `POST /api/storefronts/v2/buyItem` JSON body. */
|
||||||
|
export const BuyItemRequest = z.object({
|
||||||
|
StorefrontType: z.int().describe('Which storefront catalog (sf{N}.json)'),
|
||||||
|
PurchasableItemId: z.int(),
|
||||||
|
CurrencyType: z.int().describe('Must be a spendable account currency'),
|
||||||
|
RequestedPrice: z.int().describe('The price the client rendered; a mismatch is 409'),
|
||||||
|
Gift: z
|
||||||
|
.object({
|
||||||
|
ToPlayerId: z.int().optional(),
|
||||||
|
Anonymous: z.boolean().optional(),
|
||||||
|
Message: z.string().optional(),
|
||||||
|
GiftContext: z.int().optional(),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.describe('Present when buying for another player; the caller still pays'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `POST /api/consumables/v1/consume` JSON body. */
|
||||||
|
export const ConsumeConsumableRequest = z.object({
|
||||||
|
Id: z.int().describe('The consumable row id to spend from'),
|
||||||
|
DeltaCount: z.int().optional().describe('How many to spend; defaults to 1'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `POST /api/avatar/v2/gifts/consume` form body (posted with a trailing slash). */
|
||||||
|
export const ConsumeGiftRequest = z.object({
|
||||||
|
Id: z.string().describe('The gift-box id to open'),
|
||||||
|
UnlockedLevel: z.string().optional().describe('Consumable-level hint; unused'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `POST /api/challenge/v2/updateProgress` JSON body. */
|
||||||
|
export const ChallengeProgressRequest = z.object({
|
||||||
|
ChallengeMapId: z.union([z.string(), z.int()]).optional(),
|
||||||
|
ChallengeId: z.union([z.string(), z.int()]).optional(),
|
||||||
|
Config: z.string().optional().describe('The client-evaluated rule tree'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `POST /api/avatar/v3/saved/set` JSON body — an outfit with a target `Slot`. */
|
||||||
|
export const SaveOutfitRequest = z
|
||||||
|
.object({ Slot: z.int().describe('Which slot to overwrite; a non-integer is 400') })
|
||||||
|
.catchall(z.unknown())
|
||||||
|
.describe('Plus opaque outfit fields (OutfitSelectionsV2, FaceFeatures, …) stored verbatim')
|
||||||
|
|
||||||
|
/** An opaque JSON body stored verbatim (the avatar blob for `POST /api/avatar/v2/set`). */
|
||||||
|
export const OpaqueJsonBody = JsonObject.describe('Stored verbatim and echoed back')
|
||||||
@@ -582,7 +582,7 @@ describe('econ endpoints', () => {
|
|||||||
expect(await res.json()).toBeTruthy()
|
expect(await res.json()).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Item 73 in sf3.json — "Class of 2016", 4500 RecCenterTokens (CurrencyType 2).
|
// Item 73 in sf3.json — "Bowtie (White)", 450 RecCenterTokens (CurrencyType 2).
|
||||||
test('POST /api/storefronts/v2/buyItem 401s without a token', async () => {
|
test('POST /api/storefronts/v2/buyItem 401s without a token', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -591,7 +591,7 @@ describe('econ endpoints', () => {
|
|||||||
StorefrontType: 3,
|
StorefrontType: 3,
|
||||||
PurchasableItemId: 73,
|
PurchasableItemId: 73,
|
||||||
CurrencyType: 2,
|
CurrencyType: 2,
|
||||||
RequestedPrice: 4500,
|
RequestedPrice: 450,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(401)
|
expect(res.status).toBe(401)
|
||||||
@@ -606,7 +606,7 @@ describe('econ endpoints', () => {
|
|||||||
StorefrontType: 3,
|
StorefrontType: 3,
|
||||||
PurchasableItemId: 73,
|
PurchasableItemId: 73,
|
||||||
CurrencyType: 2,
|
CurrencyType: 2,
|
||||||
RequestedPrice: 4500,
|
RequestedPrice: 450,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -619,25 +619,25 @@ describe('econ endpoints', () => {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
// `Balance` is the change applied (the negated price), not the resulting total.
|
// `Balance` is the change applied (the negated price), not the resulting total.
|
||||||
expect(body.Balance).toBe(-4500)
|
expect(body.Balance).toBe(-450)
|
||||||
expect(body.CurrencyType).toBe(2)
|
expect(body.CurrencyType).toBe(2)
|
||||||
expect(body.BalanceType).toBe(-2)
|
expect(body.BalanceType).toBe(-2)
|
||||||
const gift = body.BalanceUpdates[0].Data[0]
|
const gift = body.BalanceUpdates[0].Data[0]
|
||||||
expect(gift.AvatarItemDesc).not.toBe('')
|
expect(gift.AvatarItemDesc).not.toBe('')
|
||||||
expect(gift.Id).toBeGreaterThan(0)
|
expect(gift.Id).toBeGreaterThan(0)
|
||||||
|
|
||||||
// The balance endpoint reflects the debit (this is the resulting total, 10000 - 4500).
|
// The balance endpoint reflects the debit (this is the resulting total, 10000 - 450).
|
||||||
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
||||||
headers: await bearer('20'),
|
headers: await bearer('20'),
|
||||||
})
|
})
|
||||||
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 5500 }])
|
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 9550 }])
|
||||||
|
|
||||||
// The item is now owned — it leads the v4/items list (owned items prepend the catalog).
|
// The item is now owned — it leads the v4/items list (owned items prepend the catalog).
|
||||||
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
||||||
headers: await bearer('20'),
|
headers: await bearer('20'),
|
||||||
})
|
})
|
||||||
const list = (await items.json()) as Array<{ AvatarItemDesc: string; FriendlyName: string }>
|
const list = (await items.json()) as Array<{ AvatarItemDesc: string; FriendlyName: string }>
|
||||||
expect(list[0].FriendlyName).toBe('Class of 2016')
|
expect(list[0].FriendlyName).toBe('Bowtie (White)')
|
||||||
expect(list[0].AvatarItemDesc).toBe(gift.AvatarItemDesc)
|
expect(list[0].AvatarItemDesc).toBe(gift.AvatarItemDesc)
|
||||||
|
|
||||||
// And a pending gift box is waiting to be opened.
|
// And a pending gift box is waiting to be opened.
|
||||||
@@ -759,7 +759,7 @@ describe('econ endpoints', () => {
|
|||||||
StorefrontType: 3,
|
StorefrontType: 3,
|
||||||
PurchasableItemId: 9999999,
|
PurchasableItemId: 9999999,
|
||||||
CurrencyType: 2,
|
CurrencyType: 2,
|
||||||
RequestedPrice: 4500,
|
RequestedPrice: 450,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(404)
|
expect(res.status).toBe(404)
|
||||||
@@ -777,7 +777,7 @@ describe('econ endpoints', () => {
|
|||||||
StorefrontType: 3,
|
StorefrontType: 3,
|
||||||
PurchasableItemId: 73,
|
PurchasableItemId: 73,
|
||||||
CurrencyType: 2,
|
CurrencyType: 2,
|
||||||
RequestedPrice: 4500,
|
RequestedPrice: 450,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
@@ -786,7 +786,7 @@ describe('econ endpoints', () => {
|
|||||||
headers: await bearer('23'),
|
headers: await bearer('23'),
|
||||||
})
|
})
|
||||||
const list = (await items.json()) as Array<{ FriendlyName: string }>
|
const list = (await items.json()) as Array<{ FriendlyName: string }>
|
||||||
expect(list.every((i) => i.FriendlyName !== 'Class of 2016')).toBe(true)
|
expect(list.every((i) => i.FriendlyName !== 'Bowtie (White)')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => {
|
test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => {
|
||||||
@@ -799,7 +799,7 @@ describe('econ endpoints', () => {
|
|||||||
StorefrontType: 3,
|
StorefrontType: 3,
|
||||||
PurchasableItemId: 73,
|
PurchasableItemId: 73,
|
||||||
CurrencyType: 2,
|
CurrencyType: 2,
|
||||||
RequestedPrice: 4500,
|
RequestedPrice: 450,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const bought = (await buy.json()) as {
|
const bought = (await buy.json()) as {
|
||||||
@@ -827,7 +827,7 @@ describe('econ endpoints', () => {
|
|||||||
headers: await bearer('24'),
|
headers: await bearer('24'),
|
||||||
})
|
})
|
||||||
const list = (await items.json()) as Array<{ FriendlyName: string }>
|
const list = (await items.json()) as Array<{ FriendlyName: string }>
|
||||||
expect(list.some((i) => i.FriendlyName === 'Class of 2016')).toBe(true)
|
expect(list.some((i) => i.FriendlyName === 'Bowtie (White)')).toBe(true)
|
||||||
|
|
||||||
// Opening it again is a harmless no-op — still 200.
|
// Opening it again is a harmless no-op — still 200.
|
||||||
const again = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume/`, {
|
const again = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume/`, {
|
||||||
@@ -855,8 +855,9 @@ describe('econ endpoints', () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
expect(buy.status).toBe(200)
|
expect(buy.status).toBe(200)
|
||||||
const giftId = ((await buy.json()) as { BalanceUpdates: Array<{ Data: Array<{ Id: number }> }> })
|
const giftId = (
|
||||||
.BalanceUpdates[0].Data[0].Id
|
(await buy.json()) as { BalanceUpdates: Array<{ Data: Array<{ Id: number }> }> }
|
||||||
|
).BalanceUpdates[0].Data[0].Id
|
||||||
|
|
||||||
// Opening the box succeeds and fires the ConsumableMappingAdded push (which no-ops
|
// Opening the box succeeds and fires the ConsumableMappingAdded push (which no-ops
|
||||||
// against the test hub stub — this asserts the notify path doesn't throw).
|
// against the test hub stub — this asserts the notify path doesn't throw).
|
||||||
@@ -871,7 +872,9 @@ describe('econ endpoints', () => {
|
|||||||
// The box is gone; the consumable stays owned (granted at purchase).
|
// The box is gone; the consumable stays owned (granted at purchase).
|
||||||
expect(
|
expect(
|
||||||
await (
|
await (
|
||||||
await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, { headers: await bearer('26') })
|
await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
|
||||||
|
headers: await bearer('26'),
|
||||||
|
})
|
||||||
).json()
|
).json()
|
||||||
).toEqual([])
|
).toEqual([])
|
||||||
const unlocked = (await (
|
const unlocked = (await (
|
||||||
@@ -891,11 +894,12 @@ describe('econ endpoints', () => {
|
|||||||
StorefrontType: 3,
|
StorefrontType: 3,
|
||||||
PurchasableItemId: 73,
|
PurchasableItemId: 73,
|
||||||
CurrencyType: 2,
|
CurrencyType: 2,
|
||||||
RequestedPrice: 4500,
|
RequestedPrice: 450,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const giftId = ((await buy.json()) as { BalanceUpdates: Array<{ Data: Array<{ Id: number }> }> })
|
const giftId = (
|
||||||
.BalanceUpdates[0].Data[0].Id
|
(await buy.json()) as { BalanceUpdates: Array<{ Data: Array<{ Id: number }> }> }
|
||||||
|
).BalanceUpdates[0].Data[0].Id
|
||||||
|
|
||||||
// Account 28 trying to open 27's box is forbidden — and 27 keeps it.
|
// Account 28 trying to open 27's box is forbidden — and 27 keeps it.
|
||||||
const forbidden = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume`, {
|
const forbidden = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume`, {
|
||||||
@@ -1002,4 +1006,70 @@ describe('econ endpoints', () => {
|
|||||||
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
||||||
expect(res.status).toBe(404)
|
expect(res.status).toBe(404)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('GET /openapi.json documents every route', async () => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const spec = (await res.json()) as {
|
||||||
|
openapi: string
|
||||||
|
paths: Record<string, Record<string, { summary?: string }>>
|
||||||
|
}
|
||||||
|
expect(spec.openapi).toMatch(/^3\.1/)
|
||||||
|
|
||||||
|
// The spec route hides itself.
|
||||||
|
expect(spec.paths['/openapi.json']).toBeUndefined()
|
||||||
|
|
||||||
|
// Every route the worker serves is described. This is the drift guard: adding a
|
||||||
|
// route without a describeRoute() block fails here rather than silently shipping
|
||||||
|
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
|
||||||
|
// `.on(['GET','POST'], …)` cleargroup route contributes both methods.
|
||||||
|
const documented = new Set(
|
||||||
|
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||||
|
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expect([...documented].sort()).toEqual([
|
||||||
|
'GET /api/avatar/v1/defaultbaseavataritems',
|
||||||
|
'GET /api/avatar/v1/defaultunlocked',
|
||||||
|
'GET /api/avatar/v2',
|
||||||
|
'GET /api/avatar/v2/gifts',
|
||||||
|
'GET /api/avatar/v2/{id}',
|
||||||
|
'GET /api/avatar/v3/saved',
|
||||||
|
'GET /api/avatar/v4/items',
|
||||||
|
'GET /api/challenge/v2/getCurrent',
|
||||||
|
'GET /api/checklist/v1/current',
|
||||||
|
'GET /api/consumables/v2/getUnlocked',
|
||||||
|
'GET /api/equipment/v2/getUnlocked',
|
||||||
|
'GET /api/gamerewards/v1/pending',
|
||||||
|
'GET /api/itemWishlists/v1/wishlist/me',
|
||||||
|
'GET /api/objectives/v1/cleargroup',
|
||||||
|
'GET /api/objectives/v1/myprogress',
|
||||||
|
'GET /api/roomconsumables/v1/roomConsumable/room/{roomId}',
|
||||||
|
'GET /api/roomconsumables/v1/roomConsumable/room/{roomId}/me',
|
||||||
|
'GET /api/roomcurrencies/v1/currencies',
|
||||||
|
'GET /api/roomcurrencies/v1/getAllBalances',
|
||||||
|
'GET /api/roomkeys/v1/mine',
|
||||||
|
'GET /api/roomkeys/v1/room',
|
||||||
|
'GET /api/storefronts/v1/adcarouselitems',
|
||||||
|
'GET /api/storefronts/v3/giftdropstore/{id}',
|
||||||
|
'GET /api/storefronts/v4/balance/{currencyType}',
|
||||||
|
'GET /econ/customAvatarItems/v1/owned',
|
||||||
|
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
|
||||||
|
'POST /api/avatar/v2/gifts/consume',
|
||||||
|
'POST /api/avatar/v2/set',
|
||||||
|
'POST /api/avatar/v3/saved/set',
|
||||||
|
'POST /api/challenge/v2/updateProgress',
|
||||||
|
'POST /api/consumables/v1/consume',
|
||||||
|
'POST /api/gamerewards/v1/request',
|
||||||
|
'POST /api/objectives/v1/cleargroup',
|
||||||
|
'POST /api/settings/v2/set',
|
||||||
|
'POST /api/storefronts/v2/buyItem',
|
||||||
|
])
|
||||||
|
|
||||||
|
// Every operation carries a summary — a path present but undescribed is not
|
||||||
|
// documentation.
|
||||||
|
for (const ops of Object.values(spec.paths)) {
|
||||||
|
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user