mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[econ] fix key images
This commit is contained in:
@@ -51,6 +51,7 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
|
|||||||
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
|
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
|
||||||
| GET | `/api/roomkeys/v1/room` | | The keys a room has listed |
|
| GET | `/api/roomkeys/v1/room` | | The keys a room has listed |
|
||||||
| POST | `/api/roomkeys/v1/create` | ✓ | List a key for a room (owner/co-owner) |
|
| POST | `/api/roomkeys/v1/create` | ✓ | List a key for a room (owner/co-owner) |
|
||||||
|
| PUT | `/api/roomkeys/v1/updateAll` | ✓ | Edit one key (owner/co-owner) |
|
||||||
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | ~ | Gold year for `developer`s, else `{}` |
|
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | ~ | Gold year for `developer`s, else `{}` |
|
||||||
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
|
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
|
||||||
|
|
||||||
|
|||||||
+128
-5
@@ -144,6 +144,7 @@ import {
|
|||||||
UpdateObjectiveRequest,
|
UpdateObjectiveRequest,
|
||||||
UpdateObjectiveResponse,
|
UpdateObjectiveResponse,
|
||||||
UpdateRoomCurrencyRequest,
|
UpdateRoomCurrencyRequest,
|
||||||
|
UpdateRoomKeyRequest,
|
||||||
UpsertRoomConsumableRequest,
|
UpsertRoomConsumableRequest,
|
||||||
} from './openapi'
|
} from './openapi'
|
||||||
import { claimReward, isNewActivity } from './reward-db'
|
import { claimReward, isNewActivity } from './reward-db'
|
||||||
@@ -162,7 +163,14 @@ import {
|
|||||||
getRoomCurrency,
|
getRoomCurrency,
|
||||||
updateRoomCurrency,
|
updateRoomCurrency,
|
||||||
} from './room-currency-db'
|
} from './room-currency-db'
|
||||||
import { awardRoomKey, createRoomKey, getRoomKeys, ownsRoomKeys } from './room-key-db'
|
import {
|
||||||
|
awardRoomKey,
|
||||||
|
createRoomKey,
|
||||||
|
getRoomKey,
|
||||||
|
getRoomKeys,
|
||||||
|
ownsRoomKeys,
|
||||||
|
updateRoomKey,
|
||||||
|
} from './room-key-db'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
|
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
|
||||||
@@ -295,6 +303,14 @@ const ROOM_KEY_STATUS_FAILED = 1
|
|||||||
*/
|
*/
|
||||||
const MAX_KEYS_PER_ROOM = 10
|
const MAX_KEYS_PER_ROOM = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most a room key may cost, by what it is priced in: tokens (a null `PurchaseCurrencyId`)
|
||||||
|
* are real money to the player paying, so they are capped low; a room's own currency is the
|
||||||
|
* room's to inflate.
|
||||||
|
*/
|
||||||
|
const MAX_KEY_PRICE_TOKENS = 1000
|
||||||
|
const MAX_KEY_PRICE_ROOM_CURRENCY = 1_000_000_000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The envelope the create endpoint answers in: `{ Value, Success, Error, error_id }`.
|
* The envelope the create endpoint answers in: `{ Value, Success, Error, error_id }`.
|
||||||
*
|
*
|
||||||
@@ -5565,12 +5581,13 @@ const app = new Hono<App>({ strict: false })
|
|||||||
'',
|
'',
|
||||||
'`Name` and `Description` are masked by the same word list as every other string a',
|
'`Name` and `Description` are masked by the same word list as every other string a',
|
||||||
'player types. A room may list at most 10 keys — the `RoomKeyConfig.MaxKeysPerRoom`',
|
'player types. A room may list at most 10 keys — the `RoomKeyConfig.MaxKeysPerRoom`',
|
||||||
'the `api` config tells the client — and the eleventh is refused.',
|
'the `api` config tells the client — and the eleventh is refused. A new key is priced',
|
||||||
|
'in tokens (the body names no currency), so a `Price` over 1000 is refused too.',
|
||||||
'',
|
'',
|
||||||
'Answers `{ Status, RoomKey }` — `Status` 0 and the created key, as the live client',
|
'Answers `{ Status, RoomKey }` — `Status` 0 and the created key, as the live client',
|
||||||
'reads it — not the `{ Value, Success, Error, error_id }` envelope the room-currency',
|
'reads it — not the `{ Value, Success, Error, error_id }` envelope the room-currency',
|
||||||
'writes use. A recoverable refusal (an unusable `RoomId`, an empty `Name`, an unknown',
|
'writes use. A recoverable refusal (an unusable `RoomId`, an empty `Name`, an unknown',
|
||||||
'room, a full room) is a 200 carrying a non-zero `Status` and a null `RoomKey`; only',
|
'room, a full room, a `Price` over the limit) is a 200 carrying a non-zero `Status` and a null `RoomKey`; only',
|
||||||
'the auth gates answer with an HTTP status of their own.',
|
'the auth gates answer with an HTTP status of their own.',
|
||||||
'',
|
'',
|
||||||
'Pushes `LocalRoomKeyCreated` (120), carrying the same object, to everyone in the',
|
'Pushes `LocalRoomKeyCreated` (120), carrying the same object, to everyone in the',
|
||||||
@@ -5599,6 +5616,10 @@ const app = new Hono<App>({ strict: false })
|
|||||||
if (!Number.isInteger(roomId)) return roomKeyEnvelope(c, null)
|
if (!Number.isInteger(roomId)) return roomKeyEnvelope(c, null)
|
||||||
const name = str(body.Name).trim()
|
const name = str(body.Name).trim()
|
||||||
if (name === '') return roomKeyEnvelope(c, null)
|
if (name === '') return roomKeyEnvelope(c, null)
|
||||||
|
// A key cannot cost less than nothing — nor, being priced in tokens until an edit
|
||||||
|
// says otherwise, more than the token cap.
|
||||||
|
const price = Math.max(0, int(body.Price, 0))
|
||||||
|
if (price > MAX_KEY_PRICE_TOKENS) return roomKeyEnvelope(c, null)
|
||||||
|
|
||||||
const canManage = await canManageRoomById(c.env.DB, roomId, accountId)
|
const canManage = await canManageRoomById(c.env.DB, roomId, accountId)
|
||||||
if (canManage === null) return roomKeyEnvelope(c, null)
|
if (canManage === null) return roomKeyEnvelope(c, null)
|
||||||
@@ -5613,8 +5634,7 @@ const app = new Hono<App>({ strict: false })
|
|||||||
Type: str(body.Type).trim() || 'Key',
|
Type: str(body.Type).trim() || 'Key',
|
||||||
Name: censorSwears(name),
|
Name: censorSwears(name),
|
||||||
Description: censorSwears(str(body.Description)),
|
Description: censorSwears(str(body.Description)),
|
||||||
// A key cannot cost less than nothing.
|
Price: price,
|
||||||
Price: Math.max(0, int(body.Price, 0)),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await pushRoomKeyCreated(c, key, accountId)
|
await pushRoomKeyCreated(c, key, accountId)
|
||||||
@@ -5622,6 +5642,109 @@ const app = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Edit a room key. "All" is the key's FIELDS, not the room's keys: the body names one
|
||||||
|
// `RoomKeyId` and carries everything editable about it. Gated like the create — the room
|
||||||
|
// comes off the KEY, never the request.
|
||||||
|
.put(
|
||||||
|
'/api/roomkeys/v1/updateAll',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Econ'],
|
||||||
|
summary: 'Update a room key',
|
||||||
|
description: [
|
||||||
|
'Edits ONE key — despite the name, "all" is its fields — form-encoded',
|
||||||
|
'(`RoomKeyId=43&Name=key1&Description=…&Price=1000&PurchaseCurrencyId=`).',
|
||||||
|
'',
|
||||||
|
'Gated to the CREATOR or a CO-OWNER of the room that lists the key; a valid token',
|
||||||
|
'from anyone else is a 403.',
|
||||||
|
'',
|
||||||
|
'`PurchaseCurrencyId` sent EMPTY prices the key in tokens (stored and served as',
|
||||||
|
'null) — that is how a key on a room currency is taken back off it; otherwise it',
|
||||||
|
'must be a `room_currency` id belonging to the key’s own room.',
|
||||||
|
'`ImageName` is the key’s art, stored as posted; sent EMPTY it clears the art',
|
||||||
|
'(served as null). That `ImageName` rides in this form is an ASSUMPTION.',
|
||||||
|
'`Price` is capped by what the key ends up priced in: 1000 in tokens, 1000000000 in',
|
||||||
|
'a room currency. A field left out of the body is left alone rather than reset, and',
|
||||||
|
'the cap is checked against the resulting pair.',
|
||||||
|
'',
|
||||||
|
'`Name` and `Description` are masked by the same word list as every other string a',
|
||||||
|
'player types. What can never change: the ids, the room, the `Type`, `CreatedAt`.',
|
||||||
|
'',
|
||||||
|
'Answers the create’s `{ Status, RoomKey }` envelope with the key as it now stands',
|
||||||
|
'— an ASSUMPTION: what the client reads from this response has not been observed.',
|
||||||
|
'A recoverable refusal (no such key, a blank `Name`, a `Price` over the cap, an',
|
||||||
|
'unknown or foreign currency) is a 200 carrying a non-zero `Status` and a null',
|
||||||
|
'`RoomKey`.',
|
||||||
|
].join('\n'),
|
||||||
|
security: AUTHED,
|
||||||
|
requestBody: form(UpdateRoomKeyRequest, 'The key and its fields'),
|
||||||
|
responses: {
|
||||||
|
200: json(RoomKeyEnvelope, 'The key as it now stands, or a refusal'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
|
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||||
|
|
||||||
|
const roomKeyId = Number.parseInt(str(body.RoomKeyId) ?? '', 10)
|
||||||
|
if (!Number.isInteger(roomKeyId)) return roomKeyEnvelope(c, null)
|
||||||
|
|
||||||
|
const existing = await getRoomKey(c.env.DB, roomKeyId)
|
||||||
|
if (!existing) return roomKeyEnvelope(c, null)
|
||||||
|
|
||||||
|
const canManage = await canManageRoomById(c.env.DB, existing.RoomId, accountId)
|
||||||
|
if (canManage === null) return roomKeyEnvelope(c, null)
|
||||||
|
if (!canManage) return c.body(null, 403)
|
||||||
|
|
||||||
|
// A name sent but blank is a bad edit, not an instruction to leave it alone.
|
||||||
|
const name = str(body.Name)?.trim()
|
||||||
|
if (name === '') return roomKeyEnvelope(c, null)
|
||||||
|
|
||||||
|
let price = existing.Price
|
||||||
|
if (str(body.Price) !== undefined) {
|
||||||
|
price = Number.parseInt(str(body.Price)!, 10)
|
||||||
|
if (!Number.isInteger(price) || price < 0) return roomKeyEnvelope(c, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Present-but-empty is a VALUE here — tokens — unlike the fields above, where only
|
||||||
|
// absence means "leave alone".
|
||||||
|
let purchaseCurrencyId = existing.PurchaseCurrencyId
|
||||||
|
const postedCurrencyId = str(body.PurchaseCurrencyId)?.trim()
|
||||||
|
if (postedCurrencyId === '') {
|
||||||
|
purchaseCurrencyId = null
|
||||||
|
} else if (postedCurrencyId !== undefined && postedCurrencyId !== purchaseCurrencyId) {
|
||||||
|
// A key is sold in its own room's money, not another's.
|
||||||
|
const currency = await getRoomCurrency(c.env.DB, postedCurrencyId)
|
||||||
|
if (!currency || currency.RoomId !== existing.RoomId) return roomKeyEnvelope(c, null)
|
||||||
|
purchaseCurrencyId = currency.CurrencyId
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPrice =
|
||||||
|
purchaseCurrencyId === null ? MAX_KEY_PRICE_TOKENS : MAX_KEY_PRICE_ROOM_CURRENCY
|
||||||
|
if (price > maxPrice) return roomKeyEnvelope(c, null)
|
||||||
|
|
||||||
|
// Blank is a value here too: no art. Only absence leaves the stored name alone.
|
||||||
|
const postedImageName = str(body.ImageName)?.trim()
|
||||||
|
const imageName = postedImageName === undefined ? existing.ImageName : postedImageName || null
|
||||||
|
|
||||||
|
const description = str(body.Description)
|
||||||
|
return roomKeyEnvelope(
|
||||||
|
c,
|
||||||
|
await updateRoomKey(c.env.DB, roomKeyId, {
|
||||||
|
Name: name === undefined ? existing.Name : censorSwears(name),
|
||||||
|
Description: description === undefined ? existing.Description : censorSwears(description),
|
||||||
|
Price: price,
|
||||||
|
PurchaseCurrencyId: purchaseCurrencyId,
|
||||||
|
ImageName: imageName,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Award the CALLER some room keys. Auth-gated (401); everything else is reported per
|
// 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
|
// 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,
|
// decodes a list of single-value envelopes here. The body is a bare array of `{ RoomKeyId,
|
||||||
|
|||||||
@@ -645,9 +645,9 @@ export const RoomKeyDto = z.object({
|
|||||||
PurchaseCurrencyId: z
|
PurchaseCurrencyId: z
|
||||||
.string()
|
.string()
|
||||||
.nullable()
|
.nullable()
|
||||||
.describe('A `room_currency` id, or null — the create body names none'),
|
.describe('A `room_currency` id, or null for tokens — which a new key is priced in'),
|
||||||
CreatedAt: z.string().describe('ISO-8601 UTC'),
|
CreatedAt: z.string().describe('ISO-8601 UTC'),
|
||||||
ImageName: z.string().nullable().describe('Null — a key cannot carry art yet'),
|
ImageName: z.string().nullable().describe('The key’s art; null until an edit sets one'),
|
||||||
Type: z.int().describe('The key type’s ordinal: 0 `Key`, the only one seen'),
|
Type: z.int().describe('The key type’s ordinal: 0 `Key`, the only one seen'),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -672,7 +672,37 @@ export const CreateRoomKeyRequest = z.object({
|
|||||||
RoomId: z.string().describe('The room the key opens'),
|
RoomId: z.string().describe('The room the key opens'),
|
||||||
Name: z.string().describe('Shown to players; profanity-masked like every typed string'),
|
Name: z.string().describe('Shown to players; profanity-masked like every typed string'),
|
||||||
Description: z.string().optional().describe('Defaults to empty'),
|
Description: z.string().optional().describe('Defaults to empty'),
|
||||||
Price: z.string().optional().describe('What the key costs; defaults to 0'),
|
Price: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('What the key costs, in tokens; defaults to 0. At most 1000'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `PUT /api/roomkeys/v1/updateAll` — form-encoded. "All" is the FIELDS, not the keys: the body
|
||||||
|
* names one key and carries every editable field of it
|
||||||
|
* (`RoomKeyId=43&Name=key1&Description=…&Price=1000&PurchaseCurrencyId=`). For the two
|
||||||
|
* fields that can be emptied — `PurchaseCurrencyId` and `ImageName` — a BLANK value is an
|
||||||
|
* instruction to clear it; only a field absent from the body is left alone.
|
||||||
|
*/
|
||||||
|
export const UpdateRoomKeyRequest = z.object({
|
||||||
|
RoomKeyId: z.string().describe('The key to edit'),
|
||||||
|
Name: z.string().optional().describe('Left alone when absent; profanity-masked'),
|
||||||
|
Description: z.string().optional().describe('Left alone when absent; profanity-masked'),
|
||||||
|
Price: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Left alone when absent. At most 1000 in tokens, 1000000000 in a room currency'),
|
||||||
|
PurchaseCurrencyId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'EMPTY means tokens; otherwise a `room_currency` id of the key’s own room. Left alone when absent'
|
||||||
|
),
|
||||||
|
ImageName: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('The key’s art. EMPTY clears it (served as null). Left alone when absent'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* a player by being awarded. The endpoints:
|
* a player by being awarded. The endpoints:
|
||||||
* - `POST /api/roomkeys/v1/create` lists one
|
* - `POST /api/roomkeys/v1/create` lists one
|
||||||
* - `GET /api/roomkeys/v1/room?roomId=` lists a room's
|
* - `GET /api/roomkeys/v1/room?roomId=` lists a room's
|
||||||
|
* - `PUT /api/roomkeys/v1/updateAll` edits one
|
||||||
* - `POST /api/roomkeys/v1/awardbulk` awards the caller some
|
* - `POST /api/roomkeys/v1/awardbulk` awards the caller some
|
||||||
* - `POST /api/roomkeys/v1/owns/bulk` says who holds which
|
* - `POST /api/roomkeys/v1/owns/bulk` says who holds which
|
||||||
*
|
*
|
||||||
@@ -24,8 +25,8 @@ export const ROOM_KEY_SCHEMA_DDL: string[] = [
|
|||||||
// back as the ORDINAL (`Type: 0`) — see {@link ROOM_KEY_TYPE_ORDINALS}. Only `Key` has
|
// back as the ORDINAL (`Type: 0`) — see {@link ROOM_KEY_TYPE_ORDINALS}. Only `Key` has
|
||||||
// been observed.
|
// been observed.
|
||||||
//
|
//
|
||||||
// `purchase_currency_id` is the `room_currency` the key is charged in — nullable, and the
|
// `purchase_currency_id` is the `room_currency` the key is charged in — null means tokens,
|
||||||
// create body names none. `image_name` is '' in the row (NOT NULL), but the client reads
|
// which is what a new key costs: the create body names none, and an edit can set it. `image_name` is '' in the row (NOT NULL), but the client reads
|
||||||
// `ImageName` as null until a key carries art, so an empty column is served as null.
|
// `ImageName` as null until a key carries art, so an empty column is served as null.
|
||||||
`CREATE TABLE IF NOT EXISTS room_key (
|
`CREATE TABLE IF NOT EXISTS room_key (
|
||||||
room_key_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
room_key_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -76,11 +77,11 @@ export interface RoomKey {
|
|||||||
Name: string
|
Name: string
|
||||||
Description: string
|
Description: string
|
||||||
Price: number
|
Price: number
|
||||||
/** A `room_currency` id, or null — the create body names none. */
|
/** A `room_currency` id, or null for tokens — which is what a new key is priced in. */
|
||||||
PurchaseCurrencyId: string | null
|
PurchaseCurrencyId: string | null
|
||||||
/** ISO-8601 UTC. */
|
/** ISO-8601 UTC. */
|
||||||
CreatedAt: string
|
CreatedAt: string
|
||||||
/** Null until a key can carry art. */
|
/** Null until an edit gives the key art. */
|
||||||
ImageName: string | null
|
ImageName: string | null
|
||||||
/** The key type's ordinal — 0 `Key`. See {@link ROOM_KEY_TYPE_ORDINALS}. */
|
/** The key type's ordinal — 0 `Key`. See {@link ROOM_KEY_TYPE_ORDINALS}. */
|
||||||
Type: number
|
Type: number
|
||||||
@@ -147,6 +148,56 @@ export async function createRoomKey(db: D1Database, key: NewRoomKey): Promise<Ro
|
|||||||
return toRoomKey(row!)
|
return toRoomKey(row!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One key by its id, or null when there is no such row. */
|
||||||
|
export async function getRoomKey(db: D1Database, roomKeyId: number): Promise<RoomKey | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(`SELECT ${SELECT_COLUMNS} FROM room_key WHERE room_key_id = ?1`)
|
||||||
|
.bind(roomKeyId)
|
||||||
|
.first<RoomKeyRow>()
|
||||||
|
|
||||||
|
return row ? toRoomKey(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What an edit may change — the rest of a key (its ids, room, type, `CreatedAt`) is fixed. */
|
||||||
|
export interface RoomKeyChanges {
|
||||||
|
Name: string
|
||||||
|
Description: string
|
||||||
|
Price: number
|
||||||
|
/** A `room_currency` id, or null for tokens. */
|
||||||
|
PurchaseCurrencyId: string | null
|
||||||
|
/** The key's art, or null for none — stored as '' (the column is NOT NULL). */
|
||||||
|
ImageName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite a key's editable fields, returning it as it now stands — null when `roomKeyId`
|
||||||
|
* names no listed key.
|
||||||
|
*/
|
||||||
|
export async function updateRoomKey(
|
||||||
|
db: D1Database,
|
||||||
|
roomKeyId: number,
|
||||||
|
changes: RoomKeyChanges
|
||||||
|
): Promise<RoomKey | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE room_key
|
||||||
|
SET name = ?2, description = ?3, price = ?4, purchase_currency_id = ?5, image_name = ?6
|
||||||
|
WHERE room_key_id = ?1
|
||||||
|
RETURNING ${SELECT_COLUMNS}`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
roomKeyId,
|
||||||
|
changes.Name,
|
||||||
|
changes.Description,
|
||||||
|
changes.Price,
|
||||||
|
changes.PurchaseCurrencyId,
|
||||||
|
changes.ImageName ?? ''
|
||||||
|
)
|
||||||
|
.first<RoomKeyRow>()
|
||||||
|
|
||||||
|
return row ? toRoomKey(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every key a room has listed, oldest first — the order its owner built them up in. The
|
* Every key a room has listed, oldest first — the order its owner built them up in. The
|
||||||
* autoincrement id IS creation order, so it is the sort key.
|
* autoincrement id IS creation order, so it is the sort key.
|
||||||
|
|||||||
@@ -2241,6 +2241,180 @@ describe('econ endpoints', () => {
|
|||||||
expect((await keysOf(2511)).length).toBe(10)
|
expect((await keysOf(2511)).length).toBe(10)
|
||||||
await env.DB.prepare('DELETE FROM room_key').run()
|
await env.DB.prepare('DELETE FROM room_key').run()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('POST create refuses a token price over 1000', async () => {
|
||||||
|
const atCap = await create({ ...body, Price: '1000' }, await bearer('1'))
|
||||||
|
expect((await envOf(atCap)).Status).toBe(0)
|
||||||
|
const over = await create({ ...body, Price: '1001' }, await bearer('1'))
|
||||||
|
expect(await envOf(over)).toEqual({ Status: 1, RoomKey: null })
|
||||||
|
await env.DB.prepare('DELETE FROM room_key').run()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PUT updateAll edits one key, capping the price by its currency', async () => {
|
||||||
|
const update = async (fields: Record<string, string>, headers?: Record<string, string>) =>
|
||||||
|
exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/updateAll`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
|
||||||
|
body: new URLSearchParams(fields),
|
||||||
|
})
|
||||||
|
const mint = async (roomId: string, as: string) =>
|
||||||
|
(
|
||||||
|
(await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/api/roomcurrencies/v1/createCurrency`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
...(await bearer(as)),
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({ RoomId: roomId, Name: 'coin' }),
|
||||||
|
})
|
||||||
|
).json()) as { Value: { CurrencyId: string } }
|
||||||
|
).Value.CurrencyId
|
||||||
|
|
||||||
|
const key = await keyOf(await create(body, await bearer('1')))
|
||||||
|
const other = await keyOf(await create({ ...body, Name: 'other' }, await bearer('1')))
|
||||||
|
const id = String(key.RoomKeyId)
|
||||||
|
// The body exactly as the client puts it.
|
||||||
|
const edit = {
|
||||||
|
RoomKeyId: id,
|
||||||
|
Name: 'key1',
|
||||||
|
Description: 'testssdfsdfsdf',
|
||||||
|
Price: '1000',
|
||||||
|
PurchaseCurrencyId: '',
|
||||||
|
}
|
||||||
|
const refused = { Status: 1, RoomKey: null }
|
||||||
|
|
||||||
|
expect((await update(edit)).status).toBe(401)
|
||||||
|
expect((await update(edit, await bearer('42'))).status).toBe(403)
|
||||||
|
|
||||||
|
// In tokens: 1000 passes, 1001 does not. A co-owner may edit.
|
||||||
|
const res = await update(edit, await bearer('2'))
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await envOf(res)).toEqual({
|
||||||
|
Status: 0,
|
||||||
|
RoomKey: { ...key, Name: 'key1', Description: 'testssdfsdfsdf', Price: 1000 },
|
||||||
|
})
|
||||||
|
expect(await envOf(await update({ ...edit, Price: '1001' }, await bearer('1')))).toEqual(
|
||||||
|
refused
|
||||||
|
)
|
||||||
|
|
||||||
|
// In the room's own currency the cap is a billion.
|
||||||
|
const currencyId = await mint('2511', '1')
|
||||||
|
const rich = { ...edit, Price: '1000000000', PurchaseCurrencyId: currencyId }
|
||||||
|
expect((await envOf(await update(rich, await bearer('1')))).RoomKey).toMatchObject({
|
||||||
|
Price: 1000000000,
|
||||||
|
PurchaseCurrencyId: currencyId,
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
await envOf(await update({ ...rich, Price: '1000000001' }, await bearer('1')))
|
||||||
|
).toEqual(refused)
|
||||||
|
// Back to tokens with the big price still on it: the token cap applies again.
|
||||||
|
expect(
|
||||||
|
await envOf(await update({ RoomKeyId: id, PurchaseCurrencyId: '' }, await bearer('1')))
|
||||||
|
).toEqual(refused)
|
||||||
|
|
||||||
|
// Refusals: no such key, a blank name, an unknown currency.
|
||||||
|
for (const bad of [
|
||||||
|
{ ...edit, RoomKeyId: '999999' },
|
||||||
|
{ ...edit, Name: ' ' },
|
||||||
|
{ ...edit, PurchaseCurrencyId: '46eddc63-e6d4-42cc-bf04-5d879e33ac17' },
|
||||||
|
]) {
|
||||||
|
expect(await envOf(await update(bad, await bearer('1')))).toEqual(refused)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the named key moved, and the refused edits left it as the last good one.
|
||||||
|
expect(await keysOf(2511)).toEqual([
|
||||||
|
{
|
||||||
|
...key,
|
||||||
|
Name: 'key1',
|
||||||
|
Description: 'testssdfsdfsdf',
|
||||||
|
Price: 1000000000,
|
||||||
|
PurchaseCurrencyId: currencyId,
|
||||||
|
},
|
||||||
|
other,
|
||||||
|
])
|
||||||
|
|
||||||
|
await env.DB.prepare('DELETE FROM room_key').run()
|
||||||
|
await env.DB.prepare('DELETE FROM room_currency WHERE currency_id = ?1')
|
||||||
|
.bind(currencyId)
|
||||||
|
.run()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PUT updateAll: a BLANK PurchaseCurrencyId or ImageName clears it; an absent one is left alone', async () => {
|
||||||
|
const update = async (fields: Record<string, string>) =>
|
||||||
|
envOf(
|
||||||
|
await exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/updateAll`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
...(await bearer('1')),
|
||||||
|
},
|
||||||
|
body: new URLSearchParams(fields),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const stored = async (roomKeyId: number) =>
|
||||||
|
env.DB.prepare(
|
||||||
|
'SELECT purchase_currency_id, image_name FROM room_key WHERE room_key_id = ?1'
|
||||||
|
)
|
||||||
|
.bind(roomKeyId)
|
||||||
|
.first<{ purchase_currency_id: string | null; image_name: string }>()
|
||||||
|
|
||||||
|
const currencyId = (
|
||||||
|
(await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/api/roomcurrencies/v1/createCurrency`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
...(await bearer('1')),
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({ RoomId: '2511', Name: 'coin' }),
|
||||||
|
})
|
||||||
|
).json()) as { Value: { CurrencyId: string } }
|
||||||
|
).Value.CurrencyId
|
||||||
|
|
||||||
|
const key = await keyOf(await create(body, await bearer('1')))
|
||||||
|
const id = String(key.RoomKeyId)
|
||||||
|
expect(key).toMatchObject({ PurchaseCurrencyId: null, ImageName: null })
|
||||||
|
|
||||||
|
// Put the key on the room's currency and give it art.
|
||||||
|
const set = await update({
|
||||||
|
RoomKeyId: id,
|
||||||
|
PurchaseCurrencyId: currencyId,
|
||||||
|
ImageName: 'key.png',
|
||||||
|
})
|
||||||
|
expect(set.RoomKey).toMatchObject({ PurchaseCurrencyId: currencyId, ImageName: 'key.png' })
|
||||||
|
expect(await stored(key.RoomKeyId)).toEqual({
|
||||||
|
purchase_currency_id: currencyId,
|
||||||
|
image_name: 'key.png',
|
||||||
|
})
|
||||||
|
|
||||||
|
// An edit that mentions neither leaves both alone.
|
||||||
|
const renamed = await update({ RoomKeyId: id, Name: 'renamed' })
|
||||||
|
expect(renamed.RoomKey).toMatchObject({
|
||||||
|
Name: 'renamed',
|
||||||
|
PurchaseCurrencyId: currencyId,
|
||||||
|
ImageName: 'key.png',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Blank clears the currency — NULL in the row, null on the wire — and nothing else.
|
||||||
|
const noCurrency = await update({ RoomKeyId: id, PurchaseCurrencyId: '' })
|
||||||
|
expect(noCurrency.RoomKey).toMatchObject({ PurchaseCurrencyId: null, ImageName: 'key.png' })
|
||||||
|
expect(await stored(key.RoomKeyId)).toEqual({
|
||||||
|
purchase_currency_id: null,
|
||||||
|
image_name: 'key.png',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Blank clears the art: '' in the row, served as null. The list read agrees.
|
||||||
|
const noArt = await update({ RoomKeyId: id, ImageName: '' })
|
||||||
|
expect(noArt.RoomKey).toMatchObject({ PurchaseCurrencyId: null, ImageName: null })
|
||||||
|
expect(await stored(key.RoomKeyId)).toEqual({ purchase_currency_id: null, image_name: '' })
|
||||||
|
expect(await keysOf(2511)).toEqual([noArt.RoomKey])
|
||||||
|
|
||||||
|
await env.DB.prepare('DELETE FROM room_key').run()
|
||||||
|
await env.DB.prepare('DELETE FROM room_currency WHERE currency_id = ?1')
|
||||||
|
.bind(currencyId)
|
||||||
|
.run()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /api/roomconsumables/v1/roomConsumable/room/:id/me returns []', async () => {
|
test('GET /api/roomconsumables/v1/roomConsumable/room/:id/me returns []', async () => {
|
||||||
@@ -6073,6 +6247,7 @@ describe('econ endpoints', () => {
|
|||||||
'POST /api/ugcPurchasables/v1/items/bulk',
|
'POST /api/ugcPurchasables/v1/items/bulk',
|
||||||
'PUT /api/equipment/v1/update',
|
'PUT /api/equipment/v1/update',
|
||||||
'PUT /api/roomconsumables/v1/roomConsumable',
|
'PUT /api/roomconsumables/v1/roomConsumable',
|
||||||
|
'PUT /api/roomkeys/v1/updateAll',
|
||||||
])
|
])
|
||||||
|
|
||||||
// Every operation carries a summary — a path present but undescribed is not
|
// Every operation carries a summary — a path present but undescribed is not
|
||||||
|
|||||||
Reference in New Issue
Block a user