From ca8f102ba1bc41c2c619314155eeab87fa1c28db Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Tue, 9 Jun 2026 23:09:08 -0400 Subject: [PATCH 01/10] phase 1 complete --- apps/web/partykit/appApi.ts | 24 ++ apps/web/partykit/gameSession.ts | 166 +++++++- apps/web/partykit/party.ts | 103 ++++- .../lib/realtime/SessionDocClient.svelte.ts | 215 +++++++++++ apps/web/src/lib/realtime/binary.ts | 27 ++ apps/web/src/lib/realtime/buildRenderProps.ts | 80 ++++ apps/web/src/lib/realtime/capabilities.ts | 39 ++ apps/web/src/lib/realtime/docSchema.test.ts | 358 ++++++++++++++++++ apps/web/src/lib/realtime/docSchema.ts | 327 ++++++++++++++++ apps/web/src/lib/realtime/index.ts | 42 ++ apps/web/src/lib/realtime/presence.svelte.ts | 235 ++++++++++++ apps/web/src/lib/realtime/types.ts | 159 ++++++++ apps/web/src/lib/realtime/wire.test.ts | 146 +++++++ apps/web/src/lib/realtime/wire.ts | 71 ++++ apps/web/src/lib/server/realtime/index.ts | 128 +++++++ .../api/internal/partySnapshot/+server.ts | 20 + .../api/internal/persistParty/+server.ts | 26 ++ .../api/internal/persistSession/+server.ts | 40 ++ .../api/internal/sessionSnapshot/+server.ts | 20 + 19 files changed, 2207 insertions(+), 19 deletions(-) create mode 100644 apps/web/partykit/appApi.ts create mode 100644 apps/web/src/lib/realtime/SessionDocClient.svelte.ts create mode 100644 apps/web/src/lib/realtime/binary.ts create mode 100644 apps/web/src/lib/realtime/buildRenderProps.ts create mode 100644 apps/web/src/lib/realtime/capabilities.ts create mode 100644 apps/web/src/lib/realtime/docSchema.test.ts create mode 100644 apps/web/src/lib/realtime/docSchema.ts create mode 100644 apps/web/src/lib/realtime/index.ts create mode 100644 apps/web/src/lib/realtime/presence.svelte.ts create mode 100644 apps/web/src/lib/realtime/types.ts create mode 100644 apps/web/src/lib/realtime/wire.test.ts create mode 100644 apps/web/src/lib/realtime/wire.ts create mode 100644 apps/web/src/lib/server/realtime/index.ts create mode 100644 apps/web/src/routes/api/internal/partySnapshot/+server.ts create mode 100644 apps/web/src/routes/api/internal/persistParty/+server.ts create mode 100644 apps/web/src/routes/api/internal/persistSession/+server.ts create mode 100644 apps/web/src/routes/api/internal/sessionSnapshot/+server.ts diff --git a/apps/web/partykit/appApi.ts b/apps/web/partykit/appApi.ts new file mode 100644 index 000000000..a57164279 --- /dev/null +++ b/apps/web/partykit/appApi.ts @@ -0,0 +1,24 @@ +import type * as Party from 'partykit/server'; + +// Server-to-server bridge from PartyKit rooms to the app's /api/internal endpoints. +// Production requires APP_API_URL and INTERNAL_API_TOKEN set as PartyKit vars; dev +// falls back to the local SvelteKit server and the shared dev token. + +export const appRequest = async (room: Party.Room, path: string, body: unknown): Promise => { + const base = (room.env.APP_API_URL as string | undefined) ?? 'http://localhost:5173'; + const token = (room.env.INTERNAL_API_TOKEN as string | undefined) ?? 'dev-internal-token'; + + const response = await fetch(`${base}${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-token': token + }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + throw new Error(`${path} failed: ${response.status} ${await response.text()}`); + } + return (await response.json()) as T; +}; diff --git a/apps/web/partykit/gameSession.ts b/apps/web/partykit/gameSession.ts index c8d61b10e..b0ce94a62 100644 --- a/apps/web/partykit/gameSession.ts +++ b/apps/web/partykit/gameSession.ts @@ -1,24 +1,168 @@ import type * as Party from 'partykit/server'; -import { onConnect } from 'y-partykit'; +import { onConnect, unstable_getYDoc, type YPartyKitOptions } from 'y-partykit'; +import type * as Y from 'yjs'; +import { classifySceneEvents, getScenesMap, hydrateGameSessionDoc, isDocHydrated } from '../src/lib/realtime/docSchema'; +import type { ScenePart } from '../src/lib/realtime/types'; +import { + hydrationDataFromWire, + sceneWireFromDoc, + type PersistSessionWire, + type SessionSnapshotWire +} from '../src/lib/realtime/wire'; +import { appRequest } from './appApi'; +const ALL_PARTS: ScenePart[] = ['settings', 'markers', 'lights', 'annotations', 'fogMask']; +const HYDRATION_ORIGIN = 'server-hydration'; +const PERSIST_RETRY_MS = 15000; + +/** + * Authoritative home of a game session's live document. The room hydrates its Y doc + * from the database on first use and is the ONLY writer of scene data back to the + * database (debounced, dirty-scenes-only). Clients never save scene state. + * + * Change attribution: client edits arrive as applied updates (transaction.local === + * false) while this server's own hydration runs in a local transaction — so the + * dirty tracker keys off `remote` and hydration never echoes back into a DB write. + */ export default class GameSessionServer implements Party.Server { - constructor(public room: Party.Room) {} + #dirty = new Map>(); + #deletedSceneIds = new Set(); + #observed = false; + #persisting = false; + #options: YPartyKitOptions; + + constructor(public room: Party.Room) { + this.#options = { + persist: { mode: 'snapshot' }, + callback: { + handler: () => this.#persistDirty(), + debounceWait: 2000, + debounceMaxWait: 10000 + } + }; + } + + #markDirty(sceneId: string, parts: Iterable) { + let set = this.#dirty.get(sceneId); + if (!set) { + set = new Set(); + this.#dirty.set(sceneId, set); + } + for (const part of parts) set.add(part); + } + + async #getDoc() { + const doc = await unstable_getYDoc(this.room, this.#options); + + if (!this.#observed) { + this.#observed = true; + getScenesMap(doc).observeDeep((events, transaction) => { + for (const change of classifySceneEvents(events as Y.YEvent>[], transaction)) { + if (!change.remote) continue; + if (change.part === 'scenes') { + for (const sceneId of change.keys) { + if (getScenesMap(doc).has(sceneId)) { + this.#markDirty(sceneId, ALL_PARTS); + } else { + this.#dirty.delete(sceneId); + this.#deletedSceneIds.add(sceneId); + } + } + } else { + this.#markDirty(change.sceneId, [change.part]); + } + } + }); + } + + if (!isDocHydrated(doc)) { + const snapshot = await appRequest(this.room, '/api/internal/sessionSnapshot', { + gameSessionId: this.room.id + }); + // Re-check: another connection may have hydrated while we awaited + if (!isDocHydrated(doc)) { + hydrateGameSessionDoc(doc, hydrationDataFromWire(snapshot), HYDRATION_ORIGIN); + } + } + + return doc; + } + + async #persistDirty() { + if (this.#persisting) return; + if (this.#dirty.size === 0 && this.#deletedSceneIds.size === 0) return; + + this.#persisting = true; + const dirty = this.#dirty; + const deleted = this.#deletedSceneIds; + this.#dirty = new Map(); + this.#deletedSceneIds = new Set(); + + try { + const doc = await unstable_getYDoc(this.room, this.#options); + const payload: PersistSessionWire = { + gameSessionId: this.room.id, + scenes: [...dirty] + .map(([sceneId, parts]) => sceneWireFromDoc(doc, sceneId, [...parts])) + .filter((scene): scene is NonNullable => scene !== null), + deletedSceneIds: [...deleted] + }; + await appRequest(this.room, '/api/internal/persistSession', payload); + } catch (error) { + // Merge back what we drained and retry on an alarm; the doc itself is safe + // in room storage regardless. + for (const [sceneId, parts] of dirty) this.#markDirty(sceneId, parts); + for (const sceneId of deleted) this.#deletedSceneIds.add(sceneId); + console.error(`persistSession failed for ${this.room.id}; retrying in ${PERSIST_RETRY_MS}ms`, error); + await this.room.storage.setAlarm(Date.now() + PERSIST_RETRY_MS); + } finally { + this.#persisting = false; + } + } async onConnect(conn: Party.Connection) { - return await onConnect(conn, this.room, { - // Use snapshot persistence mode - stores latest document state - persist: { mode: 'snapshot' } + await this.#getDoc(); + return onConnect(conn, this.room, this.#options); + } - // PartyKit automatically handles persistence with snapshot mode - // No need to load from database - clients initialize Y.js with fresh data from SSR - // No need to save to database - clients handle saving through saveScene() - }); + async onClose() { + const remaining = [...this.room.getConnections()].length; + if (remaining === 0) { + await this.#persistDirty(); + } + } + + async onAlarm() { + await this.#persistDirty(); } async onRequest(req: Party.Request) { - // Handle ping requests for diagnostics + // Ping endpoint for diagnostics if (req.method === 'POST') { - const body = await req.json(); + const body = (await req.json()) as { type?: string; timestamp?: number }; + + // After the app writes session data to the DB directly (import, admin tools), + // it calls this to rebuild the live doc from the database. + if (body.type === 'resync') { + const token = (this.room.env.INTERNAL_API_TOKEN as string | undefined) ?? 'dev-internal-token'; + if (req.headers.get('x-internal-token') !== token) { + return new Response('Unauthorized', { status: 401 }); + } + const doc = await unstable_getYDoc(this.room, this.#options); + const snapshot = await appRequest(this.room, '/api/internal/sessionSnapshot', { + gameSessionId: this.room.id + }); + doc.transact(() => { + const scenes = getScenesMap(doc); + for (const sceneId of [...scenes.keys()]) scenes.delete(sceneId); + }, HYDRATION_ORIGIN); + hydrateGameSessionDoc(doc, hydrationDataFromWire(snapshot), HYDRATION_ORIGIN); + // The resynced doc now matches the DB; drop any stale dirty state + this.#dirty.clear(); + this.#deletedSceneIds.clear(); + return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } }); + } + if (body.type === 'ping') { return new Response( JSON.stringify({ diff --git a/apps/web/partykit/party.ts b/apps/web/partykit/party.ts index ad69bcbc5..af0558835 100644 --- a/apps/web/partykit/party.ts +++ b/apps/web/partykit/party.ts @@ -1,16 +1,103 @@ import type * as Party from 'partykit/server'; -import { onConnect } from 'y-partykit'; +import { onConnect, unstable_getYDoc, type YPartyKitOptions } from 'y-partykit'; +import { + DOC_SCHEMA_VERSION, + getMeta, + getPartyState, + getPartyStateMap, + isDocHydrated +} from '../src/lib/realtime/docSchema'; +import type { PartyStateWire } from '../src/lib/realtime/wire'; +import { appRequest } from './appApi'; +const HYDRATION_ORIGIN = 'server-hydration'; +const PERSIST_RETRY_MS = 15000; + +/** + * Party-wide live state (active scene, paused). Same ownership model as the game + * session server: hydrate from the DB on first use, persist client changes back on + * a debounce, never echo hydration into a write. + */ export default class PartyServer implements Party.Server { - constructor(public room: Party.Room) {} + #dirty = false; + #observed = false; + #persisting = false; + #options: YPartyKitOptions; + + constructor(public room: Party.Room) { + this.#options = { + persist: { mode: 'snapshot' }, + callback: { + handler: () => this.#persistState(), + debounceWait: 1000, + debounceMaxWait: 5000 + } + }; + } + + async #getDoc() { + const doc = await unstable_getYDoc(this.room, this.#options); + + if (!this.#observed) { + this.#observed = true; + getPartyStateMap(doc).observe((_event, transaction) => { + if (!transaction.local) this.#dirty = true; + }); + } + + if (!isDocHydrated(doc)) { + const state = await appRequest(this.room, '/api/internal/partySnapshot', { + partyId: this.room.id + }); + if (!isDocHydrated(doc)) { + doc.transact(() => { + const map = getPartyStateMap(doc); + if (!map.has('activeSceneId')) map.set('activeSceneId', state.activeSceneId); + if (!map.has('isPaused')) map.set('isPaused', state.isPaused); + getMeta(doc).set('schemaVersion', DOC_SCHEMA_VERSION); + getMeta(doc).set('hydratedAt', Date.now()); + }, HYDRATION_ORIGIN); + } + } + + return doc; + } + + async #persistState() { + if (this.#persisting || !this.#dirty) return; + + this.#persisting = true; + this.#dirty = false; + try { + const doc = await unstable_getYDoc(this.room, this.#options); + const state = getPartyState(doc); + await appRequest(this.room, '/api/internal/persistParty', { + partyId: this.room.id, + activeSceneId: state.activeSceneId, + isPaused: state.isPaused + }); + } catch (error) { + this.#dirty = true; + console.error(`persistParty failed for ${this.room.id}; retrying in ${PERSIST_RETRY_MS}ms`, error); + await this.room.storage.setAlarm(Date.now() + PERSIST_RETRY_MS); + } finally { + this.#persisting = false; + } + } async onConnect(conn: Party.Connection) { - return await onConnect(conn, this.room, { - // Use snapshot persistence for party state - persist: { mode: 'snapshot' } + await this.#getDoc(); + return onConnect(conn, this.room, this.#options); + } + + async onClose() { + const remaining = [...this.room.getConnections()].length; + if (remaining === 0) { + await this.#persistState(); + } + } - // Party state is simpler - just pause state and active scene - // No custom load/save needed as PartyKit handles persistence - }); + async onAlarm() { + await this.#persistState(); } } diff --git a/apps/web/src/lib/realtime/SessionDocClient.svelte.ts b/apps/web/src/lib/realtime/SessionDocClient.svelte.ts new file mode 100644 index 000000000..a14b8b222 --- /dev/null +++ b/apps/web/src/lib/realtime/SessionDocClient.svelte.ts @@ -0,0 +1,215 @@ +import YPartyKitProvider from 'y-partykit/provider'; +import * as Y from 'yjs'; +import { + classifySceneEvents, + createPartyWriter, + createSessionWriter, + getAnnotationMask, + getFogMask, + getPartyState, + getSceneSnapshot, + isDocHydrated, + listScenes, + type PartyWriter, + type SessionWriter +} from './docSchema'; +import { PresenceChannel } from './presence.svelte'; +import type { PartyState, SceneChange, SceneListEntry, SceneSnapshot } from './types'; + +export interface SessionDocClientOptions { + partykitHost: string; + partyId: string; + gameSessionId: string; + userId: string; +} + +export type ConnectionState = 'connecting' | 'connected' | 'disconnected'; + +/** + * The single client-side entry point to a game session's shared state. + * + * Owns the two Y docs (game session + party), their PartyKit providers, and the + * presence channel. Exposes shared state through reactive snapshot reads — pages + * derive render props from `scene()`/`scenes()`/`partyState()` and never touch Y + * types. All writes go through `write`/`party`, tagged with this client's origin, + * so observers can tell local from remote changes exactly. + * + * There is no client-side persistence here by design: the PartyKit server is the + * only writer to the database. + */ +export class SessionDocClient { + readonly doc = new Y.Doc(); + readonly partyDoc = new Y.Doc(); + /** Identity tag for this client's transactions. */ + readonly origin: object = { client: 'SessionDocClient' }; + + readonly write: SessionWriter; + readonly party: PartyWriter; + readonly presence: PresenceChannel; + readonly gameSessionId: string; + readonly partyId: string; + readonly userId: string; + + status = $state<{ gameSession: ConnectionState; party: ConnectionState }>({ + gameSession: 'connecting', + party: 'connecting' + }); + /** True once both rooms have synced and the server has hydrated the session doc. */ + ready = $state(false); + + #sceneRevs = $state>({}); + #listRev = $state(0); + #partyRev = $state(0); + + #snapshotCache = new Map(); + #listCache: { rev: number; list: SceneListEntry[] } | null = null; + #partyCache: { rev: number; state: PartyState } | null = null; + #changeListeners = new Set<(changes: SceneChange[]) => void>(); + + #gameSessionProvider: YPartyKitProvider; + #partyProvider: YPartyKitProvider; + #gameSessionSynced = false; + #partySynced = false; + #readyResolvers: Array<() => void> = []; + + constructor(options: SessionDocClientOptions) { + this.partyId = options.partyId; + this.gameSessionId = options.gameSessionId; + this.userId = options.userId; + + this.write = createSessionWriter(this.doc, this.origin); + this.party = createPartyWriter(this.partyDoc, this.origin); + + this.#gameSessionProvider = new YPartyKitProvider(options.partykitHost, options.gameSessionId, this.doc, { + party: 'game_session', + params: { userId: options.userId } + }); + this.#partyProvider = new YPartyKitProvider(options.partykitHost, options.partyId, this.partyDoc, { + party: 'party', + params: { userId: options.userId } + }); + + this.presence = new PresenceChannel(this.#gameSessionProvider.awareness, options.userId); + + this.#gameSessionProvider.on('status', (event: { status: string }) => { + this.status.gameSession = event.status === 'connected' ? 'connected' : 'connecting'; + }); + this.#partyProvider.on('status', (event: { status: string }) => { + this.status.party = event.status === 'connected' ? 'connected' : 'connecting'; + }); + this.#gameSessionProvider.on('sync', (synced: boolean) => { + this.#gameSessionSynced = synced; + this.#checkReady(); + }); + this.#partyProvider.on('sync', (synced: boolean) => { + this.#partySynced = synced; + this.#checkReady(); + }); + + this.doc.getMap('scenes').observeDeep((events, transaction) => { + const changes = classifySceneEvents(events as Y.YEvent>[], transaction); + this.#applyChanges(changes); + }); + // Hydration arrives as a meta update; re-check readiness when it lands. + this.doc.getMap('meta').observe(() => this.#checkReady()); + this.partyDoc.getMap('meta').observe(() => this.#checkReady()); + this.partyDoc.getMap('state').observe(() => { + this.#partyRev++; + }); + } + + #applyChanges(changes: SceneChange[]) { + for (const change of changes) { + if (change.part === 'scenes') { + this.#listRev++; + for (const sceneId of change.keys) { + this.#sceneRevs[sceneId] = (this.#sceneRevs[sceneId] ?? 0) + 1; + } + } else { + this.#sceneRevs[change.sceneId] = (this.#sceneRevs[change.sceneId] ?? 0) + 1; + // The scene list mirrors a few settings fields (name, order, thumbnails) + if (change.part === 'settings') this.#listRev++; + } + } + if (changes.length > 0) { + this.#changeListeners.forEach((listener) => listener(changes)); + } + } + + #checkReady() { + const ready = + this.#gameSessionSynced && this.#partySynced && isDocHydrated(this.doc) && isDocHydrated(this.partyDoc); + if (ready && !this.ready) { + this.ready = true; + this.#readyResolvers.forEach((resolve) => resolve()); + this.#readyResolvers = []; + } + } + + /** Resolves once both rooms are synced and the session doc is hydrated. */ + whenReady(): Promise { + if (this.ready) return Promise.resolve(); + return new Promise((resolve) => this.#readyResolvers.push(resolve)); + } + + // ------------------------------------------------------------------------- + // Reactive reads — establish a rune dependency, then serve memoized snapshots + // ------------------------------------------------------------------------- + + scenes(): SceneListEntry[] { + const rev = this.#listRev; + if (this.#listCache?.rev !== rev) { + this.#listCache = { rev, list: listScenes(this.doc) }; + } + return this.#listCache.list; + } + + scene(sceneId: string): SceneSnapshot | null { + const rev = this.#sceneRevs[sceneId] ?? 0; + const cached = this.#snapshotCache.get(sceneId); + if (cached?.rev === rev) return cached.snapshot; + + const snapshot = getSceneSnapshot(this.doc, sceneId); + if (snapshot) this.#snapshotCache.set(sceneId, { rev, snapshot }); + else this.#snapshotCache.delete(sceneId); + return snapshot; + } + + partyState(): PartyState { + const rev = this.#partyRev; + if (this.#partyCache?.rev !== rev) { + this.#partyCache = { rev, state: getPartyState(this.partyDoc) }; + } + return this.#partyCache.state; + } + + // ------------------------------------------------------------------------- + // Imperative reads + change subscription (for canvas mask application etc.) + // ------------------------------------------------------------------------- + + fogMask(sceneId: string): Uint8Array | null { + return getFogMask(this.doc, sceneId); + } + + annotationMask(sceneId: string, annotationId: string): Uint8Array | null { + return getAnnotationMask(this.doc, sceneId, annotationId); + } + + /** + * Subscribe to classified doc changes. `change.remote` is exact (transaction + * identity, not timing) — use it to skip re-applying your own mask commits. + */ + onChanges(listener: (changes: SceneChange[]) => void): () => void { + this.#changeListeners.add(listener); + return () => this.#changeListeners.delete(listener); + } + + destroy() { + this.presence.destroy(); + this.#changeListeners.clear(); + this.#gameSessionProvider.destroy(); + this.#partyProvider.destroy(); + this.doc.destroy(); + this.partyDoc.destroy(); + } +} diff --git a/apps/web/src/lib/realtime/binary.ts b/apps/web/src/lib/realtime/binary.ts new file mode 100644 index 000000000..b8085a5e7 --- /dev/null +++ b/apps/web/src/lib/realtime/binary.ts @@ -0,0 +1,27 @@ +// Base64 <-> Uint8Array helpers that work in the browser, Cloudflare workers +// (PartyKit), and node (vitest). RLE masks cross JSON boundaries as base64 during +// hydration/persistence but live in the doc as raw Uint8Array. + +export const uint8ToBase64 = (bytes: Uint8Array): string => { + if (typeof Buffer !== 'undefined') { + return Buffer.from(bytes).toString('base64'); + } + let binary = ''; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); + } + return btoa(binary); +}; + +export const base64ToUint8 = (base64: string): Uint8Array => { + if (typeof Buffer !== 'undefined') { + return new Uint8Array(Buffer.from(base64, 'base64')); + } + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +}; diff --git a/apps/web/src/lib/realtime/buildRenderProps.ts b/apps/web/src/lib/realtime/buildRenderProps.ts new file mode 100644 index 000000000..6de56bf47 --- /dev/null +++ b/apps/web/src/lib/realtime/buildRenderProps.ts @@ -0,0 +1,80 @@ +import type { SelectAnnotation, SelectLight, SelectMarker, SelectScene } from '$lib/db/app/schema'; +import { buildSceneProps } from '$lib/utils/buildSceneProps'; +import type { DrawMode, MapLayerType, StageProps, ToolType } from '@tableslayer/stage'; +import type { SceneSnapshot } from './types'; + +/** + * Local-only view state layered over the shared doc snapshot. Nothing here is + * synced or persisted with the scene — it lives in plain component $state and + * its overrides win for exactly as long as the page keeps them set (e.g. for + * the duration of a drag gesture), replacing the old timing-window guards. + */ +export interface LocalView { + mode: 'editor' | 'client'; + activeLayer?: MapLayerType; + /** Editor workspace camera; never shared. */ + viewport?: { + offset?: { x: number; y: number }; + zoom?: number; + rotation?: number; + }; + /** In-progress map alignment drag (committed to the doc on gesture end). */ + mapTransform?: { + offset?: { x: number; y: number }; + zoom?: number; + }; + /** In-progress marker drags by id (committed to the doc on drop). */ + markerPositions?: Record; + /** Local tool configuration; brush size/line width are per-user preferences. */ + fogTool?: { type?: ToolType; size?: number; mode?: DrawMode }; + annotations?: { + activeLayer?: string | null; + lineWidth?: number; + smoothingEnabled?: boolean; + }; +} + +/** + * Pure derivation: shared snapshot + local view -> StageProps. The result is a + * fresh object each call; interaction callbacks must write to the doc or to the + * local view, never back into the returned props. + */ +export const buildRenderProps = (snapshot: SceneSnapshot, view: LocalView, bucketUrl?: string): StageProps => { + // Doc rows structurally match the drizzle Select* row types (see realtime/types.ts) + const props = buildSceneProps( + snapshot.settings as unknown as SelectScene, + snapshot.markers as unknown as SelectMarker[], + view.mode, + snapshot.annotations as unknown as SelectAnnotation[], + snapshot.lights as unknown as SelectLight[], + bucketUrl + ); + + if (view.activeLayer !== undefined) props.activeLayer = view.activeLayer; + + if (view.viewport?.offset) props.scene.offset = view.viewport.offset; + if (view.viewport?.zoom !== undefined) props.scene.zoom = view.viewport.zoom; + if (view.viewport?.rotation !== undefined) props.scene.rotation = view.viewport.rotation; + + if (view.mapTransform?.offset) props.map.offset = view.mapTransform.offset; + if (view.mapTransform?.zoom !== undefined) props.map.zoom = view.mapTransform.zoom; + + if (view.markerPositions) { + for (const marker of props.marker.markers) { + const override = view.markerPositions[marker.id]; + if (override) marker.position = override; + } + } + + if (view.fogTool?.type !== undefined) props.fogOfWar.tool.type = view.fogTool.type; + if (view.fogTool?.size !== undefined) props.fogOfWar.tool.size = view.fogTool.size; + if (view.fogTool?.mode !== undefined) props.fogOfWar.tool.mode = view.fogTool.mode; + + if (view.annotations?.activeLayer !== undefined) props.annotations.activeLayer = view.annotations.activeLayer; + if (view.annotations?.lineWidth !== undefined) props.annotations.lineWidth = view.annotations.lineWidth; + if (view.annotations?.smoothingEnabled !== undefined) { + props.annotations.smoothingEnabled = view.annotations.smoothingEnabled; + } + + return props; +}; diff --git a/apps/web/src/lib/realtime/capabilities.ts b/apps/web/src/lib/realtime/capabilities.ts new file mode 100644 index 000000000..2c174a4db --- /dev/null +++ b/apps/web/src/lib/realtime/capabilities.ts @@ -0,0 +1,39 @@ +// Editor and play are peers on the same shared doc; capabilities decide which +// tools each surface renders. This is UI gating only — room membership is the +// security boundary (see spec/realtime-sync-v2.md "Decided"). + +export interface SessionCapabilities { + canEditScene: boolean; + canManageScenes: boolean; + canSwitchScene: boolean; + canEditFog: boolean; + canMoveMarkers: boolean; + canMeasure: boolean; + canDrawTemporary: boolean; + canPersistDrawings: boolean; + emitsCursor: boolean; +} + +export const editorCapabilities: SessionCapabilities = { + canEditScene: true, + canManageScenes: true, + canSwitchScene: true, + canEditFog: true, + canMoveMarkers: true, + canMeasure: true, + canDrawTemporary: true, + canPersistDrawings: true, + emitsCursor: true +}; + +export const playCapabilities = (options: { isTouchDevice: boolean }): SessionCapabilities => ({ + canEditScene: false, + canManageScenes: false, + canSwitchScene: options.isTouchDevice, + canEditFog: options.isTouchDevice, + canMoveMarkers: true, + canMeasure: options.isTouchDevice, + canDrawTemporary: true, + canPersistDrawings: options.isTouchDevice, + emitsCursor: false +}); diff --git a/apps/web/src/lib/realtime/docSchema.test.ts b/apps/web/src/lib/realtime/docSchema.test.ts new file mode 100644 index 000000000..6297bfe05 --- /dev/null +++ b/apps/web/src/lib/realtime/docSchema.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, it } from 'vitest'; +import * as Y from 'yjs'; +import { base64ToUint8, uint8ToBase64 } from './binary'; +import { + classifySceneEvents, + createPartyWriter, + createSessionWriter, + getAnnotationMask, + getFogMask, + getPartyState, + getSceneSnapshot, + hydrateGameSessionDoc, + isDocHydrated, + listScenes, + orderBetween +} from './docSchema'; +import type { AnnotationRow, LightRow, MarkerRow, SceneChange, SceneSettings } from './types'; + +const makeSettings = (id: string, overrides: Partial = {}): SceneSettings => ({ + id, + gameSessionId: 'gs1', + name: `Scene ${id}`, + order: 1, + backgroundColor: '#0b0b0c', + displayPaddingX: 16, + displayPaddingY: 16, + displaySizeX: 34.86, + displaySizeY: 19.6, + displayResolutionX: 1920, + displayResolutionY: 1080, + fogOfWarUrl: null, + fogOfWarColor: '#CCC', + fogOfWarOpacityDm: 0.5, + fogOfWarOpacityPlayer: 0.9, + mapLocation: null, + mapThumbLocation: null, + mapRotation: 0, + mapOffsetX: 0, + mapOffsetY: 0, + mapZoom: 1, + gridType: 0, + gridMode: 0, + gridMapDefinedX: null, + gridMapDefinedY: null, + gridSpacing: 50, + gridOpacity: 0.8, + gridLineColor: '#E6E6E6', + gridLineThickness: 1, + gridShadowColor: '#000000', + gridShadowSpread: 2, + gridShadowBlur: 0.5, + gridShadowOpacity: 0.4, + sceneOffsetX: 0, + sceneOffsetY: 0, + sceneRotation: 0, + weatherFov: 60, + weatherIntensity: 1, + weatherOpacity: 1, + weatherType: 0, + fogEnabled: false, + fogColor: '#a0a0a0', + fogOpacity: 0.8, + edgeEnabled: false, + edgeUrl: null, + edgeOpacity: 0.3, + edgeScale: 2, + edgeFadeStart: 0.2, + edgeFadeEnd: 1, + effectsEnabled: true, + effectsBloomIntensity: 0, + effectsBloomThreshold: 0.5, + effectsBloomSmoothing: 0.3, + effectsBloomRadius: 0.5, + effectsBloomLevels: 10, + effectsBloomMipMapBlur: true, + effectsChromaticAberrationOffset: 0, + effectsLutUrl: null, + effectsToneMappingMode: 0, + markerStrokeColor: '#000000', + markerStrokeWidth: 50, + markerTextColor: '#ffffff', + markerTextStrokeColor: '#000000', + ...overrides +}); + +const makeMarker = (id: string, sceneId: string, overrides: Partial = {}): MarkerRow => ({ + id, + sceneId, + visibility: 1, + title: 'New token', + label: null, + imageLocation: null, + imageScale: 1, + positionX: 0, + positionY: 0, + shape: 1, + shapeColor: '#ffffff', + size: 1, + note: null, + pinnedTooltip: false, + ...overrides +}); + +const makeLight = (id: string, sceneId: string): LightRow => ({ + id, + sceneId, + positionX: 5, + positionY: 5, + radius: 2, + color: '#FFA500', + style: 'lantern', + pulse: 0, + opacity: 1 +}); + +const makeAnnotation = (id: string, sceneId: string, order = 0): AnnotationRow => ({ + id, + sceneId, + name: 'New Annotation', + opacity: 1, + color: '#FF0000', + url: null, + visibility: 1, + order, + effectType: null +}); + +/** Live bidirectional relay between two docs (simulates the PartyKit room). */ +const connect = (a: Y.Doc, b: Y.Doc) => { + const relayAB = (update: Uint8Array, origin: unknown) => { + if (origin !== 'relay') Y.applyUpdate(b, update, 'relay'); + }; + const relayBA = (update: Uint8Array, origin: unknown) => { + if (origin !== 'relay') Y.applyUpdate(a, update, 'relay'); + }; + a.on('update', relayAB); + b.on('update', relayBA); + return () => { + a.off('update', relayAB); + b.off('update', relayBA); + }; +}; + +/** One-shot state exchange (simulates reconnect after offline edits). */ +const syncOnce = (a: Y.Doc, b: Y.Doc) => { + const updateA = Y.encodeStateAsUpdate(a, Y.encodeStateVector(b)); + const updateB = Y.encodeStateAsUpdate(b, Y.encodeStateVector(a)); + Y.applyUpdate(b, updateA, 'relay'); + Y.applyUpdate(a, updateB, 'relay'); +}; + +const hydratedDoc = () => { + const doc = new Y.Doc(); + hydrateGameSessionDoc( + doc, + { + scenes: [ + { + settings: makeSettings('s1', { order: 1 }), + markers: [makeMarker('m1', 's1'), makeMarker('m2', 's1', { positionX: 10 })], + lights: [makeLight('l1', 's1')], + annotations: [ + { ...makeAnnotation('a2', 's1', 2), mask: new Uint8Array([9, 9]) }, + makeAnnotation('a1', 's1', 1) + ], + fogMask: new Uint8Array([1, 2, 3, 4]) + }, + { + settings: makeSettings('s2', { order: 2 }), + markers: [], + lights: [], + annotations: [] + } + ] + }, + 'hydration' + ); + return doc; +}; + +describe('docSchema hydration and reads', () => { + it('round-trips a hydrated session into snapshots', () => { + const doc = hydratedDoc(); + + expect(isDocHydrated(doc)).toBe(true); + expect(listScenes(doc).map((s) => s.id)).toEqual(['s1', 's2']); + + const snap = getSceneSnapshot(doc, 's1'); + expect(snap?.settings.name).toBe('Scene s1'); + expect(snap?.markers).toHaveLength(2); + expect(snap?.markers.find((m) => m.id === 'm2')?.positionX).toBe(10); + expect(snap?.lights[0]?.style).toBe('lantern'); + // Annotations sorted by order, mask excluded from rows + expect(snap?.annotations.map((a) => a.id)).toEqual(['a1', 'a2']); + expect(snap?.annotations.find((a) => a.id === 'a2')).not.toHaveProperty('mask'); + + expect(getFogMask(doc, 's1')).toEqual(new Uint8Array([1, 2, 3, 4])); + expect(getAnnotationMask(doc, 's1', 'a2')).toEqual(new Uint8Array([9, 9])); + expect(getSceneSnapshot(doc, 'missing')).toBeNull(); + }); +}); + +describe('concurrent edits', () => { + it('merges edits to different settings fields from two offline clients', () => { + const a = hydratedDoc(); + const b = new Y.Doc(); + syncOnce(a, b); + + createSessionWriter(a, 'clientA').setSceneSettings('s1', { gridOpacity: 0.25 }); + createSessionWriter(b, 'clientB').setSceneSettings('s1', { name: 'Renamed' }); + syncOnce(a, b); + + for (const doc of [a, b]) { + const settings = getSceneSnapshot(doc, 's1')!.settings; + expect(settings.gridOpacity).toBe(0.25); + expect(settings.name).toBe('Renamed'); + } + }); + + it('merges concurrent drags of different markers', () => { + const a = hydratedDoc(); + const b = new Y.Doc(); + syncOnce(a, b); + + createSessionWriter(a, 'clientA').setMarkerFields('s1', 'm1', { positionX: 111, positionY: 11 }); + createSessionWriter(b, 'clientB').setMarkerFields('s1', 'm2', { positionX: 222, positionY: 22 }); + syncOnce(a, b); + + for (const doc of [a, b]) { + const markers = getSceneSnapshot(doc, 's1')!.markers; + expect(markers.find((m) => m.id === 'm1')?.positionX).toBe(111); + expect(markers.find((m) => m.id === 'm2')?.positionX).toBe(222); + } + }); + + it('converges to the same winner when both clients edit the same field', () => { + const a = hydratedDoc(); + const b = new Y.Doc(); + syncOnce(a, b); + + createSessionWriter(a, 'clientA').setSceneSettings('s1', { mapZoom: 2 }); + createSessionWriter(b, 'clientB').setSceneSettings('s1', { mapZoom: 3 }); + syncOnce(a, b); + + const zoomA = getSceneSnapshot(a, 's1')!.settings.mapZoom; + const zoomB = getSceneSnapshot(b, 's1')!.settings.mapZoom; + expect(zoomA).toBe(zoomB); + expect([2, 3]).toContain(zoomA); + }); + + it('syncs marker add/delete and fog mask binary over a live relay', () => { + const a = hydratedDoc(); + const b = new Y.Doc(); + syncOnce(a, b); + const disconnect = connect(a, b); + + const writerA = createSessionWriter(a, 'clientA'); + writerA.upsertMarker('s1', makeMarker('m3', 's1', { title: 'Dragon' })); + writerA.deleteMarker('s1', 'm1'); + writerA.setFogMask('s1', new Uint8Array([7, 7, 7])); + + const markersB = getSceneSnapshot(b, 's1')!.markers.map((m) => m.id); + expect(markersB).toContain('m3'); + expect(markersB).not.toContain('m1'); + expect(getFogMask(b, 's1')).toEqual(new Uint8Array([7, 7, 7])); + disconnect(); + }); + + it('creates and deletes scenes across clients', () => { + const a = hydratedDoc(); + const b = new Y.Doc(); + syncOnce(a, b); + const disconnect = connect(a, b); + + createSessionWriter(a, 'clientA').createScene(makeSettings('s3', { order: orderBetween(2, null) })); + createSessionWriter(b, 'clientB').deleteScene('s2'); + + expect(listScenes(a).map((s) => s.id)).toEqual(['s1', 's3']); + expect(listScenes(b).map((s) => s.id)).toEqual(['s1', 's3']); + disconnect(); + }); +}); + +describe('classifySceneEvents', () => { + const collect = (doc: Y.Doc) => { + const changes: SceneChange[] = []; + doc.getMap('scenes').observeDeep((events, txn) => { + changes.push(...classifySceneEvents(events as Y.YEvent>[], txn)); + }); + return changes; + }; + + it('classifies local settings/marker/fog changes with remote=false', () => { + const doc = hydratedDoc(); + const changes = collect(doc); + const writer = createSessionWriter(doc, 'me'); + + writer.setSceneSettings('s1', { gridOpacity: 0.1 }); + writer.setMarkerFields('s1', 'm1', { positionX: 5 }); + writer.setFogMask('s1', new Uint8Array([1])); + + expect(changes).toEqual([ + { sceneId: 's1', part: 'settings', keys: ['gridOpacity'], childId: undefined, remote: false }, + { sceneId: 's1', part: 'markers', keys: ['positionX'], childId: 'm1', remote: false }, + { sceneId: 's1', part: 'fogMask', keys: ['fogMask'], remote: false } + ]); + }); + + it('flags changes applied from another client as remote', () => { + const a = hydratedDoc(); + const b = new Y.Doc(); + syncOnce(a, b); + const disconnect = connect(a, b); + const changesB = collect(b); + + createSessionWriter(a, 'clientA').setAnnotationMask('s1', 'a1', new Uint8Array([5])); + + expect(changesB).toEqual([{ sceneId: 's1', part: 'annotations', keys: ['mask'], childId: 'a1', remote: true }]); + disconnect(); + }); + + it('does not emit events for writes that do not change values', () => { + const doc = hydratedDoc(); + const changes = collect(doc); + + createSessionWriter(doc, 'me').setSceneSettings('s1', { gridOpacity: 0.8, name: 'Scene s1' }); + + expect(changes).toEqual([]); + }); +}); + +describe('party doc', () => { + it('reads and writes party state', () => { + const doc = new Y.Doc(); + expect(getPartyState(doc)).toEqual({ activeSceneId: null, isPaused: false }); + + const writer = createPartyWriter(doc, 'me'); + writer.setActiveScene('s2'); + writer.setPaused(true); + expect(getPartyState(doc)).toEqual({ activeSceneId: 's2', isPaused: true }); + }); +}); + +describe('helpers', () => { + it('orderBetween produces sortable values', () => { + expect(orderBetween(null, null)).toBe(1); + expect(orderBetween(2, null)).toBe(3); + expect(orderBetween(null, 1)).toBe(0); + const mid = orderBetween(1, 2); + expect(mid).toBeGreaterThan(1); + expect(mid).toBeLessThan(2); + }); + + it('base64 round-trips binary masks', () => { + const bytes = new Uint8Array(70000).map((_, i) => i % 256); + expect(base64ToUint8(uint8ToBase64(bytes))).toEqual(bytes); + }); +}); diff --git a/apps/web/src/lib/realtime/docSchema.ts b/apps/web/src/lib/realtime/docSchema.ts new file mode 100644 index 000000000..cf1b981a5 --- /dev/null +++ b/apps/web/src/lib/realtime/docSchema.ts @@ -0,0 +1,327 @@ +import * as Y from 'yjs'; +import type { + AnnotationRow, + GameSessionHydrationData, + LightRow, + MarkerRow, + PartyState, + SceneChange, + SceneListEntry, + ScenePart, + SceneSettings, + SceneSnapshot +} from './types'; + +// Granular Y.js layout for a game session. Every shared value is an individual +// Y.Map key so concurrent edits merge per field (LWW) instead of clobbering whole +// objects. This module is dependency-light on purpose: it is shared between the +// SvelteKit app and the PartyKit server bundle. +// +// gameSession doc +// meta: Y.Map { schemaVersion, hydratedAt } +// scenes: Y.Map +// settings: Y.Map +// markers: Y.Map> +// lights: Y.Map> +// annotations: Y.Map> +// fogMask: Uint8Array (RLE) +// party doc +// state: Y.Map { activeSceneId, isPaused } + +export const DOC_SCHEMA_VERSION = 1; +export const ANNOTATION_MASK_KEY = 'mask'; + +type SceneMap = Y.Map; + +export const getMeta = (doc: Y.Doc) => doc.getMap('meta'); +export const getScenesMap = (doc: Y.Doc) => doc.getMap('scenes'); +export const getPartyStateMap = (doc: Y.Doc) => doc.getMap('state'); + +export const isDocHydrated = (doc: Y.Doc): boolean => getMeta(doc).get('schemaVersion') === DOC_SCHEMA_VERSION; + +const yMapToObject = (map: Y.Map, omit?: string): T => { + const obj: Record = {}; + map.forEach((value, key) => { + if (key !== omit) obj[key] = value; + }); + return obj as T; +}; + +const setChangedFields = (map: Y.Map, fields: Record) => { + for (const [key, value] of Object.entries(fields)) { + if (value !== undefined && map.get(key) !== value) { + map.set(key, value); + } + } +}; + +const rowsOf = ( + scene: SceneMap | undefined, + collection: 'markers' | 'lights' | 'annotations', + omit?: string +): T[] => { + const rows: T[] = []; + const map = scene?.get(collection) as Y.Map> | undefined; + map?.forEach((row) => rows.push(yMapToObject(row, omit))); + return rows; +}; + +// --------------------------------------------------------------------------- +// Reads +// --------------------------------------------------------------------------- + +export const getSceneSettings = (doc: Y.Doc, sceneId: string): SceneSettings | null => { + const settings = getScenesMap(doc).get(sceneId)?.get('settings') as Y.Map | undefined; + return settings ? yMapToObject(settings) : null; +}; + +export const getSceneSnapshot = (doc: Y.Doc, sceneId: string): SceneSnapshot | null => { + const scene = getScenesMap(doc).get(sceneId); + const settings = scene?.get('settings') as Y.Map | undefined; + if (!scene || !settings) return null; + + return { + id: sceneId, + settings: yMapToObject(settings), + markers: rowsOf(scene, 'markers'), + lights: rowsOf(scene, 'lights'), + annotations: rowsOf(scene, 'annotations', ANNOTATION_MASK_KEY).sort((a, b) => a.order - b.order) + }; +}; + +export const listScenes = (doc: Y.Doc): SceneListEntry[] => { + const entries: SceneListEntry[] = []; + getScenesMap(doc).forEach((scene, id) => { + const settings = scene.get('settings') as Y.Map | undefined; + if (!settings) return; + entries.push({ + id, + name: settings.get('name') as string, + order: settings.get('order') as number, + gameSessionId: settings.get('gameSessionId') as string, + mapLocation: (settings.get('mapLocation') as string | null) ?? null, + mapThumbLocation: (settings.get('mapThumbLocation') as string | null) ?? null + }); + }); + return entries.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id)); +}; + +export const getFogMask = (doc: Y.Doc, sceneId: string): Uint8Array | null => + (getScenesMap(doc).get(sceneId)?.get('fogMask') as Uint8Array | undefined) ?? null; + +export const getAnnotationMask = (doc: Y.Doc, sceneId: string, annotationId: string): Uint8Array | null => { + const annotations = getScenesMap(doc).get(sceneId)?.get('annotations') as Y.Map> | undefined; + return (annotations?.get(annotationId)?.get(ANNOTATION_MASK_KEY) as Uint8Array | undefined) ?? null; +}; + +export const getPartyState = (doc: Y.Doc): PartyState => { + const state = getPartyStateMap(doc); + return { + activeSceneId: (state.get('activeSceneId') as string | null) ?? null, + isPaused: (state.get('isPaused') as boolean) ?? false + }; +}; + +// --------------------------------------------------------------------------- +// Scene ordering (fractional indexing; the server normalizes to integers on persist) +// --------------------------------------------------------------------------- + +export const orderBetween = (prev: number | null, next: number | null): number => { + if (prev === null && next === null) return 1; + if (prev === null) return next! - 1; + if (next === null) return prev + 1; + return (prev + next) / 2; +}; + +// --------------------------------------------------------------------------- +// Writes — bound to a doc + transaction origin so every local mutation is tagged +// --------------------------------------------------------------------------- + +export const createSessionWriter = (doc: Y.Doc, origin: unknown) => { + const transact = (fn: () => void) => doc.transact(fn, origin); + const scenes = () => getScenesMap(doc); + + const collectionOf = (sceneId: string, collection: 'markers' | 'lights' | 'annotations') => + scenes().get(sceneId)?.get(collection) as Y.Map> | undefined; + + const upsertRow = ( + sceneId: string, + collection: 'markers' | 'lights' | 'annotations', + id: string, + fields: Record + ) => { + transact(() => { + const map = collectionOf(sceneId, collection); + if (!map) return; + let row = map.get(id); + if (!row) { + row = new Y.Map(); + map.set(id, row); + } + setChangedFields(row, fields); + }); + }; + + const deleteRow = (sceneId: string, collection: 'markers' | 'lights' | 'annotations', id: string) => { + transact(() => collectionOf(sceneId, collection)?.delete(id)); + }; + + return { + createScene( + settings: SceneSettings, + content?: { + markers?: MarkerRow[]; + lights?: LightRow[]; + annotations?: Array; + fogMask?: Uint8Array | null; + } + ) { + transact(() => { + const scene = new Y.Map(); + scenes().set(settings.id, scene as SceneMap); + + const settingsMap = new Y.Map(); + scene.set('settings', settingsMap); + setChangedFields(settingsMap, settings as unknown as Record); + + for (const collection of ['markers', 'lights', 'annotations'] as const) { + const map = new Y.Map>(); + scene.set(collection, map); + for (const row of content?.[collection] ?? []) { + const rowMap = new Y.Map(); + const { mask, ...fields } = row as AnnotationRow & { mask?: Uint8Array | null }; + setChangedFields(rowMap, fields as unknown as Record); + if (collection === 'annotations' && mask) rowMap.set(ANNOTATION_MASK_KEY, mask); + map.set(row.id, rowMap); + } + } + + if (content?.fogMask) scene.set('fogMask', content.fogMask); + }); + }, + + deleteScene(sceneId: string) { + transact(() => scenes().delete(sceneId)); + }, + + setSceneSettings(sceneId: string, fields: Partial) { + transact(() => { + const settings = scenes().get(sceneId)?.get('settings') as Y.Map | undefined; + if (settings) setChangedFields(settings, fields as Record); + }); + }, + + setFogMask(sceneId: string, mask: Uint8Array) { + transact(() => scenes().get(sceneId)?.set('fogMask', mask)); + }, + + upsertMarker(sceneId: string, marker: MarkerRow) { + upsertRow(sceneId, 'markers', marker.id, marker as unknown as Record); + }, + setMarkerFields(sceneId: string, markerId: string, fields: Partial) { + upsertRow(sceneId, 'markers', markerId, fields as Record); + }, + deleteMarker(sceneId: string, markerId: string) { + deleteRow(sceneId, 'markers', markerId); + }, + + upsertLight(sceneId: string, light: LightRow) { + upsertRow(sceneId, 'lights', light.id, light as unknown as Record); + }, + setLightFields(sceneId: string, lightId: string, fields: Partial) { + upsertRow(sceneId, 'lights', lightId, fields as Record); + }, + deleteLight(sceneId: string, lightId: string) { + deleteRow(sceneId, 'lights', lightId); + }, + + upsertAnnotation(sceneId: string, annotation: AnnotationRow, mask?: Uint8Array | null) { + transact(() => { + upsertRow(sceneId, 'annotations', annotation.id, annotation as unknown as Record); + if (mask) { + collectionOf(sceneId, 'annotations')?.get(annotation.id)?.set(ANNOTATION_MASK_KEY, mask); + } + }); + }, + setAnnotationFields(sceneId: string, annotationId: string, fields: Partial) { + upsertRow(sceneId, 'annotations', annotationId, fields as Record); + }, + setAnnotationMask(sceneId: string, annotationId: string, mask: Uint8Array) { + transact(() => collectionOf(sceneId, 'annotations')?.get(annotationId)?.set(ANNOTATION_MASK_KEY, mask)); + }, + deleteAnnotation(sceneId: string, annotationId: string) { + deleteRow(sceneId, 'annotations', annotationId); + } + }; +}; + +export type SessionWriter = ReturnType; + +export const createPartyWriter = (partyDoc: Y.Doc, origin: unknown) => ({ + setActiveScene(sceneId: string | null) { + partyDoc.transact(() => getPartyStateMap(partyDoc).set('activeSceneId', sceneId), origin); + }, + setPaused(isPaused: boolean) { + partyDoc.transact(() => getPartyStateMap(partyDoc).set('isPaused', isPaused), origin); + } +}); + +export type PartyWriter = ReturnType; + +// --------------------------------------------------------------------------- +// Hydration (server builds the doc from the DB; clients never initialize shared state) +// --------------------------------------------------------------------------- + +export const hydrateGameSessionDoc = (doc: Y.Doc, data: GameSessionHydrationData, origin: unknown) => { + const writer = createSessionWriter(doc, origin); + doc.transact(() => { + for (const scene of data.scenes) { + writer.createScene(scene.settings, scene); + } + getMeta(doc).set('schemaVersion', DOC_SCHEMA_VERSION); + getMeta(doc).set('hydratedAt', Date.now()); + }, origin); +}; + +// --------------------------------------------------------------------------- +// Change classification for observers +// --------------------------------------------------------------------------- + +const SCENE_PARTS: ReadonlySet = new Set(['settings', 'markers', 'lights', 'annotations', 'fogMask']); + +export const classifySceneEvents = (events: Y.YEvent>[], transaction: Y.Transaction): SceneChange[] => { + const changes: SceneChange[] = []; + const remote = !transaction.local; + + for (const event of events) { + const path = event.path; + const keys = [...event.changes.keys.keys()]; + + if (path.length === 0) { + // Scenes added/removed at the top level + changes.push({ sceneId: keys[0] ?? '', part: 'scenes', keys, remote }); + } else if (path.length === 1) { + // A key set directly on a scene map (fogMask, or a collection map replaced) + const sceneId = String(path[0]); + for (const key of keys) { + if (SCENE_PARTS.has(key)) { + changes.push({ sceneId, part: key as ScenePart, keys: [key], remote }); + } + } + } else if (SCENE_PARTS.has(String(path[1]))) { + const sceneId = String(path[0]); + const part = String(path[1]) as ScenePart; + // path.length === 2 → rows added/removed (keys are row ids) + // path.length === 3 → fields changed inside a row (path[2] is the row id) + changes.push({ + sceneId, + part, + keys, + childId: path.length >= 3 ? String(path[2]) : undefined, + remote + }); + } + } + + return changes; +}; diff --git a/apps/web/src/lib/realtime/index.ts b/apps/web/src/lib/realtime/index.ts new file mode 100644 index 000000000..aa97d823a --- /dev/null +++ b/apps/web/src/lib/realtime/index.ts @@ -0,0 +1,42 @@ +export { base64ToUint8, uint8ToBase64 } from './binary'; +export { buildRenderProps, type LocalView } from './buildRenderProps'; +export { editorCapabilities, playCapabilities, type SessionCapabilities } from './capabilities'; +export { + ANNOTATION_MASK_KEY, + DOC_SCHEMA_VERSION, + classifySceneEvents, + createPartyWriter, + createSessionWriter, + getAnnotationMask, + getFogMask, + getPartyState, + getSceneSettings, + getSceneSnapshot, + hydrateGameSessionDoc, + isDocHydrated, + listScenes, + orderBetween, + type PartyWriter, + type SessionWriter +} from './docSchema'; +export { + PresenceChannel, + TEMPORARY_LAYER_TTL_MS, + type CursorData, + type MeasurementData, + type MeasurementStyleProps, + type TemporaryLayer +} from './presence.svelte'; +export { SessionDocClient, type ConnectionState, type SessionDocClientOptions } from './SessionDocClient.svelte'; +export type { + AnnotationRow, + GameSessionHydrationData, + LightRow, + MarkerRow, + PartyState, + SceneChange, + SceneListEntry, + ScenePart, + SceneSettings, + SceneSnapshot +} from './types'; diff --git a/apps/web/src/lib/realtime/presence.svelte.ts b/apps/web/src/lib/realtime/presence.svelte.ts new file mode 100644 index 000000000..334fe434d --- /dev/null +++ b/apps/web/src/lib/realtime/presence.svelte.ts @@ -0,0 +1,235 @@ +import type { HoveredMarker } from '@tableslayer/stage'; +import type YPartyKitProvider from 'y-partykit/provider'; + +type Awareness = YPartyKitProvider['awareness']; + +export interface CursorData { + userId: string; + worldPosition: { x: number; y: number; z: number }; + color?: string; + label?: string; + lastMoveTime: number; + clientId?: number; +} + +export interface MeasurementData { + userId: string; + startPoint: { x: number; y: number }; + endPoint: { x: number; y: number }; + type: number; + timestamp: number; + clientId?: number; + color: string; + thickness: number; + outlineColor: string; + outlineThickness: number; + opacity: number; + markerSize: number; + autoHideDelay: number; + fadeoutTime: number; + showDistance: boolean; + snapToGrid: boolean; + enableDMG252: boolean; + beamWidth?: number; + coneAngle?: number; +} + +export interface MeasurementStyleProps { + color: string; + thickness: number; + outlineColor: string; + outlineThickness: number; + opacity: number; + markerSize: number; + autoHideDelay: number; + fadeoutTime: number; + showDistance: boolean; + snapToGrid: boolean; + enableDMG252: boolean; + beamWidth?: number; + coneAngle?: number; +} + +export interface TemporaryLayer { + id: string; + isTemporary: boolean; + createdAt: number; + expiresAt: number; + color: string; + maskData: string; // Base64 RLE data (small enough for awareness payloads) + creatorId: string; + opacity: number; + name: string; + effectType?: number; +} + +export const TEMPORARY_LAYER_TTL_MS = 10000; + +/** Refresh awareness before Y.js's 30s timeout drops idle clients. */ +const HEARTBEAT_INTERVAL_MS = 15000; + +/** + * Reactive wrapper around Y.js awareness for all ephemeral, never-persisted state: + * cursors, measurements, hovered/pinned markers, and temporary player drawings. + */ +export class PresenceChannel { + cursors = $state>({}); + measurements = $state>({}); + hoveredMarker = $state(null); + pinnedMarkers = $state([]); + temporaryLayers = $state([]); + + #awareness: Awareness; + #userId: string; + #heartbeat: ReturnType; + + constructor(awareness: Awareness, userId: string) { + this.#awareness = awareness; + this.#userId = userId; + this.#awareness.on('change', this.#readStates); + this.#heartbeat = setInterval(() => this.#refresh(), HEARTBEAT_INTERVAL_MS); + } + + #readStates = () => { + const cursors: Record = {}; + const measurements: Record = {}; + let hoveredMarker: HoveredMarker | null = null; + let pinnedMarkers: string[] = []; + const temporaryLayers: TemporaryLayer[] = []; + + this.#awareness.getStates().forEach((state, clientId) => { + if (state.cursor && clientId !== this.#awareness.clientID) { + cursors[`${state.cursor.userId}_${clientId}`] = { ...state.cursor, clientId }; + } + if (state.measurement) { + measurements[`${state.measurement.userId}_${clientId}`] = { ...state.measurement, clientId }; + } + if (state.hoveredMarker) { + hoveredMarker = state.hoveredMarker as HoveredMarker; + } + if (Array.isArray(state.pinnedMarkers) && state.pinnedMarkers.length > 0) { + pinnedMarkers = state.pinnedMarkers; + } + if (Array.isArray(state.temporaryLayers)) { + temporaryLayers.push(...state.temporaryLayers); + } + }); + + this.cursors = cursors; + this.measurements = measurements; + this.hoveredMarker = hoveredMarker; + this.pinnedMarkers = pinnedMarkers; + this.temporaryLayers = temporaryLayers; + }; + + #refresh() { + const current = this.#awareness.getLocalState(); + if (current) this.#awareness.setLocalState(current); + } + + updateCursor(worldPosition: { x: number; y: number; z: number }, color?: string, label?: string) { + this.#awareness.setLocalStateField('cursor', { + userId: this.#userId, + worldPosition, + color: color || '#ffffff', + label: label || this.#userId, + lastMoveTime: Date.now() + }); + } + + updateMeasurement( + startPoint: { x: number; y: number } | null, + endPoint: { x: number; y: number } | null, + type: number, + style?: MeasurementStyleProps + ) { + if (startPoint === null || endPoint === null) { + this.#awareness.setLocalStateField('measurement', null); + return; + } + this.#awareness.setLocalStateField('measurement', { + userId: this.#userId, + startPoint, + endPoint, + type, + timestamp: Date.now(), + color: style?.color ?? '#FFFFFF', + thickness: style?.thickness ?? 12, + outlineColor: style?.outlineColor ?? '#000000', + outlineThickness: style?.outlineThickness ?? 4, + opacity: style?.opacity ?? 1, + markerSize: style?.markerSize ?? 24, + autoHideDelay: style?.autoHideDelay ?? 3000, + fadeoutTime: style?.fadeoutTime ?? 500, + showDistance: style?.showDistance ?? true, + snapToGrid: style?.snapToGrid ?? true, + enableDMG252: style?.enableDMG252 ?? true, + beamWidth: style?.beamWidth, + coneAngle: style?.coneAngle + }); + } + + updateHoveredMarker(marker: HoveredMarker | null) { + this.#awareness.setLocalStateField('hoveredMarker', marker); + } + + updatePinnedMarkers(markerIds: string[]) { + this.#awareness.setLocalStateField('pinnedMarkers', markerIds); + } + + #ownTemporaryLayers(): TemporaryLayer[] { + const layers = this.#awareness.getLocalState()?.temporaryLayers; + return Array.isArray(layers) ? layers : []; + } + + broadcastTemporaryLayer(layer: TemporaryLayer) { + const layers = this.#ownTemporaryLayers(); + const index = layers.findIndex((l) => l.id === layer.id); + if (index >= 0) layers[index] = layer; + else layers.push(layer); + this.#awareness.setLocalStateField('temporaryLayers', layers); + } + + removeTemporaryLayer(layerId: string) { + this.#awareness.setLocalStateField( + 'temporaryLayers', + this.#ownTemporaryLayers().filter((l) => l.id !== layerId) + ); + } + + /** Drop this client's expired temporary layers; call on a coarse interval. */ + cleanupExpiredTemporaryLayers() { + const now = Date.now(); + const layers = this.#ownTemporaryLayers(); + const valid = layers.filter((l) => l.expiresAt > now); + if (valid.length !== layers.length) { + this.#awareness.setLocalStateField('temporaryLayers', valid); + } + } + + createTemporaryLayer( + layerId: string, + color: string, + maskData: string, + options?: { ttlMs?: number; effectType?: number } + ): TemporaryLayer { + const now = Date.now(); + return { + id: layerId, + isTemporary: true, + createdAt: now, + expiresAt: now + (options?.ttlMs ?? TEMPORARY_LAYER_TTL_MS), + color, + maskData, + creatorId: this.#userId, + opacity: 1, + name: 'Temporary drawing', + effectType: options?.effectType + }; + } + + destroy() { + clearInterval(this.#heartbeat); + this.#awareness.off('change', this.#readStates); + } +} diff --git a/apps/web/src/lib/realtime/types.ts b/apps/web/src/lib/realtime/types.ts new file mode 100644 index 000000000..5d4ae5851 --- /dev/null +++ b/apps/web/src/lib/realtime/types.ts @@ -0,0 +1,159 @@ +// Row types for the shared game session document. +// These structurally mirror the drizzle Select* types in $lib/db/app/schema, but are +// defined locally so this module (and docSchema.ts) can be bundled into the PartyKit +// server without dragging in drizzle/zod. Field names must match the DB columns 1:1 — +// the server persister copies them straight into upserts. + +export interface SceneSettings { + id: string; + gameSessionId: string; + name: string; + order: number; + backgroundColor: string; + displayPaddingX: number; + displayPaddingY: number; + displaySizeX: number; + displaySizeY: number; + displayResolutionX: number; + displayResolutionY: number; + fogOfWarUrl: string | null; + fogOfWarColor: string; + fogOfWarOpacityDm: number; + fogOfWarOpacityPlayer: number; + mapLocation: string | null; + mapThumbLocation: string | null; + mapRotation: number; + mapOffsetX: number; + mapOffsetY: number; + mapZoom: number; + gridType: number; + gridMode: number; + gridMapDefinedX: number | null; + gridMapDefinedY: number | null; + gridSpacing: number; + gridOpacity: number; + gridLineColor: string; + gridLineThickness: number; + gridShadowColor: string; + gridShadowSpread: number; + gridShadowBlur: number; + gridShadowOpacity: number; + sceneOffsetX: number; + sceneOffsetY: number; + sceneRotation: number; + weatherFov: number; + weatherIntensity: number; + weatherOpacity: number; + weatherType: number; + fogEnabled: boolean; + fogColor: string; + fogOpacity: number; + edgeEnabled: boolean; + edgeUrl: string | null; + edgeOpacity: number; + edgeScale: number; + edgeFadeStart: number; + edgeFadeEnd: number; + effectsEnabled: boolean; + effectsBloomIntensity: number; + effectsBloomThreshold: number; + effectsBloomSmoothing: number; + effectsBloomRadius: number; + effectsBloomLevels: number; + effectsBloomMipMapBlur: boolean; + effectsChromaticAberrationOffset: number; + effectsLutUrl: string | null; + effectsToneMappingMode: number; + markerStrokeColor: string; + markerStrokeWidth: number; + markerTextColor: string; + markerTextStrokeColor: string; +} + +export interface MarkerRow { + id: string; + sceneId: string; + visibility: number; + title: string; + label: string | null; + imageLocation: string | null; + imageScale: number; + positionX: number; + positionY: number; + shape: number; + shapeColor: string; + size: number; + note: unknown; + pinnedTooltip: boolean; +} + +export interface LightRow { + id: string; + sceneId: string; + positionX: number; + positionY: number; + radius: number; + color: string; + style: string; + pulse: number; + opacity: number; +} + +export interface AnnotationRow { + id: string; + sceneId: string; + name: string; + opacity: number; + color: string; + url: string | null; + visibility: number; + order: number; + effectType: number | null; +} + +export interface SceneSnapshot { + id: string; + settings: SceneSettings; + markers: MarkerRow[]; + lights: LightRow[]; + annotations: AnnotationRow[]; +} + +/** Lightweight entry for scene lists/selectors, derived from settings. */ +export interface SceneListEntry { + id: string; + name: string; + order: number; + gameSessionId: string; + mapLocation: string | null; + mapThumbLocation: string | null; +} + +export interface PartyState { + activeSceneId: string | null; + isPaused: boolean; +} + +/** Everything needed to build a session doc from the database. */ +export interface GameSessionHydrationData { + scenes: Array<{ + settings: SceneSettings; + markers: MarkerRow[]; + lights: LightRow[]; + annotations: Array; + fogMask?: Uint8Array | null; + }>; +} + +export type ScenePart = 'settings' | 'markers' | 'lights' | 'annotations' | 'fogMask'; + +export interface SceneChange { + sceneId: string; + part: ScenePart | 'scenes'; + /** Changed keys at the event target (settings field names, marker/annotation ids, ...). */ + keys: string[]; + /** Set when the change happened inside a keyed child (markerId, lightId, annotationId). */ + childId?: string; + /** False when this client's own transaction produced the change. */ + remote: boolean; +} diff --git a/apps/web/src/lib/realtime/wire.test.ts b/apps/web/src/lib/realtime/wire.test.ts new file mode 100644 index 000000000..abf748214 --- /dev/null +++ b/apps/web/src/lib/realtime/wire.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import * as Y from 'yjs'; +import { uint8ToBase64 } from './binary'; +import { getAnnotationMask, getFogMask, getSceneSnapshot, hydrateGameSessionDoc } from './docSchema'; +import { hydrationDataFromWire, sceneWireFromDoc, type SessionSnapshotWire } from './wire'; + +const settings = { + id: 's1', + gameSessionId: 'gs1', + name: 'Scene 1', + order: 1.5, + backgroundColor: '#0b0b0c', + displayPaddingX: 16, + displayPaddingY: 16, + displaySizeX: 34.86, + displaySizeY: 19.6, + displayResolutionX: 1920, + displayResolutionY: 1080, + fogOfWarUrl: null, + fogOfWarColor: '#CCC', + fogOfWarOpacityDm: 0.5, + fogOfWarOpacityPlayer: 0.9, + mapLocation: 'map/s1.jpg', + mapThumbLocation: null, + mapRotation: 0, + mapOffsetX: 0, + mapOffsetY: 0, + mapZoom: 1, + gridType: 0, + gridMode: 0, + gridMapDefinedX: null, + gridMapDefinedY: null, + gridSpacing: 50, + gridOpacity: 0.8, + gridLineColor: '#E6E6E6', + gridLineThickness: 1, + gridShadowColor: '#000000', + gridShadowSpread: 2, + gridShadowBlur: 0.5, + gridShadowOpacity: 0.4, + sceneOffsetX: 0, + sceneOffsetY: 0, + sceneRotation: 0, + weatherFov: 60, + weatherIntensity: 1, + weatherOpacity: 1, + weatherType: 0, + fogEnabled: false, + fogColor: '#a0a0a0', + fogOpacity: 0.8, + edgeEnabled: false, + edgeUrl: null, + edgeOpacity: 0.3, + edgeScale: 2, + edgeFadeStart: 0.2, + edgeFadeEnd: 1, + effectsEnabled: true, + effectsBloomIntensity: 0, + effectsBloomThreshold: 0.5, + effectsBloomSmoothing: 0.3, + effectsBloomRadius: 0.5, + effectsBloomLevels: 10, + effectsBloomMipMapBlur: true, + effectsChromaticAberrationOffset: 0, + effectsLutUrl: null, + effectsToneMappingMode: 0, + markerStrokeColor: '#000000', + markerStrokeWidth: 50, + markerTextColor: '#ffffff', + markerTextStrokeColor: '#000000' +}; + +const wire: SessionSnapshotWire = { + gameSessionId: 'gs1', + scenes: [ + { + settings, + markers: [ + { + id: 'm1', + sceneId: 's1', + visibility: 1, + title: 'Token', + label: 'A', + imageLocation: null, + imageScale: 1, + positionX: 3, + positionY: 4, + shape: 1, + shapeColor: '#fff', + size: 1, + note: { blocks: [] }, + pinnedTooltip: false + } + ], + lights: [], + annotations: [ + { + id: 'a1', + sceneId: 's1', + name: 'Notes', + opacity: 1, + color: '#FF0000', + url: null, + visibility: 1, + order: 0, + effectType: null, + mask: uint8ToBase64(new Uint8Array([4, 5, 6])) + } + ], + fogMask: uint8ToBase64(new Uint8Array([1, 2, 3])) + } + ] +}; + +describe('wire round trip', () => { + it('hydrates a doc from wire and reads identical wire back out', () => { + const doc = new Y.Doc(); + hydrateGameSessionDoc(doc, hydrationDataFromWire(wire), 'server-hydration'); + + expect(getSceneSnapshot(doc, 's1')?.settings).toEqual(settings); + expect(getFogMask(doc, 's1')).toEqual(new Uint8Array([1, 2, 3])); + expect(getAnnotationMask(doc, 's1', 'a1')).toEqual(new Uint8Array([4, 5, 6])); + + const out = sceneWireFromDoc(doc, 's1', ['settings', 'markers', 'lights', 'annotations', 'fogMask']); + expect(out).toEqual({ ...wire.scenes[0], parts: ['settings', 'markers', 'lights', 'annotations', 'fogMask'] }); + }); + + it('includes only dirty parts in the persist payload', () => { + const doc = new Y.Doc(); + hydrateGameSessionDoc(doc, hydrationDataFromWire(wire), 'server-hydration'); + + const out = sceneWireFromDoc(doc, 's1', ['markers']); + expect(out?.parts).toEqual(['markers']); + expect(out?.markers).toHaveLength(1); + expect(out?.annotations).toEqual([]); + expect(out?.fogMask).toBeNull(); + // Settings always ride along so the upsert can create missing rows + expect(out?.settings.id).toBe('s1'); + }); + + it('returns null for unknown scenes', () => { + const doc = new Y.Doc(); + expect(sceneWireFromDoc(doc, 'nope', ['settings'])).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/realtime/wire.ts b/apps/web/src/lib/realtime/wire.ts new file mode 100644 index 000000000..fa742f652 --- /dev/null +++ b/apps/web/src/lib/realtime/wire.ts @@ -0,0 +1,71 @@ +import type * as Y from 'yjs'; +import { base64ToUint8, uint8ToBase64 } from './binary'; +import { getAnnotationMask, getFogMask, getSceneSnapshot } from './docSchema'; +import type { AnnotationRow, GameSessionHydrationData, LightRow, MarkerRow, ScenePart, SceneSettings } from './types'; + +// JSON-safe payloads exchanged between the PartyKit server and the app's internal +// endpoints. Masks travel as base64 strings (matching their DB columns); the doc +// stores them as Uint8Array. Dependency-light: bundled into the PartyKit server. + +export interface SceneWire { + settings: SceneSettings; + markers: MarkerRow[]; + lights: LightRow[]; + annotations: Array; + fogMask?: string | null; +} + +export interface SessionSnapshotWire { + gameSessionId: string; + scenes: SceneWire[]; +} + +export interface PersistSessionWire { + gameSessionId: string; + scenes: Array; + deletedSceneIds: string[]; +} + +export interface PartyStateWire { + activeSceneId: string | null; + isPaused: boolean; +} + +export const hydrationDataFromWire = (wire: SessionSnapshotWire): GameSessionHydrationData => ({ + scenes: wire.scenes.map((scene) => ({ + settings: scene.settings, + markers: scene.markers, + lights: scene.lights, + annotations: scene.annotations.map(({ mask, ...row }) => ({ + ...row, + mask: mask ? base64ToUint8(mask) : null + })), + fogMask: scene.fogMask ? base64ToUint8(scene.fogMask) : null + })) +}); + +/** Read one scene out of the doc in wire format, including only the dirty parts' payloads. */ +export const sceneWireFromDoc = ( + doc: Y.Doc, + sceneId: string, + parts: ScenePart[] +): (SceneWire & { parts: ScenePart[] }) | null => { + const snapshot = getSceneSnapshot(doc, sceneId); + if (!snapshot) return null; + + const fogMask = parts.includes('fogMask') ? getFogMask(doc, sceneId) : null; + + return { + settings: snapshot.settings, + markers: parts.includes('markers') ? snapshot.markers : [], + lights: parts.includes('lights') ? snapshot.lights : [], + annotations: parts.includes('annotations') + ? snapshot.annotations.map((row) => { + const mask = getAnnotationMask(doc, sceneId, row.id); + return { ...row, mask: mask ? uint8ToBase64(mask) : null }; + }) + : [], + fogMask: fogMask ? uint8ToBase64(fogMask) : null, + parts + }; +}; diff --git a/apps/web/src/lib/server/realtime/index.ts b/apps/web/src/lib/server/realtime/index.ts new file mode 100644 index 000000000..5e6307c1f --- /dev/null +++ b/apps/web/src/lib/server/realtime/index.ts @@ -0,0 +1,128 @@ +import { dev } from '$app/environment'; +import { db } from '$lib/db/app'; +import { + annotationsTable, + gameSessionTable, + lightTable, + markerTable, + partyTable, + sceneTable, + type SelectScene +} from '$lib/db/app/schema'; +import type { PartyStateWire, PersistSessionWire, SceneWire, SessionSnapshotWire } from '$lib/realtime/wire'; +import { and, eq, inArray, notInArray } from 'drizzle-orm'; + +// Server-to-server auth for /api/internal/* — the PartyKit persister is the only +// caller. In production INTERNAL_API_TOKEN must be set on both sides; in dev both +// fall back to the same well-known token. +export const assertInternalRequest = (request: Request) => { + const configured = process.env.INTERNAL_API_TOKEN || (dev ? 'dev-internal-token' : undefined); + if (!configured || request.headers.get('x-internal-token') !== configured) { + throw new Error('Unauthorized'); + } +}; + +const sceneRowToWire = ( + scene: SelectScene, + markers: SceneWire['markers'], + lights: SceneWire['lights'], + annotations: SceneWire['annotations'] +): SceneWire => { + const { fogOfWarMask, annotationLayers: _annotationLayers, lastUpdated: _lastUpdated, ...settings } = scene; + return { settings, markers, lights, annotations, fogMask: fogOfWarMask }; +}; + +export const buildSessionSnapshot = async (gameSessionId: string): Promise => { + const scenes = await db.select().from(sceneTable).where(eq(sceneTable.gameSessionId, gameSessionId)).all(); + const sceneIds = scenes.map((scene) => scene.id); + + const [markers, lights, annotations] = + sceneIds.length === 0 + ? [[], [], []] + : await Promise.all([ + db.select().from(markerTable).where(inArray(markerTable.sceneId, sceneIds)).all(), + db.select().from(lightTable).where(inArray(lightTable.sceneId, sceneIds)).all(), + db.select().from(annotationsTable).where(inArray(annotationsTable.sceneId, sceneIds)).all() + ]); + + return { + gameSessionId, + scenes: scenes.map((scene) => + sceneRowToWire( + scene, + markers.filter((m) => m.sceneId === scene.id), + lights.filter((l) => l.sceneId === scene.id), + annotations.filter((a) => a.sceneId === scene.id) + ) + ) + }; +}; + +const replaceRows = async ( + table: typeof markerTable | typeof lightTable | typeof annotationsTable, + sceneId: string, + rows: T[] +) => { + if (rows.length === 0) { + await db.delete(table).where(eq(table.sceneId, sceneId)); + return; + } + await db.delete(table).where( + and( + eq(table.sceneId, sceneId), + notInArray( + table.id, + rows.map((row) => row.id) + ) + ) + ); + for (const row of rows) { + await db.insert(table).values(row).onConflictDoUpdate({ target: table.id, set: row }); + } +}; + +export const applySessionPersist = async (payload: PersistSessionWire) => { + for (const scene of payload.scenes) { + const sceneRow: Record = { ...scene.settings, lastUpdated: new Date() }; + if (scene.parts.includes('fogMask')) { + sceneRow.fogOfWarMask = scene.fogMask ?? null; + } + await db + .insert(sceneTable) + .values(sceneRow as typeof sceneTable.$inferInsert) + .onConflictDoUpdate({ target: sceneTable.id, set: sceneRow }); + + if (scene.parts.includes('markers')) await replaceRows(markerTable, scene.settings.id, scene.markers); + if (scene.parts.includes('lights')) await replaceRows(lightTable, scene.settings.id, scene.lights); + if (scene.parts.includes('annotations')) await replaceRows(annotationsTable, scene.settings.id, scene.annotations); + } + + if (payload.deletedSceneIds.length > 0) { + await db.delete(sceneTable).where(inArray(sceneTable.id, payload.deletedSceneIds)); + } + + await db + .update(gameSessionTable) + .set({ lastUpdated: new Date() }) + .where(eq(gameSessionTable.id, payload.gameSessionId)); +}; + +export const getPartyStateSnapshot = async (partyId: string): Promise => { + const party = await db + .select({ activeSceneId: partyTable.activeSceneId, isPaused: partyTable.gameSessionIsPaused }) + .from(partyTable) + .where(eq(partyTable.id, partyId)) + .get(); + + return { + activeSceneId: party?.activeSceneId ?? null, + isPaused: party?.isPaused ?? false + }; +}; + +export const applyPartyStatePersist = async (partyId: string, state: PartyStateWire) => { + await db + .update(partyTable) + .set({ activeSceneId: state.activeSceneId, gameSessionIsPaused: state.isPaused }) + .where(eq(partyTable.id, partyId)); +}; diff --git a/apps/web/src/routes/api/internal/partySnapshot/+server.ts b/apps/web/src/routes/api/internal/partySnapshot/+server.ts new file mode 100644 index 000000000..9b8e4f395 --- /dev/null +++ b/apps/web/src/routes/api/internal/partySnapshot/+server.ts @@ -0,0 +1,20 @@ +import { apiFactory } from '$lib/factories'; +import { assertInternalRequest, getPartyStateSnapshot } from '$lib/server/realtime'; +import { z } from 'zod'; + +const validationSchema = z.object({ + partyId: z.string() +}); + +// Called by the PartyKit party server to hydrate the party room's Y doc. +export const POST = apiFactory( + async (event) => { + assertInternalRequest(event.request); + return await getPartyStateSnapshot(event.body.partyId); + }, + { + validationSchema, + unauthorizedMessage: 'Invalid internal token.', + unexpectedErrorMessage: 'Failed to load party state.' + } +); diff --git a/apps/web/src/routes/api/internal/persistParty/+server.ts b/apps/web/src/routes/api/internal/persistParty/+server.ts new file mode 100644 index 000000000..9ac79c425 --- /dev/null +++ b/apps/web/src/routes/api/internal/persistParty/+server.ts @@ -0,0 +1,26 @@ +import { apiFactory } from '$lib/factories'; +import { applyPartyStatePersist, assertInternalRequest } from '$lib/server/realtime'; +import { z } from 'zod'; + +const validationSchema = z.object({ + partyId: z.string(), + activeSceneId: z.string().nullable(), + isPaused: z.boolean() +}); + +// Called by the PartyKit party server's debounced persister. +export const POST = apiFactory( + async (event) => { + assertInternalRequest(event.request); + await applyPartyStatePersist(event.body.partyId, { + activeSceneId: event.body.activeSceneId, + isPaused: event.body.isPaused + }); + return { ok: true }; + }, + { + validationSchema, + unauthorizedMessage: 'Invalid internal token.', + unexpectedErrorMessage: 'Failed to persist party state.' + } +); diff --git a/apps/web/src/routes/api/internal/persistSession/+server.ts b/apps/web/src/routes/api/internal/persistSession/+server.ts new file mode 100644 index 000000000..5a82a29ec --- /dev/null +++ b/apps/web/src/routes/api/internal/persistSession/+server.ts @@ -0,0 +1,40 @@ +import { insertAnnotationSchema, insertLightSchema, insertMarkerSchema, insertSceneSchema } from '$lib/db/app/schema'; +import { apiFactory } from '$lib/factories'; +import type { PersistSessionWire } from '$lib/realtime/wire'; +import { applySessionPersist, assertInternalRequest } from '$lib/server/realtime'; +import { z } from 'zod'; + +const sceneWireSchema = z.object({ + settings: insertSceneSchema + .omit({ fogOfWarMask: true, annotationLayers: true, lastUpdated: true }) + // The doc uses fractional ordering; SQLite stores it fine in the integer column. + // id is required here (column defaults make it optional in the insert schema). + .extend({ id: z.string(), order: z.number() }), + markers: z.array(insertMarkerSchema), + lights: z.array(insertLightSchema), + annotations: z.array(insertAnnotationSchema), + fogMask: z.string().nullable().optional(), + parts: z.array(z.enum(['settings', 'markers', 'lights', 'annotations', 'fogMask'])) +}); + +const validationSchema = z.object({ + gameSessionId: z.string(), + scenes: z.array(sceneWireSchema), + deletedSceneIds: z.array(z.string()) +}); + +// Called by the PartyKit game session server's debounced persister. The server is +// the only writer of scene data to the DB; clients never save scene state. +export const POST = apiFactory( + async (event) => { + assertInternalRequest(event.request); + // Zod leaves defaulted columns optional; the persister always sends full rows + await applySessionPersist(event.body as PersistSessionWire); + return { ok: true }; + }, + { + validationSchema, + unauthorizedMessage: 'Invalid internal token.', + unexpectedErrorMessage: 'Failed to persist session.' + } +); diff --git a/apps/web/src/routes/api/internal/sessionSnapshot/+server.ts b/apps/web/src/routes/api/internal/sessionSnapshot/+server.ts new file mode 100644 index 000000000..57b1c8d5c --- /dev/null +++ b/apps/web/src/routes/api/internal/sessionSnapshot/+server.ts @@ -0,0 +1,20 @@ +import { apiFactory } from '$lib/factories'; +import { assertInternalRequest, buildSessionSnapshot } from '$lib/server/realtime'; +import { z } from 'zod'; + +const validationSchema = z.object({ + gameSessionId: z.string() +}); + +// Called by the PartyKit game session server to hydrate a room's Y doc from the DB. +export const POST = apiFactory( + async (event) => { + assertInternalRequest(event.request); + return await buildSessionSnapshot(event.body.gameSessionId); + }, + { + validationSchema, + unauthorizedMessage: 'Invalid internal token.', + unexpectedErrorMessage: 'Failed to build session snapshot.' + } +); From 9335d1367383bfa4b903a6a6cfee64e97d368d6e Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Wed, 10 Jun 2026 02:18:34 -0400 Subject: [PATCH 02/10] mostly working rewrite --- .gitignore | 3 + apps/web/partykit/appApi.ts | 3 +- apps/web/partykit/gameSession.ts | 68 +- apps/web/partykit/party.ts | 34 +- .../components/GameSession/FogControls.svelte | 4 - .../GameSession/LightManager.svelte | 5 +- .../components/GameSession/MapControls.svelte | 8 +- .../GameSession/MarkerManager.svelte | 5 +- .../GameSession/PlayControls.svelte | 63 +- .../GameSession/SceneControls.svelte | 18 +- .../GameSession/SceneSelector.svelte | 471 +- .../GameSession/UpdateMapImage.svelte | 112 +- .../GameSession/UpdateMapImage.svelte.d.ts | 5 +- apps/web/src/lib/realtime/binary.ts | 9 +- apps/web/src/lib/realtime/buildRenderProps.ts | 6 + apps/web/src/lib/realtime/fromDb.ts | 21 + apps/web/src/lib/realtime/index.ts | 2 + .../lib/realtime/structuralSharing.test.ts | 61 + .../web/src/lib/realtime/structuralSharing.ts | 36 + .../lib/utils/propertyUpdateBroadcaster.ts | 391 +- .../[[selectedScene]]/+page.server.ts | 64 +- .../[[selectedScene]]/+page.svelte | 3772 ++++------------- .../useEditorSession.svelte.ts | 164 + .../routes/(app)/[party]/play/+page.server.ts | 40 +- .../routes/(app)/[party]/play/+page@.svelte | 2561 +---------- .../(app)/[party]/play/PauseOverlay.svelte | 72 + .../[party]/play/usePlaySession.svelte.ts | 213 + .../(app)/[party]/play/usePlayTools.svelte.ts | 503 +++ 28 files changed, 2497 insertions(+), 6217 deletions(-) create mode 100644 apps/web/src/lib/realtime/fromDb.ts create mode 100644 apps/web/src/lib/realtime/structuralSharing.test.ts create mode 100644 apps/web/src/lib/realtime/structuralSharing.ts create mode 100644 apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/useEditorSession.svelte.ts create mode 100644 apps/web/src/routes/(app)/[party]/play/PauseOverlay.svelte create mode 100644 apps/web/src/routes/(app)/[party]/play/usePlaySession.svelte.ts create mode 100644 apps/web/src/routes/(app)/[party]/play/usePlayTools.svelte.ts diff --git a/.gitignore b/.gitignore index d94b60b8f..4f4a03744 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ yarn-error.log* # embedded dbs *.db* + +# local realtime debugging probes (see spec/realtime-sync-v2-progress.md) +scratch-*.mjs diff --git a/apps/web/partykit/appApi.ts b/apps/web/partykit/appApi.ts index a57164279..0bc17b265 100644 --- a/apps/web/partykit/appApi.ts +++ b/apps/web/partykit/appApi.ts @@ -5,7 +5,8 @@ import type * as Party from 'partykit/server'; // falls back to the local SvelteKit server and the shared dev token. export const appRequest = async (room: Party.Room, path: string, body: unknown): Promise => { - const base = (room.env.APP_API_URL as string | undefined) ?? 'http://localhost:5173'; + // 5174 is the web app's pinned dev port (apps/web/vite.config.ts) + const base = (room.env.APP_API_URL as string | undefined) ?? 'http://localhost:5174'; const token = (room.env.INTERNAL_API_TOKEN as string | undefined) ?? 'dev-internal-token'; const response = await fetch(`${base}${path}`, { diff --git a/apps/web/partykit/gameSession.ts b/apps/web/partykit/gameSession.ts index b0ce94a62..7a64a0eb7 100644 --- a/apps/web/partykit/gameSession.ts +++ b/apps/web/partykit/gameSession.ts @@ -29,19 +29,27 @@ export default class GameSessionServer implements Party.Server { #deletedSceneIds = new Set(); #observed = false; #persisting = false; + #retryTimer: ReturnType | null = null; #options: YPartyKitOptions; constructor(public room: Party.Room) { this.#options = { persist: { mode: 'snapshot' }, + gc: false, // y-partykit forces this with persist; setting it keeps the options hash stable callback: { - handler: () => this.#persistDirty(), + handler: () => { + this.#stats.flushes++; + return this.#persistDirty(); + }, debounceWait: 2000, debounceMaxWait: 10000 } }; } + // Diagnostics surfaced via the `debug` room command + #stats = { flushes: 0, persistAttempts: 0, persistOk: 0, persistFail: 0, lastError: '' }; + #markDirty(sceneId: string, parts: Iterable) { let set = this.#dirty.get(sceneId); if (!set) { @@ -93,6 +101,7 @@ export default class GameSessionServer implements Party.Server { if (this.#dirty.size === 0 && this.#deletedSceneIds.size === 0) return; this.#persisting = true; + this.#stats.persistAttempts++; const dirty = this.#dirty; const deleted = this.#deletedSceneIds; this.#dirty = new Map(); @@ -108,20 +117,47 @@ export default class GameSessionServer implements Party.Server { deletedSceneIds: [...deleted] }; await appRequest(this.room, '/api/internal/persistSession', payload); + this.#stats.persistOk++; } catch (error) { - // Merge back what we drained and retry on an alarm; the doc itself is safe - // in room storage regardless. + this.#stats.persistFail++; + this.#stats.lastError = error instanceof Error ? `${error.message}` : String(error); + // Merge back what we drained and retry; the doc itself is safe in room + // storage regardless. for (const [sceneId, parts] of dirty) this.#markDirty(sceneId, parts); for (const sceneId of deleted) this.#deletedSceneIds.add(sceneId); console.error(`persistSession failed for ${this.room.id}; retrying in ${PERSIST_RETRY_MS}ms`, error); - await this.room.storage.setAlarm(Date.now() + PERSIST_RETRY_MS); + this.#scheduleRetry(); } finally { this.#persisting = false; } } + // PartyKit alarms cannot read room.id (platform limitation), so retries use an + // in-instance timer. If the room is evicted before the retry fires, the doc + // snapshot is still safe and the next connection re-hydrates and re-persists. + #scheduleRetry() { + if (this.#retryTimer) return; + this.#retryTimer = setTimeout(async () => { + this.#retryTimer = null; + try { + await this.#getDoc(); + } catch (error) { + console.error(`hydration retry failed for game session ${this.room.id}`, error); + this.#scheduleRetry(); + } + await this.#persistDirty(); + }, PERSIST_RETRY_MS); + } + async onConnect(conn: Party.Connection) { - await this.#getDoc(); + // A failed hydration must not kill the websocket: keep the connection open, + // let clients wait on the ready gate, and retry shortly. + try { + await this.#getDoc(); + } catch (error) { + console.error(`hydration failed for game session ${this.room.id}; retrying in ${PERSIST_RETRY_MS}ms`, error); + this.#scheduleRetry(); + } return onConnect(conn, this.room, this.#options); } @@ -132,15 +168,29 @@ export default class GameSessionServer implements Party.Server { } } - async onAlarm() { - await this.#persistDirty(); - } - async onRequest(req: Party.Request) { // Ping endpoint for diagnostics if (req.method === 'POST') { const body = (await req.json()) as { type?: string; timestamp?: number }; + // Diagnostics: expose persister state without needing terminal access + if (body.type === 'debug') { + const token = (this.room.env.INTERNAL_API_TOKEN as string | undefined) ?? 'dev-internal-token'; + if (req.headers.get('x-internal-token') !== token) { + return new Response('Unauthorized', { status: 401 }); + } + return new Response( + JSON.stringify({ + observed: this.#observed, + persisting: this.#persisting, + dirty: [...this.#dirty.entries()].map(([sceneId, parts]) => [sceneId.slice(0, 8), [...parts]]), + deleted: [...this.#deletedSceneIds], + stats: this.#stats + }), + { headers: { 'Content-Type': 'application/json' } } + ); + } + // After the app writes session data to the DB directly (import, admin tools), // it calls this to rebuild the live doc from the database. if (body.type === 'resync') { diff --git a/apps/web/partykit/party.ts b/apps/web/partykit/party.ts index af0558835..fc7ced013 100644 --- a/apps/web/partykit/party.ts +++ b/apps/web/partykit/party.ts @@ -22,11 +22,13 @@ export default class PartyServer implements Party.Server { #dirty = false; #observed = false; #persisting = false; + #retryTimer: ReturnType | null = null; #options: YPartyKitOptions; constructor(public room: Party.Room) { this.#options = { persist: { mode: 'snapshot' }, + gc: false, // y-partykit forces this with persist; setting it keeps the options hash stable callback: { handler: () => this.#persistState(), debounceWait: 1000, @@ -79,14 +81,38 @@ export default class PartyServer implements Party.Server { } catch (error) { this.#dirty = true; console.error(`persistParty failed for ${this.room.id}; retrying in ${PERSIST_RETRY_MS}ms`, error); - await this.room.storage.setAlarm(Date.now() + PERSIST_RETRY_MS); + this.#scheduleRetry(); } finally { this.#persisting = false; } } + // PartyKit alarms cannot read room.id (platform limitation), so retries use an + // in-instance timer. If the room is evicted before the retry fires, the doc + // snapshot is still safe and the next connection re-hydrates and re-persists. + #scheduleRetry() { + if (this.#retryTimer) return; + this.#retryTimer = setTimeout(async () => { + this.#retryTimer = null; + try { + await this.#getDoc(); + } catch (error) { + console.error(`hydration retry failed for party ${this.room.id}`, error); + this.#scheduleRetry(); + } + await this.#persistState(); + }, PERSIST_RETRY_MS); + } + async onConnect(conn: Party.Connection) { - await this.#getDoc(); + // A failed hydration must not kill the websocket: keep the connection open, + // let clients wait on the ready gate, and retry shortly. + try { + await this.#getDoc(); + } catch (error) { + console.error(`hydration failed for party ${this.room.id}; retrying in ${PERSIST_RETRY_MS}ms`, error); + this.#scheduleRetry(); + } return onConnect(conn, this.room, this.#options); } @@ -96,8 +122,4 @@ export default class PartyServer implements Party.Server { await this.#persistState(); } } - - async onAlarm() { - await this.#persistState(); - } } diff --git a/apps/web/src/lib/components/GameSession/FogControls.svelte b/apps/web/src/lib/components/GameSession/FogControls.svelte index a4a6e6076..04dea6a7d 100644 --- a/apps/web/src/lib/components/GameSession/FogControls.svelte +++ b/apps/web/src/lib/components/GameSession/FogControls.svelte @@ -5,11 +5,9 @@ import chroma from 'chroma-js'; let { - socketUpdate, stage, stageProps = $bindable() }: { - socketUpdate: () => void; stage: StageExports; stageProps: StageProps; } = $props(); @@ -91,7 +89,6 @@ - + diff --git a/apps/web/src/routes/(app)/[party]/play/usePlaySession.svelte.ts b/apps/web/src/routes/(app)/[party]/play/usePlaySession.svelte.ts new file mode 100644 index 000000000..56d89174b --- /dev/null +++ b/apps/web/src/routes/(app)/[party]/play/usePlaySession.svelte.ts @@ -0,0 +1,213 @@ +import { browser } from '$app/environment'; +import { SessionDocClient, type SceneChange, type SceneSnapshot } from '$lib/realtime'; +import { devError, devLog } from '$lib/utils/debug'; +import { generateLargeImageUrl } from '$lib/utils/generateR2Url'; +import type { StageExports } from '@tableslayer/stage'; + +export interface SessionRoute { + id: string; + name: string; + scenes: Array<{ id: string; name: string }>; +} + +export interface PlaySessionOptions { + partyId: string; + userId: string; + partykitHost: string; + initialGameSessionId?: string; + initialActiveSceneId?: string | null; + initialIsPaused: boolean; + bucketUrl?: string; + /** Scene → session routing (from SSR) for cross-session scene switches. */ + routes: () => SessionRoute[]; + getStage: () => StageExports | undefined; +} + +/** + * The play route's connection to the live session: owns the SessionDocClient, + * follows the party's active scene across game session rooms, applies remote + * fog/annotation masks to the stage, and preloads sibling scene map images so + * scene switches are texture-swap fast. No SSR reloads, no DB fetches. + */ +export class PlaySession { + client = $state(null); + gameSessionId = $state(undefined); + + #options: PlaySessionOptions; + #lastActiveSceneId = $state(null); + #unsubChanges: (() => void) | null = null; + #preloadedMaps = new Set(); + + constructor(options: PlaySessionOptions) { + this.#options = options; + this.#lastActiveSceneId = options.initialActiveSceneId ?? null; + if (options.initialGameSessionId) { + this.#connect(options.initialGameSessionId); + } + + // Follow the active scene across game sessions: when the active scene lives + // in a different session's room, reconnect there. + $effect(() => { + const sceneId = this.activeSceneId; + if (!sceneId || !this.client?.ready) return; + const targetSession = this.#sessionForScene(sceneId); + if (targetSession && targetSession !== this.gameSessionId) { + devLog('play', `Active scene ${sceneId} lives in session ${targetSession}; reconnecting`); + this.#connect(targetSession); + } + }); + + // Remember the last known active scene so the UI stays stable across reconnects + $effect(() => { + const id = this.client?.ready ? this.client.partyState().activeSceneId : null; + if (id) this.#lastActiveSceneId = id; + }); + + // Preload sibling scene maps for instant switches + $effect(() => { + if (!this.client?.ready) return; + for (const scene of this.client.scenes()) { + if (!scene.mapLocation || scene.id === this.activeSceneId) continue; + if (this.#preloadedMaps.has(scene.mapLocation)) continue; + this.#preloadedMaps.add(scene.mapLocation); + const image = new Image(); + image.src = generateLargeImageUrl(scene.mapLocation, this.#options.bucketUrl); + } + }); + } + + #connect(gameSessionId: string) { + if (!browser) return; + this.#unsubChanges?.(); + this.client?.destroy(); + this.gameSessionId = gameSessionId; + this.client = new SessionDocClient({ + partykitHost: this.#options.partykitHost, + partyId: this.#options.partyId, + gameSessionId, + userId: this.#options.userId + }); + this.#unsubChanges = this.client.onChanges((changes) => this.#applyRemoteMaskChanges(changes)); + } + + #sessionForScene(sceneId: string): string | undefined { + if (this.client?.scenes().some((scene) => scene.id === sceneId)) return this.gameSessionId; + return this.#options.routes().find((route) => route.scenes.some((scene) => scene.id === sceneId))?.id; + } + + get ready(): boolean { + return this.client?.ready ?? false; + } + + get presence() { + return this.client?.presence ?? null; + } + + get activeSceneId(): string | null { + if (this.client?.ready) { + return this.client.partyState().activeSceneId ?? null; + } + return this.#lastActiveSceneId; + } + + get activeScene(): SceneSnapshot | null { + const sceneId = this.activeSceneId; + if (!sceneId || !this.client?.ready) return null; + return this.client.scene(sceneId); + } + + get isPaused(): boolean { + if (this.client?.ready) return this.client.partyState().isPaused; + return this.#options.initialIsPaused; + } + + /** Set the party's active scene; reconnects first if it lives in another session. */ + async switchScene(sceneId: string) { + this.#lastActiveSceneId = sceneId; + const targetSession = this.#sessionForScene(sceneId); + if (targetSession && targetSession !== this.gameSessionId) { + this.#connect(targetSession); + } + await this.client?.whenReady(); + this.client?.party.setActiveScene(sceneId); + } + + /** + * Apply the active scene's fog + annotation masks from the doc to the stage. + * Retries on a short schedule: the fog layer refills itself when the map + * texture finishes loading, and annotation layer components must mount before + * loadMask can land. Re-applying authoritative doc state is idempotent. + */ + async applyMasks() { + const sceneId = this.activeSceneId; + for (const delay of [0, 300, 1000, 3000]) { + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); + if (this.activeSceneId !== sceneId) return; // scene changed mid-retry + await this.#applyMasksOnce(sceneId); + } + } + + async #applyMasksOnce(sceneId: string | null) { + const client = this.client; + const stage = this.#options.getStage(); + if (!sceneId || !client || !stage) return; + + try { + const fogMask = client.fogMask(sceneId); + if (fogMask && stage.fogOfWar?.fromRLE && !stage.fogOfWar.isDrawing()) { + await stage.fogOfWar.fromRLE(fogMask, 1024, 1024); + } + const snapshot = client.scene(sceneId); + for (const annotation of snapshot?.annotations ?? []) { + const mask = client.annotationMask(sceneId, annotation.id); + if (mask && stage.annotations?.loadMask) { + await stage.annotations.loadMask(annotation.id, mask); + } + } + } catch (error) { + devError('play', 'Failed to apply masks', error); + } + } + + async loadAnnotationMask(annotationId: string, mask: Uint8Array) { + const stage = this.#options.getStage(); + if (!stage?.annotations?.loadMask) return; + try { + await stage.annotations.loadMask(annotationId, mask); + } catch (error) { + devError('play', `Failed to load annotation mask ${annotationId}`, error); + } + } + + #applyRemoteMaskChanges(changes: SceneChange[]) { + const sceneId = this.activeSceneId; + const client = this.client; + const stage = this.#options.getStage(); + if (!sceneId || !client || !stage) return; + + for (const change of changes) { + // `remote` is transaction identity, not timing — our own commits are skipped + // exactly, with no echo windows. + if (!change.remote || change.sceneId !== sceneId) continue; + + if (change.part === 'fogMask') { + if (stage.fogOfWar?.isDrawing()) continue; + const mask = client.fogMask(sceneId); + devLog('play', `applying remote fog mask (${mask?.length ?? 0} bytes)`); + if (mask) stage.fogOfWar.fromRLE(mask, 1024, 1024); + } + + if (change.part === 'annotations' && change.childId && change.keys.includes('mask')) { + const mask = client.annotationMask(sceneId, change.childId); + devLog('play', `applying remote annotation mask ${change.childId} (${mask?.length ?? 0} bytes)`); + if (mask) this.loadAnnotationMask(change.childId, mask); + } + } + } + + destroy() { + this.#unsubChanges?.(); + this.client?.destroy(); + this.client = null; + } +} diff --git a/apps/web/src/routes/(app)/[party]/play/usePlayTools.svelte.ts b/apps/web/src/routes/(app)/[party]/play/usePlayTools.svelte.ts new file mode 100644 index 000000000..c62fd14cf --- /dev/null +++ b/apps/web/src/routes/(app)/[party]/play/usePlayTools.svelte.ts @@ -0,0 +1,503 @@ +import { base64ToUint8, uint8ToBase64, type AnnotationRow } from '$lib/realtime'; +import { devError, devLog } from '$lib/utils/debug'; +import { + IconCircle, + IconCone2, + IconLine, + IconMap, + IconPaint, + IconPaintFilled, + IconPencil, + IconRectangleVertical, + IconRestore, + IconRuler, + IconShadow, + IconSquare, + IconTrash +} from '@tabler/icons-svelte'; +import { + AnnotationEffect, + DrawMode, + getDefaultEffectProps, + MapLayerType, + MeasurementType, + StageMode, + type AnnotationLayerData, + type RadialMenuItemProps, + type StageExports +} from '@tableslayer/stage'; +import { v4 as uuidv4 } from 'uuid'; +import type { PlaySession, SessionRoute } from './usePlaySession.svelte'; + +const DRAW_COLORS: Record = { + 'draw-red': '#d73e2e', + 'draw-orange': '#ffa500', + 'draw-yellow': '#ffd93d', + 'draw-green': '#6bcf7f', + 'draw-blue': '#2e86ab', + 'draw-purple': '#b197fc', + 'draw-pink': '#f06595', + 'draw-turquoise': '#20c997' +}; + +const DRAW_EFFECTS: Record = { + 'effect-fire': AnnotationEffect.Fire, + 'effect-water': AnnotationEffect.Water, + 'effect-ice': AnnotationEffect.Ice, + 'effect-magic': AnnotationEffect.Magic, + 'effect-grease': AnnotationEffect.Grease, + 'effect-spacetear': AnnotationEffect.SpaceTear +}; + +const PLAYFIELD_FOG_BRUSH_SIZE = 7.0; +const FOG_COMMIT_DEBOUNCE_MS = 500; +const DRAWING_IDLE_RESET_MS = 3000; +const PERSIST_BUTTON_HIDE_MS = 3000; + +export interface PlayToolsOptions { + session: PlaySession; + userId: string; + getStage: () => StageExports | undefined; + routes: () => SessionRoute[]; + defaultSessionFilter: () => string | undefined; +} + +/** + * Touch-play editing tools: every edit commits straight to the shared doc (fog + * masks, persisted drawings, scene switches) or to awareness (temporary + * drawings). There is no save pipeline here — the PartyKit server persists. + */ +export class PlayTools { + // Local-only view state (never shared) + activeLayer = $state(MapLayerType.None); + annotationsActiveLayer = $state(null); + lineWidth = $state(1.0); + fogTool = $state<{ mode: DrawMode; size: number }>({ mode: DrawMode.Erase, size: PLAYFIELD_FOG_BRUSH_SIZE }); + measurement = $state<{ type: number; coneAngle?: number; beamWidth?: number }>({ type: MeasurementType.Line }); + + // Temporary drawing state + currentTemporaryLayerId = $state(null); + tempLayerUrls = $state>({}); + + // Persist button + showPersistButton = $state(false); + persistButtonPosition = $state({ x: 0, y: 0 }); + + #options: PlayToolsOptions; + #persistButtonLayerId: string | null = null; + #pendingPersistPosition: { x: number; y: number } | null = null; + #persistButtonTimer: ReturnType | null = null; + #resetLayerTimer: ReturnType | null = null; + #fogCommitTimer: ReturnType | null = null; + #expiryTicker: ReturnType; + #now = $state(Date.now()); + #loadedTempMasks = new Map(); + + constructor(options: PlayToolsOptions) { + this.#options = options; + + // Tick once a second: expires temporary layers (display + own awareness state). + // Only touch the reactive clock while temp layers exist, so an idle playfield + // doesn't rebuild its render props every second. + this.#expiryTicker = setInterval(() => { + if (this.#temporaryLayers().length === 0) return; + this.#now = Date.now(); + this.#options.session.presence?.cleanupExpiredTemporaryLayers(); + if ( + this.currentTemporaryLayerId && + !this.#temporaryLayers().some((layer) => layer.id === this.currentTemporaryLayerId) + ) { + this.currentTemporaryLayerId = null; + } + }, 1000); + + // Load RLE masks for temporary layers drawn by other clients + $effect(() => { + for (const layer of this.#temporaryLayers()) { + if (!layer.maskData || this.#loadedTempMasks.get(layer.id) === layer.maskData) continue; + this.#loadedTempMasks.set(layer.id, layer.maskData); + // Our own active drawing is already on the canvas + if (layer.id === this.currentTemporaryLayerId) continue; + // Give Threlte a beat to create the layer component before loading + setTimeout(() => this.#loadTempMaskWithRetry(layer.id, layer.maskData), 50); + } + }); + } + + #temporaryLayers() { + return this.#options.session.presence?.temporaryLayers ?? []; + } + + /** Temporary drawings (all clients) as annotation layers, expired ones dropped. */ + get tempAnnotationLayers(): AnnotationLayerData[] { + const layers = this.#temporaryLayers(); + if (layers.length === 0) return []; // skip the ticking clock when idle + const now = this.#now; + return layers + .filter((layer) => layer.expiresAt > now || layer.id === this.currentTemporaryLayerId) + .map((layer) => ({ + id: layer.id, + name: layer.name, + color: layer.color, + opacity: layer.opacity, + url: this.tempLayerUrls[layer.id] ?? null, + visibility: StageMode.Player, + effect: + layer.effectType && layer.effectType !== AnnotationEffect.None + ? getDefaultEffectProps(layer.effectType as AnnotationEffect) + : undefined + })); + } + + get menuItems(): RadialMenuItemProps[] { + const routes = this.#options.routes(); + return [ + { + id: 'scene', + label: '', + icon: IconMap, + submenuLayout: 'table', + submenu: routes.flatMap((route) => + route.scenes.map((scene) => ({ + id: `scene-${scene.id}`, + label: scene.name, + gameSessionId: route.id + })) + ), + submenuFilterOptions: routes.map((route) => ({ value: route.id, label: route.name })), + submenuFilterDefault: this.#options.defaultSessionFilter(), + submenuFilterKey: 'gameSessionId' + }, + { + id: 'fog', + label: '', + icon: IconShadow, + submenu: [ + { id: 'fog-remove', label: '', icon: IconPaint }, + { id: 'fog-add', label: '', icon: IconPaintFilled }, + { id: 'fog-reset', label: '', icon: IconRestore }, + { id: 'fog-clear', label: '', icon: IconTrash } + ] + }, + { + id: 'draw', + label: '', + icon: IconPencil, + submenu: [ + ...Object.entries(DRAW_COLORS).map(([id, color]) => ({ id, label: '', color })), + ...Object.keys(DRAW_EFFECTS).map((id) => ({ id, label: '', effectType: DRAW_EFFECTS[id] })), + { + id: 'draw-delete-all', + label: '', + icon: IconTrash, + submenu: [ + { id: 'draw-delete-all-confirm', label: 'Yes, delete all' }, + { id: 'draw-delete-all-cancel', label: 'Cancel' } + ] + } + ] + }, + { + id: 'measure', + label: '', + icon: IconRuler, + submenu: [ + { id: 'measure-line', label: '', icon: IconLine }, + { id: 'measure-circle', label: '', icon: IconCircle }, + { id: 'measure-square', label: '', icon: IconSquare }, + { + id: 'measure-cone', + label: '', + icon: IconCone2, + submenu: [ + { id: 'measure-cone-30', label: '30°' }, + { id: 'measure-cone-60', label: '60°' }, + { id: 'measure-cone-90', label: '90°' } + ] + }, + { + id: 'measure-beam', + label: '', + icon: IconRectangleVertical, + submenu: [ + { id: 'measure-beam-5', label: '5 ft' }, + { id: 'measure-beam-10', label: '10 ft' }, + { id: 'measure-beam-15', label: '15 ft' }, + { id: 'measure-beam-20', label: '20 ft' } + ] + } + ] + } + ]; + } + + clearActiveTool() { + this.activeLayer = MapLayerType.None; + this.annotationsActiveLayer = null; + this.currentTemporaryLayerId = null; + } + + /** + * Reset the tool 3s after the user stops drawing, and surface the persist + * button for the finished temporary drawing. Restarts itself while a stroke + * is still in progress. + */ + resetToNoneAfterDelay() { + if (this.#resetLayerTimer) clearTimeout(this.#resetLayerTimer); + this.#resetLayerTimer = setTimeout(() => { + const stage = this.#options.getStage(); + if (stage?.annotations?.isDrawing() || stage?.fogOfWar?.isDrawing()) { + this.#resetLayerTimer = null; + this.resetToNoneAfterDelay(); + return; + } + + if (this.#pendingPersistPosition && this.currentTemporaryLayerId) { + if (this.#persistButtonTimer) clearTimeout(this.#persistButtonTimer); + this.#persistButtonLayerId = this.currentTemporaryLayerId; + this.persistButtonPosition = this.#pendingPersistPosition; + this.showPersistButton = true; + this.#pendingPersistPosition = null; + this.#persistButtonTimer = setTimeout(() => this.dismissPersistButton(), PERSIST_BUTTON_HIDE_MS); + } + + this.clearActiveTool(); + this.#resetLayerTimer = null; + }, DRAWING_IDLE_RESET_MS); + } + + selectMenuItem(itemId: string) { + devLog('play', 'Menu item selected:', itemId); + + if (itemId.startsWith('scene-')) { + this.#options.session.switchScene(itemId.replace('scene-', '')); + return; + } + if (itemId in DRAW_COLORS) { + this.#startTemporaryDrawing(DRAW_COLORS[itemId]); + return; + } + if (itemId in DRAW_EFFECTS) { + this.#startTemporaryDrawing('#ffffff', DRAW_EFFECTS[itemId]); + return; + } + + switch (itemId) { + case 'fog-remove': + this.activeLayer = MapLayerType.FogOfWar; + this.fogTool = { mode: DrawMode.Erase, size: PLAYFIELD_FOG_BRUSH_SIZE }; + break; + case 'fog-add': + this.activeLayer = MapLayerType.FogOfWar; + this.fogTool = { mode: DrawMode.Draw, size: PLAYFIELD_FOG_BRUSH_SIZE }; + break; + case 'fog-reset': + this.#options.getStage()?.fogOfWar?.reset(); + this.resetToNoneAfterDelay(); + break; + case 'fog-clear': + this.#options.getStage()?.fogOfWar?.clear(); + this.resetToNoneAfterDelay(); + break; + case 'draw-delete-all-confirm': + this.#deleteAllDrawings(); + break; + case 'measure-line': + this.activeLayer = MapLayerType.Measurement; + this.measurement = { type: MeasurementType.Line }; + break; + case 'measure-circle': + this.activeLayer = MapLayerType.Measurement; + this.measurement = { type: MeasurementType.Circle }; + break; + case 'measure-square': + this.activeLayer = MapLayerType.Measurement; + this.measurement = { type: MeasurementType.Square }; + break; + default: + if (itemId.startsWith('measure-cone-')) { + this.activeLayer = MapLayerType.Measurement; + this.measurement = { + type: MeasurementType.Cone, + coneAngle: parseInt(itemId.replace('measure-cone-', ''), 10) + }; + } else if (itemId.startsWith('measure-beam-')) { + this.activeLayer = MapLayerType.Measurement; + this.measurement = { + type: MeasurementType.Beam, + beamWidth: parseInt(itemId.replace('measure-beam-', ''), 10) + }; + } + } + } + + #startTemporaryDrawing(color: string, effectType?: AnnotationEffect) { + const presence = this.#options.session.presence; + if (!presence) return; + + // Continue on the existing layer when the same color is picked again + const existing = this.currentTemporaryLayerId + ? this.#temporaryLayers().find((l) => l.id === this.currentTemporaryLayerId && l.color === color && !effectType) + : null; + + if (!existing) { + this.currentTemporaryLayerId = uuidv4(); + presence.broadcastTemporaryLayer( + presence.createTemporaryLayer(this.currentTemporaryLayerId, color, '', { effectType }) + ); + } + + this.annotationsActiveLayer = this.currentTemporaryLayerId; + this.activeLayer = MapLayerType.Annotation; + this.lineWidth = 1.0; + this.resetToNoneAfterDelay(); + } + + /** Stage callback: a fog stroke changed — commit the RLE to the doc, debounced. */ + onFogUpdate = () => { + this.resetToNoneAfterDelay(); + if (this.#fogCommitTimer) clearTimeout(this.#fogCommitTimer); + this.#fogCommitTimer = setTimeout(() => { + this.#fogCommitTimer = null; + this.#commitFog(); + }, FOG_COMMIT_DEBOUNCE_MS); + }; + + async #commitFog() { + const { session, getStage } = this.#options; + const stage = getStage(); + const sceneId = session.activeSceneId; + if (!stage?.fogOfWar || !sceneId || !session.client) return; + if (stage.fogOfWar.isDrawing()) return; // the next stroke end re-arms the commit + + try { + const rle = await stage.fogOfWar.toRLE(); + if (rle && !stage.fogOfWar.isDrawing()) { + session.client.write.setFogMask(sceneId, rle); + devLog('play', `Committed fog mask (${rle.length} bytes) to doc`); + } + } catch (error) { + devError('play', 'Failed to commit fog mask', error); + } + } + + /** Stage callback: an annotation stroke finished. */ + onAnnotationUpdate = (layerId: string, blob: Promise, endPosition?: { x: number; y: number }) => { + this.resetToNoneAfterDelay(); + + blob.then(async (resolved) => { + this.tempLayerUrls[layerId] = URL.createObjectURL(resolved); + + const presence = this.#options.session.presence; + const stage = this.#options.getStage(); + const tempLayerId = this.currentTemporaryLayerId; + if (!presence || !stage?.annotations || !tempLayerId) return; + + try { + const current = this.#temporaryLayers().find((l) => l.id === tempLayerId); + const rle = await stage.annotations.toRLE(); + if (rle && rle.length > 0) { + presence.broadcastTemporaryLayer( + presence.createTemporaryLayer(tempLayerId, current?.color ?? '#ffffff', uint8ToBase64(rle), { + effectType: current?.effectType + }) + ); + this.#loadedTempMasks.set(tempLayerId, uint8ToBase64(rle)); + if (endPosition) this.#pendingPersistPosition = endPosition; + } + } catch (error) { + devError('play', 'Failed to broadcast temporary layer', error); + } + }); + }; + + /** Persist the finished temporary drawing as a permanent annotation in the doc. */ + async persistCurrentDrawing() { + const layerId = this.#persistButtonLayerId; + const { session } = this.#options; + const sceneId = session.activeSceneId; + const snapshot = session.activeScene; + this.dismissPersistButton(); + + if (!layerId || !sceneId || !snapshot || !session.client) return; + const tempLayer = this.#temporaryLayers().find((l) => l.id === layerId); + if (!tempLayer?.maskData) return; + + try { + const mask = base64ToUint8(tempLayer.maskData); + const minOrder = Math.min(1, ...snapshot.annotations.map((a) => a.order)); + const annotation: AnnotationRow = { + id: uuidv4(), + sceneId, + name: tempLayer.effectType ? 'Player effect' : 'Player drawing', + opacity: 1, + color: tempLayer.color, + url: null, + visibility: StageMode.Player, + order: minOrder - 1, + effectType: tempLayer.effectType ?? null + }; + + session.client.write.upsertAnnotation(sceneId, annotation, mask); + // Our own doc write is local-origin, so apply the mask to the new layer here + await session.loadAnnotationMask(annotation.id, mask); + + session.presence?.removeTemporaryLayer(layerId); + this.#loadedTempMasks.delete(layerId); + if (this.currentTemporaryLayerId === layerId) this.currentTemporaryLayerId = null; + devLog('play', 'Persisted drawing as annotation', annotation.id); + } catch (error) { + devError('play', 'Failed to persist drawing', error); + } + } + + dismissPersistButton() { + if (this.#persistButtonTimer) { + clearTimeout(this.#persistButtonTimer); + this.#persistButtonTimer = null; + } + this.showPersistButton = false; + this.#persistButtonLayerId = null; + } + + #deleteAllDrawings() { + const { session } = this.#options; + const sceneId = session.activeSceneId; + const snapshot = session.activeScene; + if (!sceneId || !snapshot || !session.client) return; + + for (const annotation of snapshot.annotations) { + session.client.write.deleteAnnotation(sceneId, annotation.id); + } + for (const layer of this.#temporaryLayers()) { + session.presence?.removeTemporaryLayer(layer.id); + } + this.tempLayerUrls = {}; + this.#loadedTempMasks.clear(); + this.clearActiveTool(); + devLog('play', 'Deleted all drawings for scene', sceneId); + } + + async #loadTempMaskWithRetry(layerId: string, maskData: string, retries = 3) { + for (let attempt = 0; attempt < retries; attempt++) { + if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 100 * attempt)); + try { + const stage = this.#options.getStage(); + if (stage?.annotations?.loadMask) { + await stage.annotations.loadMask(layerId, base64ToUint8(maskData)); + return; + } + } catch { + // Layer component may not exist yet; retry + } + } + devLog('play', `Failed to load temporary layer mask ${layerId} after ${retries} attempts`); + } + + destroy() { + clearInterval(this.#expiryTicker); + if (this.#resetLayerTimer) clearTimeout(this.#resetLayerTimer); + if (this.#persistButtonTimer) clearTimeout(this.#persistButtonTimer); + if (this.#fogCommitTimer) clearTimeout(this.#fogCommitTimer); + } +} From 368cbcee18c1eb583207bf853acb09d77377b946 Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Wed, 10 Jun 2026 02:53:52 -0400 Subject: [PATCH 03/10] mind blow working --- apps/web/partykit/party.ts | 28 + .../GameSession/LightManager.svelte | 78 +- .../GameSession/MarkerManager.svelte | 41 +- apps/web/src/lib/queries/annotations.ts | 39 - apps/web/src/lib/queries/index.ts | 4 - apps/web/src/lib/queries/lights.ts | 24 - apps/web/src/lib/queries/markers.ts | 55 - apps/web/src/lib/queries/masks.ts | 124 -- apps/web/src/lib/queries/scenes.ts | 148 +- apps/web/src/lib/realtime/docSchema.ts | 44 +- apps/web/src/lib/server/realtime/index.ts | 21 + .../src/lib/utils/{yjs => }/activityTimer.ts | 2 +- apps/web/src/lib/utils/index.ts | 1 - apps/web/src/lib/utils/markers/index.ts | 1 - .../markers/mergeMarkersWithProtection.ts | 57 - apps/web/src/lib/utils/measurements.ts | 2 +- .../lib/utils/stage/buildMeasurementProps.ts | 26 - .../lib/utils/stage/cleanStagePropsForYjs.ts | 30 - .../web/src/lib/utils/stage/decodeMaskData.ts | 12 - apps/web/src/lib/utils/stage/index.ts | 9 - .../src/lib/utils/stage/loadMaskFromApi.ts | 79 - .../web/src/lib/utils/yjs/PartyDataManager.ts | 980 ----------- .../src/lib/utils/yjs/sceneCoordination.ts | 145 -- apps/web/src/lib/utils/yjs/stores.ts | 145 -- apps/web/src/lib/utils/yjs/temporaryLayers.ts | 169 -- .../[[selectedScene]]/+page.svelte | 51 +- .../useEditorSession.svelte.ts | 2 - .../routes/(app)/[party]/play/+page@.svelte | 17 +- .../[party]/play/usePlaySession.svelte.ts | 2 - .../api/annotations/[annotationId]/+server.ts | 33 - .../routes/api/annotations/getMask/+server.ts | 24 - .../api/annotations/updateMask/+server.ts | 65 - .../routes/api/annotations/upsert/+server.ts | 33 - .../gameSessions/importGameSession/+server.ts | 6 + .../routes/api/light/deleteLight/+server.ts | 34 - .../routes/api/light/upsertLight/+server.ts | 54 - .../routes/api/marker/createMarker/+server.ts | 40 - .../routes/api/marker/deleteMarker/+server.ts | 40 - .../api/marker/getSceneMarkers/+server.ts | 32 - .../routes/api/marker/updateMarker/+server.ts | 45 - .../routes/api/marker/upsertMarker/+server.ts | 59 - .../api/party/savePartyState/+server.ts | 136 -- .../routes/api/scenes/deleteScene/+server.ts | 32 - .../api/scenes/duplicateScene/+server.ts | 36 - .../routes/api/scenes/getFogMask/+server.ts | 24 - .../api/scenes/reorderScenes/+server.ts | 34 - .../routes/api/scenes/timestamps/+server.ts | 56 - .../api/scenes/updateFogMask/+server.ts | 52 - .../routes/api/scenes/updateScene/+server.ts | 40 - docs/yjs-sync-architecture.md | 1511 ++--------------- .../MeasurementLayer/MeasurementLayer.svelte | 10 +- .../MeasurementManager.svelte | 32 - 52 files changed, 296 insertions(+), 4468 deletions(-) delete mode 100644 apps/web/src/lib/queries/annotations.ts delete mode 100644 apps/web/src/lib/queries/lights.ts delete mode 100644 apps/web/src/lib/queries/markers.ts delete mode 100644 apps/web/src/lib/queries/masks.ts rename apps/web/src/lib/utils/{yjs => }/activityTimer.ts (99%) delete mode 100644 apps/web/src/lib/utils/markers/index.ts delete mode 100644 apps/web/src/lib/utils/markers/mergeMarkersWithProtection.ts delete mode 100644 apps/web/src/lib/utils/stage/buildMeasurementProps.ts delete mode 100644 apps/web/src/lib/utils/stage/cleanStagePropsForYjs.ts delete mode 100644 apps/web/src/lib/utils/stage/decodeMaskData.ts delete mode 100644 apps/web/src/lib/utils/stage/index.ts delete mode 100644 apps/web/src/lib/utils/stage/loadMaskFromApi.ts delete mode 100644 apps/web/src/lib/utils/yjs/PartyDataManager.ts delete mode 100644 apps/web/src/lib/utils/yjs/sceneCoordination.ts delete mode 100644 apps/web/src/lib/utils/yjs/stores.ts delete mode 100644 apps/web/src/lib/utils/yjs/temporaryLayers.ts delete mode 100644 apps/web/src/routes/api/annotations/[annotationId]/+server.ts delete mode 100644 apps/web/src/routes/api/annotations/getMask/+server.ts delete mode 100644 apps/web/src/routes/api/annotations/updateMask/+server.ts delete mode 100644 apps/web/src/routes/api/annotations/upsert/+server.ts delete mode 100644 apps/web/src/routes/api/light/deleteLight/+server.ts delete mode 100644 apps/web/src/routes/api/light/upsertLight/+server.ts delete mode 100644 apps/web/src/routes/api/marker/createMarker/+server.ts delete mode 100644 apps/web/src/routes/api/marker/deleteMarker/+server.ts delete mode 100644 apps/web/src/routes/api/marker/getSceneMarkers/+server.ts delete mode 100644 apps/web/src/routes/api/marker/updateMarker/+server.ts delete mode 100644 apps/web/src/routes/api/marker/upsertMarker/+server.ts delete mode 100644 apps/web/src/routes/api/party/savePartyState/+server.ts delete mode 100644 apps/web/src/routes/api/scenes/deleteScene/+server.ts delete mode 100644 apps/web/src/routes/api/scenes/duplicateScene/+server.ts delete mode 100644 apps/web/src/routes/api/scenes/getFogMask/+server.ts delete mode 100644 apps/web/src/routes/api/scenes/reorderScenes/+server.ts delete mode 100644 apps/web/src/routes/api/scenes/timestamps/+server.ts delete mode 100644 apps/web/src/routes/api/scenes/updateFogMask/+server.ts delete mode 100644 apps/web/src/routes/api/scenes/updateScene/+server.ts diff --git a/apps/web/partykit/party.ts b/apps/web/partykit/party.ts index fc7ced013..f13bb5dfb 100644 --- a/apps/web/partykit/party.ts +++ b/apps/web/partykit/party.ts @@ -122,4 +122,32 @@ export default class PartyServer implements Party.Server { await this.#persistState(); } } + + async onRequest(req: Party.Request) { + // After the app writes party state to the DB directly (import, admin tools), + // it calls this to refresh the live doc from the database. + if (req.method === 'POST') { + const body = (await req.json()) as { type?: string }; + if (body.type === 'resync') { + const token = (this.room.env.INTERNAL_API_TOKEN as string | undefined) ?? 'dev-internal-token'; + if (req.headers.get('x-internal-token') !== token) { + return new Response('Unauthorized', { status: 401 }); + } + const doc = await unstable_getYDoc(this.room, this.#options); + const state = await appRequest(this.room, '/api/internal/partySnapshot', { + partyId: this.room.id + }); + doc.transact(() => { + const map = getPartyStateMap(doc); + map.set('activeSceneId', state.activeSceneId); + map.set('isPaused', state.isPaused); + getMeta(doc).set('schemaVersion', DOC_SCHEMA_VERSION); + getMeta(doc).set('hydratedAt', Date.now()); + }, HYDRATION_ORIGIN); + this.#dirty = false; + return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } }); + } + } + return new Response('OK', { status: 200 }); + } } diff --git a/apps/web/src/lib/components/GameSession/LightManager.svelte b/apps/web/src/lib/components/GameSession/LightManager.svelte index 1874b9705..90ba9acf8 100644 --- a/apps/web/src/lib/components/GameSession/LightManager.svelte +++ b/apps/web/src/lib/components/GameSession/LightManager.svelte @@ -25,9 +25,7 @@ MapLayerType } from '@tableslayer/stage'; import { IconTrash, IconArrowBack, IconFlame, IconLocationPin, IconCopy } from '@tabler/icons-svelte'; - import { useDeleteLightMutation, useUpsertLightMutation } from '$lib/queries'; import { queuePropertyUpdate, throttle } from '$lib/utils'; - import { handleMutation } from '$lib/factories'; import { v4 as uuidv4 } from 'uuid'; let { @@ -54,9 +52,6 @@ onLightDuplicated?: (newLight: Light) => void; } = $props(); - const deleteLight = useDeleteLightMutation(); - const upsertLight = useUpsertLightMutation(); - let formIsLoading = $state(false); let editingLightId = $derived(selectedLightId); @@ -68,26 +63,17 @@ selectedLightId = undefined; }; - const handleLightDelete = async (lightId: string) => { - await handleMutation({ - mutation: () => deleteLight.mutateAsync({ partyId, lightId }), - formLoadingState: (loading) => (formIsLoading = loading), - onSuccess: () => { - if (onLightDeleted) { - onLightDeleted(lightId); - } else { - const updatedLights = stageProps.light.lights.filter((light) => light.id !== lightId); - stageProps.light.lights = updatedLights; - queuePropertyUpdate(stageProps, ['light', 'lights'], updatedLights, 'light'); - } - selectedLightId = undefined; - socketUpdate(); - }, - toastMessages: { - success: { title: 'Light deleted' }, - error: { title: 'Error deleting light', body: (error) => error.message } - } - }); + // Light changes are doc writes (via callbacks); the server persists them. + const handleLightDelete = (lightId: string) => { + if (onLightDeleted) { + onLightDeleted(lightId); + } else { + const updatedLights = stageProps.light.lights.filter((light) => light.id !== lightId); + stageProps.light.lights = updatedLights; + queuePropertyUpdate(stageProps, ['light', 'lights'], updatedLights, 'light'); + } + selectedLightId = undefined; + socketUpdate(); }; const throttledUpdate = throttle((lightId: string) => { @@ -128,7 +114,7 @@ return { x: 0, y: 0 }; }; - const handleLightDuplicate = async (light: Light) => { + const handleLightDuplicate = (light: Light) => { const newPosition = calculateDuplicatePosition(light.position.x, light.position.y, light.radius); const newLight: Light = { @@ -141,38 +127,14 @@ opacity: light.opacity }; - await handleMutation({ - mutation: () => - upsertLight.mutateAsync({ - partyId, - sceneId, - lightData: { - id: newLight.id, - positionX: newLight.position.x, - positionY: newLight.position.y, - radius: newLight.radius, - color: newLight.color, - style: newLight.style, - pulse: newLight.pulse, - opacity: newLight.opacity - } - }), - formLoadingState: (loading) => (formIsLoading = loading), - onSuccess: () => { - if (onLightDuplicated) { - onLightDuplicated(newLight); - } else { - stageProps.light.lights = [...stageProps.light.lights, newLight]; - queuePropertyUpdate(stageProps, ['light', 'lights'], stageProps.light.lights, 'light'); - } - selectedLightId = newLight.id; - socketUpdate(); - }, - toastMessages: { - success: { title: 'Light duplicated' }, - error: { title: 'Error duplicating light', body: (error) => error.message } - } - }); + if (onLightDuplicated) { + onLightDuplicated(newLight); + } else { + stageProps.light.lights = [...stageProps.light.lights, newLight]; + queuePropertyUpdate(stageProps, ['light', 'lights'], stageProps.light.lights, 'light'); + } + selectedLightId = newLight.id; + socketUpdate(); }; const styleOptions = [ diff --git a/apps/web/src/lib/components/GameSession/MarkerManager.svelte b/apps/web/src/lib/components/GameSession/MarkerManager.svelte index 00817f1a0..e23c22707 100644 --- a/apps/web/src/lib/components/GameSession/MarkerManager.svelte +++ b/apps/web/src/lib/components/GameSession/MarkerManager.svelte @@ -39,7 +39,7 @@ IconHandFinger, IconLocationPin } from '@tabler/icons-svelte'; - import { useUploadFileMutation, useDeleteMarkerMutation } from '$lib/queries'; + import { useUploadFileMutation } from '$lib/queries'; import type { Snippet } from 'svelte'; import { queuePropertyUpdate, extractLocationFromUrl, throttle, trackChecklistItem } from '$lib/utils'; import { handleMutation } from '$lib/factories'; @@ -69,7 +69,6 @@ } = $props(); const uploadFile = useUploadFileMutation(); - const deleteMarker = useDeleteMarkerMutation(); let activeMarkerId = $state(null); let formIsLoading = $state(false); @@ -134,32 +133,18 @@ }); }; - const handleMarkerDelete = async (markerId: string) => { - await handleMutation({ - mutation: () => deleteMarker.mutateAsync({ partyId: partyId, markerId: markerId }), - formLoadingState: (loading) => (formIsLoading = loading), - onSuccess: () => { - // Call the deletion callback if provided (for Y.js sync) - // The callback will handle removing the marker from stageProps - if (onMarkerDeleted) { - onMarkerDeleted(markerId); - } else { - // Fallback - only update locally if no callback provided - const updatedMarkers = stageProps.marker.markers.filter((marker) => marker.id !== markerId); - stageProps.marker.markers = updatedMarkers; - queuePropertyUpdate(stageProps, ['marker', 'markers'], updatedMarkers, 'marker'); - } - - // Reset selected marker if we just deleted it - if (selectedMarkerId === markerId) { - selectedMarkerId = undefined; - } - }, - toastMessages: { - success: { title: 'Marker deleted' }, - error: { title: 'Error deleting marker', body: (error) => error.message } - } - }); + // Deleting a marker is a doc write (via the callback); the server persists it. + const handleMarkerDelete = (markerId: string) => { + if (onMarkerDeleted) { + onMarkerDeleted(markerId); + } else { + const updatedMarkers = stageProps.marker.markers.filter((marker) => marker.id !== markerId); + stageProps.marker.markers = updatedMarkers; + queuePropertyUpdate(stageProps, ['marker', 'markers'], updatedMarkers, 'marker'); + } + if (selectedMarkerId === markerId) { + selectedMarkerId = undefined; + } }; diff --git a/apps/web/src/lib/queries/annotations.ts b/apps/web/src/lib/queries/annotations.ts deleted file mode 100644 index ff0aafd80..000000000 --- a/apps/web/src/lib/queries/annotations.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { InsertAnnotation, SelectAnnotation } from '$lib/db/app/schema'; -import { mutationFactory } from '$lib/factories'; - -// Client-side mutations -export const useUpsertAnnotationMutation = () => { - return mutationFactory({ - mutationKey: ['upsertAnnotation'], - mutationFn: async (annotation) => { - const response = await fetch('/api/annotations/upsert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(annotation) - }); - - if (!response.ok) { - throw new Error('Failed to upsert annotation'); - } - - return response.json(); - } - }); -}; - -export const useDeleteAnnotationMutation = () => { - return mutationFactory<{ annotationId: string }, boolean, Error>({ - mutationKey: ['deleteAnnotation'], - mutationFn: async ({ annotationId }) => { - const response = await fetch(`/api/annotations/${annotationId}`, { - method: 'DELETE' - }); - - if (!response.ok) { - throw new Error('Failed to delete annotation'); - } - - return response.json(); - } - }); -}; diff --git a/apps/web/src/lib/queries/index.ts b/apps/web/src/lib/queries/index.ts index 1b8a4a26c..903c9e747 100644 --- a/apps/web/src/lib/queries/index.ts +++ b/apps/web/src/lib/queries/index.ts @@ -1,13 +1,9 @@ -export * from './annotations'; export * from './apiKeys'; export * from './auth'; export * from './checklist'; export * from './email'; export * from './file'; export * from './gameSessions'; -export * from './lights'; -export * from './markers'; -export * from './masks'; export * from './parties'; export * from './partyInvites'; export * from './partyMembers'; diff --git a/apps/web/src/lib/queries/lights.ts b/apps/web/src/lib/queries/lights.ts deleted file mode 100644 index be2ad649d..000000000 --- a/apps/web/src/lib/queries/lights.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { SelectLight } from '$lib/db/app/schema'; -import { mutationFactory } from '$lib/factories'; - -export const useUpsertLightMutation = () => { - return mutationFactory< - { partyId: string; sceneId: string; lightData: Partial }, - { success: boolean; light: SelectLight; operation: 'created' | 'updated' } - >({ - mutationKey: ['upsertLight'], - endpoint: '/api/light/upsertLight', - method: 'POST', - onSuccess: async () => { - return; - } - }); -}; - -export const useDeleteLightMutation = () => { - return mutationFactory<{ partyId: string; lightId: string }>({ - mutationKey: ['deleteLight'], - endpoint: '/api/light/deleteLight', - method: 'POST' - }); -}; diff --git a/apps/web/src/lib/queries/markers.ts b/apps/web/src/lib/queries/markers.ts deleted file mode 100644 index f0d341bc8..000000000 --- a/apps/web/src/lib/queries/markers.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { SelectMarker } from '$lib/db/app/schema'; -import { mutationFactory } from '$lib/factories'; - -export const useUpdateMarkerMutation = () => { - return mutationFactory< - { partyId: string; markerId: string; markerData: Partial }, - { success: boolean; marker: SelectMarker } - >({ - mutationKey: ['updateMarker'], - endpoint: '/api/marker/updateMarker', - method: 'POST', - onSuccess: async () => { - // Prevent invalidation by providing a custom onSuccess handler - return; - } - }); -}; - -export const useCreateMarkerMutation = () => { - return mutationFactory< - { partyId: string; sceneId: string; markerData: Partial }, - { success: boolean; marker: SelectMarker } - >({ - mutationKey: ['insertMarker'], - endpoint: '/api/marker/createMarker', - method: 'POST', - onSuccess: async () => { - // Prevent invalidation by providing a custom onSuccess handler - return; - } - }); -}; - -export const useDeleteMarkerMutation = () => { - return mutationFactory<{ partyId: string; markerId: string }>({ - mutationKey: ['deleteMarker'], - endpoint: '/api/marker/deleteMarker', - method: 'POST' - }); -}; - -export const useUpsertMarkerMutation = () => { - return mutationFactory< - { partyId: string; sceneId: string; markerData: Partial }, - { success: boolean; marker: SelectMarker; operation: 'created' | 'updated' } - >({ - mutationKey: ['upsertMarker'], - endpoint: '/api/marker/upsertMarker', - method: 'POST', - onSuccess: async () => { - // Prevent invalidation by providing a custom onSuccess handler - return; - } - }); -}; diff --git a/apps/web/src/lib/queries/masks.ts b/apps/web/src/lib/queries/masks.ts deleted file mode 100644 index 15eda2118..000000000 --- a/apps/web/src/lib/queries/masks.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { mutationFactory } from '$lib/factories'; -import { createQuery } from '@tanstack/svelte-query'; - -/** - * Updates the fog of war mask for a scene with RLE data - */ -export const useUpdateFogMaskMutation = () => { - return mutationFactory<{ sceneId: string; partyId: string; maskData: Uint8Array }, { success: boolean }, Error>({ - mutationKey: ['updateFogMask'], - mutationFn: async ({ sceneId, partyId, maskData }) => { - // Convert Uint8Array to regular array for JSON serialization - const response = await fetch('/api/scenes/updateFogMask', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sceneId, - partyId, - maskData: Array.from(maskData) - }) - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error('Fog mask update failed:', errorText); - throw new Error(`Failed to update fog mask: ${errorText}`); - } - - return response.json(); - } - }); -}; - -/** - * Updates the annotation mask with RLE data - */ -export const useUpdateAnnotationMaskMutation = () => { - return mutationFactory<{ annotationId: string; partyId: string; maskData: Uint8Array }, { success: boolean }, Error>({ - mutationKey: ['updateAnnotationMask'], - mutationFn: async ({ annotationId, partyId, maskData }) => { - // Convert Uint8Array to regular array for JSON serialization - const response = await fetch('/api/annotations/updateMask', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - annotationId, - partyId, - maskData: Array.from(maskData) - }) - }); - - if (!response.ok) { - throw new Error('Failed to update annotation mask'); - } - - return response.json(); - } - }); -}; - -/** - * Fetches the fog mask data for a scene - */ -export const useFogMaskQuery = (sceneId: string | undefined) => { - return createQuery(() => ({ - queryKey: ['fogMask', sceneId], - queryFn: async () => { - if (!sceneId) return null; - - const response = await fetch(`/api/scenes/getFogMask?sceneId=${sceneId}`); - - if (!response.ok) { - throw new Error('Failed to fetch fog mask'); - } - - const data = (await response.json()) as { maskData?: string }; - - // Convert base64 back to Uint8Array if mask data exists - if (data.maskData) { - const binaryString = atob(data.maskData); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes; - } - - return null; - }, - enabled: !!sceneId - })); -}; - -/** - * Fetches the annotation mask data for an annotation - */ -export const useAnnotationMaskQuery = (annotationId: string | undefined) => { - return createQuery(() => ({ - queryKey: ['annotationMask', annotationId], - queryFn: async () => { - if (!annotationId) return null; - - const response = await fetch(`/api/annotations/getMask?annotationId=${annotationId}`); - - if (!response.ok) { - throw new Error('Failed to fetch annotation mask'); - } - - const data = (await response.json()) as { maskData?: string }; - - // Convert base64 back to Uint8Array if mask data exists - if (data.maskData) { - const binaryString = atob(data.maskData); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes; - } - - return null; - }, - enabled: !!annotationId - })); -}; diff --git a/apps/web/src/lib/queries/scenes.ts b/apps/web/src/lib/queries/scenes.ts index 6e4530d19..64b541d92 100644 --- a/apps/web/src/lib/queries/scenes.ts +++ b/apps/web/src/lib/queries/scenes.ts @@ -1,23 +1,11 @@ -import type { SelectGameSession, SelectMarker, SelectScene } from '$lib/db/app/schema'; +import type { SelectScene } from '$lib/db/app/schema'; import { mutationFactory } from '$lib/factories'; -import { createQuery } from '@tanstack/svelte-query'; - -export const useUpdateSceneMutation = () => { - return mutationFactory< - { partyId: string; sceneId: string; sceneData: Partial }, - { success: boolean; scene: SelectScene } - >({ - mutationKey: ['updateScene'], - endpoint: '/api/scenes/updateScene', - method: 'POST', - onSuccess: async () => { - // Prevent invalidation by providing a custom onSuccess handler - // Scene data is managed by Y.js, no need to refetch from server - return; - } - }); -}; +// Scene creation still goes through the API: the server computes map/grid +// alignment from the uploaded image, and the response row is then added to the +// session doc. Everything else about scenes is doc-authoritative — the PartyKit +// room persists changes through /api/internal/persistSession, so there are no +// client-side update/delete/reorder mutations. export const useCreateSceneMutation = () => { return mutationFactory< { partyId: string; sceneData: Partial }, @@ -27,130 +15,8 @@ export const useCreateSceneMutation = () => { endpoint: '/api/scenes/createScene', method: 'POST', onSuccess: async () => { - // Prevent invalidation to avoid reactive loops - // Scene data is managed by Y.js + // No invalidation: the caller adds the new scene to the session doc return; } }); }; - -export const useDeleteSceneMutation = () => { - return mutationFactory< - { gameSessionId: string; partyId: string; sceneId: string }, - { success: boolean; scenes: SelectScene[] } - >({ - mutationKey: ['deleteScene'], - endpoint: '/api/scenes/deleteScene', - method: 'POST', - onSuccess: async () => { - // Prevent invalidation to avoid reactive loops - // Scene data is managed by Y.js - return; - } - }); -}; - -export const useReorderScenesMutation = () => { - return mutationFactory< - { - partyId: string; - gameSessionId: string; - sceneId: string; - newOrder: number; - oldOrder: number; - }, - { success: boolean; scenes: SelectScene[] } - >({ - mutationKey: ['reorderScenes'], - endpoint: '/api/scenes/reorderScenes', - method: 'POST', - onSuccess: async () => { - // Prevent invalidation to avoid reactive loops - // Scene data is managed by Y.js - return; - } - }); -}; - -export const useDuplicateSceneMutation = () => { - return mutationFactory< - { partyId: string; sceneId: string }, - { success: boolean; scene: SelectScene; scenes: SelectScene[] } - >({ - mutationKey: ['duplicateScene'], - endpoint: '/api/scenes/duplicateScene', - method: 'POST', - onSuccess: async () => { - // Prevent invalidation to avoid reactive loops - // Scene data is managed by Y.js - return; - } - }); -}; - -// Unified save mutation for scene + markers + party state -export const useSavePartyStateMutation = () => { - return mutationFactory< - { - partyId: string; - gameSessionId: string; - sceneId: string; - sceneData: Partial; - gameSessionData?: { lastUpdated?: Date }; - markerOperations?: Array<{ - operation: 'create' | 'update' | 'delete'; - id: string; - data?: Partial; - }>; - thumbnailLocation?: string; - }, - { - success: boolean; - results: { - scene: SelectScene; - markers: { - created: SelectMarker[]; - updated: SelectMarker[]; - deleted: string[]; - }; - gameSession: SelectGameSession; - }; - } - >({ - mutationKey: ['savePartyState'], - endpoint: '/api/party/savePartyState', - method: 'POST' - }); -}; - -type SceneTimestampsResponse = { - timestamps: Record; -}; - -export const useGetSceneTimestampsQuery = (options: { gameSessionId: string; partyId: string }) => { - return createQuery(() => ({ - queryKey: ['sceneTimestamps', options.gameSessionId], - queryFn: async () => { - const response = await fetch('/api/scenes/timestamps', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - gameSessionId: options.gameSessionId, - partyId: options.partyId - }) - }); - - if (!response.ok) { - const error = (await response.json()) as { error?: string }; - throw new Error(error.error || 'Failed to fetch scene timestamps'); - } - - return response.json(); - }, - enabled: !!options.gameSessionId && !!options.partyId, - staleTime: 30000, // Consider data fresh for 30 seconds - refetchOnWindowFocus: true - })); -}; diff --git a/apps/web/src/lib/realtime/docSchema.ts b/apps/web/src/lib/realtime/docSchema.ts index cf1b981a5..d3330eeaf 100644 --- a/apps/web/src/lib/realtime/docSchema.ts +++ b/apps/web/src/lib/realtime/docSchema.ts @@ -141,6 +141,12 @@ export const createSessionWriter = (doc: Y.Doc, origin: unknown) => { const transact = (fn: () => void) => doc.transact(fn, origin); const scenes = () => getScenesMap(doc); + // A write against a missing scene is a caller bug (stale id, not-yet-hydrated + // doc). Never fail silently — silent no-ops cost hours of debugging. + const warnMissing = (what: string, sceneId: string) => { + console.warn(`[realtime] write dropped: ${what} — scene ${sceneId} not in doc`); + }; + const collectionOf = (sceneId: string, collection: 'markers' | 'lights' | 'annotations') => scenes().get(sceneId)?.get(collection) as Y.Map> | undefined; @@ -152,7 +158,10 @@ export const createSessionWriter = (doc: Y.Doc, origin: unknown) => { ) => { transact(() => { const map = collectionOf(sceneId, collection); - if (!map) return; + if (!map) { + warnMissing(`upsert ${collection}/${id}`, sceneId); + return; + } let row = map.get(id); if (!row) { row = new Y.Map(); @@ -163,7 +172,14 @@ export const createSessionWriter = (doc: Y.Doc, origin: unknown) => { }; const deleteRow = (sceneId: string, collection: 'markers' | 'lights' | 'annotations', id: string) => { - transact(() => collectionOf(sceneId, collection)?.delete(id)); + transact(() => { + const map = collectionOf(sceneId, collection); + if (!map) { + warnMissing(`delete ${collection}/${id}`, sceneId); + return; + } + map.delete(id); + }); }; return { @@ -207,12 +223,23 @@ export const createSessionWriter = (doc: Y.Doc, origin: unknown) => { setSceneSettings(sceneId: string, fields: Partial) { transact(() => { const settings = scenes().get(sceneId)?.get('settings') as Y.Map | undefined; - if (settings) setChangedFields(settings, fields as Record); + if (!settings) { + warnMissing('setSceneSettings', sceneId); + return; + } + setChangedFields(settings, fields as Record); }); }, setFogMask(sceneId: string, mask: Uint8Array) { - transact(() => scenes().get(sceneId)?.set('fogMask', mask)); + transact(() => { + const scene = scenes().get(sceneId); + if (!scene) { + warnMissing('setFogMask', sceneId); + return; + } + scene.set('fogMask', mask); + }); }, upsertMarker(sceneId: string, marker: MarkerRow) { @@ -247,7 +274,14 @@ export const createSessionWriter = (doc: Y.Doc, origin: unknown) => { upsertRow(sceneId, 'annotations', annotationId, fields as Record); }, setAnnotationMask(sceneId: string, annotationId: string, mask: Uint8Array) { - transact(() => collectionOf(sceneId, 'annotations')?.get(annotationId)?.set(ANNOTATION_MASK_KEY, mask)); + transact(() => { + const row = collectionOf(sceneId, 'annotations')?.get(annotationId); + if (!row) { + warnMissing(`setAnnotationMask ${annotationId}`, sceneId); + return; + } + row.set(ANNOTATION_MASK_KEY, mask); + }); }, deleteAnnotation(sceneId: string, annotationId: string) { deleteRow(sceneId, 'annotations', annotationId); diff --git a/apps/web/src/lib/server/realtime/index.ts b/apps/web/src/lib/server/realtime/index.ts index 5e6307c1f..d57affe0b 100644 --- a/apps/web/src/lib/server/realtime/index.ts +++ b/apps/web/src/lib/server/realtime/index.ts @@ -126,3 +126,24 @@ export const applyPartyStatePersist = async (partyId: string, state: PartyStateW .set({ activeSceneId: state.activeSceneId, gameSessionIsPaused: state.isPaused }) .where(eq(partyTable.id, partyId)); }; + +// After writing realtime-owned state to the DB directly (import, admin tools), +// tell the live room to rebuild from the database. Best-effort: if the room +// isn't running, the next connection hydrates from the DB anyway. +const requestRoomResync = async (party: 'party' | 'game_session', roomId: string) => { + const host = process.env.PUBLIC_PARTYKIT_HOST || 'localhost:1999'; + const protocol = host.startsWith('localhost') || host.startsWith('127.') ? 'http' : 'https'; + const token = process.env.INTERNAL_API_TOKEN || 'dev-internal-token'; + try { + await fetch(`${protocol}://${host}/parties/${party}/${roomId}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-internal-token': token }, + body: JSON.stringify({ type: 'resync' }) + }); + } catch (error) { + console.warn(`room resync failed for ${party}/${roomId}`, error); + } +}; + +export const requestPartyRoomResync = (partyId: string) => requestRoomResync('party', partyId); +export const requestGameSessionRoomResync = (gameSessionId: string) => requestRoomResync('game_session', gameSessionId); diff --git a/apps/web/src/lib/utils/yjs/activityTimer.ts b/apps/web/src/lib/utils/activityTimer.ts similarity index 99% rename from apps/web/src/lib/utils/yjs/activityTimer.ts rename to apps/web/src/lib/utils/activityTimer.ts index deab0673e..5d9b01238 100644 --- a/apps/web/src/lib/utils/yjs/activityTimer.ts +++ b/apps/web/src/lib/utils/activityTimer.ts @@ -1,5 +1,5 @@ import { browser } from '$app/environment'; -import { devLog } from '../debug'; +import { devLog } from './debug'; export interface ActivityTimer { /** diff --git a/apps/web/src/lib/utils/index.ts b/apps/web/src/lib/utils/index.ts index 56f2d7acd..8e10bf614 100644 --- a/apps/web/src/lib/utils/index.ts +++ b/apps/web/src/lib/utils/index.ts @@ -20,4 +20,3 @@ export * from './randomQuotes'; export * from './sceneSettings'; export * from './stageKeyCommands'; export * from './throttle'; -export * from './yjs/stores'; diff --git a/apps/web/src/lib/utils/markers/index.ts b/apps/web/src/lib/utils/markers/index.ts deleted file mode 100644 index 933864dd1..000000000 --- a/apps/web/src/lib/utils/markers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { mergeMarkersWithProtection } from './mergeMarkersWithProtection'; diff --git a/apps/web/src/lib/utils/markers/mergeMarkersWithProtection.ts b/apps/web/src/lib/utils/markers/mergeMarkersWithProtection.ts deleted file mode 100644 index e62d9a652..000000000 --- a/apps/web/src/lib/utils/markers/mergeMarkersWithProtection.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { Marker } from '@tableslayer/stage'; - -/** - * Merge markers while protecting ones being moved or edited - * This prevents Y.js updates from overwriting local changes during drag operations - * - * @param localMarkers - Current local marker state - * @param incomingMarkers - Markers from Y.js sync - * @param beingMoved - Set of marker IDs currently being moved - * @param beingEdited - Set of marker IDs currently being edited - * @param recentlyDeleted - Set of marker IDs recently deleted locally - * @returns Merged marker array with protections applied - */ -export function mergeMarkersWithProtection( - localMarkers: Marker[], - incomingMarkers: Marker[], - beingMoved: Set, - beingEdited: Set, - recentlyDeleted: Set -): Marker[] { - const protectedMarkers = new Set([...beingMoved, ...beingEdited]); - - // Start with incoming markers as base, but exclude recently deleted ones - const resultMap = new Map(); - incomingMarkers.forEach((marker) => { - // Skip markers that were recently deleted in this editor - if (!recentlyDeleted.has(marker.id)) { - resultMap.set(marker.id, marker); - } - }); - - // Apply protections and add new markers - for (const localMarker of localMarkers) { - if (protectedMarkers.has(localMarker.id)) { - if (resultMap.has(localMarker.id)) { - // Marker exists in incoming - apply protection - const incomingMarker = resultMap.get(localMarker.id)!; - if (beingMoved.has(localMarker.id)) { - // For moved markers, only preserve position from local state - // This protects the marker being actively dragged in THIS editor - resultMap.set(localMarker.id, { - ...incomingMarker, - position: localMarker.position - }); - } else if (beingEdited.has(localMarker.id)) { - // For edited markers, preserve entire local marker - resultMap.set(localMarker.id, localMarker); - } - } else { - // New marker not in incoming yet - add it - resultMap.set(localMarker.id, localMarker); - } - } - } - - return Array.from(resultMap.values()); -} diff --git a/apps/web/src/lib/utils/measurements.ts b/apps/web/src/lib/utils/measurements.ts index afbf0ee44..e9583e002 100644 --- a/apps/web/src/lib/utils/measurements.ts +++ b/apps/web/src/lib/utils/measurements.ts @@ -1,5 +1,5 @@ +import type { MeasurementData } from '$lib/realtime'; import type { StageProps } from '@tableslayer/stage'; -import type { MeasurementData } from './yjs/PartyDataManager'; /** * Get the latest measurement from a collection by timestamp diff --git a/apps/web/src/lib/utils/stage/buildMeasurementProps.ts b/apps/web/src/lib/utils/stage/buildMeasurementProps.ts deleted file mode 100644 index 81b9b8553..000000000 --- a/apps/web/src/lib/utils/stage/buildMeasurementProps.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { StageProps } from '@tableslayer/stage'; - -/** - * Build measurement properties object from stageProps - * Used when broadcasting measurements via Y.js - * - * @param stageProps - The current stage props - * @returns Measurement properties for Y.js - */ -export function buildMeasurementProps(stageProps: StageProps) { - return { - color: stageProps.measurement.color, - thickness: stageProps.measurement.thickness, - outlineColor: stageProps.measurement.outlineColor, - outlineThickness: stageProps.measurement.outlineThickness, - opacity: stageProps.measurement.opacity, - markerSize: stageProps.measurement.markerSize, - autoHideDelay: stageProps.measurement.autoHideDelay, - fadeoutTime: stageProps.measurement.fadeoutTime, - showDistance: stageProps.measurement.showDistance, - snapToGrid: stageProps.measurement.snapToGrid, - enableDMG252: stageProps.measurement.enableDMG252, - beamWidth: stageProps.measurement.beamWidth, - coneAngle: stageProps.measurement.coneAngle - }; -} diff --git a/apps/web/src/lib/utils/stage/cleanStagePropsForYjs.ts b/apps/web/src/lib/utils/stage/cleanStagePropsForYjs.ts deleted file mode 100644 index ab9155ce5..000000000 --- a/apps/web/src/lib/utils/stage/cleanStagePropsForYjs.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { StageProps } from '@tableslayer/stage'; - -/** - * Clean stage props for Y.js synchronization - * Removes local-only properties that shouldn't be synced - * - * @param props - The stage props to clean - * @returns Cleaned props suitable for Y.js - */ -export function cleanStagePropsForYjs(props: StageProps): StageProps { - return { - ...props, - annotations: { - ...props.annotations, - activeLayer: null, - lineWidth: undefined - }, - fogOfWar: { - ...props.fogOfWar, - tool: { - ...props.fogOfWar.tool - } - }, - grid: { - ...props.grid, - worldGridUnits: props.grid.worldGridUnits || 'FT', - worldGridSize: props.grid.worldGridSize || 5 - } - }; -} diff --git a/apps/web/src/lib/utils/stage/decodeMaskData.ts b/apps/web/src/lib/utils/stage/decodeMaskData.ts deleted file mode 100644 index f7c7bd193..000000000 --- a/apps/web/src/lib/utils/stage/decodeMaskData.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { base64ToUint8Array } from '../encoding'; - -/** - * Decode base64-encoded mask data to Uint8Array - * Used for fog of war and annotation masks stored in RLE format - * - * @param base64Data - Base64-encoded mask data - * @returns Decoded Uint8Array - */ -export function decodeMaskData(base64Data: string): Uint8Array { - return base64ToUint8Array(base64Data); -} diff --git a/apps/web/src/lib/utils/stage/index.ts b/apps/web/src/lib/utils/stage/index.ts deleted file mode 100644 index 6f6687049..000000000 --- a/apps/web/src/lib/utils/stage/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { buildMeasurementProps } from './buildMeasurementProps'; -export { cleanStagePropsForYjs } from './cleanStagePropsForYjs'; -export { decodeMaskData } from './decodeMaskData'; -export { - loadAnnotationMaskFromApi, - loadFogMaskFromApi, - loadInitialAnnotationMasks, - loadInitialFogMask -} from './loadMaskFromApi'; diff --git a/apps/web/src/lib/utils/stage/loadMaskFromApi.ts b/apps/web/src/lib/utils/stage/loadMaskFromApi.ts deleted file mode 100644 index e738d5e42..000000000 --- a/apps/web/src/lib/utils/stage/loadMaskFromApi.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { decodeMaskData } from './decodeMaskData'; - -/** - * Fetch and load a fog mask from the API - * - * @param sceneId - The scene ID to fetch fog mask for - * @param fogOfWar - The fog of war stage exports with fromRLE method - * @returns Promise that resolves when mask is loaded - */ -export async function loadFogMaskFromApi( - sceneId: string, - fogOfWar: { fromRLE: (data: Uint8Array, width: number, height: number) => Promise } -): Promise { - const response = await fetch(`/api/scenes/getFogMask?sceneId=${sceneId}`); - const data = await response.json(); - - if (data.maskData && fogOfWar.fromRLE) { - const bytes = decodeMaskData(data.maskData); - await fogOfWar.fromRLE(bytes, 1024, 1024); - } -} - -/** - * Fetch and load an annotation mask from the API - * - * @param annotationId - The annotation ID to fetch mask for - * @param annotations - The annotations stage exports with loadMask method - * @returns Promise that resolves when mask is loaded - */ -export async function loadAnnotationMaskFromApi( - annotationId: string, - annotations: { loadMask: (layerId: string, data: Uint8Array) => Promise } -): Promise { - const response = await fetch(`/api/annotations/getMask?annotationId=${annotationId}`); - const data = await response.json(); - - if (data.maskData && annotations.loadMask) { - const bytes = decodeMaskData(data.maskData); - await annotations.loadMask(annotationId, bytes); - } -} - -/** - * Load initial fog mask from SSR data - * - * @param maskData - Base64-encoded mask data from SSR - * @param fogOfWar - The fog of war stage exports with fromRLE method - * @returns Promise that resolves when mask is loaded - */ -export async function loadInitialFogMask( - maskData: string, - fogOfWar: { fromRLE: (data: Uint8Array, width: number, height: number) => Promise } -): Promise { - if (!fogOfWar.fromRLE) return; - - const bytes = decodeMaskData(maskData); - await fogOfWar.fromRLE(bytes, 1024, 1024); -} - -/** - * Load initial annotation masks from SSR data - * - * @param masks - Record of annotation ID to base64-encoded mask data - * @param annotations - The annotations stage exports with loadMask method - * @returns Promise that resolves when all masks are loaded - */ -export async function loadInitialAnnotationMasks( - masks: Record, - annotations: { loadMask: (layerId: string, data: Uint8Array) => Promise } -): Promise { - if (!annotations.loadMask) return; - - const promises = Object.entries(masks).map(async ([annotationId, maskData]) => { - const bytes = decodeMaskData(maskData); - await annotations.loadMask(annotationId, bytes); - }); - - await Promise.all(promises); -} diff --git a/apps/web/src/lib/utils/yjs/PartyDataManager.ts b/apps/web/src/lib/utils/yjs/PartyDataManager.ts deleted file mode 100644 index 2253bcbd4..000000000 --- a/apps/web/src/lib/utils/yjs/PartyDataManager.ts +++ /dev/null @@ -1,980 +0,0 @@ -import type { HoveredMarker, Marker, StageProps } from '@tableslayer/stage'; -import YPartyKitProvider from 'y-partykit/provider'; -import * as Y from 'yjs'; -import { devLog, devWarn } from '../debug'; - -export interface SceneMetadata { - id: string; - name: string; - order: number; - mapLocation?: string | null; - mapThumbLocation?: string | null; - gameSessionId: string; - thumb?: { - resizedUrl: string; - originalUrl?: string; - }; -} - -export interface CursorData { - userId: string; - worldPosition: { x: number; y: number; z: number }; - color?: string; - label?: string; - lastMoveTime: number; - clientId?: number; -} - -export interface MeasurementData { - userId: string; - startPoint: { x: number; y: number }; - endPoint: { x: number; y: number }; - type: number; // MeasurementType enum value - timestamp: number; - clientId?: number; - // Visual properties - color: string; - thickness: number; - outlineColor: string; - outlineThickness: number; - opacity: number; - markerSize: number; - // Timing properties - autoHideDelay: number; - fadeoutTime: number; - // Distance properties - showDistance: boolean; - snapToGrid: boolean; - enableDMG252: boolean; - // Beam and Cone specific properties - beamWidth?: number; - coneAngle?: number; -} - -export interface LocalViewportState { - sceneOffset: { x: number; y: number }; - sceneZoom: number; -} - -export interface SceneData { - stageProps: StageProps; - markers: Marker[]; - localStates: Record; - lastUpdated: number; - saveInProgress: boolean; - activeSaver?: string; -} - -export interface PartyState { - isPaused: boolean; - activeSceneId?: string; - cursors: Record; -} - -/** - * Y.js-powered Party Data Manager using PartyKit provider - */ -export class PartyDataManager { - private doc: Y.Doc; - private gameSessionProvider: YPartyKitProvider; - private partyDoc: Y.Doc; // Separate document for party-level state - private partyProvider: YPartyKitProvider; // Separate provider for party-level state - private userId: string; - private partyId: string; - private partykitHost: string; - private clientId: number; // Unique ID for this editor instance - public gameSessionId?: string; - - // Game session-specific Y.js shared data structures - private yScenes: Y.Map>; - private yScenesList: Y.Array; - private yGameSessionMeta: Y.Map; // For game session metadata like initialization flags - private yCursors: Y.Map; - - // Party-level Y.js shared data structures - private yPartyState: Y.Map; - - // Reactive state - private subscribers = new Set<() => void>(); - private isPartyConnected = false; - private isGameSessionConnected = false; - - // Heartbeat interval for keeping awareness alive - private awarenessHeartbeatInterval: ReturnType | null = null; - - // Track which scenes this specific instance has observers for (local to this editor) - private sceneObservers = new Set(); - - // Stored handler reference for cleanup - private awarenessChangeHandler: () => void; - - // SSR data protection (handled at subscription level in page component) - private freshPageLoadTime: number; - - constructor(partyId: string, userId: string, gameSessionId?: string, partykitHost: string = 'localhost:1999') { - this.userId = userId; - this.partyId = partyId; - this.gameSessionId = gameSessionId; - this.partykitHost = partykitHost; - this.freshPageLoadTime = Date.now(); // Track when this instance was created (page load time) - - // Use provided PartyKit host - const host = partykitHost; - - // Initialize game session-specific Y.js document - this.doc = new Y.Doc(); - - // Get unique client ID for this editor instance (auto-generated by Y.js) - this.clientId = this.doc.clientID; - const gameSessionRoomName = gameSessionId ? `${gameSessionId}` : `party-${partyId}`; - - devLog( - 'yjs', - `PartyDataManager connecting to PartyKit: ${host}, room: ${gameSessionRoomName}, clientId: ${this.clientId}, userId: ${this.userId}` - ); - - // Create game session provider - this.gameSessionProvider = new YPartyKitProvider(host, gameSessionRoomName, this.doc, { - party: 'game_session', - params: { userId } - }); - - // Initialize party-level Y.js document and provider (shared across all game sessions) - this.partyDoc = new Y.Doc(); - const partyRoomName = partyId; - - devLog('yjs', `PartyDataManager connecting to party-level PartyKit: ${host}, room: ${partyRoomName}`); - - this.partyProvider = new YPartyKitProvider(host, partyRoomName, this.partyDoc, { - party: 'party', - params: { userId } - }); - - // Initialize game session-specific shared data structures - this.yScenes = this.doc.getMap('scenes'); - this.yScenesList = this.doc.getArray('scenesList'); - this.yGameSessionMeta = this.doc.getMap('gameSessionMeta'); - this.yCursors = this.doc.getMap('cursors'); - - // Initialize party-level shared data structures - this.yPartyState = this.partyDoc.getMap('partyState'); - - // Set up connection status listeners for both providers - this.gameSessionProvider.on('status', (event: { status: string }) => { - devLog( - 'yjs', - `[${this.clientId}] Y.js game session connection status: ${event.status} - room: ${gameSessionRoomName}` - ); - this.isGameSessionConnected = event.status === 'connected'; - - // When game session connects, ensure scene observers are properly set up - if (event.status === 'connected') { - devLog('yjs', `[${this.clientId}] Game session connected - refreshing scene observers for connected state`); - setTimeout(() => { - // Clear existing observers and re-add them for connected state - this.sceneObservers.clear(); - this.ensureSceneObservers(); - // Re-broadcast awareness state so other clients immediately see our cursor/measurements - this.refreshAwarenessState(); - // Trigger a data refresh to sync any missed updates - this.notifySubscribers(); - }, 100); - } - - this.updateHeartbeat(); - this.notifySubscribers(); - }); - - this.partyProvider.on('status', (event: { status: string }) => { - this.isPartyConnected = event.status === 'connected'; - devLog('yjs', `[${this.clientId}] Y.js party connection status: ${event.status} - room: ${partyRoomName}`); - this.updateHeartbeat(); - this.notifySubscribers(); - }); - - // Subscribe to awareness updates for cursor tracking - this.awarenessChangeHandler = () => { - this.notifySubscribers(); - }; - this.gameSessionProvider.awareness.on('change', this.awarenessChangeHandler); - - // Set up observers - this.setupObservers(); - } - - /** - * Returns true only when both party and game session providers are connected - */ - private get isConnected(): boolean { - return this.isPartyConnected && this.isGameSessionConnected; - } - - /** - * Start or stop the awareness heartbeat based on connection status. - * The heartbeat refreshes the Y.js awareness state every 15 seconds to prevent - * the 30-second timeout from deleting cursor/measurement data. - */ - private updateHeartbeat() { - if (this.isConnected) { - // Start heartbeat if not already running - if (!this.awarenessHeartbeatInterval) { - devLog('yjs', `[${this.clientId}] Starting awareness heartbeat`); - this.awarenessHeartbeatInterval = setInterval(() => { - this.refreshAwarenessState(); - }, 15000); // 15 seconds - well before the 30-second timeout - } - } else { - // Stop heartbeat when disconnected - if (this.awarenessHeartbeatInterval) { - devLog('yjs', `[${this.clientId}] Stopping awareness heartbeat`); - clearInterval(this.awarenessHeartbeatInterval); - this.awarenessHeartbeatInterval = null; - } - } - } - - /** - * Refresh the awareness state to prevent Y.js 30-second timeout. - * This re-sets the current local state without changing the data, - * which updates the lastUpdated timestamp in Y.js awareness. - */ - private refreshAwarenessState() { - if (!this.gameSessionProvider.awareness) return; - - const currentState = this.gameSessionProvider.awareness.getLocalState(); - if (currentState) { - // Re-set the same state to refresh the Y.js awareness timestamp - // This doesn't change lastMoveTime for cursors, preserving fade behavior - this.gameSessionProvider.awareness.setLocalState(currentState); - devLog('yjs', `[${this.clientId}] Refreshed awareness state to prevent timeout`); - } - } - - /** - * Subscribe to party data changes - */ - subscribe(callback: () => void): () => void { - this.subscribers.add(callback); - return () => this.subscribers.delete(callback); - } - - private notifySubscribers() { - this.subscribers.forEach((callback) => callback()); - } - - /** - * Update cursor position using Y.js awareness with world coordinates - */ - updateCursor(worldPosition: { x: number; y: number; z: number }, color?: string, label?: string) { - if (this.isConnected && this.gameSessionProvider.awareness) { - this.gameSessionProvider.awareness.setLocalStateField('cursor', { - userId: this.userId, - worldPosition, - color: color || '#ffffff', - label: label || this.userId, - lastMoveTime: Date.now() - }); - } - } - - /** - * Get current cursors (excluding self) from awareness - */ - getCursors(): Record { - const cursors: Record = {}; - - if (this.gameSessionProvider.awareness) { - this.gameSessionProvider.awareness.getStates().forEach((state, clientId) => { - if (state.cursor) { - if (clientId !== this.gameSessionProvider.awareness.clientID) { - // Use clientId as key to ensure each editor has a unique cursor - // Store the clientId in the cursor data for reference - const cursorKey = `${state.cursor.userId}_${clientId}`; - cursors[cursorKey] = { - ...state.cursor, - clientId: clientId - }; - } - } - }); - } - - return cursors; - } - - /** - * Update measurement data using Y.js awareness - * Measurements are ephemeral like cursors - they broadcast but don't persist - */ - updateMeasurement( - startPoint: { x: number; y: number } | null, - endPoint: { x: number; y: number } | null, - type: number, - measurementProps?: { - color: string; - thickness: number; - outlineColor: string; - outlineThickness: number; - opacity: number; - markerSize: number; - autoHideDelay: number; - fadeoutTime: number; - showDistance: boolean; - snapToGrid: boolean; - enableDMG252: boolean; - beamWidth?: number; - coneAngle?: number; - } - ) { - if (this.isConnected && this.gameSessionProvider.awareness) { - if (startPoint === null || endPoint === null) { - // Clear measurement when null points are provided - this.gameSessionProvider.awareness.setLocalStateField('measurement', null); - } else { - this.gameSessionProvider.awareness.setLocalStateField('measurement', { - userId: this.userId, - startPoint, - endPoint, - type, - timestamp: Date.now(), - // Include all measurement properties or use defaults - color: measurementProps?.color ?? '#FFFFFF', - thickness: measurementProps?.thickness ?? 12, - outlineColor: measurementProps?.outlineColor ?? '#000000', - outlineThickness: measurementProps?.outlineThickness ?? 4, - opacity: measurementProps?.opacity ?? 1, - markerSize: measurementProps?.markerSize ?? 24, - autoHideDelay: measurementProps?.autoHideDelay ?? 3000, - fadeoutTime: measurementProps?.fadeoutTime ?? 500, - showDistance: measurementProps?.showDistance ?? true, - snapToGrid: measurementProps?.snapToGrid ?? true, - enableDMG252: measurementProps?.enableDMG252 ?? true, - // Beam and Cone specific properties - beamWidth: measurementProps?.beamWidth, - coneAngle: measurementProps?.coneAngle - }); - } - } - } - - /** - * Update hovered marker state (DM only) - * This broadcasts which marker the DM is hovering over to all players - */ - updateHoveredMarker(marker: HoveredMarker | null) { - if (this.isConnected && this.gameSessionProvider.awareness) { - this.gameSessionProvider.awareness.setLocalStateField('hoveredMarker', marker); - } - } - - /** - * Get current hovered marker from awareness (for players to see what DM is hovering) - */ - getHoveredMarker(): HoveredMarker | null { - if (this.gameSessionProvider.awareness) { - const states = this.gameSessionProvider.awareness.getStates(); - - // Look for any DM's hovered marker (last one wins if multiple DMs) - let hoveredMarker: HoveredMarker | null = null; - states.forEach((state) => { - if (state.hoveredMarker) { - hoveredMarker = state.hoveredMarker as HoveredMarker; - } - }); - - return hoveredMarker; - } - - return null; - } - - /** - * Get current measurements from all users via awareness - */ - getMeasurements(): Record { - const measurements: Record = {}; - - if (this.gameSessionProvider.awareness) { - this.gameSessionProvider.awareness.getStates().forEach((state, clientId) => { - if (state.measurement) { - // Include all measurements (including self for debugging) - const measurementKey = `${state.measurement.userId}_${clientId}`; - measurements[measurementKey] = { - ...state.measurement, - clientId: clientId - }; - } - }); - } - - return measurements; - } - - /** - * Update pinned markers (DM pins markers for player view) - * Uses awareness protocol for ephemeral state like cursors - */ - updatePinnedMarkers(markerIds: string[]) { - if (this.isConnected && this.gameSessionProvider.awareness) { - this.gameSessionProvider.awareness.setLocalStateField('pinnedMarkers', markerIds); - devLog('yjs', `[${this.clientId}] Updated pinned markers:`, markerIds); - } - } - - /** - * Get current pinned markers from DM - */ - getPinnedMarkers(): string[] { - if (this.gameSessionProvider.awareness) { - // Look through all connected clients for pinned markers - // Only the DM should be broadcasting these - const states = this.gameSessionProvider.awareness.getStates(); - for (const [, state] of states) { - if (state.pinnedMarkers && Array.isArray(state.pinnedMarkers)) { - return state.pinnedMarkers; - } - } - } - return []; - } - - /** - * Set up observers for Y.js data structures - */ - private setupObservers() { - // Observe changes to the scenes list - this.yScenesList.observeDeep(() => { - devLog('yjs', `[${this.clientId}] Scenes list changed`); - this.notifySubscribers(); - }); - - // Observe changes to game session metadata - this.yGameSessionMeta.observeDeep(() => { - devLog('yjs', `[${this.clientId}] Game session metadata changed`); - this.notifySubscribers(); - }); - - // Observe changes to party state - this.yPartyState.observeDeep(() => { - devLog('yjs', `[${this.clientId}] Party state changed`); - this.notifySubscribers(); - }); - - // Observe scenes map changes to add/remove individual scene observers - this.yScenes.observe((event) => { - // Check for added scenes - event.changes.keys.forEach((change, key) => { - if (change.action === 'add') { - this.observeScene(key); - } else if (change.action === 'delete') { - this.unobserveScene(key); - } - }); - this.notifySubscribers(); - }); - - // Set up initial scene observers - this.ensureSceneObservers(); - } - - /** - * Ensure all scenes have observers - */ - private ensureSceneObservers() { - this.yScenes.forEach((_, sceneId) => { - this.observeScene(sceneId); - }); - } - - /** - * Observe changes to a specific scene - */ - private observeScene(sceneId: string) { - if (this.sceneObservers.has(sceneId)) { - return; // Already observing - } - - const sceneMap = this.yScenes.get(sceneId); - if (sceneMap && sceneMap instanceof Y.Map) { - sceneMap.observeDeep(() => { - devLog('yjs', `[${this.clientId}] Scene ${sceneId} changed`); - this.notifySubscribers(); - }); - this.sceneObservers.add(sceneId); - devLog('yjs', `[${this.clientId}] Added observer for scene ${sceneId}`); - } - } - - /** - * Remove observer for a specific scene - */ - private unobserveScene(sceneId: string) { - // Y.js handles observer cleanup automatically when the map is deleted - this.sceneObservers.delete(sceneId); - devLog('yjs', `[${this.clientId}] Removed observer for scene ${sceneId}`); - } - - /** - * Get connection status - */ - getConnectionStatus(): { - gameSession: 'disconnected' | 'connecting' | 'connected'; - party: 'disconnected' | 'connecting' | 'connected'; - overall: boolean; - } { - const gameSessionStatus = this.gameSessionProvider.shouldConnect - ? this.gameSessionProvider.wsconnected - ? 'connected' - : 'connecting' - : 'disconnected'; - - const partyStatus = this.partyProvider.shouldConnect - ? this.partyProvider.wsconnected - ? 'connected' - : 'connecting' - : 'disconnected'; - - return { - gameSession: gameSessionStatus, - party: partyStatus, - overall: this.isConnected - }; - } - - /** - * Get the list of scenes - */ - getScenesList(): SceneMetadata[] { - return this.yScenesList.toArray(); - } - - /** - * Get party state - */ - getPartyState(): PartyState { - const isPaused = this.yPartyState.get('isPaused') ?? false; - const activeSceneId = this.yPartyState.get('activeSceneId'); - const cursors = this.getCursors(); - - return { - isPaused: isPaused as boolean, - activeSceneId: activeSceneId as string | undefined, - cursors - }; - } - - /** - * Update party state - */ - updatePartyState(key: keyof PartyState, value: unknown) { - if (key === 'cursors') { - devWarn('yjs', 'Cannot directly update cursors - use updateCursor instead'); - return; - } - - this.partyDoc.transact(() => { - this.yPartyState.set(key, value); - }); - } - - /** - * Get scene data - */ - getSceneData(sceneId: string): SceneData | null { - const sceneMap = this.yScenes.get(sceneId); - if (!sceneMap) { - return null; - } - - return { - stageProps: (sceneMap.get('stageProps') || {}) as StageProps, - markers: (sceneMap.get('markers') || []) as Marker[], - localStates: (sceneMap.get('localStates') || {}) as Record, - lastUpdated: (sceneMap.get('lastUpdated') || Date.now()) as number, - saveInProgress: (sceneMap.get('saveInProgress') || false) as boolean, - activeSaver: sceneMap.get('activeSaver') as string | undefined - }; - } - - /** - * Get the party state Y.Map for external coordination functions - */ - getYPartyState(): Y.Map { - return this.yPartyState; - } - - /** - * Get the game session provider for awareness access - */ - getGameSessionProvider(): YPartyKitProvider { - return this.gameSessionProvider; - } - - /** - * Update scene stage props - */ - updateSceneStageProps(sceneId: string, stageProps: StageProps) { - this.doc.transact(() => { - let sceneMap = this.yScenes.get(sceneId); - if (!sceneMap) { - sceneMap = new Y.Map(); - this.yScenes.set(sceneId, sceneMap); - } - sceneMap.set('stageProps', stageProps); - sceneMap.set('lastUpdated', Date.now()); - // Also update markers if they're included in stageProps - if (stageProps.marker?.markers) { - sceneMap.set('markers', stageProps.marker.markers); - } - }); - } - - /** - * Update a single marker's position (for playfield → editor sync) - * This only updates the marker position without broadcasting full stageProps - */ - updateMarkerPosition(sceneId: string, markerId: string, position: { x: number; y: number }) { - this.doc.transact(() => { - const sceneMap = this.yScenes.get(sceneId); - if (!sceneMap) return; - - const markers = sceneMap.get('markers') as Marker[] | undefined; - if (!markers) return; - - const index = markers.findIndex((m) => m.id === markerId); - if (index !== -1) { - const updatedMarkers = [...markers]; - updatedMarkers[index] = { - ...updatedMarkers[index], - position: { x: position.x, y: position.y } - }; - sceneMap.set('markers', updatedMarkers); - sceneMap.set('lastUpdated', Date.now()); - } - }); - } - - /** - * Initialize scene data from SSR - */ - initializeSceneData(sceneId: string, stageProps: StageProps, markers: Marker[]) { - this.doc.transact(() => { - let sceneMap = this.yScenes.get(sceneId); - if (!sceneMap) { - sceneMap = new Y.Map(); - this.yScenes.set(sceneId, sceneMap); - } - - // Only initialize if not already set or if data is stale - const existingStageProps = sceneMap.get('stageProps'); - const existingMarkers = sceneMap.get('markers'); - - if (!existingStageProps && !existingMarkers) { - // Clean local-only properties before storing in Y.js - const cleanedStageProps = { - ...stageProps, - annotations: { - ...stageProps.annotations, - activeLayer: null, // activeLayer is local-only, not synchronized - lineWidth: undefined // lineWidth is local-only, not synchronized - }, - fogOfWar: { - ...stageProps.fogOfWar, - tool: { - ...stageProps.fogOfWar.tool, - size: undefined // Remove size to prevent syncing - } - } - }; - sceneMap.set('stageProps', cleanedStageProps); - sceneMap.set('markers', markers); - sceneMap.set('lastUpdated', Date.now()); - sceneMap.set('localStates', {}); - devLog('yjs', `[${this.clientId}] Initialized scene ${sceneId} from SSR data`); - } else { - devLog('yjs', `[${this.clientId}] Scene ${sceneId} already has data, skipping SSR initialization`); - } - }); - } - - /** - * Initialize scenes list from SSR - */ - initializeScenesList(scenes: SceneMetadata[]) { - const initializeAfterSync = () => { - const initFlag = this.yGameSessionMeta.get('scenesInitialized'); - const currentScenes = this.yScenesList.toArray(); - - // Aggressive cleanup if data is corrupted (e.g., duplicates) - if (currentScenes.length > scenes.length * 2) { - devLog( - 'yjs', - `Force clearing accumulated Y.js data. Current: ${currentScenes.length}, Expected: ${scenes.length}` - ); - this.doc.transact(() => { - this.yScenesList.delete(0, this.yScenesList.length); - this.yGameSessionMeta.clear(); - }); - - // Reinitialize after clearing - setTimeout(() => { - this.doc.transact(() => { - scenes.forEach((scene) => this.yScenesList.push([scene])); - this.yGameSessionMeta.set('scenesInitialized', true); - this.yGameSessionMeta.set('lastInitTimestamp', Date.now()); - }); - devLog('yjs', 'Y.js reinitialized with clean data'); - }, 100); - return; - } - - // If already initialized recently (within 5 seconds), skip - const lastInit = this.yGameSessionMeta.get('lastInitTimestamp'); - if (initFlag && lastInit && Date.now() - (lastInit as number) < 5000) { - devLog('yjs', 'Y.js recently initialized, skipping'); - return; - } - - // Initialize if not done or data doesn't match - const hasValidData = - currentScenes.length === scenes.length && currentScenes.every((scene) => scenes.some((s) => s.id === scene.id)); - - if (!initFlag || !hasValidData) { - devLog('yjs', `Initializing Y.js scenes. Current: ${currentScenes.length}, SSR: ${scenes.length}`); - this.doc.transact(() => { - this.yScenesList.delete(0, this.yScenesList.length); - scenes.forEach((scene) => this.yScenesList.push([scene])); - this.yGameSessionMeta.set('scenesInitialized', true); - this.yGameSessionMeta.set('lastInitTimestamp', Date.now()); - }); - } else { - devLog('yjs', 'Y.js scenes already properly initialized'); - } - }; - - // Wait for provider to connect and sync - if (this.isConnected) { - // Already connected, wait a moment for any pending sync - setTimeout(initializeAfterSync, 1000); - } else { - // Wait for connection, then sync - const unsubscribe = this.subscribe(() => { - if (this.isConnected) { - unsubscribe(); - setTimeout(initializeAfterSync, 1000); - } - }); - } - } - - /** - * Initialize party state from SSR - */ - initializePartyState(state: Partial) { - this.partyDoc.transact(() => { - Object.entries(state).forEach(([key, value]) => { - if (key !== 'cursors') { - // Don't initialize cursors from SSR - this.yPartyState.set(key, value); - } - }); - }); - } - - // Scene management methods remain the same... - addScene(scene: SceneMetadata) { - this.doc.transact(() => { - this.yScenesList.push([scene]); - }); - } - - updateScene(sceneId: string, updates: Partial) { - this.doc.transact(() => { - const scenes = this.yScenesList.toArray(); - const index = scenes.findIndex((s) => s.id === sceneId); - if (index !== -1) { - const updatedScene = { ...scenes[index], ...updates }; - this.yScenesList.delete(index, 1); - this.yScenesList.insert(index, [updatedScene]); - } - }); - } - - removeScene(sceneId: string) { - this.doc.transact(() => { - const scenes = this.yScenesList.toArray(); - const index = scenes.findIndex((s) => s.id === sceneId); - if (index !== -1) { - this.yScenesList.delete(index, 1); - } - // Also remove from scenes map - this.yScenes.delete(sceneId); - }); - } - - reorderScenes(newScenesOrder: SceneMetadata[]) { - this.doc.transact(() => { - this.yScenesList.delete(0, this.yScenesList.length); - newScenesOrder.forEach((scene) => this.yScenesList.push([scene])); - }); - } - - // Save coordination methods - becomeActiveSaver(sceneId: string): boolean { - const sceneMap = this.yScenes.get(sceneId); - if (!sceneMap) return false; - - const currentSaver = sceneMap.get('activeSaver'); - const saveInProgress = sceneMap.get('saveInProgress'); - - if (saveInProgress && currentSaver && currentSaver !== this.userId) { - devLog('yjs', `Cannot become active saver - ${currentSaver} is already saving`); - return false; - } - - this.doc.transact(() => { - sceneMap.set('activeSaver', this.userId); - sceneMap.set('saveInProgress', true); - }); - - return true; - } - - releaseActiveSaver(sceneId: string, success: boolean = true) { - const sceneMap = this.yScenes.get(sceneId); - if (!sceneMap) return; - - this.doc.transact(() => { - sceneMap.set('activeSaver', null); - sceneMap.set('saveInProgress', false); - if (success) { - sceneMap.set('lastUpdated', Date.now()); - } - }); - } - - isSaveInProgress(sceneId: string): boolean { - const sceneMap = this.yScenes.get(sceneId); - return (sceneMap?.get('saveInProgress') as boolean) || false; - } - - getActiveSaver(sceneId: string): string | null { - const sceneMap = this.yScenes.get(sceneId); - return (sceneMap?.get('activeSaver') as string) || null; - } - - // Debug utilities - clearAllData() { - this.doc.transact(() => { - this.yScenes.clear(); - this.yScenesList.delete(0, this.yScenesList.length); - this.yGameSessionMeta.clear(); - this.yCursors.clear(); - }); - this.partyDoc.transact(() => { - this.yPartyState.clear(); - }); - } - - forceSyncCheck() { - // Force a sync by making a small change - this.doc.transact(() => { - this.yGameSessionMeta.set('lastSyncCheck', Date.now()); - }); - } - - // Drift detection - getSceneLastUpdated(sceneId: string): number | null { - const sceneMap = this.yScenes.get(sceneId); - return (sceneMap?.get('lastUpdated') as number) || null; - } - - checkSceneDrift(sceneId: string, dbTimestamp: number): boolean { - const yjsTimestamp = this.getSceneLastUpdated(sceneId); - if (!yjsTimestamp) return false; - return dbTimestamp > yjsTimestamp; - } - - async detectDrift(fetchSceneTimestamps: () => Promise>): Promise { - const dbTimestamps = await fetchSceneTimestamps(); - const driftedScenes: string[] = []; - - for (const [sceneId, dbTimestamp] of Object.entries(dbTimestamps)) { - if (this.checkSceneDrift(sceneId, dbTimestamp)) { - driftedScenes.push(sceneId); - } - } - - return driftedScenes; - } - - /** - * Update scene last updated timestamp - */ - updateSceneLastUpdated(sceneId: string, timestamp: number) { - const sceneMap = this.yScenes.get(sceneId); - if (sceneMap) { - this.doc.transact(() => { - sceneMap.set('lastUpdated', timestamp); - }); - } - } - - /** - * Clean up resources - */ - destroy() { - devLog('yjs', `[${this.clientId}] Destroying PartyDataManager`); - - // Stop heartbeat - if (this.awarenessHeartbeatInterval) { - clearInterval(this.awarenessHeartbeatInterval); - this.awarenessHeartbeatInterval = null; - } - - // Unregister awareness listener before destroying providers - this.gameSessionProvider.awareness.off('change', this.awarenessChangeHandler); - - // Clean up providers - this.gameSessionProvider.destroy(); - this.partyProvider.destroy(); - - // Clear subscribers - this.subscribers.clear(); - - // Clear observers - this.sceneObservers.clear(); - } - - /** - * Connect to a different game session - */ - switchGameSession(newGameSessionId: string) { - devLog('yjs', `Switching from game session ${this.gameSessionId} to ${newGameSessionId}`); - - // Destroy current providers - this.gameSessionProvider.destroy(); - - // Create new game session provider - this.gameSessionId = newGameSessionId; - this.doc = new Y.Doc(); - this.clientId = this.doc.clientID; - - const host = this.partykitHost; - - this.gameSessionProvider = new YPartyKitProvider(host, newGameSessionId, this.doc, { - party: 'game_session', - params: { userId: this.userId } - }); - - // Reinitialize data structures - this.yScenes = this.doc.getMap('scenes'); - this.yScenesList = this.doc.getArray('scenesList'); - this.yGameSessionMeta = this.doc.getMap('gameSessionMeta'); - this.yCursors = this.doc.getMap('cursors'); - - // Re-setup observers - this.sceneObservers.clear(); - this.setupObservers(); - } -} diff --git a/apps/web/src/lib/utils/yjs/sceneCoordination.ts b/apps/web/src/lib/utils/yjs/sceneCoordination.ts deleted file mode 100644 index be0975b28..000000000 --- a/apps/web/src/lib/utils/yjs/sceneCoordination.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { devLog } from '../debug'; -import type { PartyDataManager } from './PartyDataManager'; - -/** - * Switches the active scene for all connected clients (editor and playfield) - * Uses the party-level Y.js document so it affects everyone viewing this party. - */ -export function switchActiveScene(partyData: PartyDataManager, sceneId: string): void { - if (!partyData) { - devLog('yjs', 'Cannot switch scene: partyData is undefined'); - return; - } - - // Update party state with new active scene ID - const partyState = partyData.getYPartyState(); - if (partyState) { - const currentActiveSceneId = partyState.get('activeSceneId'); - devLog('yjs', 'switchActiveScene called:', { - currentActiveSceneId, - newSceneId: sceneId, - isChange: currentActiveSceneId !== sceneId - }); - - partyState.set('activeSceneId', sceneId); - devLog('yjs', `Switched active scene to: ${sceneId}`); - - // Verify the change was applied - const verifiedActiveSceneId = partyState.get('activeSceneId'); - devLog('yjs', 'switchActiveScene verification:', { - requestedSceneId: sceneId, - verifiedActiveSceneId, - matchesRequested: verifiedActiveSceneId === sceneId - }); - } else { - devLog('yjs', 'Cannot switch scene: partyState is undefined'); - } -} - -/** - * Gets the current active scene ID from party state - */ -export function getActiveSceneId(partyData: PartyDataManager): string | undefined { - if (!partyData) { - return undefined; - } - - const partyState = partyData.getYPartyState(); - if (!partyState) { - return undefined; - } - - return partyState.get('activeSceneId') as string | undefined; -} - -/** - * Subscribes to scene changes and calls the callback when the active scene changes - * Returns an unsubscribe function - */ -export function subscribeToSceneChanges( - partyData: PartyDataManager, - callback: (sceneId: string | undefined) => void -): () => void { - if (!partyData) { - return () => {}; // No-op unsubscribe - } - - const partyState = partyData.getYPartyState(); - if (!partyState) { - return () => {}; - } - - const observer = () => { - const sceneId = partyState.get('activeSceneId') as string | undefined; - devLog('yjs', `Active scene changed to: ${sceneId}`); - callback(sceneId); - }; - - partyState.observe(observer); - - // Return unsubscribe function - return () => { - partyState.unobserve(observer); - }; -} - -/** - * Gets the pause state from party data - */ -export function getIsPaused(partyData: PartyDataManager): boolean { - if (!partyData) { - return false; - } - - const partyState = partyData.getYPartyState(); - if (!partyState) { - return false; - } - - return (partyState.get('isPaused') as boolean) ?? false; -} - -/** - * Sets the pause state for all connected clients - */ -export function setIsPaused(partyData: PartyDataManager, isPaused: boolean): void { - if (!partyData) { - return; - } - - const partyState = partyData.getYPartyState(); - if (partyState) { - partyState.set('isPaused', isPaused); - devLog('yjs', `Set isPaused to: ${isPaused}`); - } -} - -/** - * Subscribes to pause state changes - * Returns an unsubscribe function - */ -export function subscribeToPauseChanges( - partyData: PartyDataManager, - callback: (isPaused: boolean) => void -): () => void { - if (!partyData) { - return () => {}; - } - - const partyState = partyData.getYPartyState(); - if (!partyState) { - return () => {}; - } - - const observer = () => { - const isPaused = (partyState.get('isPaused') as boolean) ?? false; - devLog('yjs', `Pause state changed to: ${isPaused}`); - callback(isPaused); - }; - - partyState.observe(observer); - - return () => { - partyState.unobserve(observer); - }; -} diff --git a/apps/web/src/lib/utils/yjs/stores.ts b/apps/web/src/lib/utils/yjs/stores.ts deleted file mode 100644 index 30f1c29af..000000000 --- a/apps/web/src/lib/utils/yjs/stores.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { HoveredMarker, Marker, StageProps } from '@tableslayer/stage'; -import { devLog } from '../debug'; -import { PartyDataManager, type PartyState, type SceneData, type SceneMetadata } from './PartyDataManager'; - -let partyDataManager: PartyDataManager | null = null; - -/** - * Initialize the party data manager (call once per game session) - */ -export function initializePartyDataManager( - partyId: string, - userId: string, - gameSessionId?: string, - partykitHost?: string -): PartyDataManager { - devLog( - 'yjs', - 'Initializing PartyDataManager for party:', - partyId, - 'gameSession:', - gameSessionId, - 'host:', - partykitHost - ); - if (partyDataManager) { - partyDataManager.destroy(); - } - partyDataManager = new PartyDataManager(partyId, userId, gameSessionId, partykitHost); - devLog('yjs', 'PartyDataManager initialized successfully'); - return partyDataManager; -} - -/** - * Get the current party data manager - */ -export function getPartyDataManager(): PartyDataManager | null { - return partyDataManager; -} - -/** - * Svelte store-like hook for party data - */ -export function usePartyData() { - if (!partyDataManager) { - throw new Error('PartyDataManager not initialized. Call initializePartyDataManager first.'); - } - - return { - // Connection status - getConnectionStatus: () => partyDataManager!.getConnectionStatus(), - - // Subscribe to changes - subscribe: (callback: () => void) => partyDataManager!.subscribe(callback), - - // Cursor management - updateCursor: (worldPosition: { x: number; y: number; z: number }, color?: string, label?: string) => - partyDataManager!.updateCursor(worldPosition, color, label), - getCursors: () => partyDataManager!.getCursors(), - - // Measurement management - updateMeasurement: ( - startPoint: { x: number; y: number } | null, - endPoint: { x: number; y: number } | null, - type: number, - measurementProps?: { - color: string; - thickness: number; - outlineColor: string; - outlineThickness: number; - opacity: number; - markerSize: number; - autoHideDelay: number; - fadeoutTime: number; - showDistance: boolean; - snapToGrid: boolean; - enableDMG252: boolean; - beamWidth?: number; - coneAngle?: number; - } - ) => partyDataManager!.updateMeasurement(startPoint, endPoint, type, measurementProps), - getMeasurements: () => partyDataManager!.getMeasurements(), - - // Hovered marker management (DM hover to reveal markers to players) - updateHoveredMarker: (marker: HoveredMarker | null) => partyDataManager!.updateHoveredMarker(marker), - getHoveredMarker: () => partyDataManager!.getHoveredMarker(), - - // Pinned markers management (DM pins markers for player view) - updatePinnedMarkers: (markerIds: string[]) => partyDataManager!.updatePinnedMarkers(markerIds), - getPinnedMarkers: () => partyDataManager!.getPinnedMarkers(), - - // Scenes list - getScenesList: (): SceneMetadata[] => partyDataManager!.getScenesList(), - - // Party state - getPartyState: (): PartyState => partyDataManager!.getPartyState(), - updatePartyState: (key: keyof PartyState, value: unknown) => partyDataManager!.updatePartyState(key, value), - - // Scene data - getSceneData: (sceneId: string): SceneData | null => partyDataManager!.getSceneData(sceneId), - updateSceneStageProps: (sceneId: string, stageProps: StageProps) => - partyDataManager!.updateSceneStageProps(sceneId, stageProps), - - // Initialization helpers - initializeSceneData: (sceneId: string, stageProps: StageProps, markers: Marker[]) => - partyDataManager!.initializeSceneData(sceneId, stageProps, markers), - initializeScenesList: (scenes: SceneMetadata[]) => partyDataManager!.initializeScenesList(scenes), - initializePartyState: (state: Partial) => partyDataManager!.initializePartyState(state), - - // Scene list management - addScene: (scene: SceneMetadata) => partyDataManager!.addScene(scene), - updateScene: (sceneId: string, updates: Partial) => partyDataManager!.updateScene(sceneId, updates), - removeScene: (sceneId: string) => partyDataManager!.removeScene(sceneId), - reorderScenes: (newScenesOrder: SceneMetadata[]) => partyDataManager!.reorderScenes(newScenesOrder), - - // Save coordination - becomeActiveSaver: (sceneId: string) => partyDataManager!.becomeActiveSaver(sceneId), - releaseActiveSaver: (sceneId: string, success?: boolean) => partyDataManager!.releaseActiveSaver(sceneId, success), - isSaveInProgress: (sceneId: string) => partyDataManager!.isSaveInProgress(sceneId), - getActiveSaver: (sceneId: string) => partyDataManager!.getActiveSaver(sceneId), - - // Debug utilities - clearAllData: () => partyDataManager!.clearAllData(), - forceSyncCheck: () => partyDataManager!.forceSyncCheck(), - - // Drift detection - getSceneLastUpdated: (sceneId: string) => partyDataManager!.getSceneLastUpdated(sceneId), - checkSceneDrift: (sceneId: string, dbTimestamp: number) => partyDataManager!.checkSceneDrift(sceneId, dbTimestamp), - detectDrift: (fetchSceneTimestamps: () => Promise>) => - partyDataManager!.detectDrift(fetchSceneTimestamps), - - // Scene last updated - updateSceneLastUpdated: (sceneId: string, timestamp: number) => - partyDataManager!.updateSceneLastUpdated(sceneId, timestamp) - }; -} - -/** - * Clean up the party data manager - */ -export function destroyPartyDataManager() { - if (partyDataManager) { - partyDataManager.destroy(); - partyDataManager = null; - } -} diff --git a/apps/web/src/lib/utils/yjs/temporaryLayers.ts b/apps/web/src/lib/utils/yjs/temporaryLayers.ts deleted file mode 100644 index 55222f26d..000000000 --- a/apps/web/src/lib/utils/yjs/temporaryLayers.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { devLog } from '../debug'; -import type { PartyDataManager } from './PartyDataManager'; - -/** - * Temporary annotation layer that syncs via Y.js awareness - * but doesn't persist to database. Used for player drawings - * that can be made permanent or expire after a timeout. - */ -export interface TemporaryLayer { - id: string; - isTemporary: boolean; - createdAt: number; - expiresAt: number; - color: string; - maskData: string; // Base64 RLE data - creatorId: string; - opacity: number; - name: string; - effectType?: number; // AnnotationEffect enum value -} - -/** - * Broadcasts a temporary layer via Y.js awareness protocol - */ -export function broadcastTemporaryLayer(partyData: PartyDataManager, layer: TemporaryLayer): void { - if (!partyData) { - return; - } - - const awareness = partyData.getGameSessionProvider()?.awareness; - if (!awareness) { - return; - } - - // Get only the local client's temporary layers (not all clients) - const localState = awareness.getLocalState(); - const currentLayers: TemporaryLayer[] = localState?.temporaryLayers || []; - - // Add or update the layer - const layerIndex = currentLayers.findIndex((l) => l.id === layer.id); - if (layerIndex >= 0) { - currentLayers[layerIndex] = layer; - } else { - currentLayers.push(layer); - } - - // Broadcast via awareness - awareness.setLocalStateField('temporaryLayers', currentLayers); - devLog( - 'yjs', - `Broadcast temporary layer: ${layer.id}, expires at ${new Date(layer.expiresAt).toISOString()}, total local layers: ${currentLayers.length}` - ); -} - -/** - * Removes a temporary layer from awareness - */ -export function removeTemporaryLayer(partyData: PartyDataManager, layerId: string): void { - if (!partyData) { - return; - } - - const currentLayers = getTemporaryLayers(partyData); - const filteredLayers = currentLayers.filter((l) => l.id !== layerId); - - const awareness = partyData.getGameSessionProvider()?.awareness; - if (awareness) { - awareness.setLocalStateField('temporaryLayers', filteredLayers); - devLog('yjs', `Removed temporary layer: ${layerId}`); - } -} - -/** - * Gets all temporary layers from all connected clients - */ -export function getTemporaryLayers(partyData: PartyDataManager): TemporaryLayer[] { - if (!partyData) { - return []; - } - - const awareness = partyData.getGameSessionProvider()?.awareness; - if (!awareness) { - return []; - } - - const allLayers: TemporaryLayer[] = []; - const states = awareness.getStates(); - - states.forEach((state: Record) => { - const temporaryLayers = state.temporaryLayers; - if (temporaryLayers && Array.isArray(temporaryLayers)) { - allLayers.push(...temporaryLayers); - } - }); - - return allLayers; -} - -/** - * Cleans up expired temporary layers. - * Should be called periodically (e.g., every 1000ms) by all clients. - */ -export function cleanupExpiredLayers(partyData: PartyDataManager): void { - if (!partyData) { - return; - } - - const awareness = partyData.getGameSessionProvider()?.awareness; - if (!awareness) { - return; - } - - const now = Date.now(); - const currentLayers = getTemporaryLayers(partyData); - const expiredCount = currentLayers.filter((l) => l.expiresAt <= now).length; - - if (expiredCount > 0) { - // Only clean up our own layers from awareness - const localState = awareness.getLocalState(); - if (localState?.temporaryLayers && Array.isArray(localState.temporaryLayers)) { - const validLayers = localState.temporaryLayers.filter((l: TemporaryLayer) => l.expiresAt > now); - awareness.setLocalStateField('temporaryLayers', validLayers); - - devLog('yjs', `Cleaned up ${localState.temporaryLayers.length - validLayers.length} expired temporary layers`); - } - } -} - -/** - * Gets only temporary layers created by the current user - */ -export function getOwnTemporaryLayers(partyData: PartyDataManager, userId: string): TemporaryLayer[] { - return getTemporaryLayers(partyData).filter((layer) => layer.creatorId === userId); -} - -/** - * Checks if a specific temporary layer still exists and hasn't expired - */ -export function isTemporaryLayerActive(partyData: PartyDataManager, layerId: string): boolean { - const layer = getTemporaryLayers(partyData).find((l) => l.id === layerId); - return layer !== undefined && layer.expiresAt > Date.now(); -} - -/** - * Creates a temporary layer object with default expiration - * Default: 10 seconds from creation - */ -export function createTemporaryLayer( - layerId: string, - creatorId: string, - color: string, - maskData: string, - expirationMs: number = 10000, - effectType?: number -): TemporaryLayer { - const now = Date.now(); - return { - id: layerId, - isTemporary: true, - createdAt: now, - expiresAt: now + expirationMs, - color, - maskData, - creatorId, - opacity: 1.0, - name: 'Temporary drawing', - effectType - }; -} diff --git a/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte b/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte index bf37a0fa3..2ab14e6d9 100644 --- a/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte +++ b/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte @@ -22,7 +22,6 @@ bindPropertyUpdatesToDoc, buildSceneProps, convertMarkerToDbFormat, - devLog, extractLocationFromUrl, handleKeyCommands, handleStageZoom, @@ -51,7 +50,7 @@ type StageExports, type StageProps } from '@tableslayer/stage'; - import { FogSliders, Icon } from '@tableslayer/ui'; + import { addToast, FogSliders, Icon } from '@tableslayer/ui'; import { IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp } from '@tabler/icons-svelte'; import { Pane, PaneGroup, PaneResizer, type PaneAPI } from 'paneforge'; import { onMount, untrack } from 'svelte'; @@ -104,6 +103,25 @@ const activeSceneId = $derived(partyState.activeSceneId ?? undefined); const currentParty = $derived({ ...party, gameSessionIsPaused: partyState.isPaused }); + // "Connected" now means "your edits are durable" — the server persists doc + // changes. Toast only the transitions worth telling the user about: a drop + // after being live, and the subsequent recovery. First connect is silent. + const connectionLive = $derived( + session.client?.status.gameSession === 'connected' && session.client?.status.party === 'connected' + ); + let wasLive = false; + $effect(() => { + const live = session.ready && connectionLive; + if (wasLive && !live) { + addToast({ data: { title: 'Connection lost — edits will sync when back online', type: 'danger' } }); + } else if (!wasLive && live && wasEverLive) { + addToast({ data: { title: 'Reconnected', type: 'success' } }); + } + if (live) wasEverLive = true; + wasLive = live; + }); + let wasEverLive = false; + // Doc settings stand in for the SelectScene row that the control panels expect const selectedSceneForControls = $derived.by(() => { const snapshot = session.ready && session.client ? session.client.scene(selectedSceneId) : null; @@ -766,14 +784,8 @@ const annotationMaskTimers = new Map>(); const commitAnnotationMask = async (layerId: string) => { - if (!session.client || !stage?.annotations) { - devLog('editor', 'annotation commit skipped: no client/stage'); - return; - } - if (stage.annotations.isDrawing()) { - devLog('editor', 'annotation commit skipped: still drawing'); - return; - } + if (!session.client || !stage?.annotations) return; + if (stage.annotations.isDrawing()) return; // toRLE reads the active layer; point it at the target temporarily const originalActiveLayer = stageProps.annotations.activeLayer; @@ -782,9 +794,6 @@ const rle = await stage.annotations.toRLE(); if (rle && rle.length > 0) { session.client.write.setAnnotationMask(selectedSceneId, layerId, rle); - devLog('editor', `committed annotation mask ${layerId} (${rle.length} bytes)`); - } else { - devLog('editor', `annotation commit skipped: empty RLE for ${layerId}`); } } finally { stageProps.annotations.activeLayer = originalActiveLayer; @@ -808,23 +817,15 @@ let fogCommitTimer: ReturnType | null = null; const commitFogMask = async () => { - if (!session.client || !stage?.fogOfWar) { - devLog('editor', 'fog commit skipped: no client/stage'); - return; - } - if (stage.fogOfWar.isDrawing()) { - devLog('editor', 'fog commit skipped: still drawing'); - return; // next stroke end re-arms - } + if (!session.client || !stage?.fogOfWar) return; + if (stage.fogOfWar.isDrawing()) return; // next stroke end re-arms const rle = await stage.fogOfWar.toRLE(); if (rle && !stage.fogOfWar.isDrawing()) { session.client.write.setFogMask(selectedSceneId, rle); - devLog('editor', `committed fog mask (${rle.length} bytes)`); } }; const onFogUpdate = (_blob: Promise) => { - devLog('editor', 'fog stroke; commit scheduled in 500ms'); trackChecklistItemLocal('fog-erase'); if (fogCommitTimer) clearTimeout(fogCommitTimer); fogCommitTimer = setTimeout(() => { @@ -854,10 +855,6 @@ }); const onMeasurementStart = (startPoint: { x: number; y: number }, type: number) => { - devLog( - 'editor', - `measurement start; broadcasting=${isOnActiveScene()} (selected=${selectedSceneId}, active=${partyState.activeSceneId})` - ); if (!isOnActiveScene()) return; session.presence?.updateMeasurement(startPoint, startPoint, type, measurementStyle()); }; diff --git a/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/useEditorSession.svelte.ts b/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/useEditorSession.svelte.ts index 6e0f7582c..f6900f647 100644 --- a/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/useEditorSession.svelte.ts +++ b/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/useEditorSession.svelte.ts @@ -106,13 +106,11 @@ export class EditorSession { if (!stage) continue; if (change.part === 'fogMask' && !stage.fogOfWar?.isDrawing()) { const mask = client.fogMask(change.sceneId); - devLog('editor', `applying remote fog mask (${mask?.length ?? 0} bytes)`); if (mask) stage.fogOfWar.fromRLE(mask, 1024, 1024); } if (change.part === 'annotations' && change.childId && change.keys.includes('mask')) { if (!stage.annotations?.isDrawing()) { const mask = client.annotationMask(change.sceneId, change.childId); - devLog('editor', `applying remote annotation mask ${change.childId} (${mask?.length ?? 0} bytes)`); if (mask) stage.annotations.loadMask(change.childId, mask); } } diff --git a/apps/web/src/routes/(app)/[party]/play/+page@.svelte b/apps/web/src/routes/(app)/[party]/play/+page@.svelte index 1379fbd2b..76910fc80 100644 --- a/apps/web/src/routes/(app)/[party]/play/+page@.svelte +++ b/apps/web/src/routes/(app)/[party]/play/+page@.svelte @@ -1,12 +1,12 @@ - - - {#snippet input({ inputProps })} - - {/snippet} - -``` - -## RLE Mask System (Run-Length Encoding) - -As of January 2025, Table Slayer uses Run-Length Encoding (RLE) for fog of war and annotation masks instead of PNG storage in R2/S3. This eliminates storage costs and reduces latency while maintaining real-time synchronization. - -### Key Benefits - -1. **~600x compression**: 1MB PNG → ~1.7KB RLE string -2. **No storage costs**: Masks stored directly in database as base64 text -3. **Faster updates**: No R2/S3 upload latency (~1.8s saved per update) -4. **Real-time sync**: Only 8-byte timestamps sync via Y.js, masks fetched on-demand -5. **Backward compatible**: Old PNG annotations migrate lazily on first edit - -### Architecture Overview - -```typescript -// Instead of uploading to R2/S3: -// OLD: Canvas → PNG Blob → R2 Upload → URL in database → Y.js sync URL - -// NEW: Canvas → Binary mask → RLE compression → Base64 → Database → Y.js sync version -Canvas → Uint8Array → RLE → Base64 → SQLite → Y.js (version only) -``` - -### RLE Implementation - -The RLE system embeds dimensions in the compressed data: - -```typescript -// In DrawingMaterial.svelte -export function toRLE(): Uint8Array { - const width = renderTarget.width; - const height = renderTarget.height; - - // Read pixels from WebGL render target - const pixels = new Uint8Array(width * height * 4); - renderer.readRenderTargetPixels(renderTarget, 0, 0, width, height, pixels); - - // Extract alpha channel (mask data) - const alphaData = new Uint8Array(width * height); - for (let i = 0; i < width * height; i++) { - alphaData[i] = pixels[i * 4 + 3]; - } - - // Compress with embedded dimensions - return encodeRLE(alphaData, width, height); -} - -export function fromRLE(rleData: Uint8Array, expectedWidth: number, expectedHeight: number): void { - const { data: binaryData, width, height } = decodeRLE(rleData); - - // Create texture with vertical flip for WebGL coordinates - const rgba = new Uint8Array(width * height * 4); - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const srcIndex = y * width + x; - const dstIndex = (height - 1 - y) * width + x; // Flip vertically - const idx = dstIndex * 4; - rgba[idx + 3] = binaryData[srcIndex]; // Alpha channel - } - } - - // Apply to WebGL texture - const texture = new THREE.DataTexture(rgba, width, height); - renderTarget.texture = texture; -} -``` - -### Database Storage - -Masks are stored as base64-encoded text (Turso/LibSQL limitation): - -```typescript -// Database schema -export const scenesTable = sqliteTable('scenes', { - // ... other fields - fogOfWarMask: text('fog_of_war_mask'), // Base64 RLE data - fogOfWarUrl: text('fog_of_war_url') // Deprecated, nulled on RLE save -}); - -export const annotationsTable = sqliteTable('annotations', { - // ... other fields - mask: text('mask'), // Base64 RLE data - url: text('url') // Deprecated, nulled on RLE save -}); -``` - -### Real-time Synchronization Flow - -1. **Editor draws on mask** -2. **RLE encoding** (23ms for 1024x1024) -3. **Save to database** as base64 (~10-50ms in production with embedded DB) -4. **Update mask version** in Y.js (timestamp) -5. **Y.js broadcasts version** to all clients (4ms) -6. **Playfield detects version change** (59ms WebSocket delivery) -7. **Fetch mask via API** (134ms network round-trip) -8. **Apply RLE mask** to canvas (16ms) - -Total round-trip: ~750ms in production (2.5s in dev due to remote Turso) - -### Version-Based Sync Pattern - -Only mask versions sync through Y.js, not the actual mask data: - -```typescript -// Editor side - after saving mask -stageProps.fogOfWar.maskVersion = Date.now(); -partyData.updateSceneStageProps(selectedScene.id, stageProps); - -// Playfield side - detecting changes -if (stageProps.fogOfWar?.maskVersion !== lastFogMaskVersion) { - lastFogMaskVersion = stageProps.fogOfWar.maskVersion; - fetchFogMask(data.activeScene.id); // Fetch actual mask data -} - -// API fetch -const response = await fetch(`/api/scenes/getFogMask?sceneId=${sceneId}`); -const { maskData } = await response.json(); -const bytes = Uint8Array.from(atob(maskData), (c) => c.charCodeAt(0)); -await stage.fogOfWar.fromRLE(bytes, 1024, 1024); -``` - -### Performance Metrics - -- **Encoding speed**: 23ms for 1024x1024 mask -- **Compression ratio**: ~600:1 (1MB → 1.7KB) -- **Database save**: 10-50ms (embedded), 1.8s (remote Turso) -- **Total round-trip**: ~750ms production, 2.5s development - -### Timing Logs - -Enable fog timing logs with `?debug=fogtiming`: - -```typescript -// Custom timing log function with zero production impact -export function timingLog(category: string, message: string): void { - if (!browser) return; - const debugParam = new URLSearchParams(window.location.search).get('debug'); - if (dev || debugParam === 'fogtiming') { - console.log(`[${category}] ${message}`); - } -} -``` - -Log output shows complete round-trip: - -``` -[FOG-RT] fog_1757940658914 - 1. Fog update triggered in editor -[FOG-RT] fog_1757940658914 - 2. Starting fog processing after 500ms debounce -[FOG-RT] fog_1757940658914 - 3. Starting RLE encoding -[FOG-RT] fog_1757940658914 - 4. RLE encoding complete (7889 bytes) -[FOG-RT] fog_1757940658914 - 5. Starting database save -[FOG-RT] fog_1757940658914 - 6. Database save complete -[FOG-RT] fog_1757940658914 - 7. Setting mask version 1757940661272 -[FOG-RT] fog_1757940658914 - 8. Broadcasting Y.js update -[FOG-RT] fog_1757940658914 - 9. Y.js broadcast complete -[FOG-RT] fog_1757940661272 - 10. Playfield detected mask version change -[FOG-RT] fog_1757940661272 - 11. Fetching fog mask from API -[FOG-RT] fog_1757940661272 - 12. Fog mask API response received -[FOG-RT] fog_1757940661272 - 13. Applying 7889 bytes to fog layer -[FOG-RT] fog_1757940661272 - 14. Fog mask applied successfully -[FOG-RT] fog_1757940661272 - COMPLETE: Full round-trip completed -``` - -## Drawing Protection System - -The architecture includes a sophisticated drawing protection system for fog of war and annotation layers that prevents user drawings from being interrupted during saves and synchronization. - -### Problem - -When users draw on fog of war or annotation layers: - -1. The drawing triggers RLE encoding and database save -2. During the save/sync period, users can continue drawing -3. When the mask version update arrives via Y.js, it could interrupt new drawings -4. Multiple rapid draws could cause race conditions - -### Solution Architecture - -#### 1. Drawing State Tracking - -Each drawing layer exports an `isDrawing()` method: - -```typescript -// DrawingMaterial.svelte -let drawing = false; - -export function isDrawing() { - return drawing; -} - -function onMouseDown(e: Event, p: THREE.Vector2 | null) { - drawing = true; - // Start drawing... -} - -function onMouseUp() { - drawing = false; - // Trigger RLE encoding and save - onUpdate(toRLE()); -} -``` - -#### 2. Debounced Processing - -Drawing updates are debounced to prevent rapid saves: - -```typescript -// In page.svelte -let fogUpdateTimer: ReturnType | null = null; -let pendingFogUpdateId: string | null = null; - -const onFogUpdate = async (_blob: Promise) => { - // Generate unique ID for tracking - const updateId = `fog_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - pendingFogUpdateId = updateId; - - // Clear existing timer - if (fogUpdateTimer) { - clearTimeout(fogUpdateTimer); - } - - // Process after 500ms debounce - fogUpdateTimer = setTimeout(() => { - processFogUpdate(); - }, 500); -}; - -const processFogUpdate = async () => { - // Check if still drawing - if (stage?.fogOfWar?.isDrawing()) { - return; // Skip update - } - - // Get RLE data and save to database - const rleData = await stage?.fogOfWar?.toRLE(); - await updateFogMask({ sceneId, maskData: rleData }); - - // Update version for Y.js sync - stageProps.fogOfWar.maskVersion = Date.now(); - partyData.updateSceneStageProps(selectedScene.id, stageProps); -}; -``` - -#### 3. Y.js Update Blocking - -With the RLE system, mask version updates are blocked while drawing: - -```typescript -// In Y.js subscription -const isDrawingFog = stage?.fogOfWar?.isDrawing() ?? false; -const isDrawingAnnotation = stage?.annotations?.isDrawing() ?? false; - -const mergedStageProps = { - ...incomingStageProps, - fogOfWar: { - ...incomingStageProps.fogOfWar, - // Block mask version updates while drawing - maskVersion: isDrawingFog ? stageProps.fogOfWar.maskVersion : incomingStageProps.fogOfWar.maskVersion - }, - annotations: { - ...incomingStageProps.annotations, - // Block annotation mask version updates while drawing - layers: isDrawingAnnotation - ? stageProps.annotations.layers.map((localLayer) => { - const incomingLayer = incomingStageProps.annotations.layers.find((l) => l.id === localLayer.id); - return incomingLayer ? { ...incomingLayer, maskVersion: localLayer.maskVersion } : localLayer; - }) - : incomingStageProps.annotations.layers - } -}; -``` - -### Benefits - -1. **Uninterrupted Drawing**: Users can draw continuously without canvas resets -2. **Efficient Saves**: Debounced RLE encoding prevents excessive database writes -3. **Multi-Editor Support**: Multiple users can draw simultaneously without conflicts -4. **No Storage Costs**: Eliminated R2/S3 storage costs completely -5. **Responsive UI**: Local canvas remains responsive during sync operations -6. **Fast Updates**: ~750ms round-trip in production (vs 2-3s with R2 uploads) - -### Debug Logging - -Enable drawing protection and timing logs: - -```javascript -// Enable debug logs in development -localStorage.setItem('debug', 'fog,annotation,yjs'); - -// Enable timing logs in production -// Add ?debug=fogtiming to URL -``` - -Expected log messages: - -- "Aborting fog update - user is still drawing" -- "Fog update completed but user is drawing - skipping update" -- "[FOG-RT] Complete round-trip timing logs" - -## Temporary Drawing Layers (Playfield Touch Interaction) - -The playfield route supports temporary drawings that broadcast in real-time to other viewers without persisting to the database. This is powered by Y.js awareness protocol. - -### Architecture Overview - -``` -Player draws → RLE encode → Y.js awareness broadcast → Other clients render - ↓ - (Optional) Persist button → Database save → Permanent annotation -``` - -### Temporary Layer Structure - -```typescript -// temporaryLayers.ts -interface TemporaryLayer { - id: string; // Unique layer ID - ownerId: string; // User who created it - color: string; // Drawing color - maskData: string; // Base64 RLE data - expiresAt: number; // Auto-expire timestamp - createdAt: number; // Creation time -} -``` - -### Broadcasting Temporary Layers - -Temporary layers use Y.js awareness (ephemeral state) rather than the document (persistent state): - -```typescript -// Broadcasting a temporary layer -export function broadcastTemporaryLayer(manager: PartyDataManager, layer: TemporaryLayer) { - const awareness = manager.getAwareness(); - if (awareness) { - const localState = awareness.getLocalState() || {}; - awareness.setLocalState({ - ...localState, - temporaryLayer: layer - }); - } -} - -// Retrieving all temporary layers from other users -export function getTemporaryLayers(manager: PartyDataManager): TemporaryLayer[] { - const awareness = manager.getAwareness(); - const layers: TemporaryLayer[] = []; - - awareness?.getStates().forEach((state, clientId) => { - if (state.temporaryLayer && clientId !== awareness.clientID) { - layers.push(state.temporaryLayer); - } - }); - - return layers; -} -``` - -### Persistence Flow - -When a user clicks the persist button, the temporary layer becomes a permanent annotation: - -```typescript -// In playfield route -async function handlePersistDrawing() { - if (!persistButtonLayerId || !stage?.annotations) return; - - // Get the RLE data from the temporary layer - const rleData = await stage.annotations.toRLE(); - - // Save to database as permanent annotation - await saveAnnotationMask({ - sceneId: activeSceneId, - annotationId: persistButtonLayerId, - maskData: uint8ArrayToBase64(rleData) - }); - - // Update mask version to sync with other clients - const maskVersion = Date.now(); - stageProps.annotations.layers[layerIndex].maskVersion = maskVersion; - - // Broadcast via Y.js document (persistent) - partyData.updateSceneStageProps(activeSceneId, stageProps); - - // Clear the temporary layer from awareness - clearTemporaryLayer(partyData); -} -``` - -### Expiration and Cleanup - -Temporary layers auto-expire to prevent stale drawings: - -```typescript -// Create layer with expiration -const tempLayer = createTemporaryLayer( - layerId, - userId, - color, - base64Data, - 10000 // 10 second expiration -); - -// Cleanup expired layers (runs periodically) -function cleanupExpiredLayers(layers: TemporaryLayer[]): TemporaryLayer[] { - const now = Date.now(); - return layers.filter((layer) => layer.expiresAt > now); -} -``` - -### Key Benefits - -1. **Instant Feedback**: No database round-trip for temporary drawings -2. **Low Overhead**: Uses ephemeral awareness, not persistent Y.js document -3. **Auto-cleanup**: Expired layers are automatically removed -4. **Optional Persistence**: Users can choose to save their drawings -5. **Multi-user Support**: All connected users see temporary drawings in real-time - -## Mask Version Synchronization - -The mask synchronization system uses version timestamps to coordinate updates between clients without syncing the full mask data through Y.js. - -### Version Tracking Pattern - -```typescript -// Correct pattern: Only track version after successful load -async function loadFogMask() { - if (data.activeSceneFogMask && stage?.fogOfWar?.fromRLE) { - // Decode and apply SSR mask data - const bytes = base64ToUint8Array(data.activeSceneFogMask); - await stage.fogOfWar.fromRLE(bytes, 1024, 1024); - - // Track version ONLY after successful load - if (stageProps.fogOfWar?.maskVersion) { - lastFogMaskVersion = stageProps.fogOfWar.maskVersion; - } - } - // If no SSR data, let Y.js sync effect handle it - // Do NOT prematurely set lastFogMaskVersion -} -``` - -### Race Condition Prevention - -The key insight is that version tracking should only occur after **actually loading data**, not when merely detecting a version exists: - -```typescript -// Y.js sync effect - triggers fetch when version changes -$effect(() => { - if (stageProps.fogOfWar?.maskVersion !== lastFogMaskVersion) { - // Version differs - fetch fresh data - lastFogMaskVersion = stageProps.fogOfWar.maskVersion; - fetchFogMask(sceneId); - } -}); -``` - -If `lastFogMaskVersion` is set prematurely (before data is loaded), the effect won't trigger because `version === lastVersion`. The fix is to only set the tracking variable after successful data application. - -## Key Architecture Benefits - -1. **Simplified Infrastructure**: No Socket.IO complexity, native WebSocket support -2. **Global Edge Deployment**: PartyKit runs on Cloudflare's edge network -3. **Cost Effective**: ~$15/month vs $1000+/month for Redis -4. **First-Class Y.js Support**: Built specifically for CRDT applications -5. **Per-PR Testing**: Isolated environments for each pull request -6. **Automatic Scaling**: Cloudflare Workers scale automatically -7. **Native Apple Silicon Support**: No compatibility issues with development -8. **Cloud-prem Option**: Deploy to your own Cloudflare account for better pricing -9. **Drawing Protection**: Sophisticated system prevents drawing interruptions -10. **RLE Compression**: ~600x size reduction for masks, eliminating storage costs -11. **Real-time Masks**: Sub-second updates without R2/S3 latency -12. **Version-based Sync**: Only 8-byte timestamps sync via Y.js, masks fetched on-demand -13. **Temporary Layers**: Ephemeral drawings via awareness for instant feedback +## Thumbnails + +The editor regenerates a scene's thumbnail 10 seconds after edits go idle (Three.js capture → +R2 upload → `setSceneSettings({ mapThumbLocation })`). Failures are silent by design — thumbnails +are best-effort and never interact with data durability. ## Debugging -Enable debug logging: - -```typescript -// In browser console -localStorage.setItem('debug', 'y-*'); -``` - -Monitor PartyKit connection: - -```typescript -devLog('yjs', `PartyDataManager connecting to PartyKit: ${host}, room: ${gameSessionRoomName}`); -``` - -Monitor Y.js updates: - -```typescript -partyData.subscribe(() => { - console.log('Y.js update received', { - sceneData: partyData.getSceneData(sceneId), - timestamp: Date.now() - }); -}); -``` - -## Best Practices - -1. **Always update locally first** for immediate UI feedback -2. **Use appropriate throttle delays** based on update frequency -3. **Mark properties as local-only** when they shouldn't sync -4. **Test with multiple browsers** to ensure sync works correctly -5. **Monitor for drift** between Y.js and database state -6. **Handle save conflicts** gracefully with the coordinator system -7. **Use cursor throttling** to reduce PartyKit costs -8. **Deploy per-PR** for isolated testing environments -9. **Use cloud-prem deployment** for production cost savings -10. **Clear protections on focus loss** to prevent sync deadlocks -11. **Use underscore party names** in partykit.json configuration +- Filter the editor console by `[editor]` and play by `[play]`. +- Room debug/resync commands and the local scratch probes are documented in + `spec/realtime-sync-v2-progress.md`. +- "Connected" means "edits are durable" — the editor toasts on connection loss/recovery; there + is intentionally no "saved" toast because saving is not a client-side event. diff --git a/packages/stage/src/lib/components/Stage/components/MeasurementLayer/MeasurementLayer.svelte b/packages/stage/src/lib/components/Stage/components/MeasurementLayer/MeasurementLayer.svelte index 12ceee51d..bec182093 100644 --- a/packages/stage/src/lib/components/Stage/components/MeasurementLayer/MeasurementLayer.svelte +++ b/packages/stage/src/lib/components/Stage/components/MeasurementLayer/MeasurementLayer.svelte @@ -77,14 +77,20 @@ $effect(() => { const resChanged = display.resolution.x !== prevResX || display.resolution.y !== prevResY; - // Reset any lingering measurement state when layer deactivates or scene changes - if (!isActive || resChanged) { + if (resChanged) { + // Scene switched: remove any measurement immediately isDrawing = false; startPoint = null; if (measurementManager) { measurementManager.clearMeasurement(); measurementManager.hidePreview(); } + } else if (!isActive) { + // Tool deactivated: stop drawing and hide the preview, but let a finished + // measurement linger and auto-fade (matches how received measurements behave) + isDrawing = false; + startPoint = null; + measurementManager?.hidePreview(); } // Update tracked resolution diff --git a/packages/stage/src/lib/components/Stage/components/MeasurementLayer/MeasurementManager.svelte b/packages/stage/src/lib/components/Stage/components/MeasurementLayer/MeasurementManager.svelte index a624ab595..8a8f067f1 100644 --- a/packages/stage/src/lib/components/Stage/components/MeasurementLayer/MeasurementManager.svelte +++ b/packages/stage/src/lib/components/Stage/components/MeasurementLayer/MeasurementManager.svelte @@ -25,10 +25,6 @@ const { props, visible, displayProps, gridProps, sceneRotation = 0, onFadeComplete }: Props = $props(); - $effect(() => { - console.log('[MeasurementManager] Grid props:', gridProps); - }); - let currentMeasurement: IMeasurement | null = null; // Use $state.raw() for Three.js objects to prevent Svelte's proxy from interfering // with internal Three.js property access (like object.layers) @@ -333,20 +329,9 @@ enableDMG252?: boolean ): void { if (!props) { - console.log('[MeasurementManager] No props available for displayReceivedMeasurement'); return; } - console.log('[MeasurementManager] displayReceivedMeasurement called:', { - startPoint, - endPoint, - type, - beamWidth, - coneAngle, - thickness, - hasCurrentMeasurement: !!currentMeasurement - }); - // Clear any existing measurement clearMeasurement(); @@ -397,17 +382,9 @@ currentMeasurement = measurement; measurementGroup.add(measurement.object); - console.log('[MeasurementManager] Measurement created and added to group:', { - groupChildren: measurementGroup.children.length, - measurementObject: measurement.object, - visible: measurementGroup.visible - }); - // Update to the end point currentMeasurement.update(endPoint, sceneRotation); - console.log('[MeasurementManager] Measurement updated to endpoint'); - // Store the fadeout time if provided for the fade animation if (fadeoutTime !== undefined) { receivedFadeoutTime = fadeoutTime; @@ -415,20 +392,11 @@ // Schedule auto-fade with the received timing properties const delay = autoHideDelay ?? props.autoHideDelay; - console.log( - '[MeasurementManager] Scheduling auto-fade with delay:', - delay, - 'fadeTime:', - fadeoutTime ?? props.fadeoutTime - ); - autoHideTimeoutId = setTimeout(() => { // Start the fade animation fadeStartTime = performance.now(); isFading = true; }, delay); - - console.log('[MeasurementManager] Measurement auto-fade scheduled'); } // Export the methods for use by parent components From f727b3cc4003cdc2a28a400d39c65d4b0f93c9c8 Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Wed, 10 Jun 2026 03:03:17 -0400 Subject: [PATCH 04/10] linting --- .../lib/components/GameSession/LightManager.svelte | 2 -- .../src/lib/components/GameSession/MapControls.svelte | 1 - .../lib/components/GameSession/MarkerManager.svelte | 1 - apps/web/src/lib/realtime/fromDb.ts | 11 ++++------- apps/web/src/lib/server/realtime/index.ts | 3 ++- apps/web/src/lib/utils/propertyUpdateBroadcaster.ts | 1 + .../[gameSession]/[[selectedScene]]/+page.svelte | 3 ++- apps/web/src/routes/(app)/[party]/play/+page@.svelte | 2 +- 8 files changed, 10 insertions(+), 14 deletions(-) diff --git a/apps/web/src/lib/components/GameSession/LightManager.svelte b/apps/web/src/lib/components/GameSession/LightManager.svelte index 90ba9acf8..597d7a49f 100644 --- a/apps/web/src/lib/components/GameSession/LightManager.svelte +++ b/apps/web/src/lib/components/GameSession/LightManager.svelte @@ -31,8 +31,6 @@ let { stageProps, selectedLightId = $bindable(), - partyId = '', - sceneId = '', handleSelectActiveControl, socketUpdate = () => {}, updateLightAndSave, diff --git a/apps/web/src/lib/components/GameSession/MapControls.svelte b/apps/web/src/lib/components/GameSession/MapControls.svelte index 1b28fcd60..456da773c 100644 --- a/apps/web/src/lib/components/GameSession/MapControls.svelte +++ b/apps/web/src/lib/components/GameSession/MapControls.svelte @@ -16,7 +16,6 @@ handleMapFill, handleMapFit, errors, - party, client }: { handleSelectActiveControl: (control: string) => void; diff --git a/apps/web/src/lib/components/GameSession/MarkerManager.svelte b/apps/web/src/lib/components/GameSession/MarkerManager.svelte index e23c22707..8515e85b9 100644 --- a/apps/web/src/lib/components/GameSession/MarkerManager.svelte +++ b/apps/web/src/lib/components/GameSession/MarkerManager.svelte @@ -47,7 +47,6 @@ let { stageProps, selectedMarkerId = $bindable(), - partyId = '', handleSelectActiveControl, socketUpdate = () => {}, updateMarkerAndSave, diff --git a/apps/web/src/lib/realtime/fromDb.ts b/apps/web/src/lib/realtime/fromDb.ts index e9ee8de86..559240a30 100644 --- a/apps/web/src/lib/realtime/fromDb.ts +++ b/apps/web/src/lib/realtime/fromDb.ts @@ -4,18 +4,15 @@ import type { AnnotationRow, LightRow, MarkerRow, SceneSettings } from './types' // DB row -> doc row converters (app-side only; the PartyKit bundle never imports this). export const sceneRowToSettings = (scene: SelectScene): SceneSettings => { - const { - fogOfWarMask: _fogOfWarMask, - annotationLayers: _annotationLayers, - lastUpdated: _lastUpdated, - ...settings - } = scene; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { fogOfWarMask, annotationLayers, lastUpdated, ...settings } = scene; return settings; }; export const markerRowFromDb = (marker: SelectMarker): MarkerRow => marker; export const lightRowFromDb = (light: SelectLight): LightRow => light; export const annotationRowFromDb = (annotation: SelectAnnotation): AnnotationRow => { - const { mask: _mask, ...row } = annotation; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { mask, ...row } = annotation; return row; }; diff --git a/apps/web/src/lib/server/realtime/index.ts b/apps/web/src/lib/server/realtime/index.ts index d57affe0b..25325b261 100644 --- a/apps/web/src/lib/server/realtime/index.ts +++ b/apps/web/src/lib/server/realtime/index.ts @@ -28,7 +28,8 @@ const sceneRowToWire = ( lights: SceneWire['lights'], annotations: SceneWire['annotations'] ): SceneWire => { - const { fogOfWarMask, annotationLayers: _annotationLayers, lastUpdated: _lastUpdated, ...settings } = scene; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { fogOfWarMask, annotationLayers, lastUpdated, ...settings } = scene; return { settings, markers, lights, annotations, fogMask: fogOfWarMask }; }; diff --git a/apps/web/src/lib/utils/propertyUpdateBroadcaster.ts b/apps/web/src/lib/utils/propertyUpdateBroadcaster.ts index 6dae72c89..683c444da 100644 --- a/apps/web/src/lib/utils/propertyUpdateBroadcaster.ts +++ b/apps/web/src/lib/utils/propertyUpdateBroadcaster.ts @@ -59,6 +59,7 @@ export function queuePropertyUpdate( stageProps: StageProps, propertyPath: PropertyPath, value: unknown, + // eslint-disable-next-line @typescript-eslint/no-unused-vars _updateType: 'marker' | 'light' | 'control' | 'scene' = 'control' ) { applyUpdate(stageProps as unknown as Record, propertyPath, value); diff --git a/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte b/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte index 2ab14e6d9..218d918a3 100644 --- a/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte +++ b/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte @@ -15,7 +15,6 @@ Shortcuts, type ChecklistItemId } from '$lib/components'; - import { handleMutation } from '$lib/factories'; import { useUpdateChecklistProgressMutation, useUploadSceneThumbnailMutation } from '$lib/queries'; import { buildRenderProps, reuseUnchanged, type AnnotationRow, type MarkerRow } from '$lib/realtime'; import { @@ -800,6 +799,7 @@ } }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const onAnnotationUpdate = (layerId: string, _blob: Promise) => { const existing = annotationMaskTimers.get(layerId); if (existing) clearTimeout(existing); @@ -825,6 +825,7 @@ } }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const onFogUpdate = (_blob: Promise) => { trackChecklistItemLocal('fog-erase'); if (fogCommitTimer) clearTimeout(fogCommitTimer); diff --git a/apps/web/src/routes/(app)/[party]/play/+page@.svelte b/apps/web/src/routes/(app)/[party]/play/+page@.svelte index 76910fc80..27c9d7841 100644 --- a/apps/web/src/routes/(app)/[party]/play/+page@.svelte +++ b/apps/web/src/routes/(app)/[party]/play/+page@.svelte @@ -25,7 +25,7 @@ import { PlayTools } from './usePlayTools.svelte'; let { data } = $props(); - const { user, party } = $derived(data); + const { party } = $derived(data); let stage: StageExports | undefined = $state(); let stageElement: HTMLDivElement | undefined = $state(); From 66b58648190ab1ac9b77dd5791247fc72f99f736 Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Wed, 10 Jun 2026 03:31:27 -0400 Subject: [PATCH 05/10] fix deployment for previews --- .github/workflows/ci.yml | 5 ++- .github/workflows/fly-deploy.yml | 6 +++- apps/web/.env-example | 12 +++++-- apps/web/partykit/appApi.ts | 9 +++-- .../[[selectedScene]]/+page.svelte | 34 ++++++++++++++++++- docs/yjs-sync-architecture.md | 3 +- 6 files changed, 60 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 899f6f0a4..e4baf5bd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -296,7 +296,9 @@ jobs: working-directory: apps/web run: | echo "Deploying PartyKit for PR #${{ github.event.pull_request.number }}" - npx partykit deploy --domain tableslayer.com --preview pr-${{ github.event.pull_request.number }} + npx partykit deploy --domain tableslayer.com --preview pr-${{ github.event.pull_request.number }} \ + --var BASE_URL=https://pr-${{ github.event.pull_request.number }}-web.fly.dev \ + --var INTERNAL_API_TOKEN=${{ secrets.INTERNAL_API_TOKEN }} env: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_KEY }} @@ -330,6 +332,7 @@ jobs: GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }} PUBLIC_PARTYKIT_HOST=pr-${{ github.event.pull_request.number }}.tableslayer.com PARTYKIT_TOKEN=${{ secrets.PARTYKIT_TOKEN }} + INTERNAL_API_TOKEN=${{ secrets.INTERNAL_API_TOKEN }} EOF - name: Deploy Web uses: superfly/fly-pr-review-apps@1.6.0 diff --git a/.github/workflows/fly-deploy.yml b/.github/workflows/fly-deploy.yml index aaa466b05..109da726a 100644 --- a/.github/workflows/fly-deploy.yml +++ b/.github/workflows/fly-deploy.yml @@ -45,12 +45,16 @@ jobs: GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }} PUBLIC_PARTYKIT_HOST=partykit.tableslayer.com PARTYKIT_TOKEN=${{ secrets.PARTYKIT_TOKEN }} + INTERNAL_API_TOKEN=${{ secrets.INTERNAL_API_TOKEN }} EOF - uses: ./.github/shared - name: Deploy PartyKit servers if: github.ref == 'refs/heads/main' working-directory: apps/web - run: npx partykit deploy --name tableslayer --domain partykit.tableslayer.com + run: | + npx partykit deploy --name tableslayer --domain partykit.tableslayer.com \ + --var BASE_URL=https://tableslayer.com \ + --var INTERNAL_API_TOKEN=${{ secrets.INTERNAL_API_TOKEN }} env: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_KEY }} diff --git a/apps/web/.env-example b/apps/web/.env-example index d4a923b7d..f593a1d3f 100644 --- a/apps/web/.env-example +++ b/apps/web/.env-example @@ -11,13 +11,21 @@ # - (optional) A Google Cloud account (for Google OAuth login) # The URL of your deployed app -# For local development, use 5174 +# For local development, use http://localhost:5174 # For production use your domain or IP address -BASE_URL=https://localhost:5174 +# Also used by the PartyKit server to reach the app for realtime persistence +BASE_URL=http://localhost:5174 # Production, development, or preview. Defines if real emails are sent, etc. # Set to "production" for self-hosting ENV_NAME=production +# A shared secret between the app and the PartyKit server (any long random string) +# Required outside local development: the PartyKit server uses it to authenticate +# when saving realtime changes back to the database. +# Set the SAME value as a PartyKit var when deploying: +# npx partykit deploy --var BASE_URL= --var INTERNAL_API_TOKEN= +INTERNAL_API_TOKEN= + # Signup: https://turso.tech/ # Create a database and get keys. # The api token is used to create and destory databases in CI diff --git a/apps/web/partykit/appApi.ts b/apps/web/partykit/appApi.ts index 0bc17b265..2caee95ed 100644 --- a/apps/web/partykit/appApi.ts +++ b/apps/web/partykit/appApi.ts @@ -1,12 +1,15 @@ import type * as Party from 'partykit/server'; // Server-to-server bridge from PartyKit rooms to the app's /api/internal endpoints. -// Production requires APP_API_URL and INTERNAL_API_TOKEN set as PartyKit vars; dev -// falls back to the local SvelteKit server and the shared dev token. +// Deployments must set BASE_URL (the app's URL, same value the app itself uses) +// and INTERNAL_API_TOKEN as PartyKit vars; dev falls back to the local SvelteKit +// server and the shared dev token. APP_API_URL remains as an explicit override. export const appRequest = async (room: Party.Room, path: string, body: unknown): Promise => { // 5174 is the web app's pinned dev port (apps/web/vite.config.ts) - const base = (room.env.APP_API_URL as string | undefined) ?? 'http://localhost:5174'; + const configured = (room.env.APP_API_URL ?? room.env.BASE_URL) as string | undefined; + // partykit dev reads the app's .env; tolerate a https://localhost BASE_URL there + const base = (configured ?? 'http://localhost:5174').replace(/^https:\/\/(localhost|127\.)/, 'http://$1'); const token = (room.env.INTERNAL_API_TOKEN as string | undefined) ?? 'dev-internal-token'; const response = await fetch(`${base}${path}`, { diff --git a/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte b/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte index 218d918a3..85defed1b 100644 --- a/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte +++ b/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte @@ -174,7 +174,39 @@ $effect(() => { const snapshot = session.ready && session.client ? session.client.scene(selectedSceneId) : null; const drags = dragPositions; - if (!snapshot) return; + const ssrScene = data.selectedScene; + + if (!snapshot) { + // Realtime not ready (or scene missing from the doc): scene navigation must + // still work from SSR data — degraded mode, not a frozen editor. + if (ssrScene.id === selectedSceneId && ssrScene.id !== lastBuiltSceneId) { + lastBuiltSceneId = ssrScene.id; + untrack(() => { + const props = buildSceneProps( + ssrScene, + data.selectedSceneMarkers, + 'editor', + data.selectedSceneAnnotations, + data.selectedSceneLights, + data.bucketUrl + ); + props.fogOfWar.tool.size = clampFogBrush(getPreference('brushSizePercent') || 10.0, props); + props.annotations.lineWidth = Math.max( + 0.01, + Math.min(5.0, getPreference('annotationLineWidthPercent') || 2.0) + ); + props.annotations.smoothingEnabled = getPreference('annotationSmoothing') ?? true; + stageProps = props; + activeControl = 'none'; + selectedMarkerId = undefined; + selectedLightId = undefined; + selectedAnnotationId = undefined; + resetGridOrigin(); + sceneMasksApplied = false; + }); + } + return; + } const isSceneSwitch = snapshot.id !== lastBuiltSceneId; lastBuiltSceneId = snapshot.id; diff --git a/docs/yjs-sync-architecture.md b/docs/yjs-sync-architecture.md index 21753aff8..c8f2c8846 100644 --- a/docs/yjs-sync-architecture.md +++ b/docs/yjs-sync-architecture.md @@ -67,7 +67,8 @@ Key properties: `requestGameSessionRoomResync` in `$lib/server/realtime`. - Internal endpoints authenticate via the `INTERNAL_API_TOKEN` shared secret (`x-internal-token`); dev falls back to `dev-internal-token` on both sides. The PartyKit env - also needs `APP_API_URL` (defaults to `http://localhost:5174`, the web app's pinned dev port). + also needs `BASE_URL` (the app's URL — passed via `--var` at deploy; defaults to + `http://localhost:5174`, the web app's pinned dev port; `APP_API_URL` overrides if set). ## Clients (`apps/web/src/lib/realtime/`) From eb3c04fd2177d9f724aa068441eba7eacb389691 Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Wed, 10 Jun 2026 03:48:10 -0400 Subject: [PATCH 06/10] realtime cleanup --- apps/web/src/lib/server/realtime/index.ts | 3 ++- .../src/routes/api/gameSessions/deleteGameSession/+server.ts | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/server/realtime/index.ts b/apps/web/src/lib/server/realtime/index.ts index 25325b261..c83527806 100644 --- a/apps/web/src/lib/server/realtime/index.ts +++ b/apps/web/src/lib/server/realtime/index.ts @@ -139,7 +139,8 @@ const requestRoomResync = async (party: 'party' | 'game_session', roomId: string await fetch(`${protocol}://${host}/parties/${party}/${roomId}`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-internal-token': token }, - body: JSON.stringify({ type: 'resync' }) + body: JSON.stringify({ type: 'resync' }), + signal: AbortSignal.timeout(3000) }); } catch (error) { console.warn(`room resync failed for ${party}/${roomId}`, error); diff --git a/apps/web/src/routes/api/gameSessions/deleteGameSession/+server.ts b/apps/web/src/routes/api/gameSessions/deleteGameSession/+server.ts index 4590838a1..384e38a66 100644 --- a/apps/web/src/routes/api/gameSessions/deleteGameSession/+server.ts +++ b/apps/web/src/routes/api/gameSessions/deleteGameSession/+server.ts @@ -1,5 +1,6 @@ import { apiFactory } from '$lib/factories'; import { deleteGameSession, isUserAdminInParty } from '$lib/server'; +import { requestPartyRoomResync } from '$lib/server/realtime'; import { z } from 'zod'; const validationSchema = z.object({ @@ -17,6 +18,10 @@ export const POST = apiFactory( await deleteGameSession(gameSessionId); + // Deleting a session may reassign party.activeSceneId directly in the DB; + // tell the live party room to re-read so it doesn't persist a stale value back. + await requestPartyRoomResync(partyId); + return { success: true }; }, { From 5a0d34743620757de0e35cd0d06a2e19a7c67208 Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Wed, 10 Jun 2026 03:50:03 -0400 Subject: [PATCH 07/10] cl --- apps/web/src/lib/changelog/2026-06.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 apps/web/src/lib/changelog/2026-06.md diff --git a/apps/web/src/lib/changelog/2026-06.md b/apps/web/src/lib/changelog/2026-06.md new file mode 100644 index 000000000..ca8e64a75 --- /dev/null +++ b/apps/web/src/lib/changelog/2026-06.md @@ -0,0 +1,7 @@ +#### Real-time editing, rebuilt + +The editor and playfield now share a live, Figma-style sync system. Edits — fog, drawings, markers, lights, and scene settings — appear on the playfield almost instantly and save automatically the moment you make them, so there's no more save delay and no risk of losing work by closing a tab. Multiple editors can work on the same scene at once without overwriting each other, and switching the active scene no longer reloads the playfield. If your connection drops, Table Slayer warns you and syncs everything back up when you reconnect. [#448](https://github.com/Siege-Perilous/tableslayer/pull/448) + +#### Fixes + +- Scene links now use a stable address that doesn't change when scenes are reordered. Old links redirect automatically. [#448](https://github.com/Siege-Perilous/tableslayer/pull/448) From 14adf818fa1f50c78c3e89e3f2568044157c9961 Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Wed, 10 Jun 2026 08:16:31 -0400 Subject: [PATCH 08/10] fixes --- .../useEditorSession.svelte.ts | 24 +++++++++++++++--- .../routes/(app)/[party]/play/+page@.svelte | 20 ++++++++++++--- .../[party]/play/usePlaySession.svelte.ts | 25 ++++++++++++++++--- .../(app)/[party]/play/usePlayTools.svelte.ts | 6 +++-- 4 files changed, 63 insertions(+), 12 deletions(-) diff --git a/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/useEditorSession.svelte.ts b/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/useEditorSession.svelte.ts index f6900f647..c801974b0 100644 --- a/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/useEditorSession.svelte.ts +++ b/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/useEditorSession.svelte.ts @@ -108,15 +108,31 @@ export class EditorSession { const mask = client.fogMask(change.sceneId); if (mask) stage.fogOfWar.fromRLE(mask, 1024, 1024); } - if (change.part === 'annotations' && change.childId && change.keys.includes('mask')) { - if (!stage.annotations?.isDrawing()) { - const mask = client.annotationMask(change.sceneId, change.childId); - if (mask) stage.annotations.loadMask(change.childId, mask); + // Any annotation row change can (re)mount its layer component (new rows, + // visibility toggles); reapply masks rather than only reacting to 'mask'. + if (change.part === 'annotations' && !stage.annotations?.isDrawing()) { + const annotationIds = change.childId ? [change.childId] : change.keys; + for (const annotationId of annotationIds) { + this.#reapplyAnnotationMask(change.sceneId, annotationId); } } } } + // Reapply on a short ladder: loadMask silently no-ops if the layer component + // hasn't mounted yet, so a single immediate call can miss a remounting layer. + #reapplyAnnotationMask(sceneId: string, annotationId: string) { + for (const delay of [50, 350, 1000]) { + setTimeout(() => { + if (this.#options.selectedSceneId() !== sceneId) return; + const stage = this.#options.getStage(); + if (!stage?.annotations?.loadMask || stage.annotations.isDrawing()) return; + const mask = this.client?.annotationMask(sceneId, annotationId); + if (mask) stage.annotations.loadMask(annotationId, mask); + }, delay); + } + } + #scheduleThumbnail(sceneId: string) { const existing = this.#thumbnailTimers.get(sceneId); if (existing) clearTimeout(existing); diff --git a/apps/web/src/routes/(app)/[party]/play/+page@.svelte b/apps/web/src/routes/(app)/[party]/play/+page@.svelte index 27c9d7841..f4597c211 100644 --- a/apps/web/src/routes/(app)/[party]/play/+page@.svelte +++ b/apps/web/src/routes/(app)/[party]/play/+page@.svelte @@ -21,7 +21,7 @@ import { IconArrowBackUp } from '@tabler/icons-svelte'; import { onMount, untrack } from 'svelte'; import PauseOverlay from './PauseOverlay.svelte'; - import { PlaySession } from './usePlaySession.svelte'; + import { PlaySession, type SessionRoute } from './usePlaySession.svelte'; import { PlayTools } from './usePlayTools.svelte'; let { data } = $props(); @@ -42,7 +42,7 @@ initialActiveSceneId: data.activeScene?.id ?? null, initialIsPaused: data.party.gameSessionIsPaused, bucketUrl: data.bucketUrl, - routes: () => data.gameSessionsWithScenes, + routes: () => sceneRoutes, getStage: () => stage }) ); @@ -53,11 +53,25 @@ session, userId: data.user.id, getStage: () => stage, - routes: () => data.gameSessionsWithScenes, + routes: () => sceneRoutes, defaultSessionFilter: () => session.gameSessionId }) ); + // Scene routing for the radial menu and cross-session switches. SSR provides + // every session's list; the doc is authoritative for the connected session, so + // renames/additions/deletions there reflect live. Other sessions' rooms aren't + // connected, so their names stay as loaded (refreshed on reload). + // Explicit annotation breaks a type-inference cycle (session ↔ sceneRoutes) + const sceneRoutes: SessionRoute[] = $derived.by((): SessionRoute[] => { + const live = session.ready && session.client ? session.client.scenes() : null; + return data.gameSessionsWithScenes.map((gs) => + live && gs.id === session.gameSessionId + ? { id: gs.id, name: gs.name, scenes: live.map((scene) => ({ id: scene.id, name: scene.name })) } + : gs + ); + }); + // --------------------------------------------------------------------------- // Render props: doc snapshot + local view -> StageProps (one-directional). // SSR fallback shows the active map immediately while the doc connects; once diff --git a/apps/web/src/routes/(app)/[party]/play/usePlaySession.svelte.ts b/apps/web/src/routes/(app)/[party]/play/usePlaySession.svelte.ts index 64f1475ba..335a90e9c 100644 --- a/apps/web/src/routes/(app)/[party]/play/usePlaySession.svelte.ts +++ b/apps/web/src/routes/(app)/[party]/play/usePlaySession.svelte.ts @@ -179,6 +179,20 @@ export class PlaySession { } } + // Reapply on a short ladder: loadMask silently no-ops if the layer component + // hasn't mounted yet, so a single immediate call can miss a (re)mounting layer. + // Public because local writes need it too (e.g. persisting a player drawing + // mounts a brand-new annotation layer; own doc changes skip the remote path). + reapplyAnnotationMask(sceneId: string, annotationId: string) { + for (const delay of [50, 350, 1000]) { + setTimeout(() => { + if (this.activeSceneId !== sceneId) return; + const mask = this.client?.annotationMask(sceneId, annotationId); + if (mask) this.loadAnnotationMask(annotationId, mask); + }, delay); + } + } + #applyRemoteMaskChanges(changes: SceneChange[]) { const sceneId = this.activeSceneId; const client = this.client; @@ -196,9 +210,14 @@ export class PlaySession { if (mask) stage.fogOfWar.fromRLE(mask, 1024, 1024); } - if (change.part === 'annotations' && change.childId && change.keys.includes('mask')) { - const mask = client.annotationMask(sceneId, change.childId); - if (mask) this.loadAnnotationMask(change.childId, mask); + // Any annotation row change can (re)mount its layer component — e.g. a + // visibility toggle filters the layer in/out of the render props — and a + // fresh component needs its mask reapplied, not just on 'mask' changes. + if (change.part === 'annotations') { + const annotationIds = change.childId ? [change.childId] : change.keys; + for (const annotationId of annotationIds) { + this.reapplyAnnotationMask(sceneId, annotationId); + } } } } diff --git a/apps/web/src/routes/(app)/[party]/play/usePlayTools.svelte.ts b/apps/web/src/routes/(app)/[party]/play/usePlayTools.svelte.ts index c62fd14cf..5b410b593 100644 --- a/apps/web/src/routes/(app)/[party]/play/usePlayTools.svelte.ts +++ b/apps/web/src/routes/(app)/[party]/play/usePlayTools.svelte.ts @@ -439,8 +439,10 @@ export class PlayTools { }; session.client.write.upsertAnnotation(sceneId, annotation, mask); - // Our own doc write is local-origin, so apply the mask to the new layer here - await session.loadAnnotationMask(annotation.id, mask); + // Our own doc write is local-origin (the remote reapply path skips it), and + // the new annotation's layer component mounts on the NEXT render — use the + // retry ladder so the mask lands after the mount instead of no-oping before it. + session.reapplyAnnotationMask(sceneId, annotation.id); session.presence?.removeTemporaryLayer(layerId); this.#loadedTempMasks.delete(layerId); From da51286d7d3ac58c707b6d84bd6fab62948ebc07 Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Wed, 10 Jun 2026 08:42:39 -0400 Subject: [PATCH 09/10] fix annotation pins --- apps/web/src/lib/realtime/docSchema.test.ts | 6 +- apps/web/src/lib/realtime/docSchema.ts | 6 +- apps/web/src/lib/realtime/presence.svelte.ts | 8 ++- apps/web/src/lib/realtime/types.ts | 3 +- apps/web/src/lib/utils/buildSceneProps.ts | 3 + .../[[selectedScene]]/+page.svelte | 26 ++++----- .../useEditorSession.svelte.ts | 45 +++------------ .../[party]/play/usePlaySession.svelte.ts | 55 +++---------------- .../(app)/[party]/play/usePlayTools.svelte.ts | 54 ++++++------------ .../AnnotationLayer/AnnotationLayer.svelte | 8 ++- .../AnnotationLayer/AnnotationMaterial.svelte | 27 ++++++++- .../Stage/components/AnnotationLayer/types.ts | 6 +- 12 files changed, 102 insertions(+), 145 deletions(-) diff --git a/apps/web/src/lib/realtime/docSchema.test.ts b/apps/web/src/lib/realtime/docSchema.test.ts index 6297bfe05..a7cce3505 100644 --- a/apps/web/src/lib/realtime/docSchema.test.ts +++ b/apps/web/src/lib/realtime/docSchema.test.ts @@ -190,9 +190,11 @@ describe('docSchema hydration and reads', () => { expect(snap?.markers).toHaveLength(2); expect(snap?.markers.find((m) => m.id === 'm2')?.positionX).toBe(10); expect(snap?.lights[0]?.style).toBe('lantern'); - // Annotations sorted by order, mask excluded from rows + // Annotations sorted by order, masks included as Uint8Array references expect(snap?.annotations.map((a) => a.id)).toEqual(['a1', 'a2']); - expect(snap?.annotations.find((a) => a.id === 'a2')).not.toHaveProperty('mask'); + expect(snap?.annotations.find((a) => a.id === 'a2')?.mask).toEqual(new Uint8Array([9, 9])); + // Same reference until the mask is rewritten — feeds reference-based reactivity + expect(snap?.annotations.find((a) => a.id === 'a2')?.mask).toBe(getAnnotationMask(doc, 's1', 'a2')); expect(getFogMask(doc, 's1')).toEqual(new Uint8Array([1, 2, 3, 4])); expect(getAnnotationMask(doc, 's1', 'a2')).toEqual(new Uint8Array([9, 9])); diff --git a/apps/web/src/lib/realtime/docSchema.ts b/apps/web/src/lib/realtime/docSchema.ts index d3330eeaf..dbfa957dc 100644 --- a/apps/web/src/lib/realtime/docSchema.ts +++ b/apps/web/src/lib/realtime/docSchema.ts @@ -85,7 +85,11 @@ export const getSceneSnapshot = (doc: Y.Doc, sceneId: string): SceneSnapshot | n settings: yMapToObject(settings), markers: rowsOf(scene, 'markers'), lights: rowsOf(scene, 'lights'), - annotations: rowsOf(scene, 'annotations', ANNOTATION_MASK_KEY).sort((a, b) => a.order - b.order) + // Masks ride along as stable Uint8Array references (cheap; Y.js returns the + // same instance until a new mask is set), feeding declarative layer props + annotations: rowsOf(scene, 'annotations').sort( + (a, b) => a.order - b.order + ) }; }; diff --git a/apps/web/src/lib/realtime/presence.svelte.ts b/apps/web/src/lib/realtime/presence.svelte.ts index 334fe434d..9b0533189 100644 --- a/apps/web/src/lib/realtime/presence.svelte.ts +++ b/apps/web/src/lib/realtime/presence.svelte.ts @@ -183,11 +183,13 @@ export class PresenceChannel { } broadcastTemporaryLayer(layer: TemporaryLayer) { + // Never mutate the stored array: y-protocols deep-compares prev vs next + // state to decide whether to emit 'change', and an in-place edit makes the + // comparison see "no change" — our own reactive state would go stale. const layers = this.#ownTemporaryLayers(); const index = layers.findIndex((l) => l.id === layer.id); - if (index >= 0) layers[index] = layer; - else layers.push(layer); - this.#awareness.setLocalStateField('temporaryLayers', layers); + const next = index >= 0 ? layers.map((l, i) => (i === index ? layer : l)) : [...layers, layer]; + this.#awareness.setLocalStateField('temporaryLayers', next); } removeTemporaryLayer(layerId: string) { diff --git a/apps/web/src/lib/realtime/types.ts b/apps/web/src/lib/realtime/types.ts index 5d4ae5851..bbfa7cae3 100644 --- a/apps/web/src/lib/realtime/types.ts +++ b/apps/web/src/lib/realtime/types.ts @@ -116,7 +116,8 @@ export interface SceneSnapshot { settings: SceneSettings; markers: MarkerRow[]; lights: LightRow[]; - annotations: AnnotationRow[]; + /** Includes each annotation's RLE mask as a stable Uint8Array reference. */ + annotations: Array; } /** Lightweight entry for scene lists/selectors, derived from settings. */ diff --git a/apps/web/src/lib/utils/buildSceneProps.ts b/apps/web/src/lib/utils/buildSceneProps.ts index d67c9928a..cd8a44edd 100644 --- a/apps/web/src/lib/utils/buildSceneProps.ts +++ b/apps/web/src/lib/utils/buildSceneProps.ts @@ -1,4 +1,5 @@ import { type SelectAnnotation, type SelectLight, type SelectMarker, type SelectScene } from '$lib/db/app/schema'; +import { base64ToUint8 } from '$lib/realtime/binary'; import type { Thumb } from '$lib/server'; import { generateGradientColors } from '$lib/utils'; import { StageDefaultProps } from '$lib/utils/defaultMapState'; @@ -72,6 +73,8 @@ export const buildSceneProps = ( opacity: annotation.opacity, color: annotation.color, url: annotation.url ? `https://files.tableslayer.com/${annotation.url}` : null, + // DB rows carry base64; doc snapshots carry stable Uint8Array references + mask: typeof annotation.mask === 'string' ? base64ToUint8(annotation.mask) : (annotation.mask ?? null), visibility: annotation.visibility as StageMode, effect: annotation.effectType && annotation.effectType !== AnnotationEffect.None diff --git a/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte b/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte index 85defed1b..d09e6417e 100644 --- a/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte +++ b/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte @@ -152,12 +152,20 @@ const clampFogBrush = (value: number, props: StageProps) => Math.max(getMinBrushSize(props.grid.spacing, props.display.size.x), Math.min(20, value)); + // SSR strips masks from annotation rows (serialization) and ships them as a + // separate record; merge them back so the seed props paint layers immediately + const ssrAnnotations = () => + data.selectedSceneAnnotations.map((annotation) => ({ + ...annotation, + mask: data.selectedSceneAnnotationMasks?.[annotation.id] ?? null + })); + const initialStageProps = untrack(() => { const props = buildSceneProps( data.selectedScene, data.selectedSceneMarkers, 'editor', - data.selectedSceneAnnotations, + ssrAnnotations(), data.selectedSceneLights, data.bucketUrl ); @@ -186,7 +194,7 @@ ssrScene, data.selectedSceneMarkers, 'editor', - data.selectedSceneAnnotations, + ssrAnnotations(), data.selectedSceneLights, data.bucketUrl ); @@ -573,7 +581,8 @@ const onStageInitialized = async () => { stageIsLoading = false; - // SSR masks give a fast first paint; the doc re-applies once ready + // SSR fog mask gives a fast first paint; the doc re-applies once ready. + // (Annotation masks are declarative layer props — no application needed.) if (data.selectedSceneFogMask && stage?.fogOfWar?.fromRLE) { try { const bytes = Uint8Array.from(atob(data.selectedSceneFogMask), (c) => c.charCodeAt(0)); @@ -582,17 +591,6 @@ // doc apply will retry } } - if (data.selectedSceneAnnotationMasks && stage?.annotations?.loadMask) { - for (const [annotationId, maskData] of Object.entries(data.selectedSceneAnnotationMasks)) { - if (!maskData) continue; - try { - const bytes = Uint8Array.from(atob(maskData), (c) => c.charCodeAt(0)); - await stage.annotations.loadMask(annotationId, bytes); - } catch { - // doc apply will retry - } - } - } }; // Markers ------------------------------------------------------------------ diff --git a/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/useEditorSession.svelte.ts b/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/useEditorSession.svelte.ts index c801974b0..dd4cc119a 100644 --- a/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/useEditorSession.svelte.ts +++ b/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/useEditorSession.svelte.ts @@ -52,19 +52,19 @@ export class EditorSession { } /** - * Apply a scene's fog + annotation masks from the doc to the stage. Retries on - * a short schedule: the fog layer refills itself when the map texture loads, - * and annotation layer components must mount before loadMask can land. + * Apply a scene's fog mask from the doc to the stage. Retries on a short + * schedule: the fog layer refills itself when the map texture loads. + * (Annotation masks are declarative layer props — no application needed.) */ async applyMasks(sceneId: string) { for (const delay of [0, 300, 1000, 3000]) { if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); if (this.#options.selectedSceneId() !== sceneId) return; // scene changed mid-retry - await this.#applyMasksOnce(sceneId); + await this.#applyFogMaskOnce(sceneId); } } - async #applyMasksOnce(sceneId: string) { + async #applyFogMaskOnce(sceneId: string) { const client = this.client; const stage = this.#options.getStage(); if (!client || !stage) return; @@ -74,14 +74,8 @@ export class EditorSession { if (fogMask && stage.fogOfWar?.fromRLE && !stage.fogOfWar.isDrawing()) { await stage.fogOfWar.fromRLE(fogMask, 1024, 1024); } - for (const annotation of client.scene(sceneId)?.annotations ?? []) { - const mask = client.annotationMask(sceneId, annotation.id); - if (mask && stage.annotations?.loadMask && !stage.annotations.isDrawing()) { - await stage.annotations.loadMask(annotation.id, mask); - } - } } catch (error) { - devError('editor', 'Failed to apply masks', error); + devError('editor', 'Failed to apply fog mask', error); } } @@ -100,36 +94,15 @@ export class EditorSession { if (!change.remote || change.sceneId !== selectedSceneId) continue; - // Remote mask changes apply straight to the canvas (own commits are - // excluded by transaction identity, not timing) + // Remote fog changes apply straight to the canvas (own commits are + // excluded by transaction identity, not timing). Annotation masks are + // declarative layer props and need no handling here. const stage = this.#options.getStage(); if (!stage) continue; if (change.part === 'fogMask' && !stage.fogOfWar?.isDrawing()) { const mask = client.fogMask(change.sceneId); if (mask) stage.fogOfWar.fromRLE(mask, 1024, 1024); } - // Any annotation row change can (re)mount its layer component (new rows, - // visibility toggles); reapply masks rather than only reacting to 'mask'. - if (change.part === 'annotations' && !stage.annotations?.isDrawing()) { - const annotationIds = change.childId ? [change.childId] : change.keys; - for (const annotationId of annotationIds) { - this.#reapplyAnnotationMask(change.sceneId, annotationId); - } - } - } - } - - // Reapply on a short ladder: loadMask silently no-ops if the layer component - // hasn't mounted yet, so a single immediate call can miss a remounting layer. - #reapplyAnnotationMask(sceneId: string, annotationId: string) { - for (const delay of [50, 350, 1000]) { - setTimeout(() => { - if (this.#options.selectedSceneId() !== sceneId) return; - const stage = this.#options.getStage(); - if (!stage?.annotations?.loadMask || stage.annotations.isDrawing()) return; - const mask = this.client?.annotationMask(sceneId, annotationId); - if (mask) stage.annotations.loadMask(annotationId, mask); - }, delay); } } diff --git a/apps/web/src/routes/(app)/[party]/play/usePlaySession.svelte.ts b/apps/web/src/routes/(app)/[party]/play/usePlaySession.svelte.ts index 335a90e9c..1d35a30e5 100644 --- a/apps/web/src/routes/(app)/[party]/play/usePlaySession.svelte.ts +++ b/apps/web/src/routes/(app)/[party]/play/usePlaySession.svelte.ts @@ -133,21 +133,21 @@ export class PlaySession { } /** - * Apply the active scene's fog + annotation masks from the doc to the stage. - * Retries on a short schedule: the fog layer refills itself when the map - * texture finishes loading, and annotation layer components must mount before - * loadMask can land. Re-applying authoritative doc state is idempotent. + * Apply the active scene's fog mask from the doc to the stage. Retries on a + * short schedule: the fog layer refills itself when the map texture finishes + * loading. (Annotation masks are declarative layer props — no application + * needed here.) */ async applyMasks() { const sceneId = this.activeSceneId; for (const delay of [0, 300, 1000, 3000]) { if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); if (this.activeSceneId !== sceneId) return; // scene changed mid-retry - await this.#applyMasksOnce(sceneId); + await this.#applyFogMaskOnce(sceneId); } } - async #applyMasksOnce(sceneId: string | null) { + async #applyFogMaskOnce(sceneId: string | null) { const client = this.client; const stage = this.#options.getStage(); if (!sceneId || !client || !stage) return; @@ -157,39 +157,8 @@ export class PlaySession { if (fogMask && stage.fogOfWar?.fromRLE && !stage.fogOfWar.isDrawing()) { await stage.fogOfWar.fromRLE(fogMask, 1024, 1024); } - const snapshot = client.scene(sceneId); - for (const annotation of snapshot?.annotations ?? []) { - const mask = client.annotationMask(sceneId, annotation.id); - if (mask && stage.annotations?.loadMask) { - await stage.annotations.loadMask(annotation.id, mask); - } - } - } catch (error) { - devError('play', 'Failed to apply masks', error); - } - } - - async loadAnnotationMask(annotationId: string, mask: Uint8Array) { - const stage = this.#options.getStage(); - if (!stage?.annotations?.loadMask) return; - try { - await stage.annotations.loadMask(annotationId, mask); } catch (error) { - devError('play', `Failed to load annotation mask ${annotationId}`, error); - } - } - - // Reapply on a short ladder: loadMask silently no-ops if the layer component - // hasn't mounted yet, so a single immediate call can miss a (re)mounting layer. - // Public because local writes need it too (e.g. persisting a player drawing - // mounts a brand-new annotation layer; own doc changes skip the remote path). - reapplyAnnotationMask(sceneId: string, annotationId: string) { - for (const delay of [50, 350, 1000]) { - setTimeout(() => { - if (this.activeSceneId !== sceneId) return; - const mask = this.client?.annotationMask(sceneId, annotationId); - if (mask) this.loadAnnotationMask(annotationId, mask); - }, delay); + devError('play', 'Failed to apply fog mask', error); } } @@ -209,16 +178,6 @@ export class PlaySession { const mask = client.fogMask(sceneId); if (mask) stage.fogOfWar.fromRLE(mask, 1024, 1024); } - - // Any annotation row change can (re)mount its layer component — e.g. a - // visibility toggle filters the layer in/out of the render props — and a - // fresh component needs its mask reapplied, not just on 'mask' changes. - if (change.part === 'annotations') { - const annotationIds = change.childId ? [change.childId] : change.keys; - for (const annotationId of annotationIds) { - this.reapplyAnnotationMask(sceneId, annotationId); - } - } } } diff --git a/apps/web/src/routes/(app)/[party]/play/usePlayTools.svelte.ts b/apps/web/src/routes/(app)/[party]/play/usePlayTools.svelte.ts index 5b410b593..953cb45ad 100644 --- a/apps/web/src/routes/(app)/[party]/play/usePlayTools.svelte.ts +++ b/apps/web/src/routes/(app)/[party]/play/usePlayTools.svelte.ts @@ -91,7 +91,8 @@ export class PlayTools { #fogCommitTimer: ReturnType | null = null; #expiryTicker: ReturnType; #now = $state(Date.now()); - #loadedTempMasks = new Map(); + // Memoized base64 → bytes per layer so mask references stay stable across rebuilds + #tempMaskCache = new Map(); constructor(options: PlayToolsOptions) { this.#options = options; @@ -100,7 +101,10 @@ export class PlayTools { // Only touch the reactive clock while temp layers exist, so an idle playfield // doesn't rebuild its render props every second. this.#expiryTicker = setInterval(() => { - if (this.#temporaryLayers().length === 0) return; + if (this.#temporaryLayers().length === 0) { + if (this.#tempMaskCache.size > 0) this.#tempMaskCache.clear(); + return; + } this.#now = Date.now(); this.#options.session.presence?.cleanupExpiredTemporaryLayers(); if ( @@ -110,18 +114,14 @@ export class PlayTools { this.currentTemporaryLayerId = null; } }, 1000); + } - // Load RLE masks for temporary layers drawn by other clients - $effect(() => { - for (const layer of this.#temporaryLayers()) { - if (!layer.maskData || this.#loadedTempMasks.get(layer.id) === layer.maskData) continue; - this.#loadedTempMasks.set(layer.id, layer.maskData); - // Our own active drawing is already on the canvas - if (layer.id === this.currentTemporaryLayerId) continue; - // Give Threlte a beat to create the layer component before loading - setTimeout(() => this.#loadTempMaskWithRetry(layer.id, layer.maskData), 50); - } - }); + #decodeTempMask(layerId: string, b64: string): Uint8Array { + const cached = this.#tempMaskCache.get(layerId); + if (cached && cached.b64 === b64) return cached.bytes; + const bytes = base64ToUint8(b64); + this.#tempMaskCache.set(layerId, { b64, bytes }); + return bytes; } #temporaryLayers() { @@ -141,6 +141,7 @@ export class PlayTools { color: layer.color, opacity: layer.opacity, url: this.tempLayerUrls[layer.id] ?? null, + mask: layer.maskData ? this.#decodeTempMask(layer.id, layer.maskData) : null, visibility: StageMode.Player, effect: layer.effectType && layer.effectType !== AnnotationEffect.None @@ -402,7 +403,6 @@ export class PlayTools { effectType: current?.effectType }) ); - this.#loadedTempMasks.set(tempLayerId, uint8ToBase64(rle)); if (endPosition) this.#pendingPersistPosition = endPosition; } } catch (error) { @@ -438,14 +438,12 @@ export class PlayTools { effectType: tempLayer.effectType ?? null }; + // The mask rides into the doc with the row and reaches the new layer + // component as a declarative prop — nothing to coordinate here. session.client.write.upsertAnnotation(sceneId, annotation, mask); - // Our own doc write is local-origin (the remote reapply path skips it), and - // the new annotation's layer component mounts on the NEXT render — use the - // retry ladder so the mask lands after the mount instead of no-oping before it. - session.reapplyAnnotationMask(sceneId, annotation.id); session.presence?.removeTemporaryLayer(layerId); - this.#loadedTempMasks.delete(layerId); + this.#tempMaskCache.delete(layerId); if (this.currentTemporaryLayerId === layerId) this.currentTemporaryLayerId = null; devLog('play', 'Persisted drawing as annotation', annotation.id); } catch (error) { @@ -475,27 +473,11 @@ export class PlayTools { session.presence?.removeTemporaryLayer(layer.id); } this.tempLayerUrls = {}; - this.#loadedTempMasks.clear(); + this.#tempMaskCache.clear(); this.clearActiveTool(); devLog('play', 'Deleted all drawings for scene', sceneId); } - async #loadTempMaskWithRetry(layerId: string, maskData: string, retries = 3) { - for (let attempt = 0; attempt < retries; attempt++) { - if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 100 * attempt)); - try { - const stage = this.#options.getStage(); - if (stage?.annotations?.loadMask) { - await stage.annotations.loadMask(layerId, base64ToUint8(maskData)); - return; - } - } catch { - // Layer component may not exist yet; retry - } - } - devLog('play', `Failed to load temporary layer mask ${layerId} after ${retries} attempts`); - } - destroy() { clearInterval(this.#expiryTicker); if (this.#resetLayerTimer) clearTimeout(this.#resetLayerTimer); diff --git a/packages/stage/src/lib/components/Stage/components/AnnotationLayer/AnnotationLayer.svelte b/packages/stage/src/lib/components/Stage/components/AnnotationLayer/AnnotationLayer.svelte index fc38dd337..f738d3083 100644 --- a/packages/stage/src/lib/components/Stage/components/AnnotationLayer/AnnotationLayer.svelte +++ b/packages/stage/src/lib/components/Stage/components/AnnotationLayer/AnnotationLayer.svelte @@ -439,7 +439,13 @@ Effect annotations have a different render order to appear below plain color ann layers={[SceneLayer.Overlay]} renderOrder={hasEffect(layer) ? SceneLayerOrder.EffectAnnotation : SceneLayerOrder.Annotation} > - + drawing && props.activeLayer === layer.id} + /> {/each} diff --git a/packages/stage/src/lib/components/Stage/components/AnnotationLayer/AnnotationMaterial.svelte b/packages/stage/src/lib/components/Stage/components/AnnotationLayer/AnnotationMaterial.svelte index 5bd324f7b..e830f5242 100644 --- a/packages/stage/src/lib/components/Stage/components/AnnotationLayer/AnnotationMaterial.svelte +++ b/packages/stage/src/lib/components/Stage/components/AnnotationLayer/AnnotationMaterial.svelte @@ -16,9 +16,11 @@ props: AnnotationLayerData; display: DisplayProps; lineWidth?: number; + /** True while the user is actively drawing on this layer (skip mask re-apply) */ + isDrawingThisLayer?: () => boolean; } - const { props, display, lineWidth = 2.0 }: Props = $props(); + const { props, display, lineWidth = 2.0, isDrawingThisLayer = () => false }: Props = $props(); const lineWidthPixels = $derived.by(() => { const textureSize = Math.min(display.resolution.x, display.resolution.y); @@ -130,6 +132,29 @@ return drawMaterial.fromRLE(rleData, width, height); }; + // Declarative mask application: load on mount and whenever the mask reference + // or canvas size changes (DrawingMaterial clears its render targets on resize). + // queueMicrotask defers past the current effect flush so this always runs + // AFTER DrawingMaterial's own resize/clear effect. + let appliedMask: Uint8Array | null = null; + let appliedWidth = 0; + let appliedHeight = 0; + $effect(() => { + const mask = props.mask; + const { width, height } = size; + if (!mask) return; + if (mask === appliedMask && width === appliedWidth && height === appliedHeight) return; + + queueMicrotask(() => { + if (!drawMaterial || !props.mask) return; + if (isDrawingThisLayer()) return; // a commit follows the stroke anyway + appliedMask = props.mask; + appliedWidth = width; + appliedHeight = height; + drawMaterial.fromRLE(props.mask, 1024, 1024); + }); + }); + onDestroy(() => { material.dispose(); }); diff --git a/packages/stage/src/lib/components/Stage/components/AnnotationLayer/types.ts b/packages/stage/src/lib/components/Stage/components/AnnotationLayer/types.ts index 905d6b63b..c079d450b 100644 --- a/packages/stage/src/lib/components/Stage/components/AnnotationLayer/types.ts +++ b/packages/stage/src/lib/components/Stage/components/AnnotationLayer/types.ts @@ -108,9 +108,11 @@ export interface AnnotationLayerData { url: string | null; /** - * Version timestamp for mask data changes (for real-time sync) + * RLE-encoded mask pixels for this layer. Applied declaratively: the layer + * loads it on mount and re-applies when the reference changes or the canvas + * resizes, so callers never need to coordinate with component lifecycles. */ - maskVersion?: number; + mask?: Uint8Array | null; /** * Control who can see the layer From b4c3acc773cf072f61e4360e159a83031b66f22e Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Wed, 10 Jun 2026 08:50:36 -0400 Subject: [PATCH 10/10] tooltip fix in playfield --- .../src/lib/components/Stage/components/Stage/Stage.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/stage/src/lib/components/Stage/components/Stage/Stage.svelte b/packages/stage/src/lib/components/Stage/components/Stage/Stage.svelte index 879eb31bf..17a6292f7 100644 --- a/packages/stage/src/lib/components/Stage/components/Stage/Stage.svelte +++ b/packages/stage/src/lib/components/Stage/components/Stage/Stage.svelte @@ -271,7 +271,10 @@ // Only show if marker visibility is not DM-only (i.e., not visibility = 1) // MarkerVisibility: Always = 0, DM = 1, Player = 2, Hover = 3 if (selectedByPlayer.visibility !== 1) { - selectedNotPinned = selectedByPlayer; + // Look up the current marker by id — the selection captured at pointer-down + // holds the pre-drag position, which would place the tooltip at the origin + const currentMarker = props.marker.markers.find((m) => m.id === selectedByPlayer.id); + selectedNotPinned = currentMarker || selectedByPlayer; } }