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/.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/.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 new file mode 100644 index 000000000..2caee95ed --- /dev/null +++ b/apps/web/partykit/appApi.ts @@ -0,0 +1,28 @@ +import type * as Party from 'partykit/server'; + +// Server-to-server bridge from PartyKit rooms to the app's /api/internal endpoints. +// 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 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}`, { + 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..7a64a0eb7 100644 --- a/apps/web/partykit/gameSession.ts +++ b/apps/web/partykit/gameSession.ts @@ -1,24 +1,218 @@ 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; + #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.#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) { + 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; + this.#stats.persistAttempts++; + 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); + this.#stats.persistOk++; + } catch (error) { + 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); + 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) { - return await onConnect(conn, this.room, { - // Use snapshot persistence mode - stores latest document state - persist: { mode: 'snapshot' } + // 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); + } - // 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 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 }; + + // 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') { + 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..f13bb5dfb 100644 --- a/apps/web/partykit/party.ts +++ b/apps/web/partykit/party.ts @@ -1,16 +1,153 @@ 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; + #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, + 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); + 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) { - return await onConnect(conn, this.room, { - // Use snapshot persistence for party state - persist: { mode: 'snapshot' } + // 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); + } + + 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 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/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) 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..1d35a30e5 --- /dev/null +++ b/apps/web/src/routes/(app)/[party]/play/usePlaySession.svelte.ts @@ -0,0 +1,189 @@ +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 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.#applyFogMaskOnce(sceneId); + } + } + + async #applyFogMaskOnce(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); + } + } catch (error) { + devError('play', 'Failed to apply fog mask', 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); + if (mask) stage.fogOfWar.fromRLE(mask, 1024, 1024); + } + } + } + + 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..953cb45ad --- /dev/null +++ b/apps/web/src/routes/(app)/[party]/play/usePlayTools.svelte.ts @@ -0,0 +1,487 @@ +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()); + // Memoized base64 → bytes per layer so mask references stay stable across rebuilds + #tempMaskCache = 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) { + if (this.#tempMaskCache.size > 0) this.#tempMaskCache.clear(); + 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); + } + + #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() { + 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, + mask: layer.maskData ? this.#decodeTempMask(layer.id, layer.maskData) : 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 + }) + ); + 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 + }; + + // 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); + + session.presence?.removeTemporaryLayer(layerId); + this.#tempMaskCache.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.#tempMaskCache.clear(); + this.clearActiveTool(); + devLog('play', 'Deleted all drawings for scene', sceneId); + } + + destroy() { + clearInterval(this.#expiryTicker); + if (this.#resetLayerTimer) clearTimeout(this.#resetLayerTimer); + if (this.#persistButtonTimer) clearTimeout(this.#persistButtonTimer); + if (this.#fogCommitTimer) clearTimeout(this.#fogCommitTimer); + } +} diff --git a/apps/web/src/routes/api/annotations/[annotationId]/+server.ts b/apps/web/src/routes/api/annotations/[annotationId]/+server.ts deleted file mode 100644 index ee2482ce6..000000000 --- a/apps/web/src/routes/api/annotations/[annotationId]/+server.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { deleteAnnotation } from '$lib/server/annotations'; -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; - -export const DELETE: RequestHandler = async ({ params, locals }) => { - // Check authentication - if (!locals.user?.id) { - return json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { annotationId } = params; - - if (!annotationId) { - return json({ error: 'Annotation ID is required' }, { status: 400 }); - } - - try { - // TODO: Add authorization check - ensure user has access to delete this annotation - // This would require checking party membership and annotation ownership - - // Delete the annotation - const success = await deleteAnnotation(annotationId); - - if (!success) { - return json({ error: 'Annotation not found or could not be deleted' }, { status: 404 }); - } - - return json({ success: true }); - } catch (error) { - console.error('Error deleting annotation:', error); - return json({ error: 'Failed to delete annotation' }, { status: 500 }); - } -}; diff --git a/apps/web/src/routes/api/annotations/getMask/+server.ts b/apps/web/src/routes/api/annotations/getMask/+server.ts deleted file mode 100644 index de28916de..000000000 --- a/apps/web/src/routes/api/annotations/getMask/+server.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { getAnnotationMaskData } from '$lib/server/annotations'; -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async ({ url }) => { - try { - const annotationId = url.searchParams.get('annotationId'); - - if (!annotationId) { - return json({ success: false, error: 'Annotation ID is required' }, { status: 400 }); - } - - const maskData = await getAnnotationMaskData(annotationId); - - // Return the base64 mask data - return json({ - success: true, - maskData: maskData?.mask || null - }); - } catch (error) { - console.error('Error fetching annotation mask:', error); - return json({ success: false, error: 'Failed to fetch annotation mask' }, { status: 500 }); - } -}; diff --git a/apps/web/src/routes/api/annotations/updateMask/+server.ts b/apps/web/src/routes/api/annotations/updateMask/+server.ts deleted file mode 100644 index dd4a672a5..000000000 --- a/apps/web/src/routes/api/annotations/updateMask/+server.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { db } from '$lib/db/app/index'; -import { annotationsTable, sceneTable } from '$lib/db/app/schema'; -import { apiFactory } from '$lib/factories'; -import { isUserInParty } from '$lib/server'; -import { eq } from 'drizzle-orm'; -import { z } from 'zod'; - -const validationSchema = z.object({ - annotationId: z.string(), - partyId: z.string(), - maskData: z.instanceof(Uint8Array).or(z.array(z.number()).transform((arr) => new Uint8Array(arr))) -}); - -export const POST = apiFactory( - async ({ body, locals }) => { - const { annotationId, partyId, maskData } = body; - - if (!locals.user?.id || !(await isUserInParty(locals.user.id, partyId))) { - throw new Error('Unauthorized'); - } - - // First check if annotation exists and user has access - const [annotation] = await db - .select({ sceneId: annotationsTable.sceneId }) - .from(annotationsTable) - .where(eq(annotationsTable.id, annotationId)) - .limit(1); - - if (!annotation) { - throw new Error('Annotation not found'); - } - - // Verify the scene belongs to the party - const [scene] = await db - .select({ gameSessionId: sceneTable.gameSessionId }) - .from(sceneTable) - .where(eq(sceneTable.id, annotation.sceneId)) - .limit(1); - - if (!scene) { - throw new Error('Scene not found'); - } - - // Convert Uint8Array to base64 string for storage - // This avoids blob serialization issues with Turso/LibSQL - const base64Data = Buffer.from(maskData).toString('base64'); - - // Update the annotation mask in the database - await db - .update(annotationsTable) - .set({ - mask: base64Data, - url: null // Clear the old URL since we're using RLE now - }) - .where(eq(annotationsTable.id, annotationId)); - - return { success: true }; - }, - { - validationSchema, - validationErrorMessage: 'Invalid annotation mask data', - unauthorizedMessage: 'You are not authorized to update this annotation.', - unexpectedErrorMessage: 'An unexpected error occurred while updating the annotation mask.' - } -); diff --git a/apps/web/src/routes/api/annotations/upsert/+server.ts b/apps/web/src/routes/api/annotations/upsert/+server.ts deleted file mode 100644 index f17a4d704..000000000 --- a/apps/web/src/routes/api/annotations/upsert/+server.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { insertAnnotationSchema } from '$lib/db/app/schema'; -import { upsertAnnotation } from '$lib/server/annotations'; -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; - -export const POST: RequestHandler = async ({ request, locals }) => { - // Check authentication - if (!locals.user?.id) { - return json({ error: 'Unauthorized' }, { status: 401 }); - } - - try { - const body = await request.json(); - - // Validate the annotation data - const validatedData = insertAnnotationSchema.parse(body); - - // TODO: Add authorization check - ensure user has access to the scene - // This would require checking party membership and scene ownership - - // Upsert the annotation - const result = await upsertAnnotation(validatedData); - - if (!result) { - return json({ error: 'Failed to save annotation' }, { status: 500 }); - } - - return json(result); - } catch (error) { - console.error('Error upserting annotation:', error); - return json({ error: 'Invalid request data' }, { status: 400 }); - } -}; 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 }; }, { diff --git a/apps/web/src/routes/api/gameSessions/importGameSession/+server.ts b/apps/web/src/routes/api/gameSessions/importGameSession/+server.ts index 139eac42c..d95ac169b 100644 --- a/apps/web/src/routes/api/gameSessions/importGameSession/+server.ts +++ b/apps/web/src/routes/api/gameSessions/importGameSession/+server.ts @@ -16,6 +16,7 @@ import { import { copySceneFile } from '$lib/server/file'; import { createGameSessionForImport } from '$lib/server/gameSession'; import { getParty, getPartyFromGameSessionId, isUserInParty } from '$lib/server/party/getParty'; +import { requestPartyRoomResync } from '$lib/server/realtime'; import { setActiveSceneForParty } from '$lib/server/scene'; import { error, json, type RequestEvent } from '@sveltejs/kit'; import { v4 as uuidv4 } from 'uuid'; @@ -251,6 +252,11 @@ export const POST = async ({ request, locals }: RequestEvent) => { } } + // The import may have set party.activeSceneId in the DB; the live party room + // is authoritative, so tell it to re-read (best-effort). The new game session's + // room hydrates fresh from the DB on first connection. + await requestPartyRoomResync(partyId); + // Return the count of scenes created directly from the JSON file // This ensures we're only counting what was in the file, not what might be in the database const importedScenesCount = sortedScenes.length; 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.' + } +); diff --git a/apps/web/src/routes/api/light/deleteLight/+server.ts b/apps/web/src/routes/api/light/deleteLight/+server.ts deleted file mode 100644 index 2567bf490..000000000 --- a/apps/web/src/routes/api/light/deleteLight/+server.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { apiFactory } from '$lib/factories'; -import { deleteLight, getLight, isUserInParty, updateSceneTimestampForLightChange } from '$lib/server'; -import { z } from 'zod'; - -const validationSchema = z.object({ - partyId: z.string(), - lightId: z.string() -}); - -export const POST = apiFactory( - async ({ body, locals }) => { - const { lightId, partyId } = body; - - if (!locals.user?.id || !isUserInParty(locals.user.id, partyId)) { - throw new Error('Unauthorized'); - } - - const lightToDelete = await getLight(lightId); - - await deleteLight(lightId); - - if (lightToDelete?.sceneId) { - await updateSceneTimestampForLightChange(lightToDelete.sceneId); - } - - return { success: true }; - }, - { - validationSchema, - validationErrorMessage: 'Check your form for errors', - unauthorizedMessage: 'You are not authorized to delete this light.', - unexpectedErrorMessage: 'An unexpected error occurred while deleting the light.' - } -); diff --git a/apps/web/src/routes/api/light/upsertLight/+server.ts b/apps/web/src/routes/api/light/upsertLight/+server.ts deleted file mode 100644 index e706f5b19..000000000 --- a/apps/web/src/routes/api/light/upsertLight/+server.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { db } from '$lib/db/app'; -import { insertLightSchema, lightTable } from '$lib/db/app/schema'; -import { apiFactory } from '$lib/factories'; -import { createLight, isUserInParty, updateLight, updateSceneTimestampForLightChange } from '$lib/server'; -import { eq } from 'drizzle-orm'; -import { z } from 'zod'; - -const lightDataWithoutSceneIdSchema = insertLightSchema.omit({ sceneId: true }); - -const validationSchema = z.object({ - lightData: lightDataWithoutSceneIdSchema, - partyId: z.string(), - sceneId: z.string() -}); - -export const POST = apiFactory( - async ({ body, locals }) => { - const { lightData, sceneId, partyId } = body; - - if (!locals.user?.id || !isUserInParty(locals.user.id, partyId)) { - throw new Error('Unauthorized'); - } - - const existingLight = lightData.id - ? await db.select().from(lightTable).where(eq(lightTable.id, lightData.id)).get() - : null; - - let light; - let operation: 'created' | 'updated'; - - if (existingLight && lightData.id) { - const { id, ...updateData } = lightData; - light = await updateLight(id, updateData); - operation = 'updated'; - } else { - light = await createLight(lightData, sceneId); - operation = 'created'; - } - - await updateSceneTimestampForLightChange(sceneId); - - return { - success: true, - light, - operation - }; - }, - { - validationSchema, - validationErrorMessage: 'Check your form for errors', - unauthorizedMessage: 'You are not authorized to save lights for this party.', - unexpectedErrorMessage: 'An unexpected error occurred while saving the light.' - } -); diff --git a/apps/web/src/routes/api/marker/createMarker/+server.ts b/apps/web/src/routes/api/marker/createMarker/+server.ts deleted file mode 100644 index 2bfc59eae..000000000 --- a/apps/web/src/routes/api/marker/createMarker/+server.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { insertMarkerSchema } from '$lib/db/app/schema'; // Use or create a schema for scene creation -import { apiFactory } from '$lib/factories'; -import { createMarker, isUserInParty, updateSceneTimestampForMarkerChange } from '$lib/server'; -import { z } from 'zod'; - -// Create a custom schema that doesn't require sceneId in markerData -const markerDataWithoutSceneIdSchema = insertMarkerSchema.omit({ sceneId: true }); - -const validationSchema = z.object({ - markerData: markerDataWithoutSceneIdSchema, - partyId: z.string(), - sceneId: z.string() -}); - -export const POST = apiFactory( - async ({ body, locals }) => { - try { - const { markerData, sceneId, partyId } = body; - - if (!locals.user?.id || !isUserInParty(locals.user.id, partyId)) { - throw new Error('Unauthorized'); - } - - const marker = await createMarker(markerData, sceneId); - - // Update scene timestamp when marker is created - await updateSceneTimestampForMarkerChange(sceneId); - - return { success: true, marker }; - } catch (error) { - throw error; - } - }, - { - validationSchema, - validationErrorMessage: 'Check your form for errors', - unauthorizedMessage: 'You are not authorized to create markers for this party.', - unexpectedErrorMessage: 'An unexpected error occurred while creating the marker.' - } -); diff --git a/apps/web/src/routes/api/marker/deleteMarker/+server.ts b/apps/web/src/routes/api/marker/deleteMarker/+server.ts deleted file mode 100644 index 039a1c949..000000000 --- a/apps/web/src/routes/api/marker/deleteMarker/+server.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { apiFactory } from '$lib/factories'; -import { deleteMarker, getMarker, isUserInParty, updateSceneTimestampForMarkerChange } from '$lib/server'; -import { z } from 'zod'; - -const validationSchema = z.object({ - partyId: z.string(), - markerId: z.string() -}); - -export const POST = apiFactory( - async ({ body, locals }) => { - try { - const { markerId, partyId } = body; - - if (!locals.user?.id || !isUserInParty(locals.user.id, partyId)) { - throw new Error('Unauthorized'); - } - - // Get marker info before deleting to update scene timestamp - const markerToDelete = await getMarker(markerId); - - const marker = await deleteMarker(markerId); - - // Update scene timestamp when marker is deleted - if (markerToDelete?.sceneId) { - await updateSceneTimestampForMarkerChange(markerToDelete.sceneId); - } - - return { success: true, marker }; - } catch (error) { - throw error; - } - }, - { - validationSchema, - validationErrorMessage: 'Check your form for errors', - unauthorizedMessage: 'You are not authorized to delete this marker.', - unexpectedErrorMessage: 'An unexpected error occurred while deleting the marker.' - } -); diff --git a/apps/web/src/routes/api/marker/getSceneMarkers/+server.ts b/apps/web/src/routes/api/marker/getSceneMarkers/+server.ts deleted file mode 100644 index 09978d915..000000000 --- a/apps/web/src/routes/api/marker/getSceneMarkers/+server.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { apiFactory } from '$lib/factories'; -import { getMarkersForScene, isUserInParty } from '$lib/server'; -import { z } from 'zod'; - -const validationSchema = z.object({ - partyId: z.string(), - sceneId: z.string() -}); - -export const POST = apiFactory( - async ({ body, locals }) => { - try { - const { partyId, sceneId } = body; - - if (!locals.user?.id || !isUserInParty(locals.user.id, partyId)) { - throw new Error('Unauthorized'); - } - - const marker = await getMarkersForScene(sceneId); - - return { success: true, marker }; - } catch (error) { - throw error; - } - }, - { - validationSchema, - validationErrorMessage: 'Check your form for errors', - unauthorizedMessage: 'You are not authorized to upate this marker.', - unexpectedErrorMessage: 'An unexpected error occurred while updating the marker.' - } -); diff --git a/apps/web/src/routes/api/marker/updateMarker/+server.ts b/apps/web/src/routes/api/marker/updateMarker/+server.ts deleted file mode 100644 index c3d45ef2f..000000000 --- a/apps/web/src/routes/api/marker/updateMarker/+server.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { insertMarkerSchema } from '$lib/db/app/schema'; // Use or create a schema for scene creation -import { apiFactory } from '$lib/factories'; -import { isUserInParty, updateMarker, updateSceneTimestampForMarkerChange } from '$lib/server'; -import { z } from 'zod'; - -// Create a custom schema that doesn't require sceneId in markerData for updates -const markerDataWithoutSceneIdSchema = insertMarkerSchema.partial().omit({ sceneId: true }); - -const validationSchema = z.object({ - markerData: markerDataWithoutSceneIdSchema, - partyId: z.string(), - markerId: z.string() -}); - -export const POST = apiFactory( - async ({ body, locals }) => { - try { - const { markerData, markerId, partyId } = body; - - if (!locals.user?.id || !isUserInParty(locals.user.id, partyId)) { - throw new Error('Unauthorized'); - } - - // Exclude id from markerData to prevent attempting to update the primary key - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { id, ...updateData } = markerData; - const marker = await updateMarker(markerId, updateData); - - // Update scene timestamp when marker is updated - if (marker.sceneId) { - await updateSceneTimestampForMarkerChange(marker.sceneId); - } - - return { success: true, marker }; - } catch (error) { - throw error; - } - }, - { - validationSchema, - validationErrorMessage: 'Check your form for errors', - unauthorizedMessage: 'You are not authorized to upate this marker.', - unexpectedErrorMessage: 'An unexpected error occurred while updating the marker.' - } -); diff --git a/apps/web/src/routes/api/marker/upsertMarker/+server.ts b/apps/web/src/routes/api/marker/upsertMarker/+server.ts deleted file mode 100644 index 5b56c67dd..000000000 --- a/apps/web/src/routes/api/marker/upsertMarker/+server.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { db } from '$lib/db/app'; -import { insertMarkerSchema, markerTable } from '$lib/db/app/schema'; -import { apiFactory } from '$lib/factories'; -import { createMarker, isUserInParty, updateMarker, updateSceneTimestampForMarkerChange } from '$lib/server'; -import { eq } from 'drizzle-orm'; -import { z } from 'zod'; - -// Schema for upsert - marker data without sceneId (provided separately) -const markerDataWithoutSceneIdSchema = insertMarkerSchema.omit({ sceneId: true }); - -const validationSchema = z.object({ - markerData: markerDataWithoutSceneIdSchema, - partyId: z.string(), - sceneId: z.string() -}); - -export const POST = apiFactory( - async ({ body, locals }) => { - const { markerData, sceneId, partyId } = body; - - if (!locals.user?.id || !isUserInParty(locals.user.id, partyId)) { - throw new Error('Unauthorized'); - } - - // Check if marker exists in database - const existingMarker = markerData.id - ? await db.select().from(markerTable).where(eq(markerTable.id, markerData.id)).get() - : null; - - let marker; - let operation: 'created' | 'updated'; - - if (existingMarker && markerData.id) { - // Update existing marker - exclude id from markerData to prevent setting it in the update - const { id, ...updateData } = markerData; - marker = await updateMarker(id, updateData); - operation = 'updated'; - } else { - // Create new marker with the provided ID - marker = await createMarker(markerData, sceneId); - operation = 'created'; - } - - // Update scene timestamp when marker is created or updated - await updateSceneTimestampForMarkerChange(sceneId); - - return { - success: true, - marker, - operation - }; - }, - { - validationSchema, - validationErrorMessage: 'Check your form for errors', - unauthorizedMessage: 'You are not authorized to save markers for this party.', - unexpectedErrorMessage: 'An unexpected error occurred while saving the marker.' - } -); diff --git a/apps/web/src/routes/api/party/savePartyState/+server.ts b/apps/web/src/routes/api/party/savePartyState/+server.ts deleted file mode 100644 index 9887086a4..000000000 --- a/apps/web/src/routes/api/party/savePartyState/+server.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { insertMarkerSchema, updateGameSessionSchema, updateMarkerSchema, updateSceneSchema } from '$lib/db/app/schema'; -import { apiFactory } from '$lib/factories'; -import { - createMarker, - deleteMarker, - getScene, - isSceneInParty, - isUserInParty, - updateGameSession, - updateMarker, - updateScene -} from '$lib/server'; -import { z } from 'zod'; - -// Schema for marker operations -const markerOperationSchema = z.object({ - operation: z.enum(['create', 'update', 'delete']), - id: z.string(), - data: z.union([insertMarkerSchema, updateMarkerSchema]).optional() -}); - -// Unified save schema -const savePartyStateSchema = z.object({ - partyId: z.string(), - gameSessionId: z.string(), - sceneId: z.string(), - sceneData: updateSceneSchema, - gameSessionData: updateGameSessionSchema.optional(), - markerOperations: z.array(markerOperationSchema).optional(), - thumbnailLocation: z.string().optional() -}); - -export const POST = apiFactory( - async ({ body, locals }) => { - const { - partyId, - gameSessionId, - sceneId, - sceneData, - gameSessionData, - markerOperations = [], - thumbnailLocation - } = body; - - if (!locals.user?.id || !isUserInParty(locals.user.id, partyId)) { - throw new Error('Unauthorized'); - } - - // Validate that the scene belongs to this party - if (!(await isSceneInParty(sceneId, partyId))) { - throw new Error('Scene does not belong to this party'); - } - - const userId = locals.user.id; - - // Track results for response - const results = { - scene: null as unknown, - markers: { - created: [] as unknown[], - updated: [] as unknown[], - deleted: [] as string[] - }, - gameSession: null as unknown - }; - - try { - // Process all operations in sequence for consistency - - // 1. Update scene data (including thumbnail location if provided) - const finalSceneData = thumbnailLocation ? { ...sceneData, mapThumbLocation: thumbnailLocation } : sceneData; - - await updateScene(userId, sceneId, finalSceneData); - results.scene = await getScene(sceneId); - - // 2. Process marker operations - let markersModified = false; - for (const operation of markerOperations) { - switch (operation.operation) { - case 'create': - if (!operation.data) { - throw new Error(`Create operation for marker ${operation.id} missing data`); - } - const newMarker = await createMarker(operation.data, sceneId); - results.markers.created.push(newMarker); - markersModified = true; - break; - - case 'update': - if (!operation.data) { - throw new Error(`Update operation for marker ${operation.id} missing data`); - } - const updatedMarker = await updateMarker(operation.id, operation.data); - results.markers.updated.push(updatedMarker); - markersModified = true; - break; - - case 'delete': - await deleteMarker(operation.id); - results.markers.deleted.push(operation.id); - markersModified = true; - break; - } - } - - // Update scene timestamp if markers were modified - if (markersModified) { - await updateScene(userId, sceneId, { lastUpdated: new Date() }); - } - - // 3. Update game session timestamp (if provided) - if (gameSessionData) { - await updateGameSession(gameSessionId, { - ...gameSessionData, - lastUpdated: new Date() - }); - // Note: We don't fetch the full game session here for performance - results.gameSession = { updated: true }; - } - - return { - success: true, - results - }; - } catch (error) { - console.error('Error in savePartyState:', error); - throw error; - } - }, - { - validationSchema: savePartyStateSchema, - validationErrorMessage: 'Invalid save data provided', - unauthorizedMessage: 'You are not authorized to save this party state.', - unexpectedErrorMessage: 'An unexpected error occurred while saving party state.' - } -); diff --git a/apps/web/src/routes/api/scenes/deleteScene/+server.ts b/apps/web/src/routes/api/scenes/deleteScene/+server.ts deleted file mode 100644 index 9cdae8b86..000000000 --- a/apps/web/src/routes/api/scenes/deleteScene/+server.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { apiFactory } from '$lib/factories'; -import { deleteScene, getScenes, isUserInParty } from '$lib/server'; -import { z } from 'zod'; - -const validationSchema = z.object({ - gameSessionId: z.string(), - partyId: z.string(), - sceneId: z.string() -}); - -export const POST = apiFactory( - async ({ body, locals }) => { - const { gameSessionId, partyId, sceneId } = body; - - if (!locals.user?.id || !isUserInParty(locals.user.id, partyId)) { - throw new Error('Unauthorized'); - } - - await deleteScene(gameSessionId, sceneId); - - // Return the updated scenes list with corrected order - const updatedScenes = await getScenes(gameSessionId); - - return { success: true, scenes: updatedScenes }; - }, - { - validationSchema, - validationErrorMessage: 'Check your form for errors', - unauthorizedMessage: 'You aren not authorized to delete this scene', - unexpectedErrorMessage: 'An unexpected error occurred while deleting the scene.' - } -); diff --git a/apps/web/src/routes/api/scenes/duplicateScene/+server.ts b/apps/web/src/routes/api/scenes/duplicateScene/+server.ts deleted file mode 100644 index 75948f99f..000000000 --- a/apps/web/src/routes/api/scenes/duplicateScene/+server.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { apiFactory } from '$lib/factories'; -import { duplicateScene, getScenes, isUserInParty } from '$lib/server'; -import { z } from 'zod'; - -const validationSchema = z.object({ - partyId: z.string(), - sceneId: z.string() -}); - -export const POST = apiFactory( - async ({ body, locals }) => { - const { partyId, sceneId } = body; - - if (!locals.user?.id || !isUserInParty(locals.user.id, partyId)) { - throw new Error('Unauthorized'); - } - - const newScene = await duplicateScene(sceneId); - - if (!newScene) { - throw new Error('Failed to duplicate scene'); - } - - // Get the updated scenes list to ensure proper ordering - const gameSessionId = newScene.gameSessionId; - const updatedScenes = await getScenes(gameSessionId); - - return { success: true, scene: newScene, scenes: updatedScenes }; - }, - { - validationSchema, - validationErrorMessage: 'Check your form for errors', - unauthorizedMessage: 'You are not authorized to duplicate scenes in this game session', - unexpectedErrorMessage: 'An unexpected error occurred while duplicating the scene' - } -); diff --git a/apps/web/src/routes/api/scenes/getFogMask/+server.ts b/apps/web/src/routes/api/scenes/getFogMask/+server.ts deleted file mode 100644 index 62432874b..000000000 --- a/apps/web/src/routes/api/scenes/getFogMask/+server.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { getSceneMaskData } from '$lib/server/scene'; -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async ({ url }) => { - try { - const sceneId = url.searchParams.get('sceneId'); - - if (!sceneId) { - return json({ success: false, error: 'Scene ID is required' }, { status: 400 }); - } - - const maskData = await getSceneMaskData(sceneId); - - // Return the base64 mask data - return json({ - success: true, - maskData: maskData.fogOfWarMask - }); - } catch (error) { - console.error('Error fetching fog mask:', error); - return json({ success: false, error: 'Failed to fetch fog mask' }, { status: 500 }); - } -}; diff --git a/apps/web/src/routes/api/scenes/reorderScenes/+server.ts b/apps/web/src/routes/api/scenes/reorderScenes/+server.ts deleted file mode 100644 index 3fa5687a0..000000000 --- a/apps/web/src/routes/api/scenes/reorderScenes/+server.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { apiFactory } from '$lib/factories'; -import { getScenes, isUserInParty, reorderScenes } from '$lib/server'; -import { z } from 'zod'; - -const validationSchema = z.object({ - partyId: z.string(), - gameSessionId: z.string(), - sceneId: z.string(), - newOrder: z.number().int().positive(), - oldOrder: z.number().int().positive() -}); - -export const POST = apiFactory( - async ({ body, locals }) => { - const { partyId, gameSessionId, sceneId, newOrder } = body; - - if (!locals.user?.id || !isUserInParty(locals.user.id, partyId)) { - throw new Error('Unauthorized'); - } - - await reorderScenes(gameSessionId, sceneId, newOrder); - - // Return the updated scenes list - const scenes = await getScenes(gameSessionId); - - return { success: true, scenes }; - }, - { - validationSchema, - validationErrorMessage: 'Check your form for errors', - unauthorizedMessage: 'You are not authorized to reorder scenes in this game session', - unexpectedErrorMessage: 'An unexpected error occurred while reordering scenes' - } -); diff --git a/apps/web/src/routes/api/scenes/timestamps/+server.ts b/apps/web/src/routes/api/scenes/timestamps/+server.ts deleted file mode 100644 index 57a0e3be6..000000000 --- a/apps/web/src/routes/api/scenes/timestamps/+server.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { RequestEvent } from '@sveltejs/kit'; -import { json } from '@sveltejs/kit'; -import { eq } from 'drizzle-orm'; -import { db } from '../../../../lib/db/app/index.js'; -import { gameSessionTable, sceneTable } from '../../../../lib/db/app/schema.js'; -import { isUserInParty } from '../../../../lib/server/party/index.js'; - -export async function POST({ request, locals }: RequestEvent) { - const user = locals.user; - if (!user) { - return json({ error: 'Unauthorized' }, { status: 401 }); - } - - try { - const { gameSessionId, partyId } = (await request.json()) as { gameSessionId: string; partyId: string }; - - if (!gameSessionId || !partyId) { - return json({ error: 'Missing gameSessionId or partyId' }, { status: 400 }); - } - - // Check if user is in the party - const userInParty = await isUserInParty(user.id, partyId); - if (!userInParty) { - return json({ error: 'User not in party' }, { status: 403 }); - } - - // Verify the game session belongs to the party - const gameSession = await db.select().from(gameSessionTable).where(eq(gameSessionTable.id, gameSessionId)).get(); - - if (!gameSession || gameSession.partyId !== partyId) { - return json({ error: 'Game session not found or not in party' }, { status: 404 }); - } - - // Get timestamps for all scenes in the game session - const scenes = await db - .select({ - id: sceneTable.id, - lastUpdated: sceneTable.lastUpdated - }) - .from(sceneTable) - .where(eq(sceneTable.gameSessionId, gameSessionId)); - - // Convert to timestamp map - const timestamps: Record = {}; - for (const scene of scenes) { - if (scene.lastUpdated) { - timestamps[scene.id] = scene.lastUpdated.getTime(); - } - } - - return json({ timestamps }); - } catch (error) { - console.error('Error fetching scene timestamps:', error); - return json({ error: 'Internal server error' }, { status: 500 }); - } -} diff --git a/apps/web/src/routes/api/scenes/updateFogMask/+server.ts b/apps/web/src/routes/api/scenes/updateFogMask/+server.ts deleted file mode 100644 index 646a9f403..000000000 --- a/apps/web/src/routes/api/scenes/updateFogMask/+server.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { db } from '$lib/db/app/index'; -import { sceneTable } from '$lib/db/app/schema'; -import { apiFactory } from '$lib/factories'; -import { isUserInParty } from '$lib/server'; -import { eq } from 'drizzle-orm'; -import { z } from 'zod'; - -const validationSchema = z.object({ - sceneId: z.string(), - partyId: z.string(), - maskData: z.instanceof(Uint8Array).or(z.array(z.number()).transform((arr) => new Uint8Array(arr))) -}); - -export const POST = apiFactory( - async ({ body, locals }) => { - const { sceneId, partyId, maskData } = body; - - if (!locals.user?.id || !(await isUserInParty(locals.user.id, partyId))) { - throw new Error('Unauthorized'); - } - - // Update the fog mask in the database - // Convert Uint8Array to base64 string for storage - // This avoids blob serialization issues with Turso/LibSQL - const base64Data = Buffer.from(maskData).toString('base64'); - - try { - // Store as base64 text - await db - .update(sceneTable) - .set({ - fogOfWarMask: base64Data - }) - .where(eq(sceneTable.id, sceneId)) - .execute(); - } catch (error) { - console.error('[FOG UPDATE] Database update error:', error); - console.error('[FOG UPDATE] Base64 length:', base64Data.length); - console.error('[FOG UPDATE] Scene ID:', sceneId); - - throw new Error(`Database update failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - - return { success: true }; - }, - { - validationSchema, - validationErrorMessage: 'Invalid fog mask data', - unauthorizedMessage: 'You are not authorized to update this scene.', - unexpectedErrorMessage: 'An unexpected error occurred while updating the fog mask.' - } -); diff --git a/apps/web/src/routes/api/scenes/updateScene/+server.ts b/apps/web/src/routes/api/scenes/updateScene/+server.ts deleted file mode 100644 index d4f4b878f..000000000 --- a/apps/web/src/routes/api/scenes/updateScene/+server.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { updateSceneSchema } from '$lib/db/app/schema'; -import { apiFactory } from '$lib/factories'; -import { getScene, isSceneInParty, isUserInParty, updateScene } from '$lib/server'; -import { z } from 'zod'; - -const validationSchema = z.object({ - sceneId: z.string(), - sceneData: updateSceneSchema, - partyId: z.string() -}); - -export const POST = apiFactory( - async ({ body, locals }) => { - const { sceneId, partyId, sceneData } = body; - - if (!locals.user?.id || !isUserInParty(locals.user.id, partyId)) { - throw new Error('Unauthorized'); - } - - // Validate that the scene belongs to this party - if (!(await isSceneInParty(sceneId, partyId))) { - throw new Error('Scene does not belong to this party'); - } - - const userId = locals.user.id; - - await updateScene(userId, sceneId, sceneData); - - // Return the updated scene with thumbnails - const updatedScene = await getScene(sceneId); - - return { success: true, scene: updatedScene }; - }, - { - validationSchema, - validationErrorMessage: 'Check your form for errors', - unauthorizedMessage: 'You are not authorized to update this party.', - unexpectedErrorMessage: 'An unexpected error occurred while updating the party.' - } -); diff --git a/docs/yjs-sync-architecture.md b/docs/yjs-sync-architecture.md index 4f530b89a..c8f2c8846 100644 --- a/docs/yjs-sync-architecture.md +++ b/docs/yjs-sync-architecture.md @@ -1,1390 +1,132 @@ -# Y.js Real-time Synchronization Architecture with PartyKit +# Realtime sync architecture (v2) + +How Table Slayer's real-time collaboration works: one authoritative Y.js document per game +session, owned by the PartyKit server; every client (editor or play) commits edits to the +document immediately; the database is a snapshot the server maintains. Design rationale lives in +`spec/realtime-sync-v2.md`; implementation notes in `spec/realtime-sync-v2-progress.md`. + +## Ownership model + +| State | Owner | Transport | Persisted by | +| ----------------------------------------------------------- | ------------------------- | ------------------ | --------------------------------- | +| Scene content (settings, markers, lights, annotations, fog) | Y doc (game session room) | y-partykit sync | PartyKit server → DB (debounced) | +| Active scene, paused | Y doc (party room) | y-partykit sync | PartyKit server → DB | +| Viewport, active tool, brush size, selections | plain component `$state` | never shared | preference cookies where relevant | +| Presence (cursors, measurements, temp drawings) | awareness | awareness protocol | never | +| Account/party/session metadata, auth | DB | SSR `load` | normal API | + +No state is ever merged from two sources, and clients never write scene data to the database. + +## Document schema (`apps/web/src/lib/realtime/docSchema.ts`) + +Granular per-field Y.Maps so concurrent edits merge last-writer-wins per field: + +``` +gameSession doc (room id = gameSessionId, party "game_session") +├─ meta: Y.Map { schemaVersion, hydratedAt } +└─ scenes: Y.Map + ├─ settings: Y.Map // the scene table columns, flat keys + ├─ markers: Y.Map> + ├─ lights: Y.Map> + ├─ annotations: Y.Map> + └─ fogMask: Uint8Array (RLE) + +party doc (room id = party.id (uuid), party "party") +├─ meta: Y.Map { schemaVersion, hydratedAt } +└─ state: Y.Map { activeSceneId, isPaused } +``` + +Key properties: + +- Doc rows are **DB-shaped** (mirroring the drizzle `Select*` types) so the persister copies + fields straight into upserts and `buildSceneProps` consumes snapshots directly. +- **Masks live in the doc** as `Uint8Array` — no mask API fetches, no version counters. RLE + masks commit on stroke end (500ms debounce) and remote clients apply them via + `stage.fogOfWar.fromRLE` / `stage.annotations.loadMask`. +- Scene ordering uses **fractional `order` values** (`orderBetween`); a reorder is one field + write. SQLite stores fractional values in the integer column without complaint. +- **Own-update detection is transaction identity, not timing**: local writes are tagged with the + client's origin, and `classifySceneEvents` reports `remote = !transaction.local`. There are no + echo windows or protection timers anywhere in the system. + +## Server (PartyKit, `apps/web/partykit/`) + +`gameSession.ts` and `party.ts` are the only writers of realtime-owned state to the database. + +- **Hydrate**: on first use a room fetches `/api/internal/sessionSnapshot` (or `partySnapshot`) + and builds the doc. `meta.schemaVersion` makes hydration idempotent; y-partykit snapshot + persistence covers room eviction between DB writes. +- **Persist**: a doc observer collects dirty scene parts (origin-filtered so hydration never + echoes back). y-partykit's debounced callback (2s idle / 10s max) posts only dirty scenes to + `/api/internal/persistSession` (replace-rows semantics per collection). Failures merge the + dirty set back and retry via an in-instance timer — PartyKit alarms cannot read `room.id`, so + alarms are not used. +- **Resync**: `POST {"type":"resync"}` (internal-token guarded) rebuilds a live room from the DB + after direct DB writes (import, admin tools). `{"type":"debug"}` on the game session room + exposes persister stats. The app calls these via `requestPartyRoomResync` / + `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 `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/`) + +`SessionDocClient` owns both Y docs, their providers, and the presence channel. It exposes: + +- **Reactive snapshot reads** — `scenes()`, `scene(id)`, `partyState()` — backed by per-scene + revision counters bumped from doc observers, with memoized snapshots. +- **Origin-tagged writers** — `write.setSceneSettings/upsertMarker/setFogMask/...` and + `party.setActiveScene/setPaused`. Writers warn loudly when a target scene is missing rather + than silently no-oping. +- **`ready`** — true once both rooms are synced _and_ hydrated. Pages render SSR-seeded props + until then. +- **`onChanges`** — classified change stream (`{sceneId, part, keys, childId, remote}`) used for + imperative work like applying remote masks to the GPU canvas. + +### Render data flow (both routes) + +``` +renderProps = buildRenderProps(docSnapshot, localView) → structural sharing → Stage +``` + +- `buildRenderProps` is pure: snapshot + local view (viewport, tools, in-flight drag overrides) + → `StageProps`. Interaction callbacks write to the doc or to local state, never to the result. +- `reuseUnchanged` (structural sharing) keeps identity for unchanged subtrees so stage-internal + fine-grained effects don't re-fire on every doc change. +- Gestures use **gesture-bounded overrides**: during a marker drag the local position overrides + the snapshot while writes throttle to the doc at 50ms; the override clears shortly after the + gesture ends. No wall-clock protection windows. +- The editor's control panels still call `queuePropertyUpdate(stageProps, path, value)`; the + broadcaster (`$lib/utils/propertyUpdateBroadcaster.ts`) applies the value locally and flushes + shared paths to the doc in the same microtask. Local-only paths (viewport, tools, measurement + config) never touch the doc. + +### Editor vs play + +Both routes are peers on the same doc; the difference is capability and chrome +(`$lib/realtime/capabilities.ts`), not architecture. The play route follows +`party.state.activeSceneId` (reconnecting rooms for cross-session switches), preloads sibling +scene maps, and exposes touch tools (fog, drawing, measurement, scene switching) that write +through the same doc APIs as the editor. Scene URLs in the editor address scenes **by id**; +legacy ordinal URLs 301-redirect. + +### Presence (`presence.svelte.ts`) -This document explains how Table Slayer's real-time collaboration system works using PartyKit, Y.js, and the YPartyKitProvider for real-time synchronization. +Cursors (33ms throttle), measurements, hovered/pinned markers, and temporary player drawings ride +the awareness protocol with a 15s heartbeat. Temporary drawings expire after 10s unless persisted +into the doc as annotations. -## Table of Contents - -1. [Overview](#overview) -2. [PartyKit Architecture](#partykit-architecture) -3. [Y.js Document Architecture](#yjs-document-architecture) -4. [Data Loading Flow](#data-loading-flow) -5. [Y.js Update Flow](#yjs-update-flow) -6. [Cursor Handling](#cursor-handling) -7. [Marker Hover State](#marker-hover-state-dm-to-player-reveal) -8. [Pinned Tooltips](#pinned-tooltips-database-backed-persistence) -9. [Throttling and Batching System](#throttling-and-batching-system) -10. [Local-Only Properties](#local-only-properties) -11. [Stale Data Detection](#stale-data-detection) -12. [Save System](#save-system) -13. [Deployment Strategy](#deployment-strategy) -14. [Cost Optimization](#cost-optimization) -15. [Adding New Properties to StageProps](#adding-new-properties-to-stageprops) -16. [RLE Mask System](#rle-mask-system-run-length-encoding) -17. [Drawing Protection System](#drawing-protection-system) -18. [Temporary Drawing Layers](#temporary-drawing-layers-playfield-touch-interaction) -19. [Mask Version Synchronization](#mask-version-synchronization) - -## Overview - -Table Slayer uses PartyKit as its WebSocket infrastructure for real-time collaboration, with Y.js for conflict-free replicated data types (CRDTs). The system supports multiple users editing scenes simultaneously with automatic conflict resolution. - -### Key Components - -- **PartyKit**: WebSocket server infrastructure deployed to Cloudflare Workers -- **Y.js Documents**: Shared CRDT data structures that automatically sync between clients -- **YPartyKitProvider**: Y.js provider that connects to PartyKit servers -- **Property Update Broadcaster**: Throttles and batches updates before sending to Y.js -- **Auto-save System**: Periodically saves Y.js state to the database -- **Drift Detection**: Ensures Y.js data doesn't diverge from the database - -## PartyKit Architecture - -### Server Implementation - -PartyKit servers are implemented as Cloudflare Workers that handle WebSocket connections and Y.js synchronization: - -```typescript -// partykit/gameSession.ts -import { onConnect } from 'y-partykit'; -import type * as Party from 'partykit/server'; - -export default class GameSessionServer implements Party.Server { - constructor(public party: Party.Party) {} - - async onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) { - // Get userId from query params for awareness - const userId = new URL(ctx.request.url).searchParams.get('userId'); - - return await onConnect(conn, this.party, { - // Use snapshot persistence mode - persist: { mode: 'snapshot' }, - - // Optional: Load initial state from database - load: async () => { - // Could implement loading from SQLite/R2 here - return null; - }, - - // Optional: Save Y.js state periodically - callback: { - handler: async (yDoc) => { - // Could implement saving to database here - console.log('Document updated for session:', this.party.id); - }, - debounceWait: 10000, // Save 10 seconds after last change - debounceMaxWait: 30000 // Force save after 30 seconds - } - }); - } -} -``` - -### PartyKit Configuration - -```json -// partykit.json -{ - "name": "tableslayer", - "main": "partykit/gameSession.ts", - "compatibilityDate": "2025-06-24", - "parties": { - "game_session": "partykit/gameSession.ts", - "party": "partykit/party.ts" - } -} -``` - -Important: Party names must use underscores or hyphens, not camelCase. - -### Cloud-prem Deployment - -PartyKit is deployed to the user's own Cloudflare account for better pricing: - -```bash -# Deploy to custom domain -npx partykit deploy --name tableslayer --domain partykit.tableslayer.com - -# Required environment variables -CLOUDFLARE_ACCOUNT_ID=your-account-id -CLOUDFLARE_API_TOKEN=your-workers-api-token -``` - -This requires a Cloudflare Workers paid plan ($5/month) for Durable Objects support. - -## Y.js Document Architecture - -The system uses two separate Y.js documents for different scopes: - -### 1. Game Session Document - -Manages scene-specific data for a game session: - -```typescript -// Room naming: gameSession ID directly -const gameSessionRoom = gameSessionId || `party-${partyId}`; - -// Contains: -- yScenes: Map of scene data -- yScenesList: Ordered array of scenes -- yGameSessionMeta: Metadata like initialization flags -- yCursors: Map for cursor data (deprecated, now uses awareness) -``` - -### 2. Party Document - -Manages party-wide state across game sessions: - -```typescript -// Room naming: party ID directly -const partyRoom = partyId; - -// Contains: -- yPartyState: Active scene ID, pause status -``` - -This separation allows seamless switching between game sessions while maintaining party-level state. - -## Data Loading Flow - -### 1. Initial Server-Side Load - -When a user navigates to a scene, data is loaded server-side: - -```typescript -// +page.server.ts -export const load: PageServerLoad = async (event) => { - const partykitHost = process.env.PUBLIC_PARTYKIT_HOST || 'localhost:1999'; - const scenes = await getScenes(gameSession.id); - const markers = await getMarkersForScene(selectedScene.id); - - return { - scenes, - markers, - selectedScene, - partykitHost // Pass host to client - }; -}; -``` - -### 2. Client-Side PartyKit Connection - -On mount, the client initializes Y.js with PartyKit providers: - -```typescript -// PartyDataManager.ts -export class PartyDataManager { - private gameSessionProvider: YPartyKitProvider; - private partyProvider: YPartyKitProvider; - private doc: Y.Doc; - private partyDoc: Y.Doc; - - constructor(partyId: string, userId: string, gameSessionId?: string, partykitHost: string = 'localhost:1999') { - this.doc = new Y.Doc(); - this.partyDoc = new Y.Doc(); - - // Use provided host (passed from server) - const host = partykitHost; - - // Game session connection - const gameSessionRoom = gameSessionId || `party-${partyId}`; - this.gameSessionProvider = new YPartyKitProvider(host, gameSessionRoom, this.doc, { - party: 'game_session', - params: { userId } - }); - - // Party connection - this.partyProvider = new YPartyKitProvider(host, partyId, this.partyDoc, { - party: 'party', - params: { userId } - }); - - // Initialize Y.js structures - this.yScenes = this.doc.getMap('scenes'); - this.yScenesList = this.doc.getArray('scenesList'); - this.yGameSessionMeta = this.doc.getMap('gameSessionMeta'); - this.yPartyState = this.partyDoc.getMap('partyState'); - } -} -``` - -### 3. Connection Status - -YPartyKitProvider manages WebSocket connections automatically: - -```typescript -// Monitor 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 - }; -} -``` - -## Y.js Update Flow - -Updates flow through the Property Update Broadcaster to Y.js via PartyKit: - -### 1. User Makes a Change - -```svelte - handleMapOpacityChange(value)} -/> -``` - -### 2. Update is Queued and Broadcast - -```typescript -function handleMapOpacityChange(value: number) { - queuePropertyUpdate(stageProps, ['map', 'opacity'], value, 'control'); -} -``` - -### 3. PartyKit Synchronizes - -The YPartyKitProvider handles WebSocket communication with the PartyKit server, which broadcasts Y.js updates to all connected clients. - -### 4. Other Clients Receive Updates - -```typescript -partyData.subscribe(() => { - const sceneData = partyData.getSceneData(selectedScene.id); - if (sceneData && sceneData.stageProps) { - stageProps = sceneData.stageProps; - } -}); -``` - -## Cursor Handling - -Cursor tracking uses Y.js awareness protocol via YPartyKitProvider: - -### Sending Cursor Updates - -```typescript -updateCursor(position: { x: number; y: number }, normalizedPosition: { x: number; y: number }) { - if (this.isConnected && this.gameSessionProvider.awareness) { - this.gameSessionProvider.awareness.setLocalStateField('cursor', { - userId: this.userId, - position, - normalizedPosition, - lastMoveTime: Date.now() - }); - } -} -``` - -### Receiving Cursor Updates - -```typescript -getCursors(): Record { - const cursors: Record = {}; - - if (this.gameSessionProvider.awareness) { - this.gameSessionProvider.awareness.getStates().forEach((state, clientId) => { - if (state.cursor && clientId !== this.gameSessionProvider.awareness.clientID) { - cursors[state.cursor.userId] = state.cursor; - } - }); - } - - return cursors; -} -``` - -### Cost Optimization: Cursor Throttling - -To reduce PartyKit request costs, cursor updates are throttled to 30 FPS: - -```typescript -// Create throttled cursor update function -throttledCursorUpdate = throttle((position: { x: number; y: number }, normalizedPosition: { x: number; y: number }) => { - partyData!.updateCursor(position, normalizedPosition); -}, 33); // 33ms = ~30 FPS -``` - -This reduces cursor update frequency from ~60+ updates/second to 30 updates/second, cutting cursor-related costs by ~50%. - -### Important: Cursors are Ephemeral - -Cursors use Y.js awareness, which means: - -- They are NOT stored in Y.js documents -- They are NOT persisted between sessions -- They disappear when a user disconnects -- They still count as PartyKit requests (hence throttling) - -## Marker Hover State (DM to Player Reveal) - -The marker hover system allows DMs to temporarily reveal individual markers to players by hovering over them. This uses the Y.js awareness protocol for real-time synchronization. - -### Hover State Structure - -```typescript -interface HoveredMarker { - id: string; - position: { x: number; y: number; z: number }; - tooltip: { - title: string; - content: string; // TipTap JSON content as string - imageUrl?: string; - }; -} -``` - -### DM Hover Broadcasting - -When a DM hovers over a marker, the state is broadcast via awareness: - -```typescript -// In MarkerLayer component (DM mode only) -onMarkerHover?.(hoveredMarker); - -// In scene page -const onMarkerHover = (marker: Marker | null) => { - if (stageProps.mode === StageMode.DM && partyData) { - if (marker) { - const hoveredMarker = { - id: marker.id, - position: { x: marker.position.x, y: marker.position.y, z: 0 }, - tooltip: { - title: marker.title, - content: marker.note ? JSON.stringify(marker.note) : '', - imageUrl: marker.imageUrl || undefined - } - }; - partyData.updateHoveredMarker(hoveredMarker); - } else { - partyData.updateHoveredMarker(null); - } - } -}; -``` - -### Player Receiving Hover State - -Players receive hover state updates through Y.js subscriptions: - -```typescript -// In Y.js subscription (Player mode only) -if (stageProps.mode === StageMode.Player) { - hoveredMarker = partyData!.getHoveredMarker(); -} - -// MarkerHoverTooltip component renders the tooltip -{#if hoveredMarker && stageProps.mode === StageMode.Player} - -{/if} -``` - -### Key Features - -- **DM-Only Broadcasting**: Only DMs can trigger hover broadcasts -- **Rich Tooltips**: Supports TipTap formatted content with images and links -- **Floating UI Positioning**: Tooltips use floating-ui for intelligent positioning -- **Debounced Hover**: Hover events are debounced (100ms) to prevent flickering -- **Read-Only Display**: Players see read-only TipTap editor for rich content - -### Implementation Details - -1. **MarkerLayer**: Detects hover and calls `onMarkerHover` callback -2. **Scene Page**: Converts marker to `HoveredMarker` format and broadcasts -3. **PartyDataManager**: Sends hover state via awareness protocol -4. **MarkerHoverTooltip**: Renders floating tooltip with TipTap content - -Important: Like cursors, hover state is ephemeral: - -- Not persisted in Y.js documents -- Clears when DM moves cursor away -- Disappears when DM disconnects - -## Pinned Tooltips (Database-Backed Persistence) - -The pinned tooltips system allows DMs to permanently pin marker tooltips for players to see. Unlike hover state, pinned tooltips are **persisted in the database** and survive refreshes and disconnects. - -### Database Schema - -Pinned state is stored as a boolean field on each marker: - -```typescript -// schema.ts -export const markerTable = sqliteTable('marker', { - // ... other fields - pinnedTooltip: integer('pinned_tooltip', { mode: 'boolean' }).notNull().default(false) -}); -``` - -### Marker Interface - -The UI marker type includes the pinned state: - -```typescript -// types.ts -export interface Marker { - id: string; - title: string; - position: { x: number; y: number }; - // ... other fields - pinnedTooltip?: boolean; -} -``` - -### Data Flow - -Pinned tooltip state flows through the standard database → Y.js → UI pipeline: - -```typescript -// 1. Load from database (buildSceneProps.ts) -markers = activeSceneMarkers.map((marker) => ({ - id: marker.id, - title: marker.title, - // ... other fields - pinnedTooltip: marker.pinnedTooltip ?? false -})); - -// 2. Derive pinned marker IDs in both editor and play routes -let pinnedMarkerIds = $derived(stageProps.marker.markers.filter((m) => m.pinnedTooltip).map((m) => m.id)); - -// 3. Pin toggle updates marker and triggers save -const onPinToggle = (markerId: string, pinned: boolean) => { - if (stageProps.mode === StageMode.DM) { - const markerIndex = stageProps.marker.markers.findIndex((m) => m.id === markerId); - if (markerIndex !== -1) { - // Update the marker's pinnedTooltip property - stageProps.marker.markers[markerIndex].pinnedTooltip = pinned; - - // Trigger database save through property update queue - queuePropertyUpdate(stageProps, ['marker', 'markers'], stageProps.marker.markers, 'marker'); - } - } -}; - -// 4. Save to database (convertStagePropsToMarkerData.ts) -return { - id: marker.id, - // ... other fields - pinnedTooltip: marker.pinnedTooltip ?? false -}; -``` - -### Key Differences from Hover State - -| Feature | Hover State | Pinned Tooltips | -| --------------- | -------------------------- | ------------------------ | -| **Storage** | Y.js awareness (ephemeral) | Database (persistent) | -| **Sync Method** | Awareness protocol | Y.js document + database | -| **Persistence** | Lost on disconnect | Survives refreshes | -| **Control** | DM hover action | DM explicit pin/unpin | -| **Purpose** | Temporary reveal | Permanent display | - -### Implementation Details - -1. **Editor Route** (`/[party]/[gameSession]/[[selectedScene]]/+page.svelte`): - - Derives `pinnedMarkerIds` from markers with `pinnedTooltip: true` - - `onPinToggle` updates marker property and triggers save - - No awareness-based state management - -2. **Play Route** (`/[party]/play/+page@.svelte`): - - Derives `pinnedMarkerIds` from markers with `pinnedTooltip: true` - - Automatically receives pinned state via Y.js marker sync - - No awareness-based state management - -3. **Stage Component** (`Stage.svelte`): - - Renders pinned tooltips based on `pinnedMarkerIds` array - - Respects `activeLayer` setting in DM mode (only shows when None or Marker layer active) - - Always shows pinned tooltips in Player mode - -### Benefits - -- **Database Persistence**: Pinned state survives page refreshes and disconnects -- **Consistent State**: Editor and play routes derive from same source of truth -- **Real-time Sync**: Changes propagate via Y.js to all connected clients -- **Auto-save**: Integrated with existing marker save system -- **No Awareness Overhead**: Reduces Y.js awareness protocol usage - -### Migration from Awareness-Based System - -The system was originally implemented using Y.js awareness (ephemeral), but was migrated to database persistence to ensure state survives refreshes: - -**Before (Awareness-based)**: - -```typescript -// Ephemeral state -let pinnedMarkerIds = $state([]); - -// Update via awareness -partyData.updatePinnedMarkers(newPinnedIds); -pinnedMarkerIds = partyData.getPinnedMarkers(); -``` - -**After (Database-backed)**: - -```typescript -// Derived from database markers -let pinnedMarkerIds = $derived(stageProps.marker.markers.filter((m) => m.pinnedTooltip).map((m) => m.id)); - -// Update via marker property + database save -stageProps.marker.markers[markerIndex].pinnedTooltip = pinned; -queuePropertyUpdate(stageProps, ['marker', 'markers'], stageProps.marker.markers, 'marker'); -``` - -## Throttling and Batching System - -The property update broadcaster implements intelligent throttling: - -```typescript -// propertyUpdateBroadcaster.ts -const MARKER_UPDATE_DELAY = 50; // Fast sync for markers -const UI_CONTROL_DELAY = 100; // Medium for UI controls -const SCENE_UPDATE_DELAY = 200; // Slower for scene properties - -export function queuePropertyUpdate( - stageProps: StageProps, - propertyPath: PropertyPath, - value: any, - updateType: 'marker' | 'control' | 'scene' = 'control' -) { - // Apply update locally immediately - applyUpdate(stageProps, propertyPath, value); - - // Check if immediate sync is enabled - if (immediateYjsSyncEnabled && partyDataManager && currentSceneId) { - partyDataManager.updateSceneStageProps(currentSceneId, stageProps); - } - - // Queue for batched update - const pathKey = propertyPath.join('.'); - pendingUpdates[pathKey] = { path: propertyPath, value }; - - // Schedule batch update - if (!updateScheduled) { - updateScheduled = true; - const delay = - updateType === 'marker' ? MARKER_UPDATE_DELAY : updateType === 'scene' ? SCENE_UPDATE_DELAY : UI_CONTROL_DELAY; - - setTimeout(() => { - broadcastPropertyUpdatesViaYjs(pendingUpdates, currentSceneId); - pendingUpdates = {}; - updateScheduled = false; - }, delay); - } -} -``` - -## Local-Only Properties - -Some properties don't sync between users: - -```typescript -const LOCAL_ONLY_PROPERTIES = new Set([ - 'scene.offset.x', // Each user has their own viewport - 'scene.offset.y', - 'scene.rotation', // Individual rotation preference - 'activeLayer' // Tool selection is per-user -]); - -function queuePropertyUpdate(...) { - if (isLocalOnlyProperty(propertyPath)) { - applyUpdate(stageProps, propertyPath, value); - return; // Skip Y.js sync - } - // ... continue with sync -} -``` - -## Stale Data Detection - -The system detects when Y.js data is out of sync with the database: - -```typescript -// PartyDataManager.ts -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; -} -``` - -## Save System - -The auto-save system prevents data loss by periodically saving Y.js state to the database: - -### Save Coordination - -Only one user saves at a time to prevent conflicts: - -```typescript -const performSave = async () => { - // Try to become the active saver - const canSave = partyData.becomeActiveSaver(selectedScene.id); - if (!canSave) return; - - try { - isSaving = true; - - // Get current Y.js state - const sceneData = partyData.getSceneData(selectedScene.id); - const stagePropsToSave = sceneData.stageProps; - - // Convert to database format - const sceneUpdate = convertStagePropsToSceneData(stagePropsToSave, selectedScene); - const markersToSave = convertStagePropsToMarkerData(stagePropsToSave); - - // Save to database - await updateScene({ sceneId: selectedScene.id, sceneData: sceneUpdate }); - await upsertMarkers({ markers: markersToSave, sceneId: selectedScene.id }); - - // Update Y.js with save timestamp - partyData.updateSceneLastUpdated(selectedScene.id, Date.now()); - } finally { - partyData.releaseActiveSaver(selectedScene.id, true); - isSaving = false; - } -}; -``` - -## Deployment Strategy - -### Production Deployment (Cloud-prem) - -PartyKit servers are deployed to the user's own Cloudflare account: - -```bash -# Deploy to custom domain -npx partykit deploy --name tableslayer --domain partykit.tableslayer.com - -# Environment variables required: -CLOUDFLARE_ACCOUNT_ID=your-account-id -CLOUDFLARE_API_TOKEN=your-workers-api-token # Needs Workers permissions -``` - -GitHub Actions workflow: - -```yaml -- name: Deploy PartyKit servers - if: github.ref == 'refs/heads/main' - working-directory: apps/web - run: npx partykit deploy --name tableslayer --domain partykit.tableslayer.com - env: - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_KEY }} -``` - -### Per-PR Deployments - -Each pull request gets its own PartyKit deployment for isolated testing: - -```yaml -# .github/workflows/ci.yml -- name: Deploy PartyKit for PR - working-directory: apps/web - run: | - echo "Deploying PartyKit for PR #${{ github.event.pull_request.number }}" - npx partykit deploy --name pr-${{ github.event.pull_request.number }}-tableslayer - env: - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_KEY }} - -# Set the host in the generated .env -PUBLIC_PARTYKIT_HOST=pr-${{ github.event.pull_request.number }}-tableslayer.partykit.dev -``` - -### Cleanup on PR Close - -```yaml -# .github/workflows/destroydb.yml -- name: Delete PartyKit deployment - working-directory: apps/web - run: | - echo "Deleting PartyKit deployment for PR #${{ github.event.pull_request.number }}" - npx partykit delete --name pr-${{ github.event.pull_request.number }}-tableslayer --yes - env: - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_KEY }} -``` - -## Cost Optimization - -### Pricing Models - -1. **PartyKit Managed** (original approach): $19/month for 1M requests/day -2. **Cloudflare Workers** (cloud-prem): $5/month + usage-based pricing - - 10M requests/month included - - $0.50 per additional million requests - - Much more cost-effective at scale - -### Optimization Techniques - -1. **Cursor Throttling**: Reduce cursor updates from 60+ FPS to 30 FPS - - Before: ~20 users × 60 updates/sec = 1,200 requests/sec - - After: ~20 users × 30 updates/sec = 600 requests/sec - - 50% reduction in cursor-related costs - -2. **Batched Updates**: Group property changes within time windows - - UI controls: 100ms batching window - - Scene updates: 200ms batching window - - Reduces request count by 80-90% for rapid changes - -3. **Local-Only Properties**: Don't sync viewport state - - Offset, rotation, zoom are per-user - - Eliminates thousands of unnecessary syncs - -4. **Smart Save Coordination**: Only one user saves at a time - - Prevents duplicate save operations - - Reduces database write load - -### Cost Estimation - -With optimizations on Cloudflare Workers: - -- 20 concurrent users -- 30 cursor updates/second/user -- 10 property updates/minute/user -- Total: ~40,000 requests/hour -- Monthly: ~29M requests -- Cost: $5 base + $9.50 for additional 19M requests = **$14.50/month** - -(Compared to $19/month for PartyKit managed or ~$1000/month for Redis) - -## Adding New Properties to StageProps - -### 1. Update Type Definitions - -```typescript -// types.ts (in UI package) -export interface StageProps { - // ... existing properties - gridOverlay: { - visible: boolean; - opacity: number; - color: string; - size: number; - }; -} -``` - -### 2. Update Database Schema - -```typescript -// schema.ts -export const sceneTable = sqliteTable('scene', { - // ... existing columns - gridOverlayVisible: integer('grid_overlay_visible', { mode: 'boolean' }).notNull().default(false), - gridOverlayOpacity: real('grid_overlay_opacity').notNull().default(0.5), - gridOverlayColor: text('grid_overlay_color').notNull().default('#000000'), - gridOverlaySize: integer('grid_overlay_size').notNull().default(50) -}); -``` - -### 3. Update buildSceneProps - -```typescript -// buildSceneProps.ts -export function buildSceneProps(scene: Scene, markers: Marker[], view: string): StageProps { - return { - // ... existing properties - gridOverlay: { - visible: scene.gridOverlayVisible ?? false, - opacity: scene.gridOverlayOpacity ?? 0.5, - color: scene.gridOverlayColor ?? '#000000', - size: scene.gridOverlaySize ?? 50 - } - }; -} -``` - -### 4. Update Conversion Functions - -```typescript -// convertStagePropsToSceneData.ts -export function convertStagePropsToSceneData(stageProps: StageProps, scene: Scene) { - return { - // ... existing properties - gridOverlayVisible: stageProps.gridOverlay.visible, - gridOverlayOpacity: stageProps.gridOverlay.opacity, - gridOverlayColor: stageProps.gridOverlay.color, - gridOverlaySize: stageProps.gridOverlay.size - }; -} -``` - -### 5. Create UI Controls - -```svelte - - - - - {#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/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 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 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; } }