mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-26 15:38:52 -07:00
[econ] room key award
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
-- Who HOLDS a room key — one row per (key, player).
|
||||
--
|
||||
-- Written by `POST /api/roomkeys/v1/awardbulk`, which awards the CALLER the keys its body
|
||||
-- names. A key is held or it isn't — there is no quantity — so the pair is the primary key
|
||||
-- and awarding a key a player already holds changes nothing (`awarded_at` keeps the first
|
||||
-- award). `room_key_id` is `room_key.room_key_id`. Owned by the `econ` worker; generated
|
||||
-- from src/room-key-db.ts (ROOM_KEY_SCHEMA_DDL) — keep in sync.
|
||||
CREATE TABLE IF NOT EXISTS room_key_player (
|
||||
room_key_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
awarded_at TEXT NOT NULL,
|
||||
PRIMARY KEY (room_key_id, account_id)
|
||||
);
|
||||
|
||||
-- "This player's keys" — the primary key only serves lookups that lead with the key.
|
||||
CREATE INDEX IF NOT EXISTS idx_room_key_player_account ON room_key_player (account_id);
|
||||
+129
-2
@@ -83,6 +83,8 @@ import {
|
||||
AwardRoomConsumablesRequest,
|
||||
AwardRoomCurrencyRequest,
|
||||
AwardRoomCurrencyResultList,
|
||||
AwardRoomKeyResultList,
|
||||
AwardRoomKeysRequest,
|
||||
BalanceEntry,
|
||||
BulkPurchaseRequest,
|
||||
BulkPurchaseResponse,
|
||||
@@ -119,6 +121,8 @@ import {
|
||||
MakerAiFreeTrialEligibilityResponse,
|
||||
OpaqueJsonBody,
|
||||
OPTIONAL_AUTHED,
|
||||
OwnsRoomKeysRequest,
|
||||
OwnsRoomKeysResponse,
|
||||
ReferralProgressResponse,
|
||||
RoomConsumableDto,
|
||||
RoomConsumableEnvelope,
|
||||
@@ -158,7 +162,7 @@ import {
|
||||
getRoomCurrency,
|
||||
updateRoomCurrency,
|
||||
} from './room-currency-db'
|
||||
import { createRoomKey, getRoomKeys } from './room-key-db'
|
||||
import { awardRoomKey, createRoomKey, getRoomKeys, ownsRoomKeys } from './room-key-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
|
||||
@@ -183,7 +187,7 @@ import type { Equipment } from './equipment-db'
|
||||
import type { AvatarItem } from './inventory-db'
|
||||
import type { RoomConsumable } from './room-consumable-db'
|
||||
import type { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db'
|
||||
import type { RoomKey } from './room-key-db'
|
||||
import type { RoomKey, RoomKeyHolding } from './room-key-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
|
||||
@@ -5618,6 +5622,129 @@ const app = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Award the CALLER some room keys. Auth-gated (401); everything else is reported per
|
||||
// entry, each in its OWN `{ Value, Success, Error, error_id }` envelope — the client
|
||||
// decodes a list of single-value envelopes here. The body is a bare array of `{ RoomKeyId,
|
||||
// AccountId }` — `AccountId` is accepted and unused: the token says who gets the key.
|
||||
.post(
|
||||
'/api/roomkeys/v1/awardbulk',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Award room keys to the caller',
|
||||
description: [
|
||||
'Records the CALLER as holding each key the body names (`room_key_player`). The body',
|
||||
'is a BARE JSON ARRAY, `[{ "RoomKeyId": 43, "AccountId": 205 }]`; `AccountId` is',
|
||||
'accepted and IGNORED — the token says who is awarded.',
|
||||
'',
|
||||
'A key is held or it isn’t: awarding one the caller already holds succeeds and',
|
||||
'changes nothing.',
|
||||
'',
|
||||
'The answer is an ARRAY OF ENVELOPES — one `{ Value, Success, Error, error_id }` per',
|
||||
'entry, in the order posted, as the client’s decoder reads it; not a bare array of',
|
||||
'awards, and not one envelope around a list. `Value` is `{ RoomKeyId, AccountId }`,',
|
||||
'`AccountId` being the caller. Each entry stands alone: a `RoomKeyId` naming no listed',
|
||||
'key answers `Success` false with a null `Value` (the failure form is an assumption —',
|
||||
'only success has been observed) and leaves the rest to land.',
|
||||
'',
|
||||
'NOTE: this awards to whoever is asking, and nothing is spent for it — the key’s',
|
||||
'`Price` is not charged and no room membership is checked.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(AwardRoomKeysRequest, 'The keys to give the caller'),
|
||||
responses: {
|
||||
200: json(AwardRoomKeyResultList, 'One result per entry, in the order posted'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.json<unknown>().catch(() => null)
|
||||
// A body that is no list has no entries to report on.
|
||||
if (!Array.isArray(body)) return c.json([])
|
||||
|
||||
const failed = (error: string) => ({
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: error,
|
||||
error_id: null,
|
||||
})
|
||||
const results = []
|
||||
for (const entry of body as unknown[]) {
|
||||
const roomKeyId =
|
||||
typeof entry === 'object' && entry !== null
|
||||
? (entry as { RoomKeyId?: unknown }).RoomKeyId
|
||||
: undefined
|
||||
if (typeof roomKeyId !== 'number' || !Number.isInteger(roomKeyId)) {
|
||||
results.push(failed('Invalid room key'))
|
||||
continue
|
||||
}
|
||||
|
||||
const awardedAt = await awardRoomKey(c.env.DB, accountId, roomKeyId)
|
||||
results.push(
|
||||
awardedAt === null
|
||||
? failed('No such room key')
|
||||
: {
|
||||
Value: { RoomKeyId: roomKeyId, AccountId: accountId },
|
||||
Success: true,
|
||||
Error: null,
|
||||
error_id: null,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return c.json(results)
|
||||
}
|
||||
)
|
||||
|
||||
// Who holds which room key. Auth-gated (401). The body is a bare array of `{ AccountId,
|
||||
// RoomKeyId }` pairs and — unlike the award's — `AccountId` is READ here: a room asks
|
||||
// about the players standing in it, not only the caller. A bare array back, one answer
|
||||
// per pair.
|
||||
.post(
|
||||
'/api/roomkeys/v1/owns/bulk',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Whether players hold room keys',
|
||||
description: [
|
||||
'Answers, for each `{ AccountId, RoomKeyId }` pair posted, whether that player holds',
|
||||
'that key (`room_key_player`). The body is a BARE JSON ARRAY, and so is the answer:',
|
||||
'`[{ "AccountId": 205, "RoomKeyId": 43, "DoesPlayerOwnRoomKey": true }]`, one entry',
|
||||
'per pair in the order posted — no envelope, unlike the award beside it.',
|
||||
'',
|
||||
'`AccountId` is honoured, not replaced by the caller: any signed-in player may ask',
|
||||
'about any player. An unknown key or player is simply not held; an entry without two',
|
||||
'integer ids is skipped.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(OwnsRoomKeysRequest, 'The (player, key) pairs to check'),
|
||||
responses: {
|
||||
200: json(OwnsRoomKeysResponse, 'One answer per pair, in the order posted'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.json<unknown>().catch(() => null)
|
||||
if (!Array.isArray(body)) return c.json([])
|
||||
|
||||
const pairs: RoomKeyHolding[] = []
|
||||
for (const entry of body as unknown[]) {
|
||||
if (typeof entry !== 'object' || entry === null) continue
|
||||
const { AccountId, RoomKeyId } = entry as Record<string, unknown>
|
||||
if (typeof AccountId !== 'number' || !Number.isInteger(AccountId)) continue
|
||||
if (typeof RoomKeyId !== 'number' || !Number.isInteger(RoomKeyId)) continue
|
||||
pairs.push({ AccountId, RoomKeyId })
|
||||
}
|
||||
|
||||
const owned = await ownsRoomKeys(c.env.DB, pairs)
|
||||
return c.json(pairs.map((pair, i) => ({ ...pair, DoesPlayerOwnRoomKey: owned[i] })))
|
||||
}
|
||||
)
|
||||
|
||||
// The Rec Room Plus sign-up bonus: which bonus is running and the token price window
|
||||
// the free items are drawn from. Fixed numbers, the same for every caller.
|
||||
//
|
||||
|
||||
@@ -675,6 +675,61 @@ export const CreateRoomKeyRequest = z.object({
|
||||
Price: z.string().optional().describe('What the key costs; defaults to 0'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/roomkeys/v1/awardbulk` — a BARE JSON ARRAY of the keys to award, as the client
|
||||
* posts it. `AccountId` is accepted and IGNORED: the token says who is awarded.
|
||||
*/
|
||||
export const AwardRoomKeysRequest = z.array(
|
||||
z.object({
|
||||
RoomKeyId: z.int().describe('The key to award — a `room_key` id'),
|
||||
AccountId: z
|
||||
.int()
|
||||
.optional()
|
||||
.describe('Accepted and IGNORED — the caller is who is awarded the key'),
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /api/roomkeys/v1/awardbulk` answers an ARRAY OF ENVELOPES — every element its own
|
||||
* `{ Value, Success, Error, error_id }` around one award (recovered from the client's
|
||||
* decoder). Not a bare array of awards, and not one envelope wrapping a list. Only the
|
||||
* success form has been observed; a failed entry's null `Value` is an assumption.
|
||||
*/
|
||||
export const AwardRoomKeyResultList = z.array(
|
||||
z.object({
|
||||
Value: z
|
||||
.object({
|
||||
RoomKeyId: z.int(),
|
||||
AccountId: z.int().describe('Who now holds the key — the caller'),
|
||||
})
|
||||
.nullable()
|
||||
.describe('The award, or null on a failure'),
|
||||
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'),
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /api/roomkeys/v1/owns/bulk` — a BARE JSON ARRAY of (player, key) pairs to check.
|
||||
* Unlike the award's, this `AccountId` is READ: it names whose keys are being asked about.
|
||||
*/
|
||||
export const OwnsRoomKeysRequest = z.array(
|
||||
z.object({
|
||||
AccountId: z.int().describe('The player being asked about'),
|
||||
RoomKeyId: z.int().describe('The key — a `room_key` id'),
|
||||
})
|
||||
)
|
||||
|
||||
/** `POST /api/roomkeys/v1/owns/bulk` — a bare array, one answer per pair in the order asked. */
|
||||
export const OwnsRoomKeysResponse = z.array(
|
||||
z.object({
|
||||
AccountId: z.int(),
|
||||
RoomKeyId: z.int(),
|
||||
DoesPlayerOwnRoomKey: 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
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
* Room keys — a room's own admission tickets.
|
||||
*
|
||||
* A creator lists a key for their room: a named, priced thing a player can hold to get
|
||||
* through a door the room locks. This module is the LISTING — its name, its price, which room
|
||||
* it opens. Nothing sells a key yet, so who holds one is not recorded anywhere. The endpoints:
|
||||
* through a door the room locks. `room_key` is the LISTING — its name, its price, which room
|
||||
* it opens — and `room_key_player` is who HOLDS one. Nothing sells a key yet; a key reaches
|
||||
* a player by being awarded. The endpoints:
|
||||
* - `POST /api/roomkeys/v1/create` lists one
|
||||
* - `GET /api/roomkeys/v1/room?roomId=` lists a room's
|
||||
* - `POST /api/roomkeys/v1/awardbulk` awards the caller some
|
||||
* - `POST /api/roomkeys/v1/owns/bulk` says who holds which
|
||||
*
|
||||
* This worker (`econ`) owns the table and its migrations — see apps/econ/migrations/0024.
|
||||
* This worker (`econ`) owns the tables and their migrations — see apps/econ/migrations/0024
|
||||
* and 0025.
|
||||
*/
|
||||
|
||||
/** Schema DDL — also builds the table in tests. Mirrors migration 0024. */
|
||||
/** Schema DDL — also builds the tables in tests. Mirrors migrations 0024 and 0025. */
|
||||
export const ROOM_KEY_SCHEMA_DDL: string[] = [
|
||||
// `room_key_id` is an AUTOINCREMENT rather than a GUID, unlike a room currency's: the
|
||||
// client's own model names a key by a numeric `RoomKeyId` (see the `notify` worker's
|
||||
@@ -37,6 +41,15 @@ export const ROOM_KEY_SCHEMA_DDL: string[] = [
|
||||
)`,
|
||||
// Every read is "this room's keys" — the whole access pattern.
|
||||
`CREATE INDEX IF NOT EXISTS idx_room_key_room ON room_key (room_id)`,
|
||||
// Who holds a key: one row per (key, player). A key is held or it isn't — there is no
|
||||
// quantity — so the pair is the primary key and a repeat award changes nothing.
|
||||
`CREATE TABLE IF NOT EXISTS room_key_player (
|
||||
room_key_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
awarded_at TEXT NOT NULL,
|
||||
PRIMARY KEY (room_key_id, account_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_room_key_player_account ON room_key_player (account_id)`,
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -146,3 +159,48 @@ export async function getRoomKeys(db: D1Database, roomId: number): Promise<RoomK
|
||||
|
||||
return results.map(toRoomKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Award a player a room key, answering when they came to hold it — now, or the first award's
|
||||
* time when they already did (a key is held once; re-awarding is a no-op). Null when
|
||||
* `roomKeyId` names no listed key: a held key that cannot be described is never written.
|
||||
*/
|
||||
export async function awardRoomKey(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
roomKeyId: number
|
||||
): Promise<string | null> {
|
||||
const [, held] = await db.batch<{ awarded_at: string }>([
|
||||
db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO room_key_player (room_key_id, account_id, awarded_at)
|
||||
SELECT room_key_id, ?2, ?3 FROM room_key WHERE room_key_id = ?1`
|
||||
)
|
||||
.bind(roomKeyId, accountId, new Date().toISOString()),
|
||||
db
|
||||
.prepare('SELECT awarded_at FROM room_key_player WHERE room_key_id = ?1 AND account_id = ?2')
|
||||
.bind(roomKeyId, accountId),
|
||||
])
|
||||
return held.results[0]?.awarded_at ?? null
|
||||
}
|
||||
|
||||
/** A (player, key) pair — one question put to `POST /api/roomkeys/v1/owns/bulk`. */
|
||||
export interface RoomKeyHolding {
|
||||
AccountId: number
|
||||
RoomKeyId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether each player holds each key — one answer per pair, in the order asked. A pair
|
||||
* naming an unknown key or player is simply not held.
|
||||
*/
|
||||
export async function ownsRoomKeys(db: D1Database, pairs: RoomKeyHolding[]): Promise<boolean[]> {
|
||||
if (pairs.length === 0) return []
|
||||
const held = db.prepare(
|
||||
'SELECT 1 AS held FROM room_key_player WHERE room_key_id = ?1 AND account_id = ?2'
|
||||
)
|
||||
const results = await db.batch<{ held: number }>(
|
||||
pairs.map((pair) => held.bind(pair.RoomKeyId, pair.AccountId))
|
||||
)
|
||||
return results.map((r) => r.results.length > 0)
|
||||
}
|
||||
|
||||
@@ -2125,6 +2125,109 @@ describe('econ endpoints', () => {
|
||||
expect(free.Price).toBe(0)
|
||||
})
|
||||
|
||||
test('POST awardbulk records the CALLER as holding each key, reporting each entry', async () => {
|
||||
const award = async (payload: unknown, headers?: Record<string, string>) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/awardbulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const held = async () =>
|
||||
(
|
||||
await env.DB.prepare(
|
||||
'SELECT room_key_id, account_id, awarded_at FROM room_key_player ORDER BY room_key_id'
|
||||
).all<{ room_key_id: number; account_id: number; awarded_at: string }>()
|
||||
).results
|
||||
|
||||
const key = await keyOf(await create({ ...body, Name: 'awarded' }, await bearer('1')))
|
||||
const id = key.RoomKeyId
|
||||
|
||||
// Auth-gated.
|
||||
expect((await award([{ RoomKeyId: id, AccountId: 42 }])).status).toBe(401)
|
||||
expect(await held()).toEqual([])
|
||||
|
||||
// The body exactly as the client posts it. The token's player gets the key —
|
||||
// the body's `AccountId` is ignored, even when it names someone else.
|
||||
const res = await award(
|
||||
[
|
||||
{ RoomKeyId: id, AccountId: 205 },
|
||||
{ RoomKeyId: 999999, AccountId: 205 },
|
||||
{ AccountId: 205 },
|
||||
],
|
||||
await bearer('42')
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
// An array of envelopes, one per entry — `error_id` lowercase beside the rest.
|
||||
const awarded = {
|
||||
Value: { RoomKeyId: id, AccountId: 42 },
|
||||
Success: true,
|
||||
Error: null,
|
||||
error_id: null,
|
||||
}
|
||||
expect(await res.json()).toEqual([
|
||||
awarded,
|
||||
{ Value: null, Success: false, Error: 'No such room key', error_id: null },
|
||||
{ Value: null, Success: false, Error: 'Invalid room key', error_id: null },
|
||||
])
|
||||
const rows = await held()
|
||||
expect(rows).toEqual([{ room_key_id: id, account_id: 42, awarded_at: expect.any(String) }])
|
||||
|
||||
// Held once: a repeat award succeeds and leaves the row as it was.
|
||||
const again = await award([{ RoomKeyId: id, AccountId: 42 }], await bearer('42'))
|
||||
expect(await again.json()).toEqual([awarded])
|
||||
expect(await held()).toEqual(rows)
|
||||
|
||||
// Another player holds their own copy; a body that is no list has no entries.
|
||||
await award([{ RoomKeyId: id }], await bearer('43'))
|
||||
expect((await held()).map((r) => r.account_id).sort()).toEqual([42, 43])
|
||||
expect(await (await award({ RoomKeyId: id }, await bearer('42'))).json()).toEqual([])
|
||||
|
||||
await env.DB.prepare('DELETE FROM room_key_player').run()
|
||||
await env.DB.prepare('DELETE FROM room_key').run()
|
||||
})
|
||||
|
||||
test('POST owns/bulk answers each (player, key) pair, reading the body’s AccountId', async () => {
|
||||
const post = async (path: string, payload: unknown, headers?: Record<string, string>) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const key = await keyOf(await create({ ...body, Name: 'held' }, await bearer('1')))
|
||||
const id = key.RoomKeyId
|
||||
await post('awardbulk', [{ RoomKeyId: id, AccountId: 42 }], await bearer('42'))
|
||||
|
||||
// Auth-gated.
|
||||
expect((await post('owns/bulk', [{ AccountId: 42, RoomKeyId: id }])).status).toBe(401)
|
||||
|
||||
// Asked by 43 about 42 and others: the body's player is the one checked. A bare
|
||||
// array back, in the order posted; the malformed entry is skipped.
|
||||
const res = await post(
|
||||
'owns/bulk',
|
||||
[
|
||||
{ AccountId: 42, RoomKeyId: id },
|
||||
{ AccountId: 43, RoomKeyId: id },
|
||||
{ AccountId: 42, RoomKeyId: 999999 },
|
||||
{ AccountId: 42 },
|
||||
],
|
||||
await bearer('43')
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([
|
||||
{ AccountId: 42, RoomKeyId: id, DoesPlayerOwnRoomKey: true },
|
||||
{ AccountId: 43, RoomKeyId: id, DoesPlayerOwnRoomKey: false },
|
||||
{ AccountId: 42, RoomKeyId: 999999, DoesPlayerOwnRoomKey: false },
|
||||
])
|
||||
|
||||
// Nothing to ask is nothing to answer.
|
||||
expect(await (await post('owns/bulk', [], await bearer('43'))).json()).toEqual([])
|
||||
expect(await (await post('owns/bulk', {}, await bearer('43'))).json()).toEqual([])
|
||||
|
||||
await env.DB.prepare('DELETE FROM room_key_player').run()
|
||||
await env.DB.prepare('DELETE FROM room_key').run()
|
||||
})
|
||||
|
||||
test('POST create refuses the eleventh key in a room', async () => {
|
||||
// MaxKeysPerRoom is 10 (apps/api/static/api-config-v2.json). Fill up to it…
|
||||
while ((await keysOf(2511)).length < 10) {
|
||||
@@ -5962,7 +6065,9 @@ describe('econ endpoints', () => {
|
||||
'POST /api/roomcurrencies/v1/createCurrency',
|
||||
'POST /api/roomcurrencies/v1/createPurchaseOffer',
|
||||
'POST /api/roomcurrencies/v1/updateCurrency',
|
||||
'POST /api/roomkeys/v1/awardbulk',
|
||||
'POST /api/roomkeys/v1/create',
|
||||
'POST /api/roomkeys/v1/owns/bulk',
|
||||
'POST /api/storefronts/v2/buyItem',
|
||||
'POST /api/storefronts/v3/buyInvention',
|
||||
'POST /api/ugcPurchasables/v1/items/bulk',
|
||||
|
||||
Reference in New Issue
Block a user