From c6e035e4e1f4b47c2f7cf473dff6de3cdd982612 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 8 Oct 2026 11:45:12 -0400 Subject: [PATCH] [match] apply subroom capacity to instance cap --- apps/match/src/test/integration/api.test.ts | 29 ++++++++++++++ packages/domain/src/room-instance-db.ts | 42 +++++++++++++++++++++ packages/domain/src/rooms-db.ts | 7 ++++ 3 files changed, 78 insertions(+) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index bf77b46..bfb68d8 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -14,6 +14,7 @@ import { EMPTY_INSTANCE_GRACE_SECONDS, GAME_VERSION, getRoomInstance, + modifySubRoom, PRESENCE_SCHEMA_DDL, ROOM_INSTANCE_SCHEMA_DDL, ROOM_INVITE_SCHEMA_DDL, @@ -2130,6 +2131,34 @@ describe('auth-gated endpoints', () => { expect((await getRoomInstance(env.DB, instanceId))?.isFull).toBe(false) }) + test('raising a subroom’s MaxPlayers admits players into the running instance', async () => { + // A room with a cap of 1 and its own id, so no other test’s instances of SoloRoom + // are candidates here. + await seedRoomWithSubRooms(env.DB, { + RoomId: 5101, + Name: 'GrowingRoom', + IsDorm: false, + Accessibility: 1, + SubRooms: [{ SubRoomId: 5101, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 1 }], + } as unknown as Record) + const first = await matchmakeInto('5101', '830') + expect((await getRoomInstance(env.DB, first))?.isFull).toBe(true) + + // The owner raises the cap in the subroom’s settings while the instance is live + // (what the `rooms` worker’s subroom settings route calls). The instance carries + // its own copy of the cap, so the write has to reach the running instance. + await modifySubRoom(env.DB, 5101, 5101, { maxPlayers: 2 }) + const second = await matchmakeInto('5101', '831') + expect(second).toBe(first) + + // And the served instance follows the subroom: 2 of 2 now, full again. + const instance = await getRoomInstance(env.DB, first) + expect(instance).toMatchObject({ maxCapacity: 2, isFull: true }) + expect(await countPlayersInInstance(env.DB, first)).toBe(2) + const third = await matchmakeInto('5101', '832') + expect(third).not.toBe(first) + }) + test('leaving a full instance clears its full flag', async () => { // Fill SoloRoom, then the same player matchmakes into RecCenter — the SoloRoom // instance they left should no longer be full. diff --git a/packages/domain/src/room-instance-db.ts b/packages/domain/src/room-instance-db.ts index b2250b2..98f07a5 100644 --- a/packages/domain/src/room-instance-db.ts +++ b/packages/domain/src/room-instance-db.ts @@ -370,6 +370,48 @@ export async function refreshInstanceFullness( return isFull } +/** + * Restamp the `maxCapacity` of every live instance of a subroom and recompute each one's + * `isFull` against it. Called by the subroom settings write (`modifySubRoom`), which is + * the ONE place a subroom's `MaxPlayers` changes after creation. + * + * An instance's `maxCapacity` is a copy of the subroom's cap taken when the instance was + * created, and it is what everything enforces — the fullness recompute, the join + * searches' `is_full` filter, the served DTO. It is a copy rather than a live read + * because the instance is read on every matchmake, heartbeat and presence fan-out, and + * a subroom lookup on each of those is D1 cost paid for a setting that changes rarely. + * So the copy is invalidated at the write instead: without this, an owner who raised + * the cap mid-session found the running instance still refusing players at the old + * number until it was torn down. Returns the number of instances rewritten. + */ +export async function resyncInstanceCapacity( + db: D1Database, + roomId: number, + subRoomId: number, + maxCapacity: number, + now = Math.floor(Date.now() / 1000) +): Promise { + const { results } = await db + .prepare('SELECT data FROM room_instance WHERE room_id = ?1 AND sub_room_id = ?2') + .bind(roomId, subRoomId) + .all<{ data: string }>() + let rewritten = 0 + for (const row of results) { + const stored = parse(row.data) + const count = await countPlayersInInstance(db, stored.roomInstanceId, now) + const isFull = maxCapacity > 0 && count >= maxCapacity + if (stored.maxCapacity === maxCapacity && stored.isFull === isFull) continue + stored.maxCapacity = maxCapacity + stored.isFull = isFull + await db + .prepare('UPDATE room_instance SET data = ?1 WHERE id = ?2') + .bind(JSON.stringify(stored), stored.roomInstanceId) + .run() + rewritten++ + } + return rewritten +} + /** * How long (s) a room instance is left alone after it's created, even with nobody in * it. Every path that creates an instance writes the creator's presence in the same diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 6a35c51..bce42ab 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -20,6 +20,7 @@ import { bindPlaceholders, chunkForBinds, MAX_BOUND_PARAMS } from './d1-binds' import { Accessibility, Role } from './enums' import { countPlayersByRoom } from './presence-db' +import { resyncInstanceCapacity } from './room-instance-db' import { bakedStudioUnityAssets, isMissingStudioAssetTable, @@ -1876,6 +1877,10 @@ export interface ModifySubRoomInput { * (the fields the client's subroom `modify` form carries). Only the supplied fields * are changed; the subroom row is updated in the `subroom` table. Returns the updated * (hydrated) room, or null when the room or subroom doesn't exist. + * + * A changed `MaxPlayers` is pushed onto the subroom's LIVE instances too (see + * {@link resyncInstanceCapacity}): each instance carries a copy of the cap it enforces, + * and this is the write that makes that copy stale. */ export async function modifySubRoom( db: D1Database, @@ -1888,8 +1893,10 @@ export async function modifySubRoom( if (input.name !== undefined) sub.Name = input.name if (input.accessibility !== undefined) sub.Accessibility = input.accessibility + const capacityChanged = input.maxPlayers !== undefined && input.maxPlayers !== sub.MaxPlayers if (input.maxPlayers !== undefined) sub.MaxPlayers = input.maxPlayers await updateSubRoom(db, sub) + if (capacityChanged) await resyncInstanceCapacity(db, roomId, subRoomId, input.maxPlayers!) return getRoomById(db, roomId) }