diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index d15d210..3d89d9d 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -24,6 +24,7 @@ import { getRoomInstance, getRoomInstancesByRoom, getRoomInstanceSummariesByRoom, + getStoredRoomInstance, isClubMember, isPlayerBannedFromRoom, MatchmakingErrorCode, @@ -256,6 +257,19 @@ async function callerVersion(c: Context): Promise { return validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get()) } +/** + * The build to matchmake the caller as — {@link callerVersion} with the server's own + * `GAME_VERSION` standing in when the token doesn't say (the same fallback presence + * uses, so a player's instance and their reported build agree). + * + * Unlike presence, this can never be left unset: it is the key an instance is created + * under and searched by, and an empty one would pool every unknown-build player into a + * shared "" bucket — exactly the mixed session the stamp exists to prevent. + */ +async function callerGameVersion(c: Context): Promise { + return (await callerVersion(c)) ?? GAME_VERSION +} + /** * The "avoid juniors" preference, spelled the way the client posts it — the key a NEW * setting is written under, and the one every stored spelling is matched against. @@ -547,6 +561,32 @@ const NO_SUCH_ROOM = MatchmakingErrorCode.NoSuchRoom */ const BANNED_FROM_ROOM = MatchmakingErrorCode.BannedFromRoom +/** + * Whether a player on `callerVersion` may join a session already running + * `instanceVersion` — `null` when they may, otherwise the code to refuse with. + * + * The room matchmakes resolve this by construction: they only ever reuse an instance of + * the caller's own build and create one otherwise. The paths that join a NAMED instance + * can't — following a friend and the owner's instance listing both hand out a Photon + * room id for a session that already exists — so they ask here instead of putting two + * builds in one Photon room, where neither side sees what the other spawns. + * + * Which refusal depends on who is behind: a caller on the older build is told + * `UpdateRequired`, the one code that says "your client can't go there" and the honest + * answer. There is no code for the other direction ("they must update"), so a caller on + * the newer build gets the opaque NoSuchRoom every other unjoinable thing answers. + * Builds are date-stamped (`20230414`, `20250718.01`), so they order as strings; an + * instance carrying no version at all (written before the stamp existed) is nobody's + * build and refuses both ways. + */ +function crossBuildRefusal( + callerVersion: string, + instanceVersion: string +): MatchmakingErrorCode | null { + if (callerVersion === instanceVersion) return null + return instanceVersion > callerVersion ? MatchmakingErrorCode.UpdateRequired : NO_SUCH_ROOM +} + /** * "This event isn't open to you" — the refusal on a private event the caller wasn't * invited to. Told plainly rather than hidden behind the opaque NoSuchRoom: a player @@ -790,8 +830,10 @@ const DEFAULT_MATCHMAKING_POLICY = 0 * because sending fields the reference doesn't send is how you find out the hard way * that the client reads one of them. * - * Only the wire shape differs — the instance itself is the same row, so a v1 and a v2 - * client asking for the same public room land in the same instance. + * Only the wire shape differs — an instance is the same row whichever spelling asked for + * it. That does NOT mean a v1 and a v2 client asking for the same public room land in the + * same one: instances are scoped to the caller's client build, and these are two builds, + * so each gets its own session of the room (see resolveRoomInstance). */ function toV2RoomInstance(instance: RoomInstance) { return { @@ -995,6 +1037,14 @@ async function substituteRoom( * Every matchmake that names a room lands here, so this is also where the operator's * room substitutions apply (`ROOM_REDIRECTS`) — everything downstream, from the ban * check to presence and the visit count, sees only the room actually entered. + * + * It is also where a session's client build is decided. An instance is reused only when + * it is running the caller's own build, and a new one is stamped with it, so players + * only ever share a Photon room with others on the same version of the room — two builds + * in one instance disagree about how the scene and its objects serialize, and each side + * simply fails to see what the other spawned. Players on another build therefore don't + * count as somebody to join: a room busy with them reads as empty and the caller gets a + * fresh instance beside them. */ async function resolveRoomInstance( c: Context, @@ -1032,12 +1082,18 @@ async function resolveRoomInstance( const currentInstanceId = isPrivate ? undefined : (await getPresence(c.env.DB, ownerId))?.roomInstance?.roomInstanceId - // Reuse an existing joinable public instance *of the same subroom* — subrooms are - // separate places, so joining one must never land you in another. Private - // matchmakes always get a fresh instance. Create one when there's nothing to join. + // The build this player is on, from their token. It scopes the search below and is + // stamped on the instance when one is created, which is what keeps a session to a + // single client version. + const gameVersion = await callerGameVersion(c) + // Reuse an existing joinable public instance *of the same subroom and the same + // build* — subrooms are separate places, so joining one must never land you in + // another, and neither must a session running a different version of the room. + // Private matchmakes always get a fresh instance. Create one when there's nothing + // to join. let instance = isPrivate ? null - : await getJoinableInstance(c.env.DB, f.roomId, f.subRoomId, currentInstanceId) + : await getJoinableInstance(c.env.DB, f.roomId, gameVersion, f.subRoomId, currentInstanceId) if (!instance) { instance = await createRoomInstance(c.env.DB, { ownerAccountId: ownerId, @@ -1050,6 +1106,7 @@ async function resolveRoomInstance( maxCapacity: f.maxCapacity, isPrivate: isPrivate || f.isDorm, roomInstanceType: f.roomInstanceType, + gameVersion, }) } return { @@ -1096,17 +1153,28 @@ async function matchmakeIntoRoom(c: Context) { } /** - * The authed player's personal dorm instance. Gets-or-creates their dorm room, - * then backs it with a single persistent private `room_instance` so the dorm has - * a stable, unique Photon room id (dorms are isolated from each other) that - * survives re-entry. The room's current scene/saved data is re-read each time, so - * edits show up on the next visit. + * The authed player's personal dorm instance. Gets-or-creates their dorm room, then backs + * it with a private `room_instance` carrying its own Photon room id (dorms are isolated + * from each other). The room's current scene/saved data is re-read each time, so edits + * show up on the next visit — what persists about a dorm is the ROOM and the scene saved + * in it, not the session. + * + * One instance PER CLIENT BUILD: a dorm isn't private to its owner — they can invite + * people in — so it's an instance like any other and a build gets its own. An owner on a + * new build and a guest still on the old one end up in different sessions of the same + * dorm and can't see each other, which is the point: a mixed instance is a room where + * each side silently fails to see what the other spawned. + * + * A dorm instance is reused while it lasts — the owner leaving and coming back on the + * same build lands in the same session — but it is swept like any other once it sits + * empty past the grace window, and they simply get a fresh one on the next visit. */ async function playerDormInstance(c: Context, accountId: number): Promise { const room = await getOrCreateDormRoom(c.env.DB, accountId) const f = instanceFieldsFromRoom(room) - // Reuse the dorm's one instance (private, so getJoinableInstance won't find it). - let instance = (await getRoomInstancesByRoom(c.env.DB, f.roomId))[0] + const gameVersion = await callerGameVersion(c) + // Reuse this build's dorm instance (private, so getJoinableInstance won't find it). + let instance = (await getRoomInstancesByRoom(c.env.DB, f.roomId, gameVersion))[0] if (!instance) { instance = await createRoomInstance(c.env.DB, { ownerAccountId: accountId, @@ -1119,6 +1187,7 @@ async function playerDormInstance(c: Context, accountId: number): Promise() return matchmakeResult(c, BANNED_FROM_ROOM, null) } + // Nor does it go through the build scoping the room matchmakes get by + // construction, so a player on another build could otherwise follow their way + // into a session that can't render them. Compared against the FRIEND's presence + // rather than the instance's stamp: they're the person actually standing in + // there, their row is already read, and the two agree anyway (every matchmake + // writes the build it placed them under). + const followed = crossBuildRefusal( + await callerGameVersion(c), + targetPresence?.appVersion ?? GAME_VERSION + ) + if (followed !== null) { + logger.info('follow refused: friend is on another client build', { + roomInstanceId: instance.roomInstanceId, + targetId, + id, + }) + return matchmakeResult(c, followed, null) + } + // Join that same instance (same id + Photon room) and store it as the caller's // presence, so the heartbeat replays it and their own friend fan-out fires. await enterRoom(c, id, instance) @@ -1787,7 +1875,9 @@ const app = new Hono() if (id === null) return unauthorized(c) const instanceId = Number.parseInt(c.req.param('instanceId'), 10) - const stored = await getRoomInstance(c.env.DB, instanceId) + // The stored row rather than the client DTO: this needs the instance's own + // `gameVersion`, which the DTO drops. + const stored = await getStoredRoomInstance(c.env.DB, instanceId) // One opaque refusal for "no such instance", "no such room" and "not yours": // a distinct code for the last would confirm which instance ids are live. if (!stored) return matchmakeResult(c, NO_SUCH_ROOM, null) @@ -1814,6 +1904,20 @@ const app = new Hono() return matchmakeResult(c, BANNED_FROM_ROOM, null) } + // Owning the room doesn't make an older client able to render a session running a + // newer build. This path picks a fixed instance, so unlike a room matchmake there + // is no same-build instance to fall back to — the owner is refused and can enter + // the room normally instead, which gets them a session of their own build. + const crossBuild = crossBuildRefusal(await callerGameVersion(c), stored.gameVersion) + if (crossBuild !== null) { + logger.info('instance matchmake refused: instance is on another client build', { + roomInstanceId: instanceId, + instanceVersion: stored.gameVersion, + accountId: id, + }) + return matchmakeResult(c, crossBuild, null) + } + // Rebuild the wire instance from the room (fresh scene + published save) keyed to // this instance's own id and Photon room, so the owner lands in exactly the // session they picked rather than a new one alongside it. @@ -1893,10 +1997,14 @@ const app = new Hono() // The newer client asks for the same two room matchmakes under a `/v2/` prefix // (`/matchmake/v2/room/{roomId}` and `/matchmake/v2/room/{roomId}/{subRoomId}`). The - // MATCHMAKING is the same — same rooms, same instances, same refusals, so a v1 and a - // v2 player asking for the same public room stand in the same place — and so these - // share the handler. What differs is the wire on both ends: the request is JSON with - // real types rather than a urlencoded form, and the response is the PascalCase + // MATCHMAKING is the same — same rooms, same instance table, same refusals — and so + // these share the handler. They do NOT put the two clients in one session, though: an + // instance is scoped to the caller's build, and a v1 and a v2 player are by definition + // on different ones, so each stands in their own instance of the room. That is the + // point — the two builds can't render each other's scene. + // + // What differs is the wire on both ends: the request is JSON with real types rather + // than a urlencoded form, and the response is the PascalCase // envelope (`ErrorCode`/`CorrelationId`/`RoomInstance`, no `result` twin, no Photon // coordinates or DataBlob on the instance, plus `MatchmakingPolicy`). Neither is // handled here: `readRequestFields` takes either encoding and `matchmakeResult` picks diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index daacd2f..9b3ed9f 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -1060,6 +1060,33 @@ describe('auth-gated endpoints', () => { expect(priv.roomInstance.photonRoomId).not.toBe(a.roomInstance.photonRoomId) }) + test('POST /matchmake/room/:roomId only pools players on the same client build', async () => { + const matchmake = async (sub: string, version: string) => + (await ( + await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { + method: 'POST', + headers: await bearer(sub, version), + }) + ).json()) as { roomInstance: { photonRoomId: string; roomInstanceId: number } } + + // Two players on one build share an instance, exactly as before — the build scoping + // groups players, it doesn't stop grouping them. + const oldA = await matchmake('910', '20250424.01') + const oldB = await matchmake('911', '20250424.01') + expect(oldB.roomInstance.roomInstanceId).toBe(oldA.roomInstance.roomInstanceId) + + // A player on a different build asking for the same room gets their own instance: + // the live one is running a version of the room their client can't render, so it + // isn't somebody to join. + const next = await matchmake('912', '20250718.01') + expect(next.roomInstance.roomInstanceId).not.toBe(oldA.roomInstance.roomInstanceId) + expect(next.roomInstance.photonRoomId).not.toBe(oldA.roomInstance.photonRoomId) + + // ...and they pool with their own build in turn. + const nextB = await matchmake('913', '20250718.01') + expect(nextB.roomInstance.roomInstanceId).toBe(next.roomInstance.roomInstanceId) + }) + test('GET /player/connection-info hands back the Photon room the caller matchmade into', async () => { const matchmaked = (await ( await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { @@ -1271,6 +1298,34 @@ describe('auth-gated endpoints', () => { }) }) + test('POST /matchmake/dorm gives each client build its own dorm instance', async () => { + const dorm = async (version: string) => + (await ( + await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { + method: 'POST', + headers: await bearer('43', version), + }) + ).json()) as { + roomInstance: { roomId: number; photonRoomId: string; roomInstanceId: number } + } + + // Same dorm ROOM whichever build the owner is on — it's their one dorm... + const older = await dorm('20250424.01') + const newer = await dorm('20250718.01') + expect(newer.roomInstance.roomId).toBe(older.roomInstance.roomId) + + // ...but a separate session per build. A dorm takes guests, so a mixed one is a + // room where neither side can see what the other spawned; the owner on a new build + // and a guest still on the old one are deliberately kept apart. + expect(newer.roomInstance.roomInstanceId).not.toBe(older.roomInstance.roomInstanceId) + expect(newer.roomInstance.photonRoomId).not.toBe(older.roomInstance.photonRoomId) + + // Each build's instance is still the stable one it re-enters (id + Photon room), + // and coming back on the older build doesn't hand back the newer session. + expect(await dorm('20250424.01')).toMatchObject({ roomInstance: older.roomInstance }) + expect(await dorm('20250718.01')).toMatchObject({ roomInstance: newer.roomInstance }) + }) + test('a matchmake echoes the request’s CorrelationId (and mirrors errorCode as result)', async () => { // The client tags each attempt with a GUID and won't accept a session whose // response doesn't carry the same one back ("Unable to connect to game session"). @@ -1606,8 +1661,15 @@ describe('auth-gated endpoints', () => { ) .run() + // A stand-in instance id for presence rows whose instance is beside the point. Kept + // far above what createRoomInstance hands out (ID_BASE + 1, climbing by one per + // instance) so it can never collide with a real one — a live presence row pointing at + // a real instance makes that instance look occupied, which quietly breaks whichever + // test is watching the empty-instance sweep. + const UNRELATED_INSTANCE_ID = 1_900_042 + const seedPresence = (id: number, expiresAt: number) => - seedPresenceInInstance(id, 1000042, expiresAt) + seedPresenceInInstance(id, UNRELATED_INSTANCE_ID, expiresAt) const storedExpiresAt = async (id: number): Promise => { const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1') @@ -1651,11 +1713,12 @@ describe('auth-gated endpoints', () => { }) test('countPlayersInInstance counts live players in a room instance (excludes expired)', async () => { - // Three players in instance 1000099 — two live, one expired. - await seedPresenceInInstance(710, 1000099, nowSeconds() + 800) - await seedPresenceInInstance(711, 1000099, nowSeconds() + 800) - await seedPresenceInInstance(712, 1000099, nowSeconds() - 10) // expired → not counted - expect(await countPlayersInInstance(env.DB, 1000099)).toBe(2) + // Three players in instance 1900099 (synthetic, like UNRELATED_INSTANCE_ID above) — + // two live, one expired. + await seedPresenceInInstance(710, 1_900_099, nowSeconds() + 800) + await seedPresenceInInstance(711, 1_900_099, nowSeconds() + 800) + await seedPresenceInInstance(712, 1_900_099, nowSeconds() - 10) // expired → not counted + expect(await countPlayersInInstance(env.DB, 1_900_099)).toBe(2) expect(await countPlayersInInstance(env.DB, 999999)).toBe(0) }) @@ -1782,22 +1845,36 @@ describe('auth-gated endpoints', () => { expect(await getRoomInstance(env.DB, fresh.roomInstanceId)).not.toBeNull() }) - test('the cron sweep spares an empty dorm instance', async () => { - // A dorm is backed by one persistent instance so its Photon room id survives - // re-entry — it sits empty whenever the owner is anywhere else. + test('the cron sweep retires an empty dorm instance like any other', async () => { + // A dorm gets no exemption: once its owner is elsewhere the session is an empty + // Photon room nobody can be pointed at, exactly like a public instance everyone + // left. What persists about a dorm is the ROOM and the scene saved in it. const headers = await bearer('833') - const dorm = (await ( - await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers }) - ).json()) as { roomInstance: { roomInstanceId: number } } - const dormInstanceId = dorm.roomInstance.roomInstanceId + const enterDorm = async () => + (await ( + await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers }) + ).json()) as { + roomInstance: { roomInstanceId: number; roomId: number; photonRoomId: string } + } + + const dorm = await enterDorm() await expirePresence(833) - await backdateInstance(dormInstanceId) + await backdateInstance(dorm.roomInstance.roomInstanceId) const ctx = createExecutionContext() await scheduled(createScheduledController(), env, ctx) await waitOnExecutionContext(ctx) - expect(await getRoomInstance(env.DB, dormInstanceId)).not.toBeNull() + expect(await getRoomInstance(env.DB, dorm.roomInstance.roomInstanceId)).toBeNull() + + // And the owner walks back into their own dorm regardless — same room, a fresh + // session of it. Freshness is read off the Photon room (a new GUID per instance) + // rather than the id: ids come from MAX(id) + 1, so retiring the newest row hands + // its number straight back to the next instance created. + const again = await enterDorm() + expect(again.roomInstance.roomId).toBe(dorm.roomInstance.roomId) + expect(again.roomInstance.photonRoomId).not.toBe(dorm.roomInstance.photonRoomId) + expect(await getRoomInstance(env.DB, again.roomInstance.roomInstanceId)).not.toBeNull() }) test('player/login and exclusivelogin preserve presence', async () => { @@ -1946,6 +2023,42 @@ describe('auth-gated endpoints', () => { expect((await coOwner.json()) as unknown[]).toHaveLength(instances.length) }) + test('POST /matchmake/instance/:id refuses an instance running another client build', async () => { + const spawn = async (sub: string, version: string) => + ( + (await ( + await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, { + method: 'POST', + headers: await bearer(sub, version), + }) + ).json()) as { roomInstance: { roomInstanceId: number } } + ).roomInstance.roomInstanceId + + const join = async (instanceId: number, version: string) => + ( + await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, { + method: 'POST', + headers: await bearer('42', version), + }) + ).json() + + // The owner of room 3 (42) can't drop into a session running a build their own + // client isn't: owning the room doesn't make an older client able to render it. + // Their build is behind the instance's, so they're told to update (16) rather than + // given the opaque refusal. + const newer = await spawn('914', '20250718.01') + expect(await join(newer, '20250424.01')).toEqual(refused(16)) + + // The other direction has no code of its own — there's no "the people in there must + // update" — so it's the opaque NoSuchRoom every other unjoinable thing answers. + const older = await spawn('915', '20250424.01') + expect(await join(older, '20250718.01')).toEqual(refused(20)) + + // Same build → in they go. + const same = await spawn('916', '20250718.01') + expect(await join(same, '20250718.01')).toMatchObject({ errorCode: 0 }) + }) + test('POST /matchmake/instance/:id joins that exact instance, owner-only', async () => { // A player with no role on room 3 spins up an instance of it, which the room's // owner should then be able to drop into by id. @@ -2274,6 +2387,37 @@ describe('auth-gated endpoints', () => { expect(await sent()).toEqual([]) }) + test('POST /matchmake/player/:id refuses a friend on another client build', async () => { + // 9810 is friends with 9811, 9812 with 9813 — one pair per direction of the build gap. + const insertRel = env.DB.prepare( + 'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)' + ) + await env.DB.batch([insertRel.bind(9810, 9811, 3), insertRel.bind(9812, 9813, 3)]) + + const enter = async (sub: string, version: string) => + exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { + method: 'POST', + headers: await bearer(sub, version), + }) + const follow = async (targetId: number, sub: string, version: string) => + ( + await exports.default.fetch(`${ORIGIN}/matchmake/player/${targetId}`, { + method: 'POST', + headers: await bearer(sub, version), + }) + ).json() + + // The friend is standing in a session of a newer build: following them would put + // two builds in one Photon room, so the follower is told to update (16) instead. + await enter('9811', '20250718.01') + expect(await follow(9811, '9810', '20250424.01')).toEqual(refused(16)) + + // Following someone on an OLDER build is refused too — opaquely, since there's no + // code for "they're the ones who need to update". + await enter('9813', '20250424.01') + expect(await follow(9813, '9812', '20250718.01')).toEqual(refused(20)) + }) + test('POST /matchmake/player/:id follows a friend into their room, friends only', async () => { // 9800 is friends with 9801 (in a room) and 9803 (not in any room); 9802 is not a // friend. diff --git a/apps/rooms/migrations/0012_room_instance_version.sql b/apps/rooms/migrations/0012_room_instance_version.sql new file mode 100644 index 0000000..8f21dfc --- /dev/null +++ b/apps/rooms/migrations/0012_room_instance_version.sql @@ -0,0 +1,24 @@ +-- The client build a room instance is running, so matchmaking only ever puts players +-- on the SAME build into one session. Two builds sharing a Photon room disagree about +-- how the scene and its objects serialize, so a 2023 client and a 2025 one in the same +-- instance is a broken room rather than a mixed one — and there is nothing to notice it +-- by afterwards, since each side simply fails to see what the other spawned. +-- +-- The value is the `rn.ver` claim of the token whose matchmake CREATED the instance +-- (the match worker stamps it; see resolveRoomInstance), and the joinable-instance +-- search filters on it. A room busy with players on another build therefore reads as +-- empty and the joiner gets a fresh instance beside them. +-- +-- Rows written before this column existed have no `$.gameVersion`, so `game_version` +-- is NULL and they match no build at all. That is deliberate: an unknown build is not +-- a build to place someone into, and an instance is only ever a live session — once it +-- empties, deleteEmptyRoomInstances retires it and nothing is left carrying a NULL. +-- +-- A virtual generated column like the rest of the table (the value lives in the `data` +-- blob); no index — the search is already keyed on the indexed `room_id`. +-- +-- Generated from packages/domain/src/room-instance-db.ts (ROOM_INSTANCE_SCHEMA_DDL) — +-- keep in sync. + +ALTER TABLE room_instance + ADD COLUMN game_version TEXT GENERATED ALWAYS AS (json_extract(data, '$.gameVersion')) VIRTUAL; diff --git a/packages/domain/src/room-instance-db.ts b/packages/domain/src/room-instance-db.ts index 6a27876..ed42df2 100644 --- a/packages/domain/src/room-instance-db.ts +++ b/packages/domain/src/room-instance-db.ts @@ -9,10 +9,11 @@ * single source of truth for the helpers — both workers import it from * `@repo/domain`. Columns marked `[JsonIgnore]` in the reference (owner_account_id, * data_blob, allow_new_users, join_disabled) live in the blob but are dropped from - * the client DTO (`toDto`). + * the client DTO (`toDto`) — as does `game_version`, which is this server's own + * addition (migrations/0012_room_instance_version.sql) and keys matchmaking so that + * only players on the same client build share an instance. */ -import { RoomInstanceType } from './enums' import { countPlayersInInstance, getPlayerIdsByRoomInstance } from './presence-db' /** Schema DDL (mirror of migrations/0004_room_instance.sql). */ @@ -40,7 +41,8 @@ export const ROOM_INSTANCE_SCHEMA_DDL: string[] = [ matchmaking_policy INTEGER GENERATED ALWAYS AS (json_extract(data, '$.matchmakingPolicy')) VIRTUAL, allow_new_users INTEGER GENERATED ALWAYS AS (json_extract(data, '$.allowNewUsers')) VIRTUAL, join_disabled INTEGER GENERATED ALWAYS AS (json_extract(data, '$.joinDisabled')) VIRTUAL, - created_at TEXT GENERATED ALWAYS AS (json_extract(data, '$.createdAt')) VIRTUAL + created_at TEXT GENERATED ALWAYS AS (json_extract(data, '$.createdAt')) VIRTUAL, + game_version TEXT GENERATED ALWAYS AS (json_extract(data, '$.gameVersion')) VIRTUAL )`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_room_instance_id ON room_instance (id)`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_room_instance_photon_room_id ON room_instance (photon_room_id)`, @@ -86,12 +88,19 @@ export interface RoomInstanceSummary { playerIds: number[] } -/** The full stored instance — the DTO plus the JsonIgnore fields (in the blob). */ -interface StoredRoomInstance extends RoomInstanceDto { +/** + * The full stored instance — the DTO plus the fields that live in the blob but never + * reach the client: the reference's `[JsonIgnore]` columns, and `gameVersion`, which is + * this server's own (see {@link NewRoomInstance.gameVersion}). Exported for the callers + * that need one of those — {@link getStoredRoomInstance} is how they read it — since the + * DTO deliberately drops them. + */ +export interface StoredRoomInstance extends RoomInstanceDto { ownerAccountId: number dataBlob: string allowNewUsers: boolean joinDisabled: boolean + gameVersion: string } /** Fields for a new instance; `roomInstanceId` and `createdAt` are assigned here. */ @@ -116,6 +125,14 @@ export interface NewRoomInstance { matchmakingPolicy?: number allowNewUsers?: boolean joinDisabled?: boolean + /** + * The game build this session runs — the `rn.ver` of the player whose matchmake + * created it. Players only ever share an instance with others on the same build (see + * {@link getJoinableInstance}), because two builds in one Photon room disagree about + * how the scene and its objects serialize. Omitted (`''`) means "unknown", which + * matches nothing: rows written before the field existed are never joined into. + */ + gameVersion?: string } /** Project a stored instance to the client DTO (JsonIgnore fields dropped). */ @@ -181,6 +198,7 @@ export async function createRoomInstance( matchmakingPolicy: input.matchmakingPolicy ?? 0, allowNewUsers: input.allowNewUsers ?? true, joinDisabled: input.joinDisabled ?? false, + gameVersion: input.gameVersion ?? '', createdAt: new Date().toISOString(), } await db @@ -199,6 +217,27 @@ export async function getRoomInstance(db: D1Database, id: 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) + // A row written before the build stamp existed has no `gameVersion` at all; it reads + // as `''` (the "unknown build" the type promises) rather than as undefined, so a + // caller comparing builds doesn't have to know the field is younger than the table. + return { ...stored, gameVersion: stored.gameVersion || '' } +} + /** * Flip an instance's `isInProgress` flag, rewriting the JSON blob (the generated * `is_in_progress` column follows it). Returns the updated DTO, or null when the @@ -304,9 +343,15 @@ export const EMPTY_INSTANCE_GRACE_SECONDS = 300 * until the following sweep. * * Instances younger than `graceSeconds` are skipped (see - * {@link EMPTY_INSTANCE_GRACE_SECONDS}), as are dorms: a dorm is backed by one - * persistent instance so its Photon room id survives re-entry, and it sits empty - * whenever the owner is elsewhere. + * {@link EMPTY_INSTANCE_GRACE_SECONDS}). Nothing else is: a DORM is swept like any + * other room once its owner leaves it empty, and gets a fresh row — a new Photon room — + * the next time they walk in. Its persistence is the dorm ROOM and the scene saved in it, + * which live on the room, not on a session of it; the row here is worth no more than an + * empty Photon room nobody can be pointed at. + * + * Note that a deleted id is not retired: {@link createRoomInstance} allocates + * `MAX(id) + 1`, so sweeping the newest row hands its number to the next instance + * created. Nothing may treat an instance id as a durable reference to one session. * * Returns the ids deleted. */ @@ -322,21 +367,29 @@ export async function deleteEmptyRoomInstances( .prepare( `DELETE FROM room_instance WHERE created_at < ?1 - AND room_instance_type != ?2 AND NOT EXISTS ( SELECT 1 FROM presence WHERE presence.room_instance_id = room_instance.id ) RETURNING json_extract(data, '$.roomInstanceId') AS id` ) - .bind(createdBefore, RoomInstanceType.Dormroom) + .bind(createdBefore) .all<{ id: number }>() return results.map((r) => r.id) } /** * 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. + * 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. + * + * 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 + * room disagree about how the scene and its objects serialize, so a player is only ever + * placed with others on their own build; a room busy with players on another build looks + * empty here and the caller creates a fresh instance beside them. Instances written + * before the field existed carry no version and so match nobody — they are joined into + * again only after they empty out and the sweep retires them. * * 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 @@ -352,10 +405,11 @@ export async function deleteEmptyRoomInstances( export async function getJoinableInstance( db: D1Database, roomId: number, + gameVersion: string, subRoomId?: number, excludeInstanceId?: number ): Promise { - const binds: number[] = [roomId] + const binds: Array = [roomId, gameVersion] const filters: string[] = [] if (subRoomId !== undefined) { binds.push(subRoomId) @@ -368,7 +422,8 @@ export async function getJoinableInstance( 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 + 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` ) @@ -377,14 +432,24 @@ export async function getJoinableInstance( return row ? toDto(parse(row.data)) : null } -/** All instances of a given room. */ +/** + * All instances of a given room — every build's, unless `gameVersion` scopes it to the + * sessions running one. An instance belongs to a single client build (see + * {@link getJoinableInstance}), so a caller looking for one to place a player in wants + * the scoped form; a caller counting or listing what's live wants them all. + */ export async function getRoomInstancesByRoom( db: D1Database, - roomId: number + roomId: number, + gameVersion?: string ): Promise { const { results } = await db - .prepare('SELECT data FROM room_instance WHERE room_id = ?1') - .bind(roomId) + .prepare( + `SELECT data FROM room_instance WHERE room_id = ?1${ + gameVersion === undefined ? '' : ' AND game_version = ?2' + }` + ) + .bind(...(gameVersion === undefined ? [roomId] : [roomId, gameVersion])) .all<{ data: string }>() return results.map((r) => toDto(parse(r.data))) }