diff --git a/apps/econ/README.md b/apps/econ/README.md index 0decbe8..8292eb6 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -25,7 +25,11 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali | 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/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 | | POST | `/api/avatar/v3/saved/set` | ✓ | Save an outfit into a slot | | 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 storefronts, which the ASSETS binding can't enumerate. - Consumables are granted and listed but never spent by gameplay, so `Count` only grows. -- Several routes (room keys, wishlist, equipment, room consumables/currencies) are - empty-list stubs pending their own stores. +- Several routes (equipment, room consumables/currencies) are empty-list stubs pending + their own stores. - 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`). - The level-reward table and the served config's `GiftRarity` disagree in places (see the diff --git a/apps/econ/migrations/0027_wishlist.sql b/apps/econ/migrations/0027_wishlist.sql new file mode 100644 index 0000000..f8627d3 --- /dev/null +++ b/apps/econ/migrations/0027_wishlist.sql @@ -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; diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index d27f2f6..f92ec29 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -112,6 +112,8 @@ import { GameRewardRequest, InfluencerIdsResponse, InfluencerTierResponse, + IsOnWishlistBulkRequest, + IsOnWishlistBulkResponse, ItemPurchaseInfoList, ItemPurchaseInfosRequest, json, @@ -152,6 +154,9 @@ import { UpdateRoomCurrencyRequest, UpdateRoomKeyRequest, UpsertRoomConsumableRequest, + WishlistEnvelope, + WishlistItemList, + WishlistItemRequest, } from './openapi' import { claimReward, isNewActivity } from './reward-db' import { @@ -184,6 +189,13 @@ import { updateRoomKey, } from './room-key-db' import { getRoomPurchasables } from './room-purchasables-db' +import { + addWishlistItem, + getWishlist, + isOnWishlists, + parseWishlistTarget, + removeWishlistItem, +} from './wishlist-db' import type { Context } from 'hono' 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 { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-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). // 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, * avatars, gift boxes, weekly-challenge progress and game-reward eligibility are D1-backed; * storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS - * binding. Some routes are still empty-list stubs (room keys, wishlist, …). + * binding. Some routes are still empty-list stubs (equipment, room consumables, …). * * Auth-gated routes validate the Bearer JWT issued by the `auth` worker. */ @@ -256,6 +269,39 @@ function unauthorized(c: Context) { 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, 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) { + const body = (await c.req.parseBody().catch(() => ({}))) as Record + 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()` * output — `"True"`/`"False"`, capitalized. `Boolean(value)` is a trap here: the string @@ -3115,20 +3161,41 @@ const app = new Hono({ 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( '/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) => { const id = await authedId(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 - // somebody else is hoping for (and to mark items in the store as already wished for). - // Empty like `/me`: nothing stores wishlists, so there is nothing to show for anyone. + // somebody else is hoping for. Public to any signed-in player: a wishlist exists to be + // 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 // account id — the pattern here is digits-only, so it could not swallow `me`, but the @@ -3139,10 +3206,11 @@ const app = new Hono({ strict: false }) tags: ['Econ'], summary: 'Another player’s item wishlist', description: [ - 'The wishlist of the account named in the path, as a bare array. Empty for now —', - 'nothing on this server stores wishlists, so every player’s is empty, and an empty', - 'list is what the client renders as “nothing wished for” where a 404 would read as a', - 'failed load.', + 'The wishlist of the account named in the path, as a bare array of entries, newest', + 'first. Readable by any signed-in player — a wishlist is for others to see. An', + 'unknown account answers `[]`, which the client renders as “nothing wished for”', + 'where a 404 would read as a failed load. The keyless `?True`/`?False` the client', + 'appends is ignored, as on `/me`.', ].join(' '), security: AUTHED, parameters: [ @@ -3155,14 +3223,136 @@ const app = new Hono({ strict: false }) }, ], responses: { - 200: json(JsonArray, 'That player’s wishlist — empty for now'), + 200: json(WishlistItemList, 'That player’s wishlist, newest first'), 401: UNAUTHORIZED_RESPONSE, }, }), async (c) => { const id = await authedId(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) + // `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)) } ) diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 9f85d7a..dc270a4 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -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 * ("5 SuperTokens for 500 Rec Center Tokens"). The client's own model, member for member and diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 03076aa..272393f 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -116,6 +116,7 @@ import { ROOM_CURRENCY_SCHEMA_DDL, } from '../../room-currency-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 { 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_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 WISHLIST_SCHEMA_DDL) await env.DB.prepare(stmt).run() // The platform link table (owned by `auth`) — the Discord supporter gift reads the roles // `www` records on each Discord link. 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) }) + 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 + 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 } + 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> + 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 () => { const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`) expect(anon.status).toBe(401) @@ -6791,6 +6993,9 @@ describe('econ endpoints', () => { 'POST /api/consumables/v1/consume', 'POST /api/equipment/v1/update', '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/purchaseInfos', 'POST /api/objectives/v1/cleargroup', diff --git a/apps/econ/src/wishlist-db.ts b/apps/econ/src/wishlist-db.ts new file mode 100644 index 0000000..3cc1a38 --- /dev/null +++ b/apps/econ/src/wishlist-db.ts @@ -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 `?`. */ +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 { + 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() + 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 { + 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() + 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 { + 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 { + 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() + 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`. 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 { + 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)) +}