mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-26 07:28:53 -07:00
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
/**
|
|
* Room consumables — the things a room sells.
|
|
*
|
|
* The other half of a room's economy: `room-currency-db.ts` mints the money ("MySpecialTokens")
|
|
* and hands it out, and this is the shop that takes it back — a Health Potion for 25 tokens,
|
|
* a custom shirt for 500. A consumable is the LISTING: its name, its picture, what it costs
|
|
* and in which of the room's currencies.
|
|
*
|
|
* The endpoints:
|
|
* - `PUT /api/roomconsumables/v1/roomConsumable` creates one, or edits it when the body
|
|
* names an existing `RoomConsumableId`
|
|
* - `GET /api/roomconsumables/v1/roomConsumable/room/{roomId}` lists a room's
|
|
* - `POST /api/roomconsumables/v1/roomConsumable/awardBulk` gives one to a player
|
|
*
|
|
* `PurchaseCurrencyId` is a `room_currency`, so a room prices its goods in its own money. It
|
|
* is nullable — `Guid?` in the client's model — for a listing that names no currency, and
|
|
* nothing here resolves or validates it yet: a consumable priced in a currency that has since
|
|
* been deleted still lists, at a price nobody can pay.
|
|
*
|
|
* Nothing BUYS one yet. There is no purchase endpoint, so `MaximumCountPerPurchase` is stored
|
|
* and served and read by nothing.
|
|
*
|
|
* This worker (`econ`) owns the tables and their migrations — see apps/econ/migrations/,
|
|
* 0021 (the listings) and 0022 (what players own of them).
|
|
*/
|
|
|
|
/** Schema DDL (mirror of migrations 0021_room_consumable.sql) — also builds the table in tests. */
|
|
export const ROOM_CONSUMABLE_SCHEMA_DDL: string[] = [
|
|
// `room_consumable_id` is a GUID rather than an autoincrement, like a room currency's: it
|
|
// is how the client names this listing afterwards, and a room's consumables are created
|
|
// independently of every other room's.
|
|
//
|
|
// `image_name` and `purchase_currency_id` are nullable because the client's model has them
|
|
// so — a listing with no picture, or none priced in a room currency. `price` is not: a
|
|
// listing always has a price, even if it is zero.
|
|
//
|
|
// The body's `PriceAndCurrency` object is COLLAPSED here into `price` and
|
|
// `purchase_currency_id`. It is one price in one currency, and a nested object would buy
|
|
// nothing but a JSON blob to query through.
|
|
`CREATE TABLE IF NOT EXISTS room_consumable (
|
|
room_consumable_id TEXT PRIMARY KEY,
|
|
room_id INTEGER NOT NULL,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL,
|
|
image_name TEXT,
|
|
price INTEGER NOT NULL,
|
|
purchase_currency_id TEXT,
|
|
modified_at TEXT NOT NULL,
|
|
maximum_count_per_purchase INTEGER NOT NULL DEFAULT 0
|
|
)`,
|
|
// Every read is "this room's shop" — the whole access pattern.
|
|
`CREATE INDEX IF NOT EXISTS idx_room_consumable_room ON room_consumable (room_id)`,
|
|
]
|
|
|
|
/**
|
|
* One room consumable as the client reads it — its own model, member for member and in its
|
|
* order, taken from the client's field layout.
|
|
*
|
|
* Note `Price`/`PurchaseCurrencyId` here against the `PriceAndCurrency` object the write
|
|
* takes: the client sends them nested and reads them flat, so the two shapes are not the same
|
|
* and must not be unified.
|
|
*/
|
|
export interface RoomConsumable {
|
|
RoomConsumableId: string
|
|
RoomId: number
|
|
Name: string
|
|
Description: string
|
|
/** Null for a listing with no picture. */
|
|
ImageName: string | null
|
|
Price: number
|
|
/** A `room_currency` id — the room's own money. Nullable in the client's model. */
|
|
PurchaseCurrencyId: string | null
|
|
/** ISO-8601 UTC. */
|
|
ModifiedAt: string
|
|
/** How many may be bought at once. Stored and served; nothing buys a consumable yet. */
|
|
MaximumCountPerPurchase: number
|
|
}
|
|
|
|
/** What the write supplies. The id is minted on a create and kept on an edit. */
|
|
export interface NewRoomConsumable {
|
|
RoomId: number
|
|
Name: string
|
|
Description: string
|
|
ImageName: string | null
|
|
Price: number
|
|
PurchaseCurrencyId: string | null
|
|
MaximumCountPerPurchase: number
|
|
}
|
|
|
|
interface RoomConsumableRow {
|
|
room_consumable_id: string
|
|
room_id: number
|
|
name: string
|
|
description: string
|
|
image_name: string | null
|
|
price: number
|
|
purchase_currency_id: string | null
|
|
modified_at: string
|
|
maximum_count_per_purchase: number
|
|
}
|
|
|
|
const SELECT_COLUMNS = `room_consumable_id, room_id, name, description, image_name, price, purchase_currency_id, modified_at, maximum_count_per_purchase`
|
|
|
|
const toRoomConsumable = (row: RoomConsumableRow): RoomConsumable => ({
|
|
RoomConsumableId: row.room_consumable_id,
|
|
RoomId: row.room_id,
|
|
Name: row.name,
|
|
Description: row.description,
|
|
ImageName: row.image_name,
|
|
Price: row.price,
|
|
PurchaseCurrencyId: row.purchase_currency_id,
|
|
ModifiedAt: row.modified_at,
|
|
MaximumCountPerPurchase: row.maximum_count_per_purchase,
|
|
})
|
|
|
|
/** One consumable by its id, or null when there is no such row. */
|
|
export async function getRoomConsumable(
|
|
db: D1Database,
|
|
roomConsumableId: string
|
|
): Promise<RoomConsumable | null> {
|
|
const row = await db
|
|
.prepare(`SELECT ${SELECT_COLUMNS} FROM room_consumable WHERE room_consumable_id = ?1`)
|
|
.bind(roomConsumableId)
|
|
.first<RoomConsumableRow>()
|
|
|
|
return row ? toRoomConsumable(row) : null
|
|
}
|
|
|
|
/**
|
|
* Create a listing, or replace one when `roomConsumableId` names an existing row.
|
|
*
|
|
* One function because the client uses one call for both — a `PUT` whose `RoomConsumableId`
|
|
* is null on a create and the listing's id on an edit — and because the two write the same
|
|
* columns. An edit REPLACES rather than merges: the client sends the whole form back, so an
|
|
* absent field is a cleared field, not an unmentioned one. (That is the opposite of the
|
|
* currency edit, whose body is genuinely partial.)
|
|
*
|
|
* `ModifiedAt` is set on both. `RoomId` is written from the caller's already-checked value,
|
|
* so an edit cannot move a listing into another room — the room comes off the STORED listing
|
|
* on an edit, never the body.
|
|
*/
|
|
export async function upsertRoomConsumable(
|
|
db: D1Database,
|
|
roomConsumableId: string | null,
|
|
consumable: NewRoomConsumable
|
|
): Promise<RoomConsumable> {
|
|
const row: RoomConsumableRow = {
|
|
room_consumable_id: roomConsumableId ?? crypto.randomUUID(),
|
|
room_id: consumable.RoomId,
|
|
name: consumable.Name,
|
|
description: consumable.Description,
|
|
image_name: consumable.ImageName,
|
|
price: consumable.Price,
|
|
purchase_currency_id: consumable.PurchaseCurrencyId,
|
|
modified_at: new Date().toISOString(),
|
|
maximum_count_per_purchase: consumable.MaximumCountPerPurchase,
|
|
}
|
|
|
|
await db
|
|
.prepare(
|
|
`INSERT INTO room_consumable (${SELECT_COLUMNS})
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
ON CONFLICT (room_consumable_id) DO UPDATE SET
|
|
name = ?3, description = ?4, image_name = ?5, price = ?6,
|
|
purchase_currency_id = ?7, modified_at = ?8, maximum_count_per_purchase = ?9`
|
|
)
|
|
.bind(
|
|
row.room_consumable_id,
|
|
row.room_id,
|
|
row.name,
|
|
row.description,
|
|
row.image_name,
|
|
row.price,
|
|
row.purchase_currency_id,
|
|
row.modified_at,
|
|
row.maximum_count_per_purchase
|
|
)
|
|
.run()
|
|
|
|
return toRoomConsumable(row)
|
|
}
|
|
|
|
/**
|
|
* A room's shop, oldest first — the order the owner built it up in, which is the order they
|
|
* expect to see it in. A room that sells nothing is an empty list.
|
|
*/
|
|
export async function getRoomConsumables(
|
|
db: D1Database,
|
|
roomId: number
|
|
): Promise<RoomConsumable[]> {
|
|
const { results } = await db
|
|
.prepare(
|
|
`SELECT ${SELECT_COLUMNS} FROM room_consumable
|
|
WHERE room_id = ?1 ORDER BY modified_at, room_consumable_id`
|
|
)
|
|
.bind(roomId)
|
|
.all<RoomConsumableRow>()
|
|
|
|
return results.map(toRoomConsumable)
|
|
}
|
|
|
|
/**
|
|
* What a player owns of a room's consumables — the shop's other side, filled by
|
|
* `POST /api/roomconsumables/v1/roomConsumable/awardBulk`.
|
|
*
|
|
* Keyed by CONSUMABLE, not by room: `room_consumable` already says which room a listing
|
|
* belongs to, and repeating it here would be a second place for that to be wrong. A missing
|
|
* row is a quantity of zero — nothing is inserted until a player is first given one, so a
|
|
* room's shop costs a row per player who actually owns something rather than one per player
|
|
* who has looked at it.
|
|
*
|
|
* `consumable_id` is not a foreign key, but the award route checks the listing exists before
|
|
* writing: an inventory row naming nothing would be a thing a player owns that cannot be
|
|
* described.
|
|
*/
|
|
export const ROOM_INVENTORY_SCHEMA_DDL: string[] = [
|
|
`CREATE TABLE IF NOT EXISTS room_inventory (
|
|
player_id INTEGER NOT NULL,
|
|
consumable_id TEXT NOT NULL,
|
|
quantity INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (player_id, consumable_id)
|
|
)`,
|
|
// "What does this player own?" is the read the client makes on entering a room, so the
|
|
// player half of the key earns its own index for the lookups that start there.
|
|
`CREATE INDEX IF NOT EXISTS idx_room_inventory_player ON room_inventory (player_id)`,
|
|
]
|
|
|
|
/** What a player owns of one consumable. */
|
|
export interface RoomInventoryItem {
|
|
PlayerId: number
|
|
ConsumableId: string
|
|
Quantity: number
|
|
}
|
|
|
|
/**
|
|
* Give a player some of a consumable, returning what they own afterwards.
|
|
*
|
|
* Floored at zero in the statement itself, so a negative `quantity` deducts without a
|
|
* deduction and a concurrent award racing it below zero. Returns the RESULTING total, never
|
|
* the change — the same rule the currency balances follow, and for the same reason: every
|
|
* surface that shows an inventory shows the total.
|
|
*
|
|
* Read BACK rather than taken from a `RETURNING`, for the reason {@link awardRoomCurrency}
|
|
* spells out: an empty RETURNING is indistinguishable from an award that changed nothing, so
|
|
* a write that didn't land would report a plausible-looking but wrong total. All three
|
|
* statements are one `batch`, which D1 runs as a transaction.
|
|
*/
|
|
export async function awardRoomConsumable(
|
|
db: D1Database,
|
|
playerId: number,
|
|
consumableId: string,
|
|
quantity: number
|
|
): Promise<number> {
|
|
const read = db.prepare(
|
|
'SELECT quantity FROM room_inventory WHERE player_id = ?1 AND consumable_id = ?2'
|
|
)
|
|
const [, , after] = await db.batch<{ quantity: number }>([
|
|
read.bind(playerId, consumableId),
|
|
db
|
|
.prepare(
|
|
`INSERT INTO room_inventory (player_id, consumable_id, quantity)
|
|
VALUES (?1, ?2, MAX(0, ?3))
|
|
ON CONFLICT (player_id, consumable_id)
|
|
DO UPDATE SET quantity = MAX(0, room_inventory.quantity + ?3)`
|
|
)
|
|
.bind(playerId, consumableId, quantity),
|
|
read.bind(playerId, consumableId),
|
|
])
|
|
|
|
// No row means a quantity of zero — nothing is inserted until a player is first given one.
|
|
return after.results[0]?.quantity ?? 0
|
|
}
|
|
|
|
/** Everything a player owns of one room's consumables. A listing they own none of is absent. */
|
|
export async function getRoomInventory(
|
|
db: D1Database,
|
|
roomId: number,
|
|
playerId: number
|
|
): Promise<RoomInventoryItem[]> {
|
|
const { results } = await db
|
|
.prepare(
|
|
`SELECT i.player_id AS playerId, i.consumable_id AS consumableId, i.quantity AS quantity
|
|
FROM room_inventory AS i
|
|
JOIN room_consumable AS c ON c.room_consumable_id = i.consumable_id
|
|
WHERE c.room_id = ?1 AND i.player_id = ?2
|
|
ORDER BY c.modified_at, c.room_consumable_id`
|
|
)
|
|
.bind(roomId, playerId)
|
|
.all<{ playerId: number; consumableId: string; quantity: number }>()
|
|
|
|
return results.map((r) => ({
|
|
PlayerId: r.playerId,
|
|
ConsumableId: r.consumableId,
|
|
Quantity: r.quantity,
|
|
}))
|
|
}
|