import { collectSceneElementIds } from './validate' import type { Annotation, DemoAct, DemoScene, DemoStep, InteractiveDemoV1, ScopeId, } from './types' const SCOPE_RANK: Record = { transient: 1, act: 2, scene: 3, } function longestScope(a: ScopeId, b: ScopeId): ScopeId { return SCOPE_RANK[a] >= SCOPE_RANK[b] ? a : b } export type ResolvedAnnotation = Annotation & { badgeIndex?: number } export type StepResolvedState = { actId: string stepId: string stepIndex: number speak: string /** Elements revealed and their effective scope */ revealed: Record /** Spotlight targets this step (empty ⇒ overview / full brightness) */ spotlight: string[] /** Active annotations after applying this step (transient of prior steps purged) */ annotations: ResolvedAnnotation[] overview: boolean } export type ActResolvedState = { actId: string title: string steps: StepResolvedState[] /** State after the last step — static/SSR/print/export default */ final: StepResolvedState } function purgeScope( revealed: Map, annotations: ResolvedAnnotation[], scopes: ScopeId[] ): { revealed: Map; annotations: ResolvedAnnotation[] } { const drop = new Set(scopes) const nextRevealed = new Map() for (const [id, scope] of revealed) { if (!drop.has(scope)) nextRevealed.set(id, scope) } const nextAnn = annotations.filter((a) => !drop.has(a.scope)) return { revealed: nextRevealed, annotations: nextAnn } } function mergeReveal( revealed: Map, id: string, scope: ScopeId ): void { const prev = revealed.get(id) revealed.set(id, prev ? longestScope(prev, scope) : scope) } function applyStep( step: DemoStep, elementIds: Set, revealed: Map, annotations: ResolvedAnnotation[], badgeCounter: { n: number } ): { revealed: Map annotations: ResolvedAnnotation[] spotlight: string[] overview: boolean } { // Drop previous step's transient ;({ revealed, annotations } = purgeScope(revealed, annotations, ['transient'])) const spotlightSet = new Set() const revealId = (id: string, scope: ScopeId) => { if (id === '*') { for (const eid of elementIds) { if (!revealed.has(eid)) mergeReveal(revealed, eid, scope) } return } mergeReveal(revealed, id, scope) } for (const rev of step.reveal ?? []) { for (const id of rev.ids) { revealId(id, rev.scope) if (id !== '*') spotlightSet.add(id) } } // Designation ⇒ reveal (pointTo default act) for (const id of step.pointTo ?? []) { revealId(id, 'act') spotlightSet.add(id) } for (const ann of step.annotate ?? []) { for (const id of ann.targetIds) { revealId(id, ann.scope) spotlightSet.add(id) } const resolved: ResolvedAnnotation = { ...ann } if (ann.kind === 'badge') { badgeCounter.n += 1 resolved.badgeIndex = badgeCounter.n } annotations.push(resolved) } const overview = spotlightSet.size === 0 return { revealed, annotations, spotlight: [...spotlightSet], overview, } } function resolveAct( act: DemoAct, scene: DemoScene, sceneChanged: boolean, carried: { revealed: Map annotations: ResolvedAnnotation[] } ): ActResolvedState { let { revealed, annotations } = carried if (sceneChanged) { // New scene: purge everything from old scene (refs would be dead) revealed = new Map() annotations = [] } else { // Soft reset: purge transient + act, keep scene-scoped ;({ revealed, annotations } = purgeScope(revealed, annotations, [ 'transient', 'act', ])) } const { ids: elementIds } = collectSceneElementIds(scene) const badgeCounter = { n: 0 } const steps: StepResolvedState[] = [] for (const [stepIndex, step] of act.steps.entries()) { const applied = applyStep( step, elementIds, revealed, annotations, badgeCounter ) revealed = applied.revealed annotations = applied.annotations const snapshot: StepResolvedState = { actId: act.id, stepId: step.id, stepIndex, speak: step.speak, revealed: Object.fromEntries(revealed), spotlight: applied.spotlight, annotations: annotations.map((a) => ({ ...a })), overview: applied.overview, } steps.push(snapshot) } const last = steps[steps.length - 1]! return { actId: act.id, title: act.title, steps, final: last, } } /** * Pure resolver: auto-reveal, wildcard, longest-scope-wins, spotlight. * Shared by player / SSR / noscript / print / export — no React. */ export function resolveInteractiveDemo(demo: InteractiveDemoV1): { acts: ActResolvedState[] } { let scene = demo.scene let revealed = new Map() let annotations: ResolvedAnnotation[] = [] const acts: ActResolvedState[] = [] for (const act of demo.acts) { const sceneChanged = Boolean(act.scene) if (act.scene) scene = act.scene const resolved = resolveAct(act, scene, sceneChanged, { revealed, annotations, }) acts.push(resolved) // Carry state into next act (may be purged on soft reset / scene change) const last = resolved.final revealed = new Map(Object.entries(last.revealed) as [string, ScopeId][]) annotations = last.annotations.map((a) => ({ ...a })) } return { acts } } /** Convenience: final static state for a given act (P10 default). */ export function resolveActFinalState( demo: InteractiveDemoV1, actId: string ): StepResolvedState | undefined { return resolveInteractiveDemo(demo).acts.find((a) => a.actId === actId)?.final }