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
+8 -3
View File
@@ -23,10 +23,15 @@ export enum Accessibility {
}
/**
* A room-role tier (the `Role` byte on a room's `Roles` entries). Named tiers we
* reference by value today — the owner (max byte) and co-owner.
* A room-role tier (the `Role` byte on a room's `Roles` entries), matching the
* client's values. Host and Moderator are limited-permission helper tiers; CoOwner
* and Creator are the owner-level tiers that may manage the room (see
* {@link canManageRoom}). Creator is the room's owner (its `CreatorAccountId`) —
* the max byte.
*/
export enum Role {
Host = 10,
Moderator = 20,
CoOwner = 30,
Owner = 255,
Creator = 255,
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Received gifts — the "gift boxes" a player is handed on the shared `recflare` D1.
* A box is created when a player buys a storefront item (for themselves or as a
* gift) and lingers until the client opens it. Opening is purely cosmetic: the item
* itself is granted into the player's inventory at purchase time (see the `econ`
* worker's inventory-db.ts), so consuming a box just deletes the row — there is
* nothing left to grant.
*
* The `econ` worker owns the schema/migration (apps/econ/migrations/
* 0003_received_gift.sql) and is the only writer: `POST /api/storefronts/v2/buyItem`
* inserts a box and `GET /api/avatar/v2/gifts` lists a player's pending boxes. The
* `api` worker only deletes, from `POST /api/avatar/v2/gifts/consume`. Both import
* these helpers so the table name and row shape live in one place.
*
* One row per gift box. `data` is the box's rendered content as an opaque JSON blob
* (the currency/avatar-item fields the client draws); `id` and `created_at` are
* columns so a box can be listed and deleted by id without parsing the blob.
*/
/** Schema DDL (mirror of apps/econ/migrations/0003_received_gift.sql). */
export const RECEIVED_GIFT_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS received_gift (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
data TEXT NOT NULL,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_received_gift_account ON received_gift (account_id)`,
]
/**
* The rendered content of a gift box, as the client draws it. Written verbatim by
* `buyItem` from the storefront item's `GiftDrop`; never queried on. `Id` and
* `CreatedAt` are NOT part of this — they come from the row (see {@link StoredGift}).
*/
export interface GiftContent extends Record<string, unknown> {
ConsumableItemDesc: string
ConsumableCount: number
AvatarItemDesc: string
AvatarItemType: number | null
CurrencyType: number
Currency: number
Xp: number
PackageType: number
Message: string
EquipmentPrefabName: string
EquipmentModificationGuid: string
GiftRarity: number
Platform: number
PlatformsToSpawnOn: number
BalanceType: number | null
}
/** A stored gift box: its content plus the row's identity (`Id`, `CreatedAt`). */
export interface StoredGift extends GiftContent {
Id: number
CreatedAt: string
}
interface GiftRow {
id: number
data: string
created_at: string
}
/**
* Create a gift box for `accountId`, returning its assigned id and creation time so
* the caller can echo the box back in the purchase response.
*/
export async function createGift(
db: D1Database,
accountId: number,
content: GiftContent
): Promise<{ id: number; createdAt: string }> {
const createdAt = new Date().toISOString()
const row = await db
.prepare(
'INSERT INTO received_gift (account_id, data, created_at) VALUES (?1, ?2, ?3) RETURNING id'
)
.bind(accountId, JSON.stringify(content), createdAt)
.first<{ id: number }>()
// RETURNING always yields a row on a successful insert; the guard is for the types.
return { id: row?.id ?? 0, createdAt }
}
/** A player's pending gift boxes, oldest first, with `Id`/`CreatedAt` merged in. */
export async function getPendingGifts(db: D1Database, accountId: number): Promise<StoredGift[]> {
const { results } = await db
.prepare('SELECT id, data, created_at FROM received_gift WHERE account_id = ?1 ORDER BY id')
.bind(accountId)
.all<GiftRow>()
return results.map((r) => ({
...(JSON.parse(r.data) as GiftContent),
Id: r.id,
CreatedAt: r.created_at,
}))
}
/**
* Delete (consume) a player's gift box by id. Returns false — changing nothing —
* when the box doesn't exist or isn't theirs. The item was already granted at
* purchase, so this only dismisses the box.
*/
export async function consumeGift(
db: D1Database,
accountId: number,
giftId: number
): Promise<boolean> {
const { meta } = await db
.prepare('DELETE FROM received_gift WHERE id = ?1 AND account_id = ?2')
.bind(giftId, accountId)
.run()
return meta.changes > 0
}
+1
View File
@@ -3,3 +3,4 @@ export * from './accounts-db'
export * from './rooms-db'
export * from './room-instance-db'
export * from './presence-db'
export * from './gifts-db'
+11
View File
@@ -167,3 +167,14 @@ export async function deleteExpiredPresence(db: D1Database, now = nowSeconds()):
const res = await db.prepare('DELETE FROM presence WHERE expires_at <= ?1').bind(now).run()
return res.meta.changes ?? 0
}
/**
* Delete a single player's presence row — the player goes offline immediately
* (rather than waiting out the TTL). Used on logout. Returns rows removed (0 when
* they had no live presence). The caller is responsible for recomputing the
* fullness of the instance they were in.
*/
export async function deletePresence(db: D1Database, accountId: number): Promise<number> {
const res = await db.prepare('DELETE FROM presence WHERE account_id = ?1').bind(accountId).run()
return res.meta.changes ?? 0
}
+21 -4
View File
@@ -245,21 +245,38 @@ export async function refreshInstanceFullness(
* A room's subrooms are separate places, so `subRoomId` scopes the search: joining
* subroom 35 must never drop you into a live instance of subroom 1. Omitting it
* matches any subroom.
*
* `excludeInstanceId` drops one instance from the search — the one the player is
* already in. Matchmaking must land them in a *different* instance (the client keys
* the room transition off a changing `roomInstanceId`), so re-matchmaking into the
* only instance of a room they're already in must skip it and fall through to a
* fresh instance rather than hand back the same id. A no-op when they're not in this
* room; instance ids are globally unique.
*/
export async function getJoinableInstance(
db: D1Database,
roomId: number,
subRoomId?: number
subRoomId?: number,
excludeInstanceId?: number
): Promise<RoomInstanceDto | null> {
const bySubRoom = subRoomId === undefined ? '' : 'AND sub_room_id = ?2'
const binds: number[] = [roomId]
const filters: string[] = []
if (subRoomId !== undefined) {
binds.push(subRoomId)
filters.push(`AND sub_room_id = ?${binds.length}`)
}
if (excludeInstanceId !== undefined) {
binds.push(excludeInstanceId)
filters.push(`AND id != ?${binds.length}`)
}
const row = await db
.prepare(
`SELECT data FROM room_instance
WHERE room_id = ?1 AND is_private = 0 AND is_full = 0 AND join_disabled = 0
AND is_in_progress = 0 ${bySubRoom}
AND is_in_progress = 0 ${filters.join(' ')}
ORDER BY id LIMIT 1`
)
.bind(...(subRoomId === undefined ? [roomId] : [roomId, subRoomId]))
.bind(...binds)
.first<{ data: string }>()
return row ? toDto(parse(row.data)) : null
}
+57 -2
View File
@@ -55,6 +55,26 @@ interface RoomRole {
InvitedRole: number
}
/**
* Room roles that confer owner-level management of a room: Creator (255) and
* CoOwner (30). The reference gates its room-admin actions on this set. (Host and
* Moderator are lower tiers and are deliberately excluded.)
*/
const MANAGE_ROLES: ReadonlySet<number> = new Set([Role.Creator, Role.CoOwner])
/**
* Whether an account may manage a room — its creator, or the holder of a
* Creator/CoOwner role on the room's `Roles`. This is the owner-or-co-owner gate
* the reference applies to room-admin actions (editing room data, viewing a room's
* live instances). Shared so the `rooms` and `match` workers apply the same check
* rather than each re-deriving the role set.
*/
export function canManageRoom(room: Room, accountId: number): boolean {
if (room.CreatorAccountId === accountId) return true
const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : []
return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role))
}
/**
* Clone an existing room into a new one owned by `accountId`. Copies the source
* room's content (scene/subrooms/settings), assigning a fresh RoomId, the given
@@ -86,7 +106,7 @@ export async function cloneRoom(
// any co-owners, e.g. the seeded base-room roles for accounts 1/2) must NOT
// carry over, or the clone would still list the template's owner as owner.
const roles: RoomRole[] = [
{ AccountId: accountId, Role: Role.Owner, LastChangedByAccountId: null, InvitedRole: 0 },
{ AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 },
]
const cloned: Room = {
@@ -132,6 +152,41 @@ export async function setRoomImage(db: D1Database, roomId: number, imageName: st
.run()
}
/**
* Set a target account's room `Role` — updating their existing `Roles` entry or
* appending a new one — and stamp `LastChangedByAccountId` with the editor. The
* caller supplies the already-loaded room (after its owner/co-owner check) to avoid
* a re-read; the whole room JSON is rewritten. Returns the updated room.
*/
export async function setRoomRole(
db: D1Database,
roomId: number,
targetAccountId: number,
role: number,
changedByAccountId: number,
room: Room
): Promise<Room> {
const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : []
const existing = roles.find((r) => r.AccountId === targetAccountId)
if (existing) {
existing.Role = role
existing.LastChangedByAccountId = changedByAccountId
} else {
roles.push({
AccountId: targetAccountId,
Role: role,
LastChangedByAccountId: changedByAccountId,
InvitedRole: 0,
})
}
const updated: Room = { ...room, Roles: roles }
await db
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
.bind(roomId, JSON.stringify(updated))
.run()
return updated
}
/**
* Mutually-exclusive "main" room tags. The UI presents these as radio buttons, so
* setting one clears any other main tag. Compared case-insensitively.
@@ -810,7 +865,7 @@ export async function getOrCreateDormRoom(db: D1Database, accountId: number): Pr
Name: `@${username}'s Dorm`,
CreatorAccountId: accountId,
IsDorm: true,
Roles: [{ AccountId: accountId, Role: Role.Owner, LastChangedByAccountId: null, InvitedRole: 0 }],
Roles: [{ AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 }],
SubRooms: [{ ...templateSub, CreatorAccountId: accountId }],
CreatedAt: new Date().toISOString(),
}