| 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; |
| } |
| } |
|
|