import { emptyTokenPathSessionState, tokenPathKey, type TokenPathPlaybackEntry, type TokenPathSessionEvent, type TokenPathSessionState, type TokenPathTargetRef, } from '../../shared/types/tokenPathSession'; function bump( state: TokenPathSessionState, patch: Partial>, ): TokenPathSessionState { return { ...state, ...patch, revision: state.revision + 1, serverNowMs: Date.now(), }; } export class TokenPathSessionStore { private state: TokenPathSessionState = emptyTokenPathSessionState(); getState(): TokenPathSessionState { return this.state; } /** Refresh clock without logical change (optional heartbeat). */ touchClock(): TokenPathSessionState { this.state = { ...this.state, serverNowMs: Date.now() }; return this.state; } reset(): TokenPathSessionState { if ( Object.keys(this.state.playback).length === 0 && Object.keys(this.state.presentationVisible).length === 0 ) { return this.state; } this.state = emptyTokenPathSessionState(this.state.revision + 1); return this.state; } dispatch(event: TokenPathSessionEvent): TokenPathSessionState { switch (event.kind) { case 'clear': return this.reset(); case 'showPresentation': { const key = tokenPathKey(event.target.kind, event.target.placementId); if (this.state.presentationVisible[key]) return this.state; this.state = bump(this.state, { presentationVisible: { ...this.state.presentationVisible, [key]: true }, }); return this.state; } case 'hidePresentation': { const key = tokenPathKey(event.target.kind, event.target.placementId); if (!this.state.presentationVisible[key]) return this.state; const { [key]: _removed, ...rest } = this.state.presentationVisible; this.state = bump(this.state, { presentationVisible: rest }); return this.state; } case 'stop': { const key = tokenPathKey(event.target.kind, event.target.placementId); const prev = this.state.playback[key]; if (!prev || prev.phase === 'stopped') return this.state; const atDist = typeof event.atDist === 'number' && Number.isFinite(event.atDist) ? Math.max(0, event.atDist) : prev.baseDist; this.state = bump(this.state, { playback: { ...this.state.playback, [key]: { ...prev, phase: 'stopped', baseDist: atDist, rejoinDist: null, segmentStartedAtMs: Date.now(), }, }, }); return this.state; } case 'resume': { const key = tokenPathKey(event.target.kind, event.target.placementId); const prev = this.state.playback[key]; if (!prev) return this.state; if (prev.phase !== 'stopped' && prev.phase !== 'done') return this.state; const entry: TokenPathPlaybackEntry = { ...prev, phase: 'moving', segmentStartedAtMs: Date.now(), // Jump to nearest-ahead on path, then continue (v1: no off-path lerp). baseDist: Math.max(0, event.fromDist), rejoinDist: null, direction: 1, }; this.state = bump(this.state, { playback: { ...this.state.playback, [key]: entry }, }); return this.state; } case 'resetToStart': { const key = tokenPathKey(event.target.kind, event.target.placementId); const prev = this.state.playback[key]; const entry: TokenPathPlaybackEntry = { kind: event.target.kind, placementId: event.target.placementId, phase: 'moving', segmentStartedAtMs: Date.now(), baseDist: 0, direction: 1, rejoinDist: null, durationSec: prev?.durationSec ?? 8, pathLength: prev?.pathLength ?? 1, }; this.state = bump(this.state, { playback: { ...this.state.playback, [key]: entry }, }); return this.state; } case 'seedPlayback': { const e = event.entry; const key = tokenPathKey(e.kind, e.placementId); const entry: TokenPathPlaybackEntry = { kind: e.kind, placementId: e.placementId, phase: e.phase, segmentStartedAtMs: e.segmentStartedAtMs ?? Date.now(), baseDist: e.baseDist, direction: e.direction, rejoinDist: e.rejoinDist, durationSec: e.durationSec, pathLength: e.pathLength, }; this.state = bump(this.state, { playback: { ...this.state.playback, [key]: entry }, }); return this.state; } case 'markDone': { const key = tokenPathKey(event.target.kind, event.target.placementId); const prev = this.state.playback[key]; if (!prev || prev.phase === 'done') return this.state; this.state = bump(this.state, { playback: { ...this.state.playback, [key]: { ...prev, phase: 'done', baseDist: prev.pathLength, rejoinDist: null, segmentStartedAtMs: Date.now(), }, }, }); return this.state; } default: { const _exhaustive: never = event; void _exhaustive; return this.state; } } } } export type { TokenPathTargetRef };