[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 |
| 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
+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,
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<App>) {
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()`
* 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(
'/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<App>({ 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<App>({ 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<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
* ("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,
} 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<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 () => {
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',
+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))
}