Currency, storefronts, purchasing (#12)

And a few other minor things, but primarily, the balance table exists and also consumable/inventory table.
This commit is contained in:
devin
2026-07-15 14:10:49 -04:00
committed by GitHub
parent 8314e54439
commit c33919bc67
29 changed files with 2204 additions and 524 deletions
+108
View File
@@ -0,0 +1,108 @@
/**
* 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). */
export async function grantConsumable(
db: D1Database,
accountId: number,
consumableItemDesc: string,
count: number
): Promise<void> {
await db
.prepare(
`INSERT INTO consumable (account_id, consumable_item_desc, count, created_at)
VALUES (?1, ?2, ?3, ?4)`
)
.bind(accountId, consumableItemDesc, count, new Date().toISOString())
.run()
}
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<UnlockedConsumable[]> {
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<ConsumableRow>()
const byDesc = new Map<string, UnlockedConsumable>()
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()]
}