mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
migrate subrooms to own storage
This commit is contained in:
@@ -2,7 +2,7 @@ import { adminSecretsStore, env } from 'cloudflare:test'
|
||||
import { exports } from 'cloudflare:workers'
|
||||
import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import { GAME_VERSION } from '@repo/domain'
|
||||
import { GAME_VERSION, seedRoomWithSubRooms, SUBROOM_SCHEMA_DDL } from '@repo/domain'
|
||||
|
||||
import '../../api.app'
|
||||
|
||||
@@ -52,8 +52,10 @@ beforeAll(async () => {
|
||||
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL
|
||||
)`
|
||||
).run()
|
||||
const insert = env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)')
|
||||
await env.DB.batch(TEST_ROOMS.map((r) => insert.bind(JSON.stringify(r))))
|
||||
// Subrooms live in their own table now; getRoomById hydrates from it, so create it and
|
||||
// split each seeded room's subrooms into it (mirrors the rooms worker's 0007 migration).
|
||||
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const r of TEST_ROOMS) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
|
||||
|
||||
// Accounts table (matching the auth worker's migration) — uploadsaved records
|
||||
// profile thumbnails on the account row. Seed the account the test token (sub
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getAccountByUsername,
|
||||
getAccountsByPlatformId,
|
||||
getPasswordHash,
|
||||
getRoomById,
|
||||
hashPassword,
|
||||
RoomInstanceType,
|
||||
setLastLoginTime,
|
||||
@@ -101,12 +102,11 @@ async function placeNewPlayerInOrientation(
|
||||
accountId: number,
|
||||
deviceClass: number
|
||||
): Promise<void> {
|
||||
const row = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1')
|
||||
.bind(ORIENTATION_ROOM_ID)
|
||||
.first<{ data: string }>()
|
||||
if (!row) return
|
||||
// getRoomById hydrates the room's SubRooms from the subroom table (they no longer
|
||||
// live in the room blob), so the Orientation scene resolves the same way match does.
|
||||
const room = await getRoomById(env.DB, ORIENTATION_ROOM_ID)
|
||||
if (!room) return
|
||||
|
||||
const room = JSON.parse(row.data) as Record<string, unknown>
|
||||
const subRooms = room.SubRooms
|
||||
const sub = (Array.isArray(subRooms) ? subRooms[0] : undefined) as
|
||||
Record<string, unknown> | undefined
|
||||
|
||||
@@ -4,7 +4,14 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import '../../auth.app'
|
||||
|
||||
import { getAccountsByDeviceId, hashPassword, PRESENCE_SCHEMA_DDL, SCHEMA_DDL } from '@repo/domain'
|
||||
import {
|
||||
getAccountsByDeviceId,
|
||||
hashPassword,
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { isLinkedToPlatformIdentity } from '../../auth.app'
|
||||
import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
|
||||
@@ -48,16 +55,14 @@ beforeAll(async () => {
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
|
||||
)`
|
||||
).run()
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
RoomId: 13,
|
||||
Name: 'Orientation',
|
||||
IsDorm: false,
|
||||
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
|
||||
})
|
||||
)
|
||||
.run()
|
||||
// Subrooms live in their own table; seed the Orientation room and split its subroom into it.
|
||||
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
RoomId: 13,
|
||||
Name: 'Orientation',
|
||||
IsDorm: false,
|
||||
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
|
||||
})
|
||||
})
|
||||
|
||||
/** Decode a JWT payload (no verification) for asserting claims. */
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
getRoomInstance,
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
ROOM_INSTANCE_SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { scheduled } from '../../match.app'
|
||||
@@ -90,8 +92,9 @@ beforeAll(async () => {
|
||||
is_dorm INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsDorm')) VIRTUAL
|
||||
)`
|
||||
).run()
|
||||
const insert = env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)')
|
||||
await env.DB.batch(TEST_ROOMS.map((r) => insert.bind(JSON.stringify(r))))
|
||||
// Subrooms live in their own table now; seed each room and split its subrooms into it.
|
||||
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const r of TEST_ROOMS) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
|
||||
// Room instances (owned by the rooms worker) — matchmaking finds/creates here.
|
||||
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Presence table (owned by the rooms worker) — written/read by matchmake + heartbeat.
|
||||
@@ -508,6 +511,26 @@ describe('auth-gated endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('each player’s dorm gets a distinct global subroom id', async () => {
|
||||
// Dorms used to copy the template subroom verbatim, so every dorm carried SubRoomId 1.
|
||||
// With subrooms minted from the global sequence, each dorm gets its own unique id.
|
||||
const dormSubRoomId = async (sub: string): Promise<number> => {
|
||||
const body = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
).json()) as { roomInstance: { subRoomId: number } }
|
||||
return body.roomInstance.subRoomId
|
||||
}
|
||||
const a = await dormSubRoomId('7001')
|
||||
const b = await dormSubRoomId('7002')
|
||||
expect(a).not.toBe(b)
|
||||
// Neither reuses the seed dorm template's SubRoomId (1).
|
||||
expect(a).not.toBe(1)
|
||||
expect(b).not.toBe(1)
|
||||
})
|
||||
|
||||
test('POST /matchmake/room/:roomId resolves a room by name from D1', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/RecCenter`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
-- Subrooms as first-class entities. In the original game a subroom has its own
|
||||
-- globally-unique, autoincrementing `SubRoomId` (minted from a single sequence, not
|
||||
-- per-room), so they can't live inside the room JSON blob — cloning/dorm creation
|
||||
-- would otherwise reuse ids and collide across rooms. This moves them into their own
|
||||
-- `subroom` table keyed by an AUTOINCREMENT `sub_room_id`, backfills from each room's
|
||||
-- embedded `SubRooms`, and drops `SubRooms` from the room blob. Rooms re-embed their
|
||||
-- `SubRooms` array on read. Generated from packages/domain/src/rooms-db.ts
|
||||
-- (SUBROOM_SCHEMA_DDL) — keep in sync.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subroom (
|
||||
sub_room_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id INTEGER NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_subroom_room ON subroom (room_id);
|
||||
|
||||
-- Backfill. The live blob already contains collisions (the bug this migration fixes:
|
||||
-- every dorm carries SubRoomId 1, clones reuse per-room ids), so we can't preserve all
|
||||
-- ids. We keep the FIRST occurrence of each distinct SubRoomId at its original id (so the
|
||||
-- seeded rooms keep their canonical ids) and mint fresh globally-unique ids for the rest.
|
||||
|
||||
-- 1) First occurrence of each non-null SubRoomId keeps its id (lowest room_id wins).
|
||||
INSERT INTO subroom (sub_room_id, room_id, data)
|
||||
SELECT sid, room_id, data FROM (
|
||||
SELECT
|
||||
CAST(json_extract(je.value, '$.SubRoomId') AS INTEGER) AS sid,
|
||||
r.room_id AS room_id,
|
||||
je.value AS data,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY json_extract(je.value, '$.SubRoomId')
|
||||
ORDER BY r.room_id
|
||||
) AS rn
|
||||
FROM room r, json_each(r.data, '$.SubRooms') je
|
||||
)
|
||||
WHERE rn = 1 AND sid IS NOT NULL;
|
||||
|
||||
-- 2) Everything else (the duplicates, and any null ids) gets a fresh autoincrement id.
|
||||
-- Inserting the explicit ids above advanced sqlite_sequence, so these continue past
|
||||
-- the highest kept id and never collide.
|
||||
INSERT INTO subroom (room_id, data)
|
||||
SELECT room_id, data FROM (
|
||||
SELECT
|
||||
CAST(json_extract(je.value, '$.SubRoomId') AS INTEGER) AS sid,
|
||||
r.room_id AS room_id,
|
||||
je.value AS data,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY json_extract(je.value, '$.SubRoomId')
|
||||
ORDER BY r.room_id
|
||||
) AS rn
|
||||
FROM room r, json_each(r.data, '$.SubRooms') je
|
||||
)
|
||||
WHERE NOT (rn = 1 AND sid IS NOT NULL);
|
||||
|
||||
-- Single source of truth: drop the now-migrated SubRooms array from the room blob.
|
||||
UPDATE room SET data = json_remove(data, '$.SubRooms');
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
ROOM_INSTANCE_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
import importRooms from '../../../static/ImportRooms.json'
|
||||
@@ -50,11 +52,12 @@ beforeAll(async () => {
|
||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Presence table (read by the photon access-token handler).
|
||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
const insert = env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)')
|
||||
await env.DB.batch(importRooms.map((r) => insert.bind(JSON.stringify(r))))
|
||||
// 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<string, unknown>)
|
||||
})
|
||||
|
||||
describe('rooms endpoints', () => {
|
||||
@@ -1358,4 +1361,28 @@ describe('rooms endpoints', () => {
|
||||
).json()) as { SubRoomId: number }
|
||||
expect(fetched.SubRoomId).toBe(body.value?.SubRoomId)
|
||||
})
|
||||
|
||||
it('subroom clone mints a globally-unique SubRoomId (no cross-room clash)', async () => {
|
||||
// The old per-room `max(SubRoomId)+1` allocator would mint id 3 for room 2's clone —
|
||||
// colliding with another room that already owns subroom 3. The subroom table's
|
||||
// autoincrement mints an id above every existing subroom instead.
|
||||
const maxBefore = (await env.DB.prepare('SELECT MAX(sub_room_id) AS m FROM subroom').first<{
|
||||
m: number
|
||||
}>())!.m
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/clone`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('1'),
|
||||
})
|
||||
const body = (await res.json()) as { value: { SubRoomId: number; RoomId: number } }
|
||||
// Above every prior subroom id — a fresh global id, not a per-room collision.
|
||||
expect(body.value.SubRoomId).toBeGreaterThan(maxBefore)
|
||||
expect(body.value.RoomId).toBe(2)
|
||||
|
||||
// The id is unique across the whole table (exactly one row owns it).
|
||||
const dupes = (await env.DB.prepare('SELECT COUNT(*) AS n FROM subroom WHERE sub_room_id = ?1')
|
||||
.bind(body.value.SubRoomId)
|
||||
.first<{ n: number }>())!.n
|
||||
expect(dupes).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user