[econ] wishlist

This commit is contained in:
Devin Zuczek
2026-10-05 16:22:55 -04:00
parent 53844c807f
commit 032e37733d
6 changed files with 754 additions and 15 deletions
+7 -3
View File
@@ -25,7 +25,11 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
| GET | `/api/avatar/v2` | ✓ | The player's own avatar | | GET | `/api/avatar/v2` | ✓ | The player's own avatar |
| POST | `/api/avatar/v2/set` | ✓ | Save the player's avatar | | POST | `/api/avatar/v2/set` | ✓ | Save the player's avatar |
| GET | `/api/checklist/v1/current` | ✓ | NUX checklist (stub `[]`) | | GET | `/api/checklist/v1/current` | ✓ | NUX checklist (stub `[]`) |
| GET | `/api/itemWishlists/v1/wishlist/me` | ✓ | Item wishlist (stub `[]`) | | GET | `/api/itemWishlists/v1/wishlist/me` | ✓ | The caller's item wishlist |
| GET | `/api/itemWishlists/v1/wishlist/:accountId` | ✓ | Another player's item wishlist |
| POST | `/api/itemWishlists/v1/wishlist/add` | ✓ | Wish for an item (form, enveloped) |
| POST | `/api/itemWishlists/v1/wishlist/remove` | ✓ | Un-wish an item (a POST, not DELETE) |
| POST | `/api/itemWishlists/v1/isonwishlist/bulk` | ✓ | Per-player "has it wished for" booleans |
| GET | `/api/avatar/v3/saved` | ✓ | Saved outfits | | GET | `/api/avatar/v3/saved` | ✓ | Saved outfits |
| POST | `/api/avatar/v3/saved/set` | ✓ | Save an outfit into a slot | | POST | `/api/avatar/v3/saved/set` | ✓ | Save an outfit into a slot |
| GET | `/api/avatar/v2/gifts` | ✓ | Pending (unopened) gift boxes | | GET | `/api/avatar/v2/gifts` | ✓ | Pending (unopened) gift boxes |
@@ -638,8 +642,8 @@ Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no cod
publish; per-item weighting and a multi-catalog pool would both need a manifest of the publish; per-item weighting and a multi-catalog pool would both need a manifest of the
storefronts, which the ASSETS binding can't enumerate. storefronts, which the ASSETS binding can't enumerate.
- Consumables are granted and listed but never spent by gameplay, so `Count` only grows. - Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
- Several routes (room keys, wishlist, equipment, room consumables/currencies) are - Several routes (equipment, room consumables/currencies) are empty-list stubs pending
empty-list stubs pending their own stores. their own stores.
- Game rewards pay a flat 5 XP; there is no daily XP cap beyond the hourly cooldown (the - Game rewards pay a flat 5 XP; there is no daily XP cap beyond the hourly cooldown (the
reference caps activity XP per day in `daily_xp_ledgers`). reference caps activity XP per day in `daily_xp_ledgers`).
- The level-reward table and the served config's `GiftRarity` disagree in places (see the - The level-reward table and the served config's `GiftRarity` disagree in places (see the
+34
View File
@@ -0,0 +1,34 @@
-- Item wishlists, owned by the `econ` worker. One row per item a player has wished for —
-- what the store's heart toggle writes (`POST /api/itemWishlists/v1/wishlist/add|remove`,
-- form-encoded `purchasableItemId=1534&customAvatarItemId=`) and the wishlist page reads
-- (`GET /api/itemWishlists/v1/wishlist/me|{accountId}`, a bare array of entries).
--
-- `wishlist_item_id` is a GUID: the client's `WishlistItemId` is a `Guid`, not an int, so the
-- row's key has to be one. `player_id` is the account the list belongs to.
--
-- A row names ONE item in one of two ways — a catalog item by `purchasable_item_id` (the
-- storefront's `PurchasableItemId`) or a player-made/first-party custom avatar item by
-- `custom_avatar_item_id` (its GUID, stored lowercase) — so exactly one of the two is set.
-- On the wire the client's `PurchasableItemId` is a plain int, so a custom-item row is served
-- as `PurchasableItemId: 0` with the GUID beside it; the column is still NULL here so the two
-- kinds of row can't be confused.
--
-- The two partial unique indexes make a wish boolean: a second `add` of the same item finds
-- the existing row rather than making a twin. `idx_wishlist_player` is the read's access
-- pattern (one player's whole list).
--
-- Kept in sync with WISHLIST_SCHEMA_DDL in src/wishlist-db.ts.
CREATE TABLE IF NOT EXISTS wishlist (
wishlist_item_id TEXT PRIMARY KEY,
player_id INTEGER NOT NULL,
purchasable_item_id INTEGER,
custom_avatar_item_id TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_wishlist_player ON wishlist (player_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_wishlist_player_purchasable
ON wishlist (player_id, purchasable_item_id) WHERE purchasable_item_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_wishlist_player_custom
ON wishlist (player_id, custom_avatar_item_id) WHERE custom_avatar_item_id IS NOT NULL;
+202 -12
View File
@@ -112,6 +112,8 @@ import {
GameRewardRequest, GameRewardRequest,
InfluencerIdsResponse, InfluencerIdsResponse,
InfluencerTierResponse, InfluencerTierResponse,
IsOnWishlistBulkRequest,
IsOnWishlistBulkResponse,
ItemPurchaseInfoList, ItemPurchaseInfoList,
ItemPurchaseInfosRequest, ItemPurchaseInfosRequest,
json, json,
@@ -152,6 +154,9 @@ import {
UpdateRoomCurrencyRequest, UpdateRoomCurrencyRequest,
UpdateRoomKeyRequest, UpdateRoomKeyRequest,
UpsertRoomConsumableRequest, UpsertRoomConsumableRequest,
WishlistEnvelope,
WishlistItemList,
WishlistItemRequest,
} from './openapi' } from './openapi'
import { claimReward, isNewActivity } from './reward-db' import { claimReward, isNewActivity } from './reward-db'
import { import {
@@ -184,6 +189,13 @@ import {
updateRoomKey, updateRoomKey,
} from './room-key-db' } from './room-key-db'
import { getRoomPurchasables } from './room-purchasables-db' import { getRoomPurchasables } from './room-purchasables-db'
import {
addWishlistItem,
getWishlist,
isOnWishlists,
parseWishlistTarget,
removeWishlistItem,
} from './wishlist-db'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain' import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
@@ -209,6 +221,7 @@ import type { AvatarItem } from './inventory-db'
import type { RoomConsumable } from './room-consumable-db' import type { RoomConsumable } from './room-consumable-db'
import type { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db' import type { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db'
import type { RoomKey, RoomKeyHolding } from './room-key-db' import type { RoomKey, RoomKeyHolding } from './room-key-db'
import type { WishlistItem } from './wishlist-db'
// Invention storage (owned by the `api` worker, on this same `recflare` database). // Invention storage (owned by the `api` worker, on this same `recflare` database).
// Imported directly rather than copied: these are plain D1 helpers with no bindings of // Imported directly rather than copied: these are plain D1 helpers with no bindings of
@@ -222,7 +235,7 @@ import type { RoomKey, RoomKeyHolding } from './room-key-db'
* inventory (avatar items, equipment, bought inventions), consumables, saved outfits, * inventory (avatar items, equipment, bought inventions), consumables, saved outfits,
* avatars, gift boxes, weekly-challenge progress and game-reward eligibility are D1-backed; * avatars, gift boxes, weekly-challenge progress and game-reward eligibility are D1-backed;
* storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS * storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS
* binding. Some routes are still empty-list stubs (room keys, wishlist, …). * binding. Some routes are still empty-list stubs (equipment, room consumables, …).
* *
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker. * Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
*/ */
@@ -256,6 +269,39 @@ function unauthorized(c: Context<App>) {
return c.body(null, 401) return c.body(null, 401)
} }
/**
* The `Error` a wishlist write answers when its body names no item. Shown to the player
* verbatim behind the client's “Wishlist error: ” prefix, so it is a sentence, not a code.
*/
const WISHLIST_NO_ITEM = 'No item was specified.'
/**
* The `{ Value, Success, Error, error_id }` envelope the wishlist writes answer in — the
* same shape the room-currency writes use. A null `item` with no `error` is still a success
* (removing something that wasn't listed); an `error` makes it a refusal.
*/
function wishlistEnvelope(c: Context<App>, item: WishlistItem | null, error?: string) {
return c.json({
Value: item,
Success: error === undefined,
Error: error ?? null,
error_id: null,
})
}
/**
* The item a wishlist write's form body names — `purchasableItemId` or `customAvatarItemId`,
* as the client spells them, with the PascalCase spellings accepted for a hand-written
* request. Null when the body names nothing usable (see `parseWishlistTarget`).
*/
async function wishlistTargetFromBody(c: Context<App>) {
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
return parseWishlistTarget(
body.purchasableItemId ?? body.PurchasableItemId,
body.customAvatarItemId ?? body.CustomAvatarItemId
)
}
/** /**
* A boolean the client may send either as a JSON `true` or as .NET's `bool.ToString()` * A boolean the client may send either as a JSON `true` or as .NET's `bool.ToString()`
* output — `"True"`/`"False"`, capitalized. `Boolean(value)` is a trap here: the string * output — `"True"`/`"False"`, capitalized. `Boolean(value)` is a trap here: the string
@@ -3115,20 +3161,41 @@ const app = new Hono<App>({ strict: false })
} }
) )
// The caller's item wishlist. [Authorize]; empty — nothing stores wishlists yet. // The caller's item wishlist. [Authorize]. A bare array of entries, newest wish first;
// `[]` for a player who has wished for nothing. The client appends a KEYLESS query string
// (`/me?True`, `/me?False` — a raw `bool.ToString()` after the `?`, no parameter name) that
// no server can bind by name, so it is ignored: both spellings serve the same list. The
// client caches this for five seconds and drops the cache when an add succeeds.
.get( .get(
'/api/itemWishlists/v1/wishlist/me', '/api/itemWishlists/v1/wishlist/me',
listRoute('The player’s item wishlist', 'Empty for now', true), describeRoute({
tags: ['Econ'],
summary: 'The player’s item wishlist',
description: [
'The caller’s wishlist as a bare array of entries, newest first — `[]` when empty.',
'Each entry names ONE item: a storefront item by `PurchasableItemId`, or a custom',
'avatar item by `CustomAvatarItemId` (with `PurchasableItemId` 0, the client’s field',
'being a plain int).',
'',
'The client appends `?True` or `?False` with no parameter name; it cannot be read',
'and is ignored — both serve the same list.',
].join('\n'),
security: AUTHED,
responses: {
200: json(WishlistItemList, 'The caller’s wishlist, newest first'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => { async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
return c.json([]) return c.json(await getWishlist(c.env.DB, id))
} }
) )
// Another player's item wishlist, by account id — what the client reads to show what // Another player's item wishlist, by account id — what the client reads to show what
// somebody else is hoping for (and to mark items in the store as already wished for). // somebody else is hoping for. Public to any signed-in player: a wishlist exists to be
// Empty like `/me`: nothing stores wishlists, so there is nothing to show for anyone. // read by others. An unknown account has simply wished for nothing.
// //
// Registered AFTER `/me` so that path stays its own route rather than being read as an // Registered AFTER `/me` so that path stays its own route rather than being read as an
// account id — the pattern here is digits-only, so it could not swallow `me`, but the // account id — the pattern here is digits-only, so it could not swallow `me`, but the
@@ -3139,10 +3206,11 @@ const app = new Hono<App>({ strict: false })
tags: ['Econ'], tags: ['Econ'],
summary: 'Another player’s item wishlist', summary: 'Another player’s item wishlist',
description: [ description: [
'The wishlist of the account named in the path, as a bare array. Empty for now —', 'The wishlist of the account named in the path, as a bare array of entries, newest',
'nothing on this server stores wishlists, so every player’s is empty, and an empty', 'first. Readable by any signed-in player — a wishlist is for others to see. An',
'list is what the client renders as “nothing wished for” where a 404 would read as a', 'unknown account answers `[]`, which the client renders as “nothing wished for”',
'failed load.', 'where a 404 would read as a failed load. The keyless `?True`/`?False` the client',
'appends is ignored, as on `/me`.',
].join(' '), ].join(' '),
security: AUTHED, security: AUTHED,
parameters: [ parameters: [
@@ -3155,14 +3223,136 @@ const app = new Hono<App>({ strict: false })
}, },
], ],
responses: { responses: {
200: json(JsonArray, 'That player’s wishlist — empty for now'), 200: json(WishlistItemList, 'That player’s wishlist, newest first'),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
}, },
}), }),
async (c) => { async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
return c.json([]) const accountId = Number.parseInt(c.req.param('accountId'), 10)
if (!Number.isInteger(accountId)) return c.json([])
return c.json(await getWishlist(c.env.DB, accountId))
}
)
// Wish for an item. [Authorize]. Form-encoded: the client posts BOTH `purchasableItemId`
// and `customAvatarItemId` every time with the unused one empty (its two nullables are
// boxed without a HasValue guard), so either may be empty or absent; exactly one has to
// name an item. Answers the `{ Value, Success, Error, error_id }` envelope with the entry.
// An item already on the list answers its EXISTING entry: the toggle is driven by the
// client's idea of the current state, and re-adding changes nothing.
//
// The row has to persist: on success the client drops its cached list and re-reads it,
// so a 200 that stored nothing shows the heart un-filling again at once.
.post(
'/api/itemWishlists/v1/wishlist/add',
describeRoute({
tags: ['Econ'],
summary: 'Wish for an item',
description: [
'Adds an item to the caller’s wishlist. Form-encoded; the client posts both',
'`purchasableItemId` and `customAvatarItemId` with the unused one EMPTY',
'(`purchasableItemId=1534&customAvatarItemId=`), and either may also be absent —',
'exactly one has to name an item.',
'',
'Answers `{ Value, Success, Error, error_id }` with the entry as `Value`. An item',
'already on the list answers its existing entry rather than a twin or a refusal.',
'A body naming no item is `Success: false` with a player-readable `Error` — the',
'client shows it verbatim behind “Wishlist error: ”. A bare entry or a 204 fails the',
'client the same way `Success: false` does.',
].join('\n'),
security: AUTHED,
requestBody: form(WishlistItemRequest, 'The item to wish for'),
responses: {
200: json(WishlistEnvelope, 'The entry, or a refusal in the same shape'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await wishlistTargetFromBody(c)
if (target === null) return wishlistEnvelope(c, null, WISHLIST_NO_ITEM)
return wishlistEnvelope(c, await addWishlistItem(c.env.DB, id, target))
}
)
// Take an item off the caller's wishlist. [Authorize]. A POST, not a DELETE, with the same
// form body as `add`. The client parses the same envelope and discards `Value`, so the
// removed entry is served anyway; an item that wasn't on the list is still a success —
// the list ends up as asked — with a null `Value`.
.post(
'/api/itemWishlists/v1/wishlist/remove',
describeRoute({
tags: ['Econ'],
summary: 'Un-wish an item',
description: [
'Removes an item from the caller’s wishlist. A POST (not a DELETE) with the same',
'form body as `add`. Answers the same envelope with the REMOVED entry as `Value`;',
'the client discards it but parses the shape, so a bare body or 204 fails it. An',
'item that wasn’t on the list is still `Success: true`, with a null `Value`. A body',
'naming no item is `Success: false`.',
].join('\n'),
security: AUTHED,
requestBody: form(WishlistItemRequest, 'The item to un-wish'),
responses: {
200: json(WishlistEnvelope, 'The removed entry, or a refusal in the same shape'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await wishlistTargetFromBody(c)
if (target === null) return wishlistEnvelope(c, null, WISHLIST_NO_ITEM)
return wishlistEnvelope(c, await removeWishlistItem(c.env.DB, id, target))
}
)
// Whether each of several players has an item wished for. [Authorize]. Form-encoded:
// `accountIds` repeated once per player plus the item named as on add/remove. A BARE
// positional array of booleans back, one per id in the order posted — a short array would
// leave the tail reading as unset, so every id is answered, unknown players as false.
.post(
'/api/itemWishlists/v1/isonwishlist/bulk',
describeRoute({
tags: ['Econ'],
summary: 'Whether players have an item wished for',
description: [
'For each `accountIds` entry, whether that player’s wishlist has the item named by',
'`purchasableItemId` or `customAvatarItemId`. Form-encoded, `accountIds` repeated',
'once per id (a comma-separated single value is accepted too). The answer is a BARE',
'array of booleans, one per id in the order posted. A body naming no item answers',
'false for everyone.',
].join(' '),
security: AUTHED,
requestBody: form(IsOnWishlistBulkRequest, 'The players to ask about, and the item'),
responses: {
200: json(IsOnWishlistBulkResponse, 'One boolean per `accountIds` entry, in order'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req
.parseBody({ all: true })
.catch(() => ({}) as Record<string, string | string[] | File | File[]>)
// `all: true` keeps the repeated field a list; a single value arrives as a string.
const accountIds = [body.accountIds, body.AccountIds]
.flat()
.filter((v): v is string => typeof v === 'string')
.flatMap((v) => v.split(','))
.map((v) => Number.parseInt(v.trim(), 10))
.filter((n) => Number.isInteger(n))
const first = (v: unknown): unknown => (Array.isArray(v) ? v[0] : v)
const target = parseWishlistTarget(
first(body.purchasableItemId ?? body.PurchasableItemId),
first(body.customAvatarItemId ?? body.CustomAvatarItemId)
)
if (target === null) return c.json(accountIds.map(() => false))
return c.json(await isOnWishlists(c.env.DB, accountIds, target))
} }
) )
+73
View File
@@ -846,6 +846,79 @@ export const OwnsRoomKeysResponse = z.array(
}) })
) )
/**
* One wishlist entry — the client's five-key DTO, in its order. An element of the GET's bare
* array and the `Value` of the add/remove envelope.
*
* `PurchasableItemId` is a plain int on the client, so an entry for a custom avatar item
* carries 0 there and the item's GUID in `CustomAvatarItemId`; a storefront-item entry has
* the id and a null GUID. Exactly one of the two names the item.
*/
export const WishlistItemDto = z.object({
WishlistItemId: z.string().describe('GUID — the client’s `Guid`, so never an int'),
AccountId: z.int().describe('Whose list the entry is on'),
PurchasableItemId: z
.int()
.describe('The storefront item wished for, or 0 when the entry names a custom avatar item'),
CustomAvatarItemId: z
.string()
.nullable()
.describe('The custom avatar item wished for, or null when the entry names a storefront item'),
CreatedAt: z.string().describe('ISO-8601 UTC'),
})
/** `GET /api/itemWishlists/v1/wishlist/me|{accountId}` — a bare array, newest wish first. */
export const WishlistItemList = z.array(WishlistItemDto)
/**
* The envelope `wishlist/add` and `wishlist/remove` answer in — the same `{ Value, Success,
* Error, error_id }` the room-currency writes use. The client reads `add`’s `Value` and
* discards `remove`’s, but parses both as this shape: a bare entry or a 204 fails the same
* way `Success: false` does. `Error` is shown to the player verbatim behind “Wishlist error: ”.
*/
export const WishlistEnvelope = z.object({
Value: WishlistItemDto.nullable().describe(
'The entry added or removed; null on a refusal, or on removing an item that wasn’t listed'
),
Success: z.boolean(),
Error: z.string().nullable().describe('Null on success; the failure message otherwise'),
error_id: z.null().describe('Always null. Present as a key, and lowercase'),
})
/**
* `wishlist/add` and `wishlist/remove` — form-encoded. The client posts BOTH fields every
* time with the unused one empty (`purchasableItemId=1534&customAvatarItemId=`); either may
* also be omitted. Exactly one has to name an item.
*/
export const WishlistItemRequest = z.object({
purchasableItemId: z
.string()
.optional()
.describe('The storefront item, as a positive integer; empty or absent for a custom item'),
customAvatarItemId: z
.string()
.optional()
.describe('The custom avatar item’s GUID; empty or absent for a storefront item'),
})
/**
* `POST /api/itemWishlists/v1/isonwishlist/bulk` — form-encoded. `accountIds` is REPEATED,
* once per account (`accountIds=205&accountIds=207`); a comma-separated single value is
* accepted too. The item is named as on add/remove.
*/
export const IsOnWishlistBulkRequest = WishlistItemRequest.extend({
accountIds: z
.array(z.string())
.describe('The players to ask about, repeated once per id — answered in this order'),
})
/**
* `isonwishlist/bulk`’s answer: a bare POSITIONAL array of booleans, one per `accountIds`
* entry in the order posted. A short array leaves the tail reading as “not wished for”, so
* every id gets an element — an unknown player’s is false.
*/
export const IsOnWishlistBulkResponse = z.array(z.boolean())
/** /**
* One purchase offer on a room currency — a way to BUY that currency, priced in another * One purchase offer on a room currency — a way to BUY that currency, priced in another
* ("5 SuperTokens for 500 Rec Center Tokens"). The client's own model, member for member and * ("5 SuperTokens for 500 Rec Center Tokens"). The client's own model, member for member and
+205
View File
@@ -116,6 +116,7 @@ import {
ROOM_CURRENCY_SCHEMA_DDL, ROOM_CURRENCY_SCHEMA_DDL,
} from '../../room-currency-db' } from '../../room-currency-db'
import { createRoomKey, ROOM_KEY_SCHEMA_DDL } from '../../room-key-db' import { createRoomKey, ROOM_KEY_SCHEMA_DDL } from '../../room-key-db'
import { WISHLIST_SCHEMA_DDL } from '../../wishlist-db'
import type { CatalogLoadRow, CatalogRow, CatalogValue, StoreListing } from '../../catalog-db' import type { CatalogLoadRow, CatalogRow, CatalogValue, StoreListing } from '../../catalog-db'
import type { Env } from '../../context' import type { Env } from '../../context'
@@ -223,6 +224,7 @@ beforeAll(async () => {
for (const stmt of ROOM_CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of ROOM_CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of ROOM_INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_KEY_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of ROOM_KEY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of WISHLIST_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// The platform link table (owned by `auth`) — the Discord supporter gift reads the roles // The platform link table (owned by `auth`) — the Discord supporter gift reads the roles
// `www` records on each Discord link. // `www` records on each Discord link.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -829,6 +831,206 @@ describe('econ endpoints', () => {
expect(mine.status).toBe(200) expect(mine.status).toBe(200)
}) })
describe('item wishlist', () => {
const WISHLIST = `${ORIGIN}/api/itemWishlists/v1`
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
// The form body exactly as the client posts it: both fields, the unused one empty.
const post = async (path: string, sub: string, body: string) =>
exports.default.fetch(`${WISHLIST}${path}`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const list = async (who: string, sub: string) =>
(
await exports.default.fetch(`${WISHLIST}/wishlist/${who}`, { headers: await bearer(sub) })
).json()
test('the writes 401 without a token', async () => {
for (const path of ['/wishlist/add', '/wishlist/remove', '/isonwishlist/bulk']) {
const res = await exports.default.fetch(`${WISHLIST}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'purchasableItemId=1534&customAvatarItemId=',
})
expect(res.status, path).toBe(401)
}
})
test('add stores an entry, serves it enveloped, and the list reads it back newest first', async () => {
const first = await post('/wishlist/add', '601', 'purchasableItemId=1534&customAvatarItemId=')
expect(first.status).toBe(200)
const env1 = (await first.json()) as {
Value: Record<string, unknown>
Success: boolean
Error: null
error_id: null
}
// The envelope, `Value` first; the entry is the client's five keys in its order, with
// a GUID id and a NULL custom id for a storefront item.
expect(Object.keys(env1)).toEqual(['Value', 'Success', 'Error', 'error_id'])
expect(env1.Success).toBe(true)
expect(env1.Error).toBeNull()
expect(env1.error_id).toBeNull()
expect(Object.keys(env1.Value)).toEqual([
'WishlistItemId',
'AccountId',
'PurchasableItemId',
'CustomAvatarItemId',
'CreatedAt',
])
expect(env1.Value.WishlistItemId).toMatch(GUID_RE)
expect(env1.Value.AccountId).toBe(601)
expect(env1.Value.PurchasableItemId).toBe(1534)
expect(env1.Value.CustomAvatarItemId).toBeNull()
expect(typeof env1.Value.CreatedAt).toBe('string')
// Wait so the second wish sorts after the first by its timestamp.
await new Promise((r) => setTimeout(r, 5))
// A custom avatar item is the mirror image: `PurchasableItemId` 0 (the client's field
// is a plain int), the GUID beside it — lowercased, however the client spelled it.
const second = await post(
'/wishlist/add',
'601',
'purchasableItemId=&customAvatarItemId=C3ED9841-1B4A-40A6-B266-5DBCFD7F2865'
)
const env2 = (await second.json()) as { Value: Record<string, unknown> }
expect(env2.Value.PurchasableItemId).toBe(0)
expect(env2.Value.CustomAvatarItemId).toBe('c3ed9841-1b4a-40a6-b266-5dbcfd7f2865')
// Newest first — the order the reference served. The keyless `?True`/`?False` the
// client appends is ignored, so both spellings serve the same list.
const mine = (await list('me?True', '601')) as Array<Record<string, unknown>>
expect(mine.map((e) => e.WishlistItemId)).toEqual([
env2.Value.WishlistItemId,
env1.Value.WishlistItemId,
])
expect(await list('me?False', '601')).toEqual(mine)
expect(await list('me', '601')).toEqual(mine)
// Readable by another signed-in player under the account id.
expect(await list('601?False', '602')).toEqual(mine)
// And nobody else's list picked it up.
expect(await list('me', '602')).toEqual([])
})
test('re-adding an item answers the existing entry rather than a twin', async () => {
const first = (await (
await post('/wishlist/add', '603', 'purchasableItemId=2137&customAvatarItemId=')
).json()) as { Value: { WishlistItemId: string } }
const again = (await (
await post('/wishlist/add', '603', 'purchasableItemId=2137&customAvatarItemId=')
).json()) as { Success: boolean; Value: { WishlistItemId: string } }
expect(again.Success).toBe(true)
expect(again.Value.WishlistItemId).toBe(first.Value.WishlistItemId)
expect(((await list('me', '603')) as unknown[]).length).toBe(1)
})
test('remove answers the removed entry; removing an unlisted item is still a success', async () => {
const added = (await (
await post('/wishlist/add', '604', 'purchasableItemId=1274&customAvatarItemId=')
).json()) as { Value: { WishlistItemId: string } }
const removed = await post(
'/wishlist/remove',
'604',
'purchasableItemId=1274&customAvatarItemId='
)
expect(removed.status).toBe(200)
expect(await removed.json()).toEqual({
Value: expect.objectContaining({
WishlistItemId: added.Value.WishlistItemId,
AccountId: 604,
PurchasableItemId: 1274,
}),
Success: true,
Error: null,
error_id: null,
})
expect(await list('me', '604')).toEqual([])
// Already gone: the list is as asked, so this is not an error — a null `Value`.
const again = await post(
'/wishlist/remove',
'604',
'purchasableItemId=1274&customAvatarItemId='
)
expect(await again.json()).toEqual({
Value: null,
Success: true,
Error: null,
error_id: null,
})
})
test('remove only touches the caller’s own list', async () => {
await post('/wishlist/add', '605', 'purchasableItemId=1274&customAvatarItemId=')
const other = await post(
'/wishlist/remove',
'606',
'purchasableItemId=1274&customAvatarItemId='
)
expect(((await other.json()) as { Value: unknown }).Value).toBeNull()
expect(((await list('me', '605')) as unknown[]).length).toBe(1)
})
test('a body naming no item is a refusal in the envelope, with a readable Error', async () => {
for (const body of [
'',
'purchasableItemId=&customAvatarItemId=',
'purchasableItemId=abc',
'customAvatarItemId=not-a-guid',
'purchasableItemId=0',
]) {
for (const path of ['/wishlist/add', '/wishlist/remove']) {
const res = await post(path, '607', body)
expect(res.status, `${path} ${body}`).toBe(200)
expect(await res.json(), `${path} ${body}`).toEqual({
Value: null,
Success: false,
Error: 'No item was specified.',
error_id: null,
})
}
}
expect(await list('me', '607')).toEqual([])
})
test('POST /isonwishlist/bulk answers one boolean per accountId, in order', async () => {
await post('/wishlist/add', '608', 'purchasableItemId=3001&customAvatarItemId=')
await post('/wishlist/add', '610', 'purchasableItemId=3001&customAvatarItemId=')
await post(
'/wishlist/add',
'609',
'purchasableItemId=&customAvatarItemId=8f0c1e4a-2b77-4b16-9a3e-5dbcfd7f2865'
)
// `accountIds` repeated once per id, as the client's list parameter encodes; the
// unknown account 611 reads false rather than shortening the array.
const res = await post(
'/isonwishlist/bulk',
'608',
'accountIds=608&accountIds=609&accountIds=610&accountIds=611&purchasableItemId=3001&customAvatarItemId='
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([true, false, true, false])
// A comma-separated single value, and a custom item, matched case-insensitively.
const custom = await post(
'/isonwishlist/bulk',
'608',
'accountIds=609,608&purchasableItemId=&customAvatarItemId=8F0C1E4A-2B77-4B16-9A3E-5DBCFD7F2865'
)
expect(await custom.json()).toEqual([true, false])
// No item named: false for everyone, still one per id.
const none = await post('/isonwishlist/bulk', '608', 'accountIds=608&accountIds=610')
expect(await none.json()).toEqual([false, false])
// No ids: an empty array.
const empty = await post('/isonwishlist/bulk', '608', 'purchasableItemId=3001')
expect(await empty.json()).toEqual([])
})
})
test('GET /api/avatar/v3/saved 401s without a token, returns [] with one', async () => { test('GET /api/avatar/v3/saved 401s without a token, returns [] with one', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`) const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`)
expect(anon.status).toBe(401) expect(anon.status).toBe(401)
@@ -6791,6 +6993,9 @@ describe('econ endpoints', () => {
'POST /api/consumables/v1/consume', 'POST /api/consumables/v1/consume',
'POST /api/equipment/v1/update', 'POST /api/equipment/v1/update',
'POST /api/gamerewards/v1/request', 'POST /api/gamerewards/v1/request',
'POST /api/itemWishlists/v1/isonwishlist/bulk',
'POST /api/itemWishlists/v1/wishlist/add',
'POST /api/itemWishlists/v1/wishlist/remove',
'POST /api/items/bulkpurchase', 'POST /api/items/bulkpurchase',
'POST /api/items/purchaseInfos', 'POST /api/items/purchaseInfos',
'POST /api/objectives/v1/cleargroup', 'POST /api/objectives/v1/cleargroup',
+233
View File
@@ -0,0 +1,233 @@
/**
* Item wishlists — the store's heart toggle.
*
* A player wishes for an item from its store page and reads the list back on the wishlist
* page; other players can read it too (that is what a wishlist is for). One row per
* (player, item) in `wishlist`. The endpoints, all on `/api/itemWishlists/v1`:
* - `GET …/wishlist/me` and `…/wishlist/:accountId` — a bare array of entries
* - `POST …/wishlist/add` and `…/wishlist/remove` — form-encoded, enveloped
* - `POST …/isonwishlist/bulk` — a bare array of booleans, one per account asked about
*
* An entry names ONE item in one of two ways: a catalog item by `PurchasableItemId` (the
* storefront's id, what `RRUI.Data.StoreItemModel` toggles) or a custom avatar item by
* `CustomAvatarItemId` (its GUID, what `CustomAvatarItemModel` toggles). Both columns are
* nullable and exactly one is set — but the client's `PurchasableItemId` is a plain `int`,
* so a custom-item row is SERVED as `PurchasableItemId: 0`; see {@link toWishlistItem}.
*
* This worker (`econ`) owns the table and its migration — see apps/econ/migrations/
* 0027_wishlist.sql.
*/
/** Schema DDL (mirror of migrations/0027_wishlist.sql) — also builds the table in tests. */
export const WISHLIST_SCHEMA_DDL: string[] = [
// `wishlist_item_id` is a GUID: the client's `WishlistItemId` is a `Guid`, so the row's
// key has to be one. `custom_avatar_item_id` is stored LOWERCASE so the unique index below
// holds whatever case the client sent the GUID in.
`CREATE TABLE IF NOT EXISTS wishlist (
wishlist_item_id TEXT PRIMARY KEY,
player_id INTEGER NOT NULL,
purchasable_item_id INTEGER,
custom_avatar_item_id TEXT,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_wishlist_player ON wishlist (player_id)`,
// A wish is boolean: these make a second `add` of the same item find the existing row.
`CREATE UNIQUE INDEX IF NOT EXISTS idx_wishlist_player_purchasable
ON wishlist (player_id, purchasable_item_id) WHERE purchasable_item_id IS NOT NULL`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_wishlist_player_custom
ON wishlist (player_id, custom_avatar_item_id) WHERE custom_avatar_item_id IS NOT NULL`,
]
/**
* A wishlist entry as the client reads it (its `AMJGGOKLEGL`, five keys in this order). The
* same object is an element of the GET's bare array and the `Value` of the add/remove
* envelope.
*
* `PurchasableItemId` is a plain `int` on the client — there is no null to send — so a
* custom-item entry carries 0 there and its GUID in `CustomAvatarItemId`, which IS nullable
* and null on a catalog-item entry.
*/
export interface WishlistItem {
/** GUID, minted here. */
WishlistItemId: string
AccountId: number
/** The storefront item wished for, or 0 when the entry names a custom avatar item. */
PurchasableItemId: number
/** The custom avatar item wished for, or null when the entry names a storefront item. */
CustomAvatarItemId: string | null
/** ISO-8601 UTC. */
CreatedAt: string
}
/**
* Which item a request is about — exactly one of the two, already parsed. The add/remove/
* isonwishlist bodies all name an item this way (`purchasableItemId=1534&customAvatarItemId=`,
* the unused one empty), and `parseWishlistTarget` turns that into one of these or null.
*/
export type WishlistTarget =
| { purchasableItemId: number; customAvatarItemId: null }
| { purchasableItemId: null; customAvatarItemId: string }
/** The 36-char dashed GUID the client's formatter writes and reads; braces or `N` form fail it. */
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/**
* Read the item a wishlist body names. Both fields are always posted (the client boxes two
* nullables without a HasValue guard, so the unused one arrives empty), and a hand-written
* request may leave one out — either is accepted. The storefront id wins when both are
* somehow set. Null when neither names anything usable: no id, a non-integer id, or a string
* that isn't a GUID.
*/
export function parseWishlistTarget(
purchasableItemId: unknown,
customAvatarItemId: unknown
): WishlistTarget | null {
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '')
const id = str(purchasableItemId)
if (/^\d+$/.test(id)) {
const n = Number(id)
if (n > 0) return { purchasableItemId: n, customAvatarItemId: null }
}
const guid = str(customAvatarItemId)
if (GUID_RE.test(guid)) {
return { purchasableItemId: null, customAvatarItemId: guid.toLowerCase() }
}
return null
}
interface WishlistRow {
wishlist_item_id: string
player_id: number
purchasable_item_id: number | null
custom_avatar_item_id: string | null
created_at: string
}
const SELECT_COLUMNS = `wishlist_item_id, player_id, purchasable_item_id, custom_avatar_item_id, created_at`
const toWishlistItem = (row: WishlistRow): WishlistItem => ({
WishlistItemId: row.wishlist_item_id,
AccountId: row.player_id,
PurchasableItemId: row.purchasable_item_id ?? 0,
CustomAvatarItemId: row.custom_avatar_item_id,
CreatedAt: row.created_at,
})
/** The WHERE clause that picks a target's row(s), with the target bound as `?<param>`. */
function targetClause(
target: WishlistTarget,
param: number
): { sql: string; value: number | string } {
return target.purchasableItemId !== null
? { sql: `purchasable_item_id = ?${param}`, value: target.purchasableItemId }
: { sql: `custom_avatar_item_id = ?${param}`, value: target.customAvatarItemId }
}
/**
* A player's wishlist, newest wish first — the order the reference served (its capture lists
* 15:37:08 above 15:36:56). A player who has wished for nothing gets [].
*/
export async function getWishlist(db: D1Database, playerId: number): Promise<WishlistItem[]> {
const { results } = await db
.prepare(
`SELECT ${SELECT_COLUMNS} FROM wishlist WHERE player_id = ?1
ORDER BY created_at DESC, wishlist_item_id`
)
.bind(playerId)
.all<WishlistRow>()
return results.map(toWishlistItem)
}
/** The player's entry for an item, or null when it isn't on their list. */
export async function getWishlistItem(
db: D1Database,
playerId: number,
target: WishlistTarget
): Promise<WishlistItem | null> {
const where = targetClause(target, 2)
const row = await db
.prepare(`SELECT ${SELECT_COLUMNS} FROM wishlist WHERE player_id = ?1 AND ${where.sql}`)
.bind(playerId, where.value)
.first<WishlistRow>()
return row === null ? null : toWishlistItem(row)
}
/**
* Wish for an item. A wish is boolean, so an item already on the list is answered with its
* EXISTING entry — original id and `CreatedAt` — rather than a twin or a refusal: the client
* toggles by its own idea of the current state, and a stale page re-adding something changes
* nothing. The unique indexes back this up under a race, in which case the loser re-reads.
*/
export async function addWishlistItem(
db: D1Database,
playerId: number,
target: WishlistTarget,
now: Date = new Date()
): Promise<WishlistItem> {
const existing = await getWishlistItem(db, playerId, target)
if (existing !== null) return existing
const row: WishlistRow = {
wishlist_item_id: crypto.randomUUID(),
player_id: playerId,
purchasable_item_id: target.purchasableItemId,
custom_avatar_item_id: target.customAvatarItemId,
created_at: now.toISOString(),
}
const { meta } = await db
.prepare(`INSERT OR IGNORE INTO wishlist (${SELECT_COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5)`)
.bind(
row.wishlist_item_id,
row.player_id,
row.purchasable_item_id,
row.custom_avatar_item_id,
row.created_at
)
.run()
if (meta.changes === 0) {
// Lost a race with an identical add: the row that won is the entry.
const winner = await getWishlistItem(db, playerId, target)
if (winner !== null) return winner
}
return toWishlistItem(row)
}
/**
* Take an item off a player's list. Answers the entry that was removed, or null when the item
* wasn't on the list — which the caller treats as success: the list ends up as asked either
* way, and the client discards the value.
*/
export async function removeWishlistItem(
db: D1Database,
playerId: number,
target: WishlistTarget
): Promise<WishlistItem | null> {
const where = targetClause(target, 2)
const row = await db
.prepare(
`DELETE FROM wishlist WHERE player_id = ?1 AND ${where.sql} RETURNING ${SELECT_COLUMNS}`
)
.bind(playerId, where.value)
.first<WishlistRow>()
return row === null ? null : toWishlistItem(row)
}
/**
* Which of `playerIds` have `target` on their list — one boolean per id, in the order given,
* so the caller can serve the client's positional `List<bool>`. Duplicated ids each get their
* own answer; an unknown player simply hasn't wished for it.
*/
export async function isOnWishlists(
db: D1Database,
playerIds: number[],
target: WishlistTarget
): Promise<boolean[]> {
if (playerIds.length === 0) return []
const where = targetClause(target, 1)
const placeholders = playerIds.map((_, i) => `?${i + 2}`).join(', ')
const { results } = await db
.prepare(`SELECT player_id FROM wishlist WHERE ${where.sql} AND player_id IN (${placeholders})`)
.bind(where.value, ...playerIds)
.all<{ player_id: number }>()
const wished = new Set(results.map((r) => r.player_id))
return playerIds.map((id) => wished.has(id))
}