import { CATALOG_VERSION, SEED_ARTIFACT_ID, getRecipe, constraintsFor } from "./catalog.js"; export const SCHEMA_VERSION = 1; export const MAX_VOTES_PER_OUTCOME = 999; export const seedNodeId = (artifactId) => `n:seed:${artifactId}`; export const roundIdFor = (parentNodeId, recipeId) => `r:${parentNodeId}:${recipeId}`; export const candidateNodeId = (roundId, slot) => `n:${roundId}:${slot}`; const clone = (state) => structuredClone(state); const fail = (message) => { throw new Error(message); }; export function createSession(source) { const id = seedNodeId(source.seedArtifactId ?? SEED_ARTIFACT_ID); return { schemaVersion: SCHEMA_VERSION, catalogVersion: source.version ?? CATALOG_VERSION, phase: "choose", seedNodeId: id, activeNodeId: id, inspectedNodeId: id, selectedConstraintId: null, currentRoundId: null, compareSelection: null, nodesById: { [id]: { nodeId: id, parentNodeId: null, artifactId: source.seedArtifactId, recipeId: null, outcomeSlot: null, status: "seed", depth: 0 } }, roundsById: {} }; } function assertPhase(state, phases, action) { if (!phases.includes(state.phase)) fail(`${action} is not legal during ${state.phase}.`); } export function selectConstraint(state, source, constraintId) { assertPhase(state, ["choose"], "selectConstraint"); const parent = state.nodesById[state.activeNodeId]; if (!getRecipe(source, parent.artifactId, constraintId)) fail("Constraint has no recipe for the active artifact."); const next = clone(state); next.selectedConstraintId = constraintId; return next; } function insertImmutable(map, id, entity) { if (!map[id]) { map[id] = entity; return; } if (JSON.stringify(map[id]) !== JSON.stringify(entity)) fail(`Derived ID collision: ${id}`); } export function revealOutcomes(state, source) { assertPhase(state, ["choose"], "revealOutcomes"); if (!state.selectedConstraintId) fail("Select a constraint before reveal."); const parent = state.nodesById[state.activeNodeId]; const recipe = getRecipe(source, parent.artifactId, state.selectedConstraintId); if (!recipe) fail("Selected recipe is unavailable."); const roundId = roundIdFor(parent.nodeId, recipe.id); const nodes = recipe.outcomes.map((outcome) => ({ nodeId: candidateNodeId(roundId, outcome.slot), parentNodeId: parent.nodeId, artifactId: outcome.artifactId, recipeId: recipe.id, outcomeSlot: outcome.slot, status: "candidate", depth: parent.depth + 1 })); const round = { roundId, parentNodeId: parent.nodeId, recipeId: recipe.id, candidateNodeIds: nodes.map((node) => node.nodeId), votes: { a: 0, b: 0 }, winnerSlot: null, resolution: null }; const next = clone(state); insertImmutable(next.roundsById, roundId, round); nodes.forEach((node) => insertImmutable(next.nodesById, node.nodeId, node)); next.phase = "compare"; next.currentRoundId = roundId; next.compareSelection = "a"; next.inspectedNodeId = nodes[0].nodeId; return next; } export function selectComparison(state, slot) { assertPhase(state, ["compare", "vote"], "selectComparison"); if (!["a", "b"].includes(slot)) fail("Comparison slot must be a or b."); const next = clone(state); const round = next.roundsById[next.currentRoundId]; next.compareSelection = slot; next.inspectedNodeId = round.candidateNodeIds[slot === "a" ? 0 : 1]; return next; } export function incrementVote(state, slot) { assertPhase(state, ["compare", "vote"], "incrementVote"); if (!["a", "b"].includes(slot)) fail("Vote slot must be a or b."); if (state.roundsById[state.currentRoundId].votes[slot] >= MAX_VOTES_PER_OUTCOME) { fail(`Vote count cannot exceed ${MAX_VOTES_PER_OUTCOME}.`); } const next = clone(state); next.phase = "vote"; next.roundsById[next.currentRoundId].votes[slot] += 1; return next; } export function decrementVote(state, slot) { assertPhase(state, ["vote"], "decrementVote"); if (!["a", "b"].includes(slot)) fail("Vote slot must be a or b."); if (state.roundsById[state.currentRoundId].votes[slot] === 0) fail("Vote count cannot be negative."); const next = clone(state); next.roundsById[next.currentRoundId].votes[slot] -= 1; return next; } export function resolveVote(state, source) { assertPhase(state, ["vote"], "resolveVote"); const next = clone(state); const round = next.roundsById[next.currentRoundId]; if (round.votes.a + round.votes.b === 0) fail("Record at least one vote before resolution."); const recipe = source.recipes.find((item) => item.id === round.recipeId); if (!recipe) fail("Round recipe is unavailable."); const winner = round.votes.a === round.votes.b ? recipe.tieWinnerSlot : (round.votes.a > round.votes.b ? "a" : "b"); round.winnerSlot = winner; round.resolution = round.votes.a === round.votes.b ? "authored-tie-rule" : "votes"; round.candidateNodeIds.forEach((id, index) => { next.nodesById[id].status = (index === (winner === "a" ? 0 : 1)) ? "winner" : "not-selected"; }); next.activeNodeId = round.candidateNodeIds[winner === "a" ? 0 : 1]; next.inspectedNodeId = next.activeNodeId; next.phase = "resolved"; return next; } export function continueFromWinner(state, source) { assertPhase(state, ["resolved"], "continueFromWinner"); const next = clone(state); next.currentRoundId = null; next.selectedConstraintId = null; next.compareSelection = null; next.phase = constraintsFor(source, next.nodesById[next.activeNodeId].artifactId).length ? "choose" : "complete"; return next; } export function inspectNode(state, nodeId) { if (!state.nodesById[nodeId]) fail("Cannot inspect an unknown node."); const next = clone(state); next.inspectedNodeId = nodeId; return next; } export function resetSession(_state, source) { return createSession(source); } export function historyPreorder(state) { const children = new Map(); for (const node of Object.values(state.nodesById)) { if (node.parentNodeId) { const list = children.get(node.parentNodeId) ?? []; list.push(node); children.set(node.parentNodeId, list); } } for (const list of children.values()) list.sort((a, b) => (a.outcomeSlot ?? "").localeCompare(b.outcomeSlot ?? "")); const result = []; const visit = (id) => { result.push(state.nodesById[id]); for (const child of children.get(id) ?? []) visit(child.nodeId); }; visit(state.seedNodeId); return result; } export function validateSession(state, source) { try { if (!state || state.schemaVersion !== SCHEMA_VERSION || state.catalogVersion !== source.version) return false; if (!["choose", "compare", "vote", "resolved", "complete"].includes(state.phase)) return false; if (!state.nodesById || typeof state.nodesById !== "object" || Array.isArray(state.nodesById)) return false; if (!state.roundsById || typeof state.roundsById !== "object" || Array.isArray(state.roundsById)) return false; const seedId = seedNodeId(source.seedArtifactId); if (state.seedNodeId !== seedId || !state.nodesById[state.activeNodeId] || !state.nodesById[state.inspectedNodeId]) return false; const seed = state.nodesById[seedId]; if (!seed || seed.nodeId !== seedId || seed.parentNodeId !== null || seed.artifactId !== source.seedArtifactId || seed.recipeId !== null || seed.outcomeSlot !== null || seed.status !== "seed" || seed.depth !== 0) return false; const artifactIds = new Set(source.artifacts.map(({ id }) => id)); const expectedCandidateIds = new Set(); const roundParentIds = new Set(); for (const [mapId, round] of Object.entries(state.roundsById)) { if (!round || round.roundId !== mapId) return false; const parent = state.nodesById[round.parentNodeId]; const recipe = source.recipes.find(({ id }) => id === round.recipeId); if (!parent || !["seed", "winner"].includes(parent.status) || !recipe || recipe.parentArtifactId !== parent.artifactId || roundParentIds.has(parent.nodeId)) return false; roundParentIds.add(parent.nodeId); const expectedRoundId = roundIdFor(parent.nodeId, recipe.id); const expectedIds = ["a", "b"].map((slot) => candidateNodeId(expectedRoundId, slot)); if (mapId !== expectedRoundId || !Array.isArray(round.candidateNodeIds) || round.candidateNodeIds.length !== 2 || round.candidateNodeIds[0] !== expectedIds[0] || round.candidateNodeIds[1] !== expectedIds[1]) return false; if (!round.votes || !Number.isInteger(round.votes.a) || round.votes.a < 0 || round.votes.a > MAX_VOTES_PER_OUTCOME || !Number.isInteger(round.votes.b) || round.votes.b < 0 || round.votes.b > MAX_VOTES_PER_OUTCOME) return false; const resolved = round.winnerSlot !== null || round.resolution !== null; if (resolved) { if (!["a", "b"].includes(round.winnerSlot) || !["votes", "authored-tie-rule"].includes(round.resolution)) return false; const expectedWinner = round.votes.a === round.votes.b ? recipe.tieWinnerSlot : (round.votes.a > round.votes.b ? "a" : "b"); const expectedResolution = round.votes.a === round.votes.b ? "authored-tie-rule" : "votes"; if (round.winnerSlot !== expectedWinner || round.resolution !== expectedResolution) return false; } else if (round.winnerSlot !== null || round.resolution !== null) return false; for (let index = 0; index < expectedIds.length; index += 1) { const id = expectedIds[index]; const slot = index === 0 ? "a" : "b"; const node = state.nodesById[id]; const outcome = recipe.outcomes[index]; if (!node || node.nodeId !== id || node.parentNodeId !== parent.nodeId || node.artifactId !== outcome.artifactId || node.recipeId !== recipe.id || node.outcomeSlot !== slot || node.depth !== parent.depth + 1) return false; const expectedStatus = resolved ? (slot === round.winnerSlot ? "winner" : "not-selected") : "candidate"; if (node.status !== expectedStatus) return false; expectedCandidateIds.add(id); } } for (const [mapId, node] of Object.entries(state.nodesById)) { if (!node || node.nodeId !== mapId || !artifactIds.has(node.artifactId) || !Number.isInteger(node.depth) || node.depth < 0) return false; if (mapId !== seedId && !expectedCandidateIds.has(mapId)) return false; } const currentPhases = ["compare", "vote", "resolved"]; const currentRound = state.currentRoundId === null ? null : state.roundsById[state.currentRoundId]; if (currentPhases.includes(state.phase) !== Boolean(currentRound)) return false; for (const [id, round] of Object.entries(state.roundsById)) { const isCurrentUnresolved = id === state.currentRoundId && ["compare", "vote"].includes(state.phase); if (isCurrentUnresolved !== (round.winnerSlot === null)) return false; } if (state.phase === "choose") { if (state.currentRoundId !== null || state.compareSelection !== null) return false; const available = constraintsFor(source, state.nodesById[state.activeNodeId].artifactId); if (!available.length || (state.selectedConstraintId !== null && !available.some(({ id }) => id === state.selectedConstraintId))) return false; } else if (state.phase === "complete") { if (state.currentRoundId !== null || state.selectedConstraintId !== null || state.compareSelection !== null || constraintsFor(source, state.nodesById[state.activeNodeId].artifactId).length) return false; } else { const recipe = source.recipes.find(({ id }) => id === currentRound.recipeId); if (state.selectedConstraintId !== recipe.constraintId || !["a", "b"].includes(state.compareSelection)) return false; if (state.phase === "compare" && (currentRound.votes.a !== 0 || currentRound.votes.b !== 0)) return false; if (["vote", "resolved"].includes(state.phase) && currentRound.votes.a + currentRound.votes.b === 0) return false; if (state.phase === "resolved") { const winnerId = currentRound.candidateNodeIds[currentRound.winnerSlot === "a" ? 0 : 1]; if (state.activeNodeId !== winnerId) return false; } else { if (state.activeNodeId !== currentRound.parentNodeId) return false; } } const active = state.nodesById[state.activeNodeId]; if (active.status !== "seed" && active.status !== "winner") return false; return true; } catch { return false; } }