From d56dd99315de434166ea3816072753680710392a Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sun, 12 Jul 2026 22:44:09 -0400 Subject: [PATCH] misc fixes to clubs/moderation/room schema --- apps/api/src/routes/gameplay.ts | 12 +++++ apps/api/src/routes/moderation.ts | 14 ++++-- apps/api/src/routes/progression.ts | 14 ++++-- apps/api/src/test/integration/api.test.ts | 56 +++++++++++++++++++++-- packages/domain/src/room-instance-db.ts | 17 +++++-- 5 files changed, 99 insertions(+), 14 deletions(-) diff --git a/apps/api/src/routes/gameplay.ts b/apps/api/src/routes/gameplay.ts index 187e47b..55ced1b 100644 --- a/apps/api/src/routes/gameplay.ts +++ b/apps/api/src/routes/gameplay.ts @@ -24,6 +24,18 @@ export const gameplayRoutes = new Hono({ strict: false }) .post('/api/objectives/v1/updateobjective', (c) => c.body(null, 200)) .get('/api/communityboard/v2/current', (c) => c.json({})) // TODO: hydrate from JSON/communityboard.json .get('/api/playerevents/v1/all', (c) => c.json({ Created: [], Responses: [] })) + + // Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's + // page. A bare array: the client deserializes this one as a list, and chokes on the + // `{ ContinuationToken, Events }` envelope the single-club form uses. No + // player-event storage yet, so the feed is empty. + .get('/api/playerevents/v1/clubs', (c) => c.json([])) + + // The same feed for a single club (`/club/1`) — the form the reference serves, + // which *does* wrap the events with a paging cursor (empty = no next page). + .get('/api/playerevents/v1/club/:clubId{[0-9]+}', (c) => + c.json({ ContinuationToken: '', Events: [] }) + ) .get('/api/announcement/v1/get', (c) => c.json([])) // TODO: hydrate from JSON/announcements.json // GameSight attribution/analytics event sink. Accept and ack without persisting. diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index 1e813fe..0cd0890 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -4,15 +4,21 @@ import type { App } from '../context' // ---- Player reporting ------------------------------------------------------ export const moderationRoutes = new Hono({ strict: false }) + // Whether the caller is currently blocked (banned / timed out / host-kicked). No + // ban storage yet, so this is always the "not blocked" answer. `ReportCategory` is + // -1 (no category) rather than 0, which is a real category; `Message` is null, not + // an empty string — the client distinguishes "no message" from a blank one. .get('/api/PlayerReporting/v1/moderationBlockDetails', (c) => c.json({ - ReportCategory: 0, + ReportCategory: -1, Duration: 0, GameSessionId: 0, - IsHostKick: false, - Message: '', - PlayerIdReporter: null, IsBan: false, + IsHostKick: false, + IsVoiceModAutoban: false, + Message: null, + PlayerIdReporter: null, + TimeoutStartedAt: null, }) ) .get('/api/PlayerReporting/v1/voteToKickReasons', (c) => c.json([])) // TODO: hydrate from JSON/vtkreasons.json diff --git a/apps/api/src/routes/progression.ts b/apps/api/src/routes/progression.ts index ea38c7c..b64a93e 100644 --- a/apps/api/src/routes/progression.ts +++ b/apps/api/src/routes/progression.ts @@ -4,18 +4,26 @@ import { parseFormIds, queryIds } from '../http' import type { App } from '../context' -/** Default reputation for an account — the fallback used with no DB. */ +/** + * Default reputation for an account — the fallback used with no DB. Nobody has + * earned cheers yet, so every counter is 0 and everyone has their full cheer credit. + * `SelectedCheer` is an int (0 = none selected), not null, and `IsCheerful` is true: + * the client reads it to decide whether the player may hand out cheers at all. + */ function defaultReputation(id: number) { return { AccountId: id, + IsCheerful: true, Noteriety: 0, + SelectedCheer: 0, + CheerCredit: 20, CheerGeneral: 0, CheerHelpful: 0, CheerCreative: 0, CheerGreatHost: 0, CheerSportsman: 0, - CheerCredit: 20, - SelectedCheer: null, + SubscriberCount: 0, + SubscribedCount: 0, } } diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index c2af67a..0b6d4e9 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -154,11 +154,61 @@ describe('public endpoints', () => { }) test('GET /api/playerReputation/v2/bulk?id= returns a reputation per id', async () => { - const res = await exports.default.fetch(`${ORIGIN}/api/playerReputation/v2/bulk?id=1&id=2`) + const res = await exports.default.fetch(`${ORIGIN}/api/playerReputation/v2/bulk?id=1380`) expect(res.status).toBe(200) - const reps = (await res.json()) as Array<{ AccountId: number; CheerCredit: number }> + // The full reputation shape the client expects, field for field. + expect(await res.json()).toEqual([ + { + AccountId: 1380, + IsCheerful: true, + Noteriety: 0, + SelectedCheer: 0, + CheerCredit: 20, + CheerGeneral: 0, + CheerHelpful: 0, + CheerCreative: 0, + CheerGreatHost: 0, + CheerSportsman: 0, + SubscriberCount: 0, + SubscribedCount: 0, + }, + ]) + + const many = await exports.default.fetch(`${ORIGIN}/api/playerReputation/v2/bulk?id=1&id=2`) + const reps = (await many.json()) as Array<{ AccountId: number }> expect(reps.map((r) => r.AccountId)).toEqual([1, 2]) - expect(reps[0]).toMatchObject({ CheerCredit: 20 }) + }) + + test('GET /api/playerevents/v1/clubs returns an empty event list', async () => { + // The client deserializes this as a bare array — an envelope here fails with + // "expected:'[', actual:'{'". No player-event storage yet → empty. + const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/clubs?id=1&id=2`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + + // The single-club form does wrap its events with a paging cursor. + const one = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/club/1`) + expect(one.status).toBe(200) + expect(await one.json()).toEqual({ ContinuationToken: '', Events: [] }) + }) + + test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => { + const res = await exports.default.fetch( + `${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails` + ) + expect(res.status).toBe(200) + // ReportCategory -1 = no category (0 is a real one), and Message is null. + expect(await res.json()).toEqual({ + ReportCategory: -1, + Duration: 0, + GameSessionId: 0, + IsBan: false, + IsHostKick: false, + IsVoiceModAutoban: false, + Message: null, + PlayerIdReporter: null, + TimeoutStartedAt: null, + }) }) test('POST /api/playerReputation/v2/bulk returns a reputation per id', async () => { diff --git a/packages/domain/src/room-instance-db.ts b/packages/domain/src/room-instance-db.ts index afaa460..79a9f31 100644 --- a/packages/domain/src/room-instance-db.ts +++ b/packages/domain/src/room-instance-db.ts @@ -166,7 +166,10 @@ export async function createRoomInstance( joinDisabled: input.joinDisabled ?? false, createdAt: new Date().toISOString(), } - await db.prepare('INSERT INTO room_instance (data) VALUES (?1)').bind(JSON.stringify(stored)).run() + await db + .prepare('INSERT INTO room_instance (data) VALUES (?1)') + .bind(JSON.stringify(stored)) + .run() return toDto(stored) } @@ -238,19 +241,25 @@ export async function refreshInstanceFullness( * The oldest joinable public instance of a room (not private, not full, joins * enabled, not already in progress), or null when there's none to join. Used by * matchmaking to reuse an existing instance before creating a new one. + * + * 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. */ export async function getJoinableInstance( db: D1Database, - roomId: number + roomId: number, + subRoomId?: number ): Promise { + const bySubRoom = subRoomId === undefined ? '' : 'AND sub_room_id = ?2' 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 + AND is_in_progress = 0 ${bySubRoom} ORDER BY id LIMIT 1` ) - .bind(roomId) + .bind(...(subRoomId === undefined ? [roomId] : [roomId, subRoomId])) .first<{ data: string }>() return row ? toDto(parse(row.data)) : null }