From f71d55c4d38a9dd5a42f6d634bff838d0aa91eed Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 7 Oct 2026 14:43:52 -0400 Subject: [PATCH] [rooms] room progression --- apps/rooms/migrations/0025_room_xp.sql | 17 ++ apps/rooms/src/openapi.ts | 54 ++++-- apps/rooms/src/rooms.app.ts | 199 ++++++++++++++++---- apps/rooms/src/test/integration/api.test.ts | 140 +++++++++++--- packages/domain/src/index.ts | 1 + packages/domain/src/room-instance-db.ts | 68 ++++++- packages/domain/src/room-xp-db.ts | 81 ++++++++ packages/domain/src/rooms-db.ts | 39 ++++ 8 files changed, 522 insertions(+), 77 deletions(-) create mode 100644 apps/rooms/migrations/0025_room_xp.sql create mode 100644 packages/domain/src/room-xp-db.ts diff --git a/apps/rooms/migrations/0025_room_xp.sql b/apps/rooms/migrations/0025_room_xp.sql new file mode 100644 index 0000000..39d9b9f --- /dev/null +++ b/apps/rooms/migrations/0025_room_xp.sql @@ -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) +); diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index 1bb16ea..65aba73 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -395,17 +395,34 @@ export const SearchSuggestions = z /** * `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. - * - * Nothing here meters per-room XP: progression is the `api` worker's, and it applies no - * room-scoped daily cap. So this is the config the client reads, not a limit this server - * enforces — the same answer for every room. + * counts in a day. A bare two-key object, no envelope. Read off the room blob + * (`progressionEnabled` / `progressionDailyLimit`), which `POST /rooms/{roomId}/experience` + * sets; a room that has never been configured is off with a 0 cap. */ 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'), }) +/** `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 * 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 - * entries. Stubbed empty; the element shape is unknown until something stores one. + * `GET /rooms/{roomId}/experience/player` — the caller's experience in this room, from the + * `room_xp` row for (room, caller). No row is 0 XP. `ConcurrencyCode` is minted per + * response, not stored. */ -export const RoomExperiencePlayer = z - .array(z.unknown()) - .describe('Always empty — no per-room experience is tracked') +export const RoomExperiencePlayer = z.object({ + RoomExperienceEnabled: z.boolean().describe('The room’s `Enabled`, echoed'), + 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 diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index cc4c4f8..95ef53e 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -40,6 +40,7 @@ import { getRoomBans, getRoomById, getRoomByName, + getRoomExperience, getRoomsByCreator, getRoomsByIds, getSimilarRooms, @@ -50,6 +51,7 @@ import { getSubRoomSaves, getTrendingRooms, getVisitedRooms, + incrementRoomExperience, inviteRoomRole, isMissingStudioAssetTable, isPlayerBannedFromRoom, @@ -64,6 +66,7 @@ import { removeRoomRole, Role, roomNameRejection, + roomProgression, roomRoles, saveSubRoomData, searchRooms, @@ -72,6 +75,7 @@ import { setRoomImage, setRoomLeaderboard, setRoomName, + setRoomProgression, setRoomRole, setSubRoomPermissions, sha256HexToBase64, @@ -112,6 +116,8 @@ import { CuratedPlaylists, DescriptionRequest, DormRoomId, + ExperienceIncrementRequest, + ExperienceRequest, FeaturedRoomGroupDto, FORBIDDEN_RESPONSE, form, @@ -150,6 +156,7 @@ import { RoomEnvelope, RoomExperience, RoomExperiencePlayer, + RoomExperiencePlayerEnvelope, roomIdParam, RoomLookup, RoomPascalEnvelope, @@ -1023,6 +1030,9 @@ function leaderboardEnvelope(c: Context, error: string | null = 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). */ async function ownedRooms(c: Context) { const accountId = await authedAccountId(c) @@ -1044,19 +1054,6 @@ async function ownedRoomsExcludingDorm(c: Context) { /** Suggestions `/rooms/autocomplete_search` returns when the client names no `take`. */ 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() .use( '*', @@ -4504,14 +4501,9 @@ const app = new Hono() ) // 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` - // worker and applies no per-room daily cap, so there is nothing room-scoped to read and - // nothing here enforces the number. It is what the client displays and meters against. - // - // 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. + // toward their day. Stored on the room blob by the POST below; a room that was never + // configured is off with a 0 cap. A bare two-key object, no envelope, and no auth — + // nothing in the answer is per-player (`experience/player` below is that half). .get( '/rooms/:roomId{[0-9]+}/experience', describeRoute({ @@ -4519,33 +4511,172 @@ const app = new Hono() summary: 'A room’s XP settings', description: [ '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 —', - 'no room awards XP here. Progression is the `api` worker’s and applies no per-room', - 'cap, so nothing is stored per room and nothing enforces the limit; the client is what', - 'reads it. No auth: the answer is the same for every caller and every room.', + 'day (`DailyLimit`), as a bare two-key object — the `progressionEnabled` and', + '`progressionDailyLimit` the room’s owner set with `POST /rooms/{roomId}/experience`.', + 'A room that was never configured is off with a 0 cap. No auth: the answer is the', + 'same for every caller. 404 for a room that does not exist.', ].join(' '), 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 + 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( '/rooms/:roomId{[0-9]+}/experience/player', describeRoute({ tags: ['Rooms'], summary: 'The caller’s per-room experience', description: [ - 'Per-room experience/progression for the calling player. Nothing tracks any yet, so', - 'this is an empty list — which the client reads as “no progress in this room”, where', - 'a 404 would stall the room load. No auth: the answer is the same for every caller', - 'until something writes here.', + 'The calling player’s XP in this room (`Experience`), the room’s `Enabled` echoed as', + '`RoomExperienceEnabled`, and a `ConcurrencyCode` GUID minted for this response (not', + 'stored). A player who has earned nothing here reads as 0 — no row is written on a read.', + 'Auth-gated (401); 404 for a room that does not exist.', ].join(' '), + security: AUTHED, 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=`; 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 + 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 diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index b41b85e..6d20b1a 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -22,6 +22,7 @@ import { ROOM_INSTANCE_SCHEMA_DDL, ROOM_INVITE_SCHEMA_DDL, ROOM_SCHEMA_DDL, + ROOM_XP_SCHEMA_DDL, seedRoomWithSubRooms, sha256HexToBase64, 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() // 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() + // 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). for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record) @@ -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 // unregistered path leaves the profile half-drawn. No auth, and no such player is // 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() }) - 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`) expect(res.status).toBe(200) - // A bare two-key object — no `{ success, error, value }` envelope around it. Disabled: - // no room awards XP here, and DailyLimit is the cap that would apply if one did. - expect(await res.json()).toEqual({ Enabled: false, DailyLimit: 1000 }) + // A bare two-key object — no `{ success, error, value }` envelope around it. A room + // nobody has configured is off with a 0 cap. + expect(await res.json()).toEqual({ Enabled: false, DailyLimit: 0 }) - // Nothing is stored per room, so every room answers the same — including one that - // doesn't exist, which is never looked up. - expect(await (await SELF.fetch(`${ORIGIN}/rooms/77/experience`)).json()).toEqual({ - 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. + // A room that doesn't exist is a 404, and the id is digits-only like the other + // room-scoped routes. + expect((await SELF.fetch(`${ORIGIN}/rooms/99999/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() }) + 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 + 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, 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 () => { type Ban = { Reason: string | null; ExpiresAt: string | null; CreatedAt: string } type Sent = { playerIds: number[]; data: { duration: number; isBan: boolean; message: string } } @@ -5841,6 +5931,8 @@ describe('rooms endpoints', () => { 'POST /rooms/bulk', 'POST /rooms/{roomId}/bans', 'POST /rooms/{roomId}/clone', + 'POST /rooms/{roomId}/experience', + 'POST /rooms/{roomId}/experience/player', 'POST /rooms/{roomId}/leaderboards/{leaderboardId}', 'POST /rooms/{roomId}/subrooms', 'POST /rooms/{roomId}/subrooms/{subRoomId}/clone', diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 642295c..42a98b4 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -19,6 +19,7 @@ export * from './rooms-db' export * from './room-instance-db' export * from './room-comments-db' export * from './room-invites-db' +export * from './room-xp-db' export * from './dorm-presence' export * from './presence-db' export * from './stats-db' diff --git a/packages/domain/src/room-instance-db.ts b/packages/domain/src/room-instance-db.ts index 14e016a..b2250b2 100644 --- a/packages/domain/src/room-instance-db.ts +++ b/packages/domain/src/room-instance-db.ts @@ -291,6 +291,54 @@ export async function setRoomInstancePrivate( 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 { + 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 * 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 - * enabled, not already in progress) that is running `gameVersion`, or null when - * there's none to join. Used by matchmaking to reuse an existing instance before - * creating a new one. + * enabled, not already in progress, not set to `Ignore`) that is running `gameVersion`, + * or null when there's none to join. Used by matchmaking to reuse an existing instance + * 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 * 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 WHERE room_id = ?1 AND game_version = ?2 AND is_private = 0 AND is_full = 0 AND join_disabled = 0 - AND is_in_progress = 0 ${filters.join(' ')} - ORDER BY id LIMIT 1` + AND is_in_progress = 0 + AND matchmaking_policy != ${MatchmakingPolicy.Ignore} ${filters.join(' ')} + ORDER BY matchmaking_policy, id LIMIT 1` ) .bind(...binds) .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 * 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 - * player into — and `excludeInstanceId` drops the one the caller is already standing in, + * caller opens one beside it). Full instances and ones set to `Ignore` are skipped — there + * 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 * handed the same one back. * @@ -472,7 +523,8 @@ export async function getSharedPrivateInstance( .prepare( `SELECT data FROM room_instance 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 ( SELECT COUNT(*) FROM presence WHERE presence.room_instance_id = room_instance.id AND presence.expires_at > ?4 diff --git a/packages/domain/src/room-xp-db.ts b/packages/domain/src/room-xp-db.ts new file mode 100644 index 0000000..fb19ef5 --- /dev/null +++ b/packages/domain/src/room-xp-db.ts @@ -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 { + 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 { + 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 { + 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 +} diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 28df1b0..6a35c51 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -856,6 +856,45 @@ export async function setRoomDescription( .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 { + 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). */ export async function setRoomName(db: D1Database, roomId: number, name: string): Promise { await db