[match] do not allow crossplay between 2023 and 2025

This commit is contained in:
Devin Zuczek
2026-08-19 11:33:33 -04:00
parent 178d3b5b0e
commit 244b3bca70
4 changed files with 392 additions and 51 deletions
+126 -18
View File
@@ -24,6 +24,7 @@ import {
getRoomInstance,
getRoomInstancesByRoom,
getRoomInstanceSummariesByRoom,
getStoredRoomInstance,
isClubMember,
isPlayerBannedFromRoom,
MatchmakingErrorCode,
@@ -256,6 +257,19 @@ async function callerVersion(c: Context<App>): Promise<string | null> {
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<App>): Promise<string> {
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<App>,
@@ -1032,12 +1082,18 @@ async function resolveRoomInstance(
const currentInstanceId = isPrivate
? undefined
: (await getPresence<RoomInstance>(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<App>) {
}
/**
* 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<App>, accountId: number): Promise<RoomInstance> {
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<App>, accountId: number): Promise<R
maxCapacity: f.maxCapacity,
isPrivate: true,
roomInstanceType: f.roomInstanceType,
gameVersion,
})
}
return roomInstanceFromRoom(c.env, room, true, instance.roomInstanceId, instance.photonRoomId)
@@ -1734,6 +1803,25 @@ const app = new Hono<App>()
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<App>()
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<App>()
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<App>()
// 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
+159 -15
View File
@@ -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 requests 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<number> => {
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.
@@ -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;
+83 -18
View File
@@ -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<RoomI
return row ? toDto(parse(row.data)) : null
}
/**
* The stored instance behind {@link getRoomInstance} — the same row, un-projected, so a
* caller can read the fields the client DTO drops (`ownerAccountId`, `gameVersion`, the
* join flags). Server-side only: never answer a client with this shape.
*/
export async function getStoredRoomInstance(
db: D1Database,
id: number
): Promise<StoredRoomInstance | 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)
// 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<RoomInstanceDto | null> {
const binds: number[] = [roomId]
const binds: Array<number | string> = [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<RoomInstanceDto[]> {
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)))
}