/** * Owned consumables on the shared `recflare` D1 database — the consumable items a * player has bought from a storefront (e.g. a "Supreme Pizza"). One row per granted * instance: unlike avatar items (own-once, keyed by their desc), consumables stack, so * each purchase inserts a fresh row carrying its own id, count and created_at. * * Granted at purchase time (`POST /api/storefronts/v2/buyItem`, when the gift-drop * carries a `ConsumableItemDesc`) and read back by `GET /api/consumables/v2/getUnlocked`, * which groups a player's rows by `consumable_item_desc` into the client's unlocked- * consumable DTO — its `Ids`/`CreatedAts` are these per-instance columns and `Count` * their sum. * * This worker (`econ`) owns the table and its migration — see apps/econ/migrations/ * 0005_consumable.sql. */ /** Schema DDL (mirror of migrations 0005_consumable.sql) — also builds the table in tests. */ export const CONSUMABLE_SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS consumable ( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER NOT NULL, consumable_item_desc TEXT NOT NULL, count INTEGER NOT NULL, created_at TEXT NOT NULL )`, `CREATE INDEX IF NOT EXISTS idx_consumable_account ON consumable (account_id)`, ] /** * An unlocked consumable as `/api/consumables/v2/getUnlocked` serves it: one entry per * distinct `ConsumableItemDesc`, aggregating every instance the player owns. `Ids` and * `CreatedAts` line up per instance; `Count`/`InitialCount` are the summed quantity (no * consumption is tracked yet, so they stay equal). The activation fields are inert * defaults until timed consumables exist. */ export interface UnlockedConsumable { Ids: number[] CreatedAts: string[] ConsumableItemDesc: string Count: number InitialCount: number IsActive: boolean ActiveDurationMinutes: number IsTransferable: boolean } /** * Grant `count` of a consumable to a player as a new owned instance (they stack). * Returns the new row's id — the consumable mapping id the client keys on. */ export async function grantConsumable( db: D1Database, accountId: number, consumableItemDesc: string, count: number ): Promise { const row = await db .prepare( `INSERT INTO consumable (account_id, consumable_item_desc, count, created_at) VALUES (?1, ?2, ?3, ?4) RETURNING id` ) .bind(accountId, consumableItemDesc, count, new Date().toISOString()) .first<{ id: number }>() return row?.id ?? 0 } /** A player's total owned count of a consumable, summed across its stacked instances. */ export async function countConsumable( db: D1Database, accountId: number, consumableItemDesc: string ): Promise { const row = await db .prepare( 'SELECT COALESCE(SUM(count), 0) AS total FROM consumable WHERE account_id = ?1 AND consumable_item_desc = ?2' ) .bind(accountId, consumableItemDesc) .first<{ total: number }>() return row?.total ?? 0 } /** The outcome of consuming an instance — its identity plus the resulting count. */ export interface ConsumeResult { id: number consumableItemDesc: string createdAt: string /** The instance's count before this consumption. */ previousCount: number /** The count left after consuming (0 when the row was deleted). */ remaining: number } /** * Consume `deltaCount` from one owned consumable instance, by row `id` and scoped to * its owner (so a player can only consume their own). Reduces that instance's `count`; * once it would reach zero (or below) the row is deleted entirely. Returns the * instance's details plus the resulting count, or null when the row didn't exist / * isn't the caller's. */ export async function consumeConsumable( db: D1Database, accountId: number, id: number, deltaCount: number ): Promise { const row = await db .prepare( 'SELECT consumable_item_desc, count, created_at FROM consumable WHERE id = ?1 AND account_id = ?2' ) .bind(id, accountId) .first<{ consumable_item_desc: string; count: number; created_at: string }>() if (row === null) return null const remaining = row.count - deltaCount if (remaining > 0) { await db.prepare('UPDATE consumable SET count = ?2 WHERE id = ?1').bind(id, remaining).run() } else { await db .prepare('DELETE FROM consumable WHERE id = ?1 AND account_id = ?2') .bind(id, accountId) .run() } return { id, consumableItemDesc: row.consumable_item_desc, createdAt: row.created_at, previousCount: row.count, remaining: Math.max(remaining, 0), } } interface ConsumableRow { id: number consumable_item_desc: string count: number created_at: string } /** * Every consumable a player owns, grouped by item into the unlocked-consumable DTO. * Rows are read oldest-first so each group's `Ids`/`CreatedAts` are in purchase order. */ export async function getConsumables( db: D1Database, accountId: number ): Promise { const { results } = await db .prepare( `SELECT id, consumable_item_desc, count, created_at FROM consumable WHERE account_id = ?1 ORDER BY id` ) .bind(accountId) .all() const byDesc = new Map() for (const r of results) { const existing = byDesc.get(r.consumable_item_desc) if (existing === undefined) { byDesc.set(r.consumable_item_desc, { Ids: [r.id], CreatedAts: [r.created_at], ConsumableItemDesc: r.consumable_item_desc, Count: r.count, InitialCount: r.count, IsActive: false, ActiveDurationMinutes: 0, IsTransferable: false, }) } else { existing.Ids.push(r.id) existing.CreatedAts.push(r.created_at) existing.Count += r.count existing.InitialCount += r.count } } return [...byDesc.values()] }