mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[rooms] record invention room usage
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
-- Which inventions a room has in it — one row per (room, invention).
|
||||||
|
--
|
||||||
|
-- Generated from packages/domain/src/rooms-db.ts (SUBROOM_SCHEMA_DDL) — keep in sync.
|
||||||
|
--
|
||||||
|
-- A room save (`POST /rooms/:id/subrooms/:sid/data`) carries `InventionUsage`, a base64
|
||||||
|
-- protobuf listing the inventions in the saved scene (see `decodeInventionUsage`). Each
|
||||||
|
-- save re-derives the room's rows here from every one of its subrooms, so the table is the
|
||||||
|
-- union across the room and an invention removed from it drops out on the next save.
|
||||||
|
--
|
||||||
|
-- Empty until rooms are next saved: the `InventionUsage` already stored on subrooms is not
|
||||||
|
-- backfilled, since decoding it is not something SQL can do.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_invention (
|
||||||
|
room_id INTEGER NOT NULL,
|
||||||
|
invention_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (room_id, invention_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The reverse lookup: which rooms use this invention.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_room_invention_invention ON room_invention (invention_id);
|
||||||
@@ -10,7 +10,9 @@ import { beforeAll, describe, expect, it } from 'vitest'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AUDIT_LOG_SCHEMA_DDL,
|
AUDIT_LOG_SCHEMA_DDL,
|
||||||
|
countRoomsUsingInvention,
|
||||||
createRoomInstance,
|
createRoomInstance,
|
||||||
|
decodeInventionUsage,
|
||||||
getRoomInstance,
|
getRoomInstance,
|
||||||
MessageType,
|
MessageType,
|
||||||
NOTIFICATION_SCHEMA_DDL,
|
NOTIFICATION_SCHEMA_DDL,
|
||||||
@@ -22,6 +24,7 @@ import {
|
|||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
|
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
|
||||||
import { SCHEMA_DDL as ROOM_VOTE_SCHEMA_DDL } from '../../../../api/src/votes-db'
|
import { SCHEMA_DDL as ROOM_VOTE_SCHEMA_DDL } from '../../../../api/src/votes-db'
|
||||||
import { NotificationType } from '../../../../notify/src/notification-types'
|
import { NotificationType } from '../../../../notify/src/notification-types'
|
||||||
import importRooms from '../../../static/ImportRooms.json'
|
import importRooms from '../../../static/ImportRooms.json'
|
||||||
@@ -119,6 +122,8 @@ beforeAll(async () => {
|
|||||||
// Vote-to-kick ballots (owned by `api`) and game invites (owned by `match`) — the cron
|
// Vote-to-kick ballots (owned by `api`) and game invites (owned by `match`) — the cron
|
||||||
// here sweeps the stale ones.
|
// here sweeps the stale ones.
|
||||||
for (const stmt of ROOM_VOTE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of ROOM_VOTE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
// Inventions (owned by the api worker) — a room save writes each one's room count.
|
||||||
|
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
// Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill).
|
// 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>)
|
for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
|
||||||
@@ -3625,6 +3630,61 @@ describe('rooms endpoints', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('a room save records which inventions the room uses in room_invention', async () => {
|
||||||
|
const save = async (inventionUsage: string) => {
|
||||||
|
const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(await bearer('1')) },
|
||||||
|
body: JSON.stringify({ InventionUsage: inventionUsage, AutoPublish: true }),
|
||||||
|
})
|
||||||
|
expect(((await res.json()) as { success: boolean }).success).toBe(true)
|
||||||
|
}
|
||||||
|
const rows = async (): Promise<number[]> =>
|
||||||
|
(
|
||||||
|
await env.DB.prepare(
|
||||||
|
'SELECT invention_id FROM room_invention WHERE room_id = 2 ORDER BY invention_id'
|
||||||
|
).all<{ invention_id: number }>()
|
||||||
|
).results.map((r) => r.invention_id)
|
||||||
|
|
||||||
|
// The invention records the counts are written onto (the `api` worker's table).
|
||||||
|
for (const id of [257, 27941]) {
|
||||||
|
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
|
||||||
|
.bind(JSON.stringify({ InventionId: id, NumPlayersHaveUsedInRoom: 0 }))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
const usedIn = async (id: number): Promise<number> =>
|
||||||
|
(
|
||||||
|
await env.DB.prepare(
|
||||||
|
"SELECT json_extract(data, '$.NumPlayersHaveUsedInRoom') AS n FROM invention WHERE id = ?1"
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.first<{ n: number }>()
|
||||||
|
)?.n ?? -1
|
||||||
|
|
||||||
|
// A real client value: `{ 1: 1, 2: [{ 1: 27941, 2: 1 }, { 1: 257, 2: 1 }] }`.
|
||||||
|
expect(decodeInventionUsage('CAESBgil2gEQARIFCIECEAE=')).toEqual([27941, 257])
|
||||||
|
await save('CAESBgil2gEQARIFCIECEAE=')
|
||||||
|
expect(await rows()).toEqual([257, 27941])
|
||||||
|
expect(await countRoomsUsingInvention(env.DB, 27941)).toBe(1)
|
||||||
|
// The invention's own counter is the number of rooms it is in.
|
||||||
|
expect([await usedIn(257), await usedIn(27941)]).toEqual([1, 1])
|
||||||
|
|
||||||
|
// Saving the same inventions again is not a second use.
|
||||||
|
await save('CAESBgil2gEQARIFCIECEAE=')
|
||||||
|
expect([await usedIn(257), await usedIn(27941)]).toEqual([1, 1])
|
||||||
|
|
||||||
|
// An invention taken out of the room drops out, and its counter comes back down.
|
||||||
|
await save('CAESBQiBAhAB')
|
||||||
|
expect(await rows()).toEqual([257])
|
||||||
|
expect(await countRoomsUsingInvention(env.DB, 27941)).toBe(0)
|
||||||
|
expect([await usedIn(257), await usedIn(27941)]).toEqual([1, 0])
|
||||||
|
|
||||||
|
// A value that doesn't decode records nothing and does not fail the save.
|
||||||
|
await save('not base64 !!')
|
||||||
|
expect(await rows()).toEqual([])
|
||||||
|
expect(await usedIn(257)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('a real client room-save body stages, and publish_save makes it live', async () => {
|
it('a real client room-save body stages, and publish_save makes it live', async () => {
|
||||||
const save = async (body: unknown) =>
|
const save = async (body: unknown) =>
|
||||||
SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/data`, {
|
SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/data`, {
|
||||||
|
|||||||
@@ -218,6 +218,17 @@ export const SUBROOM_SCHEMA_DDL: string[] = [
|
|||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
PRIMARY KEY (sub_room_id, permission, role)
|
PRIMARY KEY (sub_room_id, permission, role)
|
||||||
)`,
|
)`,
|
||||||
|
// Which inventions a room has in it (migrations/0023_room_invention.sql): one row per
|
||||||
|
// (room, invention), re-derived on every room save from the `InventionUsage` each of the
|
||||||
|
// room's subrooms last reported — see {@link syncRoomInventions}. The second index is
|
||||||
|
// the reverse lookup, "which rooms use this invention" — what an invention's
|
||||||
|
// `NumPlayersHaveUsedInRoom` is counted from.
|
||||||
|
`CREATE TABLE IF NOT EXISTS room_invention (
|
||||||
|
room_id INTEGER NOT NULL,
|
||||||
|
invention_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (room_id, invention_id)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_room_invention_invention ON room_invention (invention_id)`,
|
||||||
]
|
]
|
||||||
|
|
||||||
/** A stored room — the parsed JSON blob (full client-facing room response). */
|
/** A stored room — the parsed JSON blob (full client-facing room response). */
|
||||||
@@ -1419,6 +1430,152 @@ function legacySubRoomSave(sub: SubRoom): SubRoomDataSave | null {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The invention ids inside a room save's `InventionUsage`, in the order it lists them.
|
||||||
|
*
|
||||||
|
* The value is a base64 PROTOBUF message, recovered by reading real saves — there is no
|
||||||
|
* schema, only field numbers:
|
||||||
|
*
|
||||||
|
* 1: varint — 1 on every save seen; a format version by the look of it
|
||||||
|
* 2: repeated message — one per invention in the scene
|
||||||
|
* 1: varint — the invention id
|
||||||
|
* 2: varint — 1 on every save seen; probably how many times it is placed
|
||||||
|
*
|
||||||
|
* `CAESBgil2gEQARIFCIECEAE=` is `{ 1: 1, 2: [{ 1: 27941, 2: 1 }, { 1: 257, 2: 1 }] }`.
|
||||||
|
*
|
||||||
|
* Only the ids are read. Anything that doesn't parse — not base64, a truncated message, a
|
||||||
|
* wire type this doesn't know — yields the ids found up to that point rather than
|
||||||
|
* throwing: this is bookkeeping riding on a room save, and a format the client changes
|
||||||
|
* must not be able to fail the save.
|
||||||
|
*/
|
||||||
|
export function decodeInventionUsage(inventionUsage: string): number[] {
|
||||||
|
let bytes: Uint8Array
|
||||||
|
try {
|
||||||
|
bytes = Uint8Array.from(atob(inventionUsage), (ch) => ch.charCodeAt(0))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk one message's fields; false from `visit`, or anything malformed, stops the walk. */
|
||||||
|
const walk = (
|
||||||
|
buf: Uint8Array,
|
||||||
|
visit: (field: number, value: number | Uint8Array) => void
|
||||||
|
): void => {
|
||||||
|
let at = 0
|
||||||
|
const varint = (): number | null => {
|
||||||
|
let value = 0
|
||||||
|
for (let shift = 0; at < buf.length; shift += 7) {
|
||||||
|
const byte = buf[at++]
|
||||||
|
// Multiplication, not `<<`: ids past 2^31 would wrap as a 32-bit shift.
|
||||||
|
value += (byte & 0x7f) * 2 ** shift
|
||||||
|
if ((byte & 0x80) === 0) return value
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
while (at < buf.length) {
|
||||||
|
const key = varint()
|
||||||
|
if (key === null) return
|
||||||
|
const field = Math.floor(key / 8)
|
||||||
|
const wireType = key % 8
|
||||||
|
if (wireType === 0) {
|
||||||
|
const value = varint()
|
||||||
|
if (value === null) return
|
||||||
|
visit(field, value)
|
||||||
|
} else if (wireType === 2) {
|
||||||
|
const length = varint()
|
||||||
|
if (length === null || at + length > buf.length) return
|
||||||
|
visit(field, buf.subarray(at, at + length))
|
||||||
|
at += length
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids: number[] = []
|
||||||
|
walk(bytes, (field, entry) => {
|
||||||
|
if (field !== 2 || typeof entry === 'number') return
|
||||||
|
walk(entry, (inner, value) => {
|
||||||
|
if (inner === 1 && typeof value === 'number' && Number.isSafeInteger(value)) ids.push(value)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-derive a room's `room_invention` rows from its subrooms: the union of the inventions
|
||||||
|
* each subroom's last save reported in `InventionUsage`. An invention removed from the room
|
||||||
|
* drops out on the next save — and the set is derived from EVERY subroom rather than the
|
||||||
|
* one just saved, because the table is per room and a save of one subroom says nothing
|
||||||
|
* about what the others still hold.
|
||||||
|
*
|
||||||
|
* A subroom's `InventionUsage` is whatever its latest save sent, staged or published, so
|
||||||
|
* this is the room as its builders last left it, not strictly what players are loading.
|
||||||
|
*
|
||||||
|
* Every invention that entered or left the room then has its `NumPlayersHaveUsedInRoom`
|
||||||
|
* resynced — the number of ROOMS it is in, which is what this server has to put behind
|
||||||
|
* that field. Derived from the table, like `CheerCount` and `NumDownloads` from theirs, and
|
||||||
|
* a `json_set` on the one key of the `invention` record (the `api` worker's table, same
|
||||||
|
* database). Only the rows that changed are written, so the usual save — same inventions
|
||||||
|
* as last time — costs one read.
|
||||||
|
*/
|
||||||
|
export async function syncRoomInventions(
|
||||||
|
db: D1Database,
|
||||||
|
roomId: number,
|
||||||
|
room: Room
|
||||||
|
): Promise<void> {
|
||||||
|
const subRooms = Array.isArray(room.SubRooms) ? (room.SubRooms as SubRoom[]) : []
|
||||||
|
const now = new Set<number>()
|
||||||
|
for (const sub of subRooms) {
|
||||||
|
if (typeof sub.InventionUsage !== 'string') continue
|
||||||
|
for (const id of decodeInventionUsage(sub.InventionUsage)) now.add(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { results } = await db
|
||||||
|
.prepare('SELECT invention_id FROM room_invention WHERE room_id = ?1')
|
||||||
|
.bind(roomId)
|
||||||
|
.all<{ invention_id: number }>()
|
||||||
|
const before = new Set(results.map((r) => r.invention_id))
|
||||||
|
const added = [...now].filter((id) => !before.has(id))
|
||||||
|
const removed = [...before].filter((id) => !now.has(id))
|
||||||
|
if (added.length === 0 && removed.length === 0) return
|
||||||
|
|
||||||
|
// One batch, in order: the membership changes land before the counts are re-derived.
|
||||||
|
await db.batch([
|
||||||
|
...removed.map((id) =>
|
||||||
|
db
|
||||||
|
.prepare('DELETE FROM room_invention WHERE room_id = ?1 AND invention_id = ?2')
|
||||||
|
.bind(roomId, id)
|
||||||
|
),
|
||||||
|
...added.map((id) =>
|
||||||
|
db
|
||||||
|
.prepare('INSERT OR IGNORE INTO room_invention (room_id, invention_id) VALUES (?1, ?2)')
|
||||||
|
.bind(roomId, id)
|
||||||
|
),
|
||||||
|
...[...added, ...removed].map((id) =>
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE invention SET data = json_set(data, '$.NumPlayersHaveUsedInRoom',
|
||||||
|
(SELECT COUNT(*) FROM room_invention WHERE invention_id = ?1))
|
||||||
|
WHERE id = ?1`
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many rooms an invention is used in — the stat `room_invention` exists to answer. */
|
||||||
|
export async function countRoomsUsingInvention(
|
||||||
|
db: D1Database,
|
||||||
|
inventionId: number
|
||||||
|
): Promise<number> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT COUNT(*) AS n FROM room_invention WHERE invention_id = ?1')
|
||||||
|
.bind(inventionId)
|
||||||
|
.first<{ n: number }>()
|
||||||
|
return row?.n ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist a room-save against a specific subroom. Everything the save carries belongs to
|
* Persist a room-save against a specific subroom. Everything the save carries belongs to
|
||||||
* that subroom's revision — nothing is written to the room. Returns the updated
|
* that subroom's revision — nothing is written to the room. Returns the updated
|
||||||
@@ -1521,6 +1678,9 @@ export async function saveSubRoomData(
|
|||||||
|
|
||||||
// Re-hydrate so the returned room reflects the just-saved subroom.
|
// Re-hydrate so the returned room reflects the just-saved subroom.
|
||||||
await attachSubRooms(db, [room])
|
await attachSubRooms(db, [room])
|
||||||
|
// The one thing a save records OUTSIDE the subroom and its save row: which inventions
|
||||||
|
// the room now has in it. Its own table, so the room row is still not rewritten.
|
||||||
|
await syncRoomInventions(db, roomId, room)
|
||||||
return { room, save }
|
return { room, save }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user