mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user