mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[rooms] room progression
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
-- Room experience — the XP a player has earned in one room.
|
||||||
|
--
|
||||||
|
-- Generated from packages/domain/src/room-xp-db.ts (ROOM_XP_SCHEMA_DDL) — keep in sync.
|
||||||
|
--
|
||||||
|
-- One row per (room, player), read by `GET /rooms/:id/experience/player` as `Experience`.
|
||||||
|
-- A player with no row reads as 0 XP; reads never insert. The `ConcurrencyCode` that
|
||||||
|
-- response carries is minted per response, not stored.
|
||||||
|
--
|
||||||
|
-- Whether a room has progression on, and its daily cap, live on the room blob
|
||||||
|
-- (`progressionEnabled` / `progressionDailyLimit`, set by `POST /rooms/:id/experience`).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_xp (
|
||||||
|
room_id INTEGER NOT NULL,
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
xp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (room_id, player_id)
|
||||||
|
);
|
||||||
+43
-11
@@ -395,17 +395,34 @@ export const SearchSuggestions = z
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* `GET /rooms/{roomId}/experience` — whether players earn XP in a room and how much of it
|
* `GET /rooms/{roomId}/experience` — whether players earn XP in a room and how much of it
|
||||||
* counts in a day. A bare two-key object, no envelope.
|
* counts in a day. A bare two-key object, no envelope. Read off the room blob
|
||||||
*
|
* (`progressionEnabled` / `progressionDailyLimit`), which `POST /rooms/{roomId}/experience`
|
||||||
* Nothing here meters per-room XP: progression is the `api` worker's, and it applies no
|
* sets; a room that has never been configured is off with a 0 cap.
|
||||||
* room-scoped daily cap. So this is the config the client reads, not a limit this server
|
|
||||||
* enforces — the same answer for every room.
|
|
||||||
*/
|
*/
|
||||||
export const RoomExperience = z.object({
|
export const RoomExperience = z.object({
|
||||||
Enabled: z.boolean().describe('Whether XP is earned in the room at all. Always false here'),
|
Enabled: z.boolean().describe('Whether XP is earned in the room at all'),
|
||||||
DailyLimit: z.int().describe('XP from this room that counts toward a player’s day'),
|
DailyLimit: z.int().describe('XP from this room that counts toward a player’s day'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** `POST /rooms/{roomId}/experience/player` — add XP to the caller’s total in the room. */
|
||||||
|
export const ExperienceIncrementRequest = z.object({
|
||||||
|
increment: z.string().describe('XP to add, as an integer; a missing or non-numeric one adds 0'),
|
||||||
|
concurrencyCode: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'The GUID from the last read. Accepted and ignored — nothing is stored to check it against'
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `POST /rooms/{roomId}/experience` — the client’s .NET-style form body. */
|
||||||
|
export const ExperienceRequest = z.object({
|
||||||
|
enabled: z.string().describe('`True` / `False`'),
|
||||||
|
dailyLimit: z
|
||||||
|
.string()
|
||||||
|
.describe('XP from this room that counts toward a player’s day, as an integer'),
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `GET /dormroom/me` — the dorm's `RoomId` as a BARE JSON number, not a room and not an
|
* `GET /dormroom/me` — the dorm's `RoomId` as a BARE JSON number, not a room and not an
|
||||||
* envelope around one. The caller follows it with `GET /rooms/{roomId}` when it wants the
|
* envelope around one. The caller follows it with `GET /rooms/{roomId}` when it wants the
|
||||||
@@ -1074,12 +1091,27 @@ export const PlayerDataRequest = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `GET /rooms/{roomId}/experience/player` — the caller's per-room experience/progression
|
* `GET /rooms/{roomId}/experience/player` — the caller's experience in this room, from the
|
||||||
* entries. Stubbed empty; the element shape is unknown until something stores one.
|
* `room_xp` row for (room, caller). No row is 0 XP. `ConcurrencyCode` is minted per
|
||||||
|
* response, not stored.
|
||||||
*/
|
*/
|
||||||
export const RoomExperiencePlayer = z
|
export const RoomExperiencePlayer = z.object({
|
||||||
.array(z.unknown())
|
RoomExperienceEnabled: z.boolean().describe('The room’s `Enabled`, echoed'),
|
||||||
.describe('Always empty — no per-room experience is tracked')
|
Experience: z.int().describe('XP the caller has earned in this room; 0 when none is recorded'),
|
||||||
|
ConcurrencyCode: z.string().describe('A GUID minted for this response; not stored'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What `POST /rooms/{roomId}/experience/player` answers: the read’s DTO wrapped in the
|
||||||
|
* leaderboard write’s bare envelope — PascalCase `Success`/`Error` with a lowercase
|
||||||
|
* `error_id` — with `Value` first. The read serves the DTO BARE; only the write wraps it.
|
||||||
|
*/
|
||||||
|
export const RoomExperiencePlayerEnvelope = z.object({
|
||||||
|
Value: RoomExperiencePlayer.describe('The caller’s experience in the room, after the add'),
|
||||||
|
Success: z.boolean(),
|
||||||
|
Error: z.string().nullable().describe('Null on success'),
|
||||||
|
error_id: z.string().nullable().describe('Null. Lowercase, unlike its siblings'),
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `GET /showcase/{playerId}` — the rooms a player showcases on their profile. Stubbed
|
* `GET /showcase/{playerId}` — the rooms a player showcases on their profile. Stubbed
|
||||||
|
|||||||
+165
-34
@@ -40,6 +40,7 @@ import {
|
|||||||
getRoomBans,
|
getRoomBans,
|
||||||
getRoomById,
|
getRoomById,
|
||||||
getRoomByName,
|
getRoomByName,
|
||||||
|
getRoomExperience,
|
||||||
getRoomsByCreator,
|
getRoomsByCreator,
|
||||||
getRoomsByIds,
|
getRoomsByIds,
|
||||||
getSimilarRooms,
|
getSimilarRooms,
|
||||||
@@ -50,6 +51,7 @@ import {
|
|||||||
getSubRoomSaves,
|
getSubRoomSaves,
|
||||||
getTrendingRooms,
|
getTrendingRooms,
|
||||||
getVisitedRooms,
|
getVisitedRooms,
|
||||||
|
incrementRoomExperience,
|
||||||
inviteRoomRole,
|
inviteRoomRole,
|
||||||
isMissingStudioAssetTable,
|
isMissingStudioAssetTable,
|
||||||
isPlayerBannedFromRoom,
|
isPlayerBannedFromRoom,
|
||||||
@@ -64,6 +66,7 @@ import {
|
|||||||
removeRoomRole,
|
removeRoomRole,
|
||||||
Role,
|
Role,
|
||||||
roomNameRejection,
|
roomNameRejection,
|
||||||
|
roomProgression,
|
||||||
roomRoles,
|
roomRoles,
|
||||||
saveSubRoomData,
|
saveSubRoomData,
|
||||||
searchRooms,
|
searchRooms,
|
||||||
@@ -72,6 +75,7 @@ import {
|
|||||||
setRoomImage,
|
setRoomImage,
|
||||||
setRoomLeaderboard,
|
setRoomLeaderboard,
|
||||||
setRoomName,
|
setRoomName,
|
||||||
|
setRoomProgression,
|
||||||
setRoomRole,
|
setRoomRole,
|
||||||
setSubRoomPermissions,
|
setSubRoomPermissions,
|
||||||
sha256HexToBase64,
|
sha256HexToBase64,
|
||||||
@@ -112,6 +116,8 @@ import {
|
|||||||
CuratedPlaylists,
|
CuratedPlaylists,
|
||||||
DescriptionRequest,
|
DescriptionRequest,
|
||||||
DormRoomId,
|
DormRoomId,
|
||||||
|
ExperienceIncrementRequest,
|
||||||
|
ExperienceRequest,
|
||||||
FeaturedRoomGroupDto,
|
FeaturedRoomGroupDto,
|
||||||
FORBIDDEN_RESPONSE,
|
FORBIDDEN_RESPONSE,
|
||||||
form,
|
form,
|
||||||
@@ -150,6 +156,7 @@ import {
|
|||||||
RoomEnvelope,
|
RoomEnvelope,
|
||||||
RoomExperience,
|
RoomExperience,
|
||||||
RoomExperiencePlayer,
|
RoomExperiencePlayer,
|
||||||
|
RoomExperiencePlayerEnvelope,
|
||||||
roomIdParam,
|
roomIdParam,
|
||||||
RoomLookup,
|
RoomLookup,
|
||||||
RoomPascalEnvelope,
|
RoomPascalEnvelope,
|
||||||
@@ -1023,6 +1030,9 @@ function leaderboardEnvelope(c: Context<App>, error: string | null = null) {
|
|||||||
return c.json({ Success: error === null, Error: error, error_id: null })
|
return c.json({ Success: error === null, Error: error, error_id: null })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The same bare envelope for the room progression write (`POST /rooms/:id/experience`). */
|
||||||
|
const experienceEnvelope = leaderboardEnvelope
|
||||||
|
|
||||||
/** Rooms created/owned by the authed caller (shared by the createdby routes). */
|
/** Rooms created/owned by the authed caller (shared by the createdby routes). */
|
||||||
async function ownedRooms(c: Context<App>) {
|
async function ownedRooms(c: Context<App>) {
|
||||||
const accountId = await authedAccountId(c)
|
const accountId = await authedAccountId(c)
|
||||||
@@ -1044,19 +1054,6 @@ async function ownedRoomsExcludingDorm(c: Context<App>) {
|
|||||||
/** Suggestions `/rooms/autocomplete_search` returns when the client names no `take`. */
|
/** Suggestions `/rooms/autocomplete_search` returns when the client names no `take`. */
|
||||||
const DEFAULT_SUGGESTION_COUNT = 10
|
const DEFAULT_SUGGESTION_COUNT = 10
|
||||||
|
|
||||||
/**
|
|
||||||
* The XP settings every room reports (`GET /rooms/{roomId}/experience`). Constants because
|
|
||||||
* nothing stores them per room and nothing enforces them: the `api` worker's progression
|
|
||||||
* grants XP without a room-scoped daily cap, so these are what the client is told, not a
|
|
||||||
* limit this server applies.
|
|
||||||
*
|
|
||||||
* Disabled, which is the honest answer here — no room awards XP. `DailyLimit` is kept at
|
|
||||||
* the reference's number rather than zeroed: it is the cap that WOULD apply, and the client
|
|
||||||
* reads both keys whatever `Enabled` says.
|
|
||||||
*/
|
|
||||||
const ROOM_XP_ENABLED = false
|
|
||||||
const ROOM_XP_DAILY_LIMIT = 1000
|
|
||||||
|
|
||||||
const app = new Hono<App>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.use(
|
||||||
'*',
|
'*',
|
||||||
@@ -4504,14 +4501,9 @@ const app = new Hono<App>()
|
|||||||
)
|
)
|
||||||
|
|
||||||
// A room's XP settings — whether players earn experience there and how much of it counts
|
// A room's XP settings — whether players earn experience there and how much of it counts
|
||||||
// toward their day. Fixed values, the same for every room: progression lives in the `api`
|
// toward their day. Stored on the room blob by the POST below; a room that was never
|
||||||
// worker and applies no per-room daily cap, so there is nothing room-scoped to read and
|
// configured is off with a 0 cap. A bare two-key object, no envelope, and no auth —
|
||||||
// nothing here enforces the number. It is what the client displays and meters against.
|
// nothing in the answer is per-player (`experience/player` below is that half).
|
||||||
//
|
|
||||||
// A bare two-key object, no `{ success, error, value }` envelope, and no auth — nothing
|
|
||||||
// in the answer is per-player (`experience/player` below is the per-player half). The
|
|
||||||
// room isn't looked up either: the answer would be the same for a room that doesn't
|
|
||||||
// exist, so a lookup would only add a way to fail.
|
|
||||||
.get(
|
.get(
|
||||||
'/rooms/:roomId{[0-9]+}/experience',
|
'/rooms/:roomId{[0-9]+}/experience',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -4519,33 +4511,172 @@ const app = new Hono<App>()
|
|||||||
summary: 'A room’s XP settings',
|
summary: 'A room’s XP settings',
|
||||||
description: [
|
description: [
|
||||||
'Whether players earn XP in the room (`Enabled`) and how much of it counts toward a',
|
'Whether players earn XP in the room (`Enabled`) and how much of it counts toward a',
|
||||||
'day (`DailyLimit`), as a bare two-key object. Fixed values, and `Enabled` is FALSE —',
|
'day (`DailyLimit`), as a bare two-key object — the `progressionEnabled` and',
|
||||||
'no room awards XP here. Progression is the `api` worker’s and applies no per-room',
|
'`progressionDailyLimit` the room’s owner set with `POST /rooms/{roomId}/experience`.',
|
||||||
'cap, so nothing is stored per room and nothing enforces the limit; the client is what',
|
'A room that was never configured is off with a 0 cap. No auth: the answer is the',
|
||||||
'reads it. No auth: the answer is the same for every caller and every room.',
|
'same for every caller. 404 for a room that does not exist.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
parameters: [roomIdParam],
|
parameters: [roomIdParam],
|
||||||
responses: { 200: json(RoomExperience, 'The room’s XP settings — always the same') },
|
responses: {
|
||||||
|
200: json(RoomExperience, 'The room’s XP settings'),
|
||||||
|
404: { description: 'No such room' },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
(c) => c.json({ Enabled: ROOM_XP_ENABLED, DailyLimit: ROOM_XP_DAILY_LIMIT })
|
async (c) => {
|
||||||
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
|
if (!room) return c.body(null, 404)
|
||||||
|
return c.json(roomProgression(room))
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// The caller's per-room experience/progression. Stub → empty list.
|
// Turn a room's progression on or off and set its daily cap. Auth-gated (401) and
|
||||||
|
// owner/co-owner-only (403), like the leaderboard write, and answers the same bare
|
||||||
|
// `{ Success, Error, error_id }` envelope. Body is the client's .NET-style form:
|
||||||
|
// `enabled=True&dailyLimit=1000`.
|
||||||
|
.post(
|
||||||
|
'/rooms/:roomId{[0-9]+}/experience',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Room settings'],
|
||||||
|
summary: 'Set a room’s XP settings',
|
||||||
|
description: [
|
||||||
|
'Sets `progressionEnabled` and `progressionDailyLimit` on the room, which',
|
||||||
|
'`GET /rooms/{roomId}/experience` reads back as `Enabled`/`DailyLimit`. Owner or',
|
||||||
|
'co-owner only (403 otherwise). `enabled` is the client’s `True`/`False` string —',
|
||||||
|
'anything but a `true` reads false; `dailyLimit` is an integer, and a missing or',
|
||||||
|
'non-numeric one is 0.',
|
||||||
|
'',
|
||||||
|
'Answers a bare `{ Success, Error, error_id }` — PascalCase with a lowercase',
|
||||||
|
'`error_id`, like the leaderboard write, carrying no entity. An unknown room is a',
|
||||||
|
'rejection envelope, not a 404.',
|
||||||
|
].join('\n'),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
requestBody: form(ExperienceRequest, 'The room’s XP settings'),
|
||||||
|
responses: {
|
||||||
|
200: json(LeaderboardResultEnvelope, 'Stored, or a rejection with `Success: false`'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
403: FORBIDDEN_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
|
if (!room) return experienceEnvelope(c, 'This room does not exist!')
|
||||||
|
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||||
|
|
||||||
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
|
const enabled = typeof body.enabled === 'string' && body.enabled.toLowerCase() === 'true'
|
||||||
|
const dailyLimit =
|
||||||
|
typeof body.dailyLimit === 'string' ? Number.parseInt(body.dailyLimit, 10) : Number.NaN
|
||||||
|
|
||||||
|
await setRoomProgression(c.env.DB, roomId, enabled, Number.isNaN(dailyLimit) ? 0 : dailyLimit)
|
||||||
|
return experienceEnvelope(c)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// The caller's experience in this room: the `room_xp` row for (room, caller), with the
|
||||||
|
// room's switch echoed so the client knows whether what it earns here counts. A player
|
||||||
|
// with no row is 0 XP — reading never writes a row. `ConcurrencyCode` is a GUID minted
|
||||||
|
// per response; it only travels in requests and is not stored.
|
||||||
.get(
|
.get(
|
||||||
'/rooms/:roomId{[0-9]+}/experience/player',
|
'/rooms/:roomId{[0-9]+}/experience/player',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Rooms'],
|
tags: ['Rooms'],
|
||||||
summary: 'The caller’s per-room experience',
|
summary: 'The caller’s per-room experience',
|
||||||
description: [
|
description: [
|
||||||
'Per-room experience/progression for the calling player. Nothing tracks any yet, so',
|
'The calling player’s XP in this room (`Experience`), the room’s `Enabled` echoed as',
|
||||||
'this is an empty list — which the client reads as “no progress in this room”, where',
|
'`RoomExperienceEnabled`, and a `ConcurrencyCode` GUID minted for this response (not',
|
||||||
'a 404 would stall the room load. No auth: the answer is the same for every caller',
|
'stored). A player who has earned nothing here reads as 0 — no row is written on a read.',
|
||||||
'until something writes here.',
|
'Auth-gated (401); 404 for a room that does not exist.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
parameters: [roomIdParam],
|
parameters: [roomIdParam],
|
||||||
responses: { 200: json(RoomExperiencePlayer, 'An empty list') },
|
responses: {
|
||||||
|
200: json(RoomExperiencePlayer, 'The caller’s experience in the room'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
404: { description: 'No such room' },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
(c) => c.json([])
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
|
if (!room) return c.body(null, 404)
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
RoomExperienceEnabled: roomProgression(room).Enabled,
|
||||||
|
Experience: await getRoomExperience(c.env.DB, roomId, accountId),
|
||||||
|
ConcurrencyCode: crypto.randomUUID(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add to the caller's XP in this room. Auth-gated (401), 404 for no such room. Body is
|
||||||
|
// the client's form `increment=2500&concurrencyCode=<guid>`; the code is accepted and
|
||||||
|
// ignored (nothing is stored to check it against). Answers the read's DTO — with the
|
||||||
|
// NEW total and a fresh code — WRAPPED in the leaderboard write's `{ Value, Success,
|
||||||
|
// Error, error_id }` envelope; the read serves it bare. The room's switch and daily cap
|
||||||
|
// are not enforced here — the increment lands whatever they say.
|
||||||
|
.post(
|
||||||
|
'/rooms/:roomId{[0-9]+}/experience/player',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Rooms'],
|
||||||
|
summary: 'Add to the caller’s per-room experience',
|
||||||
|
description: [
|
||||||
|
'Adds `increment` to the calling player’s XP in this room, creating their `room_xp`',
|
||||||
|
'row on the first write, and answers the read’s DTO with the new total and a fresh',
|
||||||
|
'`ConcurrencyCode`, wrapped in `{ Value, Success, Error, error_id }` — the read serves',
|
||||||
|
'it bare. The posted `concurrencyCode` is accepted and ignored.',
|
||||||
|
'A missing or non-numeric `increment` adds 0. Neither the room’s `Enabled` nor its',
|
||||||
|
'`DailyLimit` is enforced. Auth-gated (401); 404 for a room that does not exist.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
requestBody: form(ExperienceIncrementRequest, 'The XP to add'),
|
||||||
|
responses: {
|
||||||
|
200: json(
|
||||||
|
RoomExperiencePlayerEnvelope,
|
||||||
|
'The caller’s experience in the room, after the add'
|
||||||
|
),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
404: { description: 'No such room' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
|
if (!room) return c.body(null, 404)
|
||||||
|
|
||||||
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
|
const increment =
|
||||||
|
typeof body.increment === 'string' ? Number.parseInt(body.increment, 10) : Number.NaN
|
||||||
|
|
||||||
|
const experience = await incrementRoomExperience(
|
||||||
|
c.env.DB,
|
||||||
|
roomId,
|
||||||
|
accountId,
|
||||||
|
Number.isNaN(increment) ? 0 : increment
|
||||||
|
)
|
||||||
|
return c.json({
|
||||||
|
Value: {
|
||||||
|
RoomExperienceEnabled: roomProgression(room).Enabled,
|
||||||
|
Experience: experience,
|
||||||
|
ConcurrencyCode: crypto.randomUUID(),
|
||||||
|
},
|
||||||
|
Success: true,
|
||||||
|
Error: null,
|
||||||
|
error_id: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Single room by id. 404 when the room isn't in D1. A Studio save's
|
// Single room by id. 404 when the room isn't in D1. A Studio save's
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
ROOM_INSTANCE_SCHEMA_DDL,
|
ROOM_INSTANCE_SCHEMA_DDL,
|
||||||
ROOM_INVITE_SCHEMA_DDL,
|
ROOM_INVITE_SCHEMA_DDL,
|
||||||
ROOM_SCHEMA_DDL,
|
ROOM_SCHEMA_DDL,
|
||||||
|
ROOM_XP_SCHEMA_DDL,
|
||||||
seedRoomWithSubRooms,
|
seedRoomWithSubRooms,
|
||||||
sha256HexToBase64,
|
sha256HexToBase64,
|
||||||
STUDIO_UNITY_ASSET_SCHEMA_DDL,
|
STUDIO_UNITY_ASSET_SCHEMA_DDL,
|
||||||
@@ -132,6 +133,8 @@ beforeAll(async () => {
|
|||||||
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
// Baked unity asset bundles — what `POST /unity_assets/baked/bulk` serves.
|
// Baked unity asset bundles — what `POST /unity_assets/baked/bulk` serves.
|
||||||
for (const stmt of UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
// Per-room XP — what `GET /rooms/:id/experience/player` reads.
|
||||||
|
for (const stmt of ROOM_XP_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
// Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill).
|
// Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill).
|
||||||
for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
|
for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
|
||||||
|
|
||||||
@@ -201,14 +204,6 @@ describe('rooms endpoints', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Stub. Registered (not 404) matters more than the body: the client asks for this on
|
|
||||||
// room entry, and an unregistered path stalls the load rather than erroring visibly.
|
|
||||||
it('GET /rooms/:id/experience/player returns [] for any room', async () => {
|
|
||||||
const res = await SELF.fetch(`${ORIGIN}/rooms/92/experience/player`)
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(await res.json()).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
// Stub, same reasoning: the profile asks for a showcase for any player, and an
|
// Stub, same reasoning: the profile asks for a showcase for any player, and an
|
||||||
// unregistered path leaves the profile half-drawn. No auth, and no such player is
|
// unregistered path leaves the profile half-drawn. No auth, and no such player is
|
||||||
// still [] rather than a 404.
|
// still [] rather than a 404.
|
||||||
@@ -548,25 +543,16 @@ describe('rooms endpoints', () => {
|
|||||||
await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30407').run()
|
await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30407').run()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('GET /rooms/:roomId/experience serves the fixed XP settings, no auth', async () => {
|
it('GET /rooms/:roomId/experience serves the room’s stored XP settings, no auth', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/rooms/2/experience`)
|
const res = await SELF.fetch(`${ORIGIN}/rooms/2/experience`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
// A bare two-key object — no `{ success, error, value }` envelope around it. Disabled:
|
// A bare two-key object — no `{ success, error, value }` envelope around it. A room
|
||||||
// no room awards XP here, and DailyLimit is the cap that would apply if one did.
|
// nobody has configured is off with a 0 cap.
|
||||||
expect(await res.json()).toEqual({ Enabled: false, DailyLimit: 1000 })
|
expect(await res.json()).toEqual({ Enabled: false, DailyLimit: 0 })
|
||||||
|
|
||||||
// Nothing is stored per room, so every room answers the same — including one that
|
// A room that doesn't exist is a 404, and the id is digits-only like the other
|
||||||
// doesn't exist, which is never looked up.
|
// room-scoped routes.
|
||||||
expect(await (await SELF.fetch(`${ORIGIN}/rooms/77/experience`)).json()).toEqual({
|
expect((await SELF.fetch(`${ORIGIN}/rooms/99999/experience`)).status).toBe(404)
|
||||||
Enabled: false,
|
|
||||||
DailyLimit: 1000,
|
|
||||||
})
|
|
||||||
expect(await (await SELF.fetch(`${ORIGIN}/rooms/99999/experience`)).json()).toEqual({
|
|
||||||
Enabled: false,
|
|
||||||
DailyLimit: 1000,
|
|
||||||
})
|
|
||||||
|
|
||||||
// The id is digits-only, like the other room-scoped routes.
|
|
||||||
expect((await SELF.fetch(`${ORIGIN}/rooms/abc/experience`)).status).toBe(404)
|
expect((await SELF.fetch(`${ORIGIN}/rooms/abc/experience`)).status).toBe(404)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2721,6 +2707,110 @@ describe('rooms endpoints', () => {
|
|||||||
await env.DB.prepare('DELETE FROM room_leaderboard WHERE room_id IN (2, 3)').run()
|
await env.DB.prepare('DELETE FROM room_leaderboard WHERE room_id IN (2, 3)').run()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('POST /rooms/:id/experience sets a room’s progression, which the GETs read back', async () => {
|
||||||
|
// RecCenter (room 2) is owned by account 1, with account 2 as co-owner.
|
||||||
|
const getXp = async (roomId: number, sub?: string) =>
|
||||||
|
SELF.fetch(`${ORIGIN}/rooms/${roomId}/experience/player`, {
|
||||||
|
headers: sub ? await bearer(sub) : {},
|
||||||
|
})
|
||||||
|
// The real client body, verbatim.
|
||||||
|
const body = { enabled: 'True', dailyLimit: '1000' }
|
||||||
|
// The same bare envelope as the leaderboard write.
|
||||||
|
const OK = { Success: true, Error: null, error_id: null }
|
||||||
|
|
||||||
|
// No token → 401; a valid token but no role on the room → 403.
|
||||||
|
expect((await postForm('/rooms/2/experience', body)).status).toBe(401)
|
||||||
|
expect((await postForm('/rooms/2/experience', body, '999')).status).toBe(403)
|
||||||
|
// Unknown room → failure envelope, not a 404.
|
||||||
|
expect(await (await postForm('/rooms/99999/experience', body, '1')).json()).toEqual({
|
||||||
|
Success: false,
|
||||||
|
Error: 'This room does not exist!',
|
||||||
|
error_id: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The owner turns it on, and the room-level GET reads it back as a bare pair.
|
||||||
|
const ok = await postForm('/rooms/2/experience', body, '1')
|
||||||
|
expect(ok.status).toBe(200)
|
||||||
|
expect(await ok.json()).toEqual(OK)
|
||||||
|
expect(await (await SELF.fetch(`${ORIGIN}/rooms/2/experience`)).json()).toEqual({
|
||||||
|
Enabled: true,
|
||||||
|
DailyLimit: 1000,
|
||||||
|
})
|
||||||
|
// It lands on the room blob under the names the room DTO carries.
|
||||||
|
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as Record<string, unknown>
|
||||||
|
expect(room).toMatchObject({ progressionEnabled: true, progressionDailyLimit: 1000 })
|
||||||
|
|
||||||
|
// The per-player read is auth-gated, 404 for no such room, and echoes the switch. A
|
||||||
|
// player with no XP recorded here is 0 — and nothing is written by reading. The
|
||||||
|
// `ConcurrencyCode` is a GUID minted per response, not stored.
|
||||||
|
expect((await getXp(2)).status).toBe(401)
|
||||||
|
expect((await getXp(99999, '999')).status).toBe(404)
|
||||||
|
const fresh = (await (await getXp(2, '999')).json()) as {
|
||||||
|
RoomExperienceEnabled: boolean
|
||||||
|
Experience: number
|
||||||
|
ConcurrencyCode: string
|
||||||
|
}
|
||||||
|
expect(fresh).toMatchObject({ RoomExperienceEnabled: true, Experience: 0 })
|
||||||
|
expect(fresh.ConcurrencyCode).toMatch(/^[0-9a-f-]{36}$/)
|
||||||
|
expect(
|
||||||
|
await env.DB.prepare('SELECT 1 FROM room_xp WHERE room_id = 2 AND player_id = 999').first()
|
||||||
|
).toBeNull()
|
||||||
|
|
||||||
|
// The increment write creates the row on first use and answers the read's DTO with the
|
||||||
|
// new total, wrapped in the leaderboard write's envelope — the read serves it bare. The
|
||||||
|
// real client body, verbatim; the code is accepted and ignored.
|
||||||
|
const add = { increment: '2500', concurrencyCode: '79e10fb4-1956-4251-b482-9f21d76c3776' }
|
||||||
|
const addXp = async (roomId: number, fields: Record<string, string>, sub: string) =>
|
||||||
|
(await (await postForm(`/rooms/${roomId}/experience/player`, fields, sub)).json()) as {
|
||||||
|
Value: { RoomExperienceEnabled: boolean; Experience: number; ConcurrencyCode: string }
|
||||||
|
Success: boolean
|
||||||
|
Error: string | null
|
||||||
|
error_id: string | null
|
||||||
|
}
|
||||||
|
expect((await postForm('/rooms/2/experience/player', add)).status).toBe(401)
|
||||||
|
expect((await postForm('/rooms/99999/experience/player', add, '999')).status).toBe(404)
|
||||||
|
const added = await addXp(2, add, '999')
|
||||||
|
expect(added).toMatchObject({
|
||||||
|
Value: { RoomExperienceEnabled: true, Experience: 2500 },
|
||||||
|
Success: true,
|
||||||
|
Error: null,
|
||||||
|
error_id: null,
|
||||||
|
})
|
||||||
|
expect(added.Value.ConcurrencyCode).toMatch(/^[0-9a-f-]{36}$/)
|
||||||
|
// It accumulates, per (room, player); the read serves the running total.
|
||||||
|
expect((await addXp(2, { increment: '250' }, '999')).Value.Experience).toBe(2750)
|
||||||
|
expect((await addXp(3, { increment: '1' }, '999')).Value.Experience).toBe(1)
|
||||||
|
expect(await (await getXp(2, '999')).json()).toMatchObject({
|
||||||
|
RoomExperienceEnabled: true,
|
||||||
|
Experience: 2750,
|
||||||
|
})
|
||||||
|
// A missing increment adds nothing.
|
||||||
|
expect((await addXp(2, {}, '999')).Value.Experience).toBe(2750)
|
||||||
|
|
||||||
|
// A stored row serves its XP.
|
||||||
|
await env.DB.prepare('UPDATE room_xp SET xp = 1250 WHERE room_id = 2 AND player_id = 999').run()
|
||||||
|
expect(await (await getXp(2, '999')).json()).toMatchObject({
|
||||||
|
RoomExperienceEnabled: true,
|
||||||
|
Experience: 1250,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The co-owner may turn it off; `enabled` parses case-insensitively, and a missing
|
||||||
|
// or non-numeric `dailyLimit` is 0. Off is echoed to the per-player read.
|
||||||
|
expect(await (await postForm('/rooms/2/experience', { enabled: 'false' }, '2')).json()).toEqual(
|
||||||
|
OK
|
||||||
|
)
|
||||||
|
expect(await (await SELF.fetch(`${ORIGIN}/rooms/2/experience`)).json()).toEqual({
|
||||||
|
Enabled: false,
|
||||||
|
DailyLimit: 0,
|
||||||
|
})
|
||||||
|
expect(await (await getXp(2, '999')).json()).toMatchObject({
|
||||||
|
RoomExperienceEnabled: false,
|
||||||
|
Experience: 1250,
|
||||||
|
})
|
||||||
|
|
||||||
|
await env.DB.prepare('DELETE FROM room_xp WHERE player_id = 999').run()
|
||||||
|
})
|
||||||
|
|
||||||
it('POST /rooms/:id/bans takes a reason and a duration', async () => {
|
it('POST /rooms/:id/bans takes a reason and a duration', async () => {
|
||||||
type Ban = { Reason: string | null; ExpiresAt: string | null; CreatedAt: string }
|
type Ban = { Reason: string | null; ExpiresAt: string | null; CreatedAt: string }
|
||||||
type Sent = { playerIds: number[]; data: { duration: number; isBan: boolean; message: string } }
|
type Sent = { playerIds: number[]; data: { duration: number; isBan: boolean; message: string } }
|
||||||
@@ -5841,6 +5931,8 @@ describe('rooms endpoints', () => {
|
|||||||
'POST /rooms/bulk',
|
'POST /rooms/bulk',
|
||||||
'POST /rooms/{roomId}/bans',
|
'POST /rooms/{roomId}/bans',
|
||||||
'POST /rooms/{roomId}/clone',
|
'POST /rooms/{roomId}/clone',
|
||||||
|
'POST /rooms/{roomId}/experience',
|
||||||
|
'POST /rooms/{roomId}/experience/player',
|
||||||
'POST /rooms/{roomId}/leaderboards/{leaderboardId}',
|
'POST /rooms/{roomId}/leaderboards/{leaderboardId}',
|
||||||
'POST /rooms/{roomId}/subrooms',
|
'POST /rooms/{roomId}/subrooms',
|
||||||
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
|
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export * from './rooms-db'
|
|||||||
export * from './room-instance-db'
|
export * from './room-instance-db'
|
||||||
export * from './room-comments-db'
|
export * from './room-comments-db'
|
||||||
export * from './room-invites-db'
|
export * from './room-invites-db'
|
||||||
|
export * from './room-xp-db'
|
||||||
export * from './dorm-presence'
|
export * from './dorm-presence'
|
||||||
export * from './presence-db'
|
export * from './presence-db'
|
||||||
export * from './stats-db'
|
export * from './stats-db'
|
||||||
|
|||||||
@@ -291,6 +291,54 @@ export async function setRoomInstancePrivate(
|
|||||||
return toDto(stored)
|
return toDto(stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How matchmaking treats an instance (`matchmakingPolicy`). Set by whoever is standing
|
||||||
|
* in it via `PUT /roominstance/:id/matchpolicy` (the `match` worker).
|
||||||
|
*/
|
||||||
|
export const MatchmakingPolicy = {
|
||||||
|
/** Normal matchmaking — the instance accepts new players. */
|
||||||
|
Default: 0,
|
||||||
|
/** Deprioritise this instance; matchmaking sends players elsewhere when it can. */
|
||||||
|
Avoid: 1,
|
||||||
|
/** Exclude this instance from matchmaking entirely. */
|
||||||
|
Ignore: 2,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** Whether a number names a {@link MatchmakingPolicy}. */
|
||||||
|
export function isMatchmakingPolicy(value: number): boolean {
|
||||||
|
return Object.values(MatchmakingPolicy).includes(value as 0 | 1 | 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an instance's `matchmakingPolicy`, rewriting the JSON blob (the generated
|
||||||
|
* `matchmaking_policy` column follows it). Returns the updated DTO, or null when the
|
||||||
|
* instance doesn't exist.
|
||||||
|
*
|
||||||
|
* `Ignore` (2) takes the instance out of both reuse searches ({@link getJoinableInstance}
|
||||||
|
* and {@link getSharedPrivateInstance}) — nobody matchmakes into it again until the policy
|
||||||
|
* is lowered; `Avoid` (1) ranks it behind every `Default` instance. Direct joins (an
|
||||||
|
* invite, a room code, the owner picking a session) are not matchmaking and are unaffected,
|
||||||
|
* and everyone already inside stays.
|
||||||
|
*/
|
||||||
|
export async function setRoomInstanceMatchmakingPolicy(
|
||||||
|
db: D1Database,
|
||||||
|
id: number,
|
||||||
|
policy: number
|
||||||
|
): Promise<RoomInstanceDto | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT data FROM room_instance WHERE id = ?1')
|
||||||
|
.bind(id)
|
||||||
|
.first<{ data: string }>()
|
||||||
|
if (!row) return null
|
||||||
|
const stored = parse(row.data)
|
||||||
|
stored.matchmakingPolicy = policy
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE room_instance SET data = ?1 WHERE id = ?2')
|
||||||
|
.bind(JSON.stringify(stored), id)
|
||||||
|
.run()
|
||||||
|
return toDto(stored)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recompute an instance's `isFull` flag from live match presence: full once the
|
* Recompute an instance's `isFull` flag from live match presence: full once the
|
||||||
* number of players currently present in the instance reaches its `maxCapacity`
|
* number of players currently present in the instance reaches its `maxCapacity`
|
||||||
@@ -379,9 +427,10 @@ export async function deleteEmptyRoomInstances(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The oldest joinable public instance of a room (not private, not full, joins
|
* The oldest joinable public instance of a room (not private, not full, joins
|
||||||
* enabled, not already in progress) that is running `gameVersion`, or null when
|
* enabled, not already in progress, not set to `Ignore`) that is running `gameVersion`,
|
||||||
* there's none to join. Used by matchmaking to reuse an existing instance before
|
* or null when there's none to join. Used by matchmaking to reuse an existing instance
|
||||||
* creating a new one.
|
* before creating a new one. An instance set to `Avoid` is a candidate, but ranks behind
|
||||||
|
* every `Default` one however old it is (see {@link MatchmakingPolicy}).
|
||||||
*
|
*
|
||||||
* The build is part of the search, not a detail of it — which is why it's a required
|
* The build is part of the search, not a detail of it — which is why it's a required
|
||||||
* argument rather than an optional filter a caller can forget. Two builds in one Photon
|
* argument rather than an optional filter a caller can forget. Two builds in one Photon
|
||||||
@@ -424,8 +473,9 @@ export async function getJoinableInstance(
|
|||||||
`SELECT data FROM room_instance
|
`SELECT data FROM room_instance
|
||||||
WHERE room_id = ?1 AND game_version = ?2
|
WHERE room_id = ?1 AND game_version = ?2
|
||||||
AND is_private = 0 AND is_full = 0 AND join_disabled = 0
|
AND is_private = 0 AND is_full = 0 AND join_disabled = 0
|
||||||
AND is_in_progress = 0 ${filters.join(' ')}
|
AND is_in_progress = 0
|
||||||
ORDER BY id LIMIT 1`
|
AND matchmaking_policy != ${MatchmakingPolicy.Ignore} ${filters.join(' ')}
|
||||||
|
ORDER BY matchmaking_policy, id LIMIT 1`
|
||||||
)
|
)
|
||||||
.bind(...binds)
|
.bind(...binds)
|
||||||
.first<{ data: string }>()
|
.first<{ data: string }>()
|
||||||
@@ -444,8 +494,9 @@ export async function getJoinableInstance(
|
|||||||
*
|
*
|
||||||
* Scoped like the public search: by subroom (separate places) and by build (a session
|
* Scoped like the public search: by subroom (separate places) and by build (a session
|
||||||
* belongs to one client version; another build's session reads as nothing to join and the
|
* belongs to one client version; another build's session reads as nothing to join and the
|
||||||
* caller opens one beside it). Full instances are skipped — there is nothing to put a
|
* caller opens one beside it). Full instances and ones set to `Ignore` are skipped — there
|
||||||
* player into — and `excludeInstanceId` drops the one the caller is already standing in,
|
* is nothing to put a player into, or its people asked that nobody be matched in — and
|
||||||
|
* `excludeInstanceId` drops the one the caller is already standing in,
|
||||||
* because the client keys its room transition off a CHANGING instance id and hangs when
|
* because the client keys its room transition off a CHANGING instance id and hangs when
|
||||||
* handed the same one back.
|
* handed the same one back.
|
||||||
*
|
*
|
||||||
@@ -472,7 +523,8 @@ export async function getSharedPrivateInstance(
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT data FROM room_instance
|
`SELECT data FROM room_instance
|
||||||
WHERE room_id = ?1 AND game_version = ?2 AND sub_room_id = ?3
|
WHERE room_id = ?1 AND game_version = ?2 AND sub_room_id = ?3
|
||||||
AND is_private = 1 AND is_full = 0 ${exclude}
|
AND is_private = 1 AND is_full = 0
|
||||||
|
AND matchmaking_policy != ${MatchmakingPolicy.Ignore} ${exclude}
|
||||||
ORDER BY (
|
ORDER BY (
|
||||||
SELECT COUNT(*) FROM presence
|
SELECT COUNT(*) FROM presence
|
||||||
WHERE presence.room_instance_id = room_instance.id AND presence.expires_at > ?4
|
WHERE presence.room_instance_id = room_instance.id AND presence.expires_at > ?4
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Room experience — the per-room XP a player earns in a room that has progression
|
||||||
|
* turned on (`POST /rooms/:id/experience`, the `rooms` worker), on the shared `recflare` D1.
|
||||||
|
*
|
||||||
|
* `room_xp` holds one row per (room, player): the XP earned so far. A player with no row
|
||||||
|
* has earned nothing there and reads as 0 — no row is written on a read.
|
||||||
|
*
|
||||||
|
* The `ConcurrencyCode` the client reads from `GET /rooms/:id/experience/player` is NOT
|
||||||
|
* stored: it is a per-response GUID that only travels in requests, minted by the route.
|
||||||
|
*
|
||||||
|
* The room-level switch and its daily cap are not here either: they are
|
||||||
|
* `progressionEnabled` and `progressionDailyLimit` on the room blob (see
|
||||||
|
* `setRoomProgression` in rooms-db.ts).
|
||||||
|
*
|
||||||
|
* The `rooms` worker owns the table and its migration — see
|
||||||
|
* apps/rooms/migrations/0025_room_xp.sql.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Schema DDL (mirror of apps/rooms/migrations/0025_room_xp.sql) — tests apply this. */
|
||||||
|
export const ROOM_XP_SCHEMA_DDL: string[] = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS room_xp (
|
||||||
|
room_id INTEGER NOT NULL,
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
xp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (room_id, player_id)
|
||||||
|
)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player's XP in a room. Never writes: a player who has earned nothing in the room reads
|
||||||
|
* as 0, so a row only appears once XP is first recorded.
|
||||||
|
*/
|
||||||
|
export async function getRoomExperience(
|
||||||
|
db: D1Database,
|
||||||
|
roomId: number,
|
||||||
|
playerId: number
|
||||||
|
): Promise<number> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT xp FROM room_xp WHERE room_id = ?1 AND player_id = ?2')
|
||||||
|
.bind(roomId, playerId)
|
||||||
|
.first<{ xp: number }>()
|
||||||
|
return row?.xp ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a player's XP in a room as an absolute value. Creates the row on first write. */
|
||||||
|
export async function setRoomExperience(
|
||||||
|
db: D1Database,
|
||||||
|
roomId: number,
|
||||||
|
playerId: number,
|
||||||
|
xp: number
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO room_xp (room_id, player_id, xp) VALUES (?1, ?2, ?3)
|
||||||
|
ON CONFLICT(room_id, player_id) DO UPDATE SET xp = ?3`
|
||||||
|
)
|
||||||
|
.bind(roomId, playerId, xp)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add to a player's XP in a room and return the new total. Creates the row on first write;
|
||||||
|
* a negative `delta` subtracts, and the total is never clamped.
|
||||||
|
*/
|
||||||
|
export async function incrementRoomExperience(
|
||||||
|
db: D1Database,
|
||||||
|
roomId: number,
|
||||||
|
playerId: number,
|
||||||
|
delta: number
|
||||||
|
): Promise<number> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO room_xp (room_id, player_id, xp) VALUES (?1, ?2, ?3)
|
||||||
|
ON CONFLICT(room_id, player_id) DO UPDATE SET xp = xp + ?3
|
||||||
|
RETURNING xp`
|
||||||
|
)
|
||||||
|
.bind(roomId, playerId, delta)
|
||||||
|
.first<{ xp: number }>()
|
||||||
|
// RETURNING always yields the upserted row.
|
||||||
|
return row!.xp
|
||||||
|
}
|
||||||
@@ -856,6 +856,45 @@ export async function setRoomDescription(
|
|||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A room's progression settings, as `GET /rooms/:id/experience` serves them. */
|
||||||
|
export interface RoomProgression {
|
||||||
|
Enabled: boolean
|
||||||
|
DailyLimit: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a room's progression settings off its blob: `progressionEnabled` and
|
||||||
|
* `progressionDailyLimit`. A room that has never had them set reads as off with a 0 cap.
|
||||||
|
*/
|
||||||
|
export function roomProgression(room: Room): RoomProgression {
|
||||||
|
const limit = room.progressionDailyLimit
|
||||||
|
return {
|
||||||
|
Enabled: room.progressionEnabled === true,
|
||||||
|
DailyLimit: typeof limit === 'number' && Number.isFinite(limit) ? limit : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a room's progression settings in place (the caller checks ownership first). These
|
||||||
|
* are `progressionEnabled` / `progressionDailyLimit` on the blob; the per-player XP is in
|
||||||
|
* `room_xp` (see room-xp-db.ts).
|
||||||
|
*/
|
||||||
|
export async function setRoomProgression(
|
||||||
|
db: D1Database,
|
||||||
|
roomId: number,
|
||||||
|
enabled: boolean,
|
||||||
|
dailyLimit: number
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE room
|
||||||
|
SET data = json_set(data, '$.progressionEnabled', json(?2), '$.progressionDailyLimit', ?3)
|
||||||
|
WHERE room_id = ?1`
|
||||||
|
)
|
||||||
|
.bind(roomId, enabled ? 'true' : 'false', dailyLimit)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
/** Set a room's Name in place (the caller checks ownership + name uniqueness first). */
|
/** Set a room's Name in place (the caller checks ownership + name uniqueness first). */
|
||||||
export async function setRoomName(db: D1Database, roomId: number, name: string): Promise<void> {
|
export async function setRoomName(db: D1Database, roomId: number, name: string): Promise<void> {
|
||||||
await db
|
await db
|
||||||
|
|||||||
Reference in New Issue
Block a user