import { SIGNAL_GARDEN_MODES } from "./artifact.js"; export const CATALOG_VERSION = 2; export const SEED_ARTIFACT_ID = "signal-garden"; const artifact = (id, title, summary, mode, accent, rule, target = 5) => ({ id, title, summary, instructions: "Move the spark with Arrow keys or WASD. Pointer or touch: tap a neighboring tile. Collect every pulse. Use Reset artifact to replay.", parameters: { mode, accent, rule, target } }); export const catalog = { version: CATALOG_VERSION, seedArtifactId: SEED_ARTIFACT_ID, artifacts: [ artifact("signal-garden", "Signal Garden", "Guide one spark through a tiny field and collect five pulses.", "open", "gold", "Collect pulses in any order."), artifact("echo-trail", "Echo Trail", "Every step leaves a solid echo, turning your route into the puzzle.", "trail", "coral", "Visited tiles become walls; plan a route without trapping the spark."), artifact("tidal-loop", "Tidal Loop", "The garden wraps at its edges, rewarding bold shortcuts.", "wrap", "cyan", "Cross an edge to re-enter from the opposite side."), artifact("patient-pulse", "Patient Pulse", "Pulses must be collected from lowest to highest.", "sequence", "violet", "Only the numbered next pulse can be collected."), artifact("wild-orbit", "Wild Orbit", "Each pulse rotates the movement directions clockwise.", "rotate", "lime", "After collecting, your next direction is rotated."), artifact("mirror-moss", "Mirror Moss", "The spark mirrors horizontal commands.", "mirror", "pink", "Left moves right and right moves left."), artifact("stone-rhythm", "Stone Rhythm", "Every second move plants a lasting stone.", "rhythm", "orange", "Every second move leaves the previous tile blocked."), artifact("echo-sprint", "Echo Sprint", "A tighter trail garden asks for six pickups without revisiting.", "trail", "cyan", "Visited tiles remain blocked and the denser pulse layout rewards a clean sweep.", 6), artifact("echo-gates", "Echo Gates", "Edge crossings reopen the oldest trail tile.", "trail-gates", "violet", "Wrapping clears your oldest echo, trading distance for space.", 6), artifact("loop-current", "Loop Current", "Wrapping also pushes the spark one extra tile.", "wrap-current", "lime", "An edge crossing carries momentum one additional tile.", 6), artifact("loop-anchors", "Loop Anchors", "Collected pulses become anchors that block later routes.", "wrap-anchor", "coral", "Shortcuts remain available, but collected tiles become obstacles.", 6), artifact("patient-chain", "Patient Chain", "Ordered pulses appear as a compact chain.", "sequence", "gold", "Follow the numbered chain precisely through a denser field.", 6), artifact("patient-reverse", "Patient Reverse", "The ordered sequence runs from highest to lowest.", "reverse", "pink", "Read and collect the sequence in descending order.", 6), artifact("orbit-double", "Double Orbit", "Each pickup turns movement directions halfway around.", "rotate-double", "coral", "After each pickup, every direction is rotated 180 degrees.", 6), artifact("orbit-counter", "Counter Orbit", "Each pickup turns movement directions counterclockwise.", "rotate-counter", "gold", "After each pickup, directions rotate 90 degrees counterclockwise.", 6), artifact("mirror-all", "Full Mirror", "Every command is reversed on both axes.", "mirror-all", "violet", "Up becomes down, down becomes up, left becomes right, and right becomes left.", 6), artifact("mirror-toggle", "Flicker Mirror", "Horizontal mirroring switches on or off after every pickup.", "mirror-toggle", "cyan", "Each collected pulse toggles whether left and right commands are mirrored.", 6), artifact("rhythm-every", "Stone Drum", "Every move leaves the previous tile blocked.", "rhythm-every", "pink", "Each step permanently closes the tile the spark just left.", 6), artifact("rhythm-third", "Stone Waltz", "Every third move leaves the previous tile blocked.", "rhythm-third", "lime", "Only each third step permanently closes the tile the spark just left.", 6) ], constraints: [ { id: "leave-a-trace", title: "Leave a trace", description: "Make every move reshape the route behind you." }, { id: "bend-the-edges", title: "Bend the edges", description: "Turn the boundary into part of the movement strategy." }, { id: "change-the-order", title: "Change the order", description: "Make collection sequence matter." }, { id: "raise-the-stakes", title: "Raise the stakes", description: "Deepen the winning mechanic for one final fork." } ], recipes: [ recipe("seed-trace", "signal-garden", "leave-a-trace", "echo-trail", "tidal-loop", ["Echo Trail makes every visited tile a permanent wall; Tidal Loop keeps visited tiles open and instead wraps at edges.", "Tidal Loop wraps at edges while leaving visited tiles reusable; Echo Trail has ordinary boundaries and accumulating walls."], "a"), recipe("seed-edges", "signal-garden", "bend-the-edges", "patient-pulse", "wild-orbit", ["Patient Pulse keeps commands literal but accepts pickups only in ascending order; Wild Orbit accepts any pickup and rotates later commands.", "Wild Orbit rotates commands clockwise after each pickup while allowing any order; Patient Pulse keeps commands literal and enforces ascending order."], "b"), recipe("seed-order", "signal-garden", "change-the-order", "mirror-moss", "stone-rhythm", ["Mirror Moss reverses every horizontal command but never blocks a departed tile; Stone Rhythm keeps commands literal and blocks every second departed tile.", "Stone Rhythm permanently blocks the previous tile every second move while keeping directions literal; Mirror Moss reverses horizontal commands without adding walls."], "a"), recipe("echo-final", "echo-trail", "raise-the-stakes", "echo-sprint", "echo-gates", ["Echo Sprint permanently blocks every departed tile across six pickups; Echo Gates can reopen the oldest blocked tile by crossing an edge.", "Echo Gates wraps and reopens the oldest trail tile after an edge crossing; Echo Sprint never reopens a wall or wraps."], "a"), recipe("loop-final", "tidal-loop", "raise-the-stakes", "loop-current", "loop-anchors", ["Loop Current carries the spark one additional tile after an edge crossing and leaves pickups traversable; Loop Anchors has normal wrapping and blocks collected tiles.", "Loop Anchors turns collected pulses into permanent obstacles but wraps one tile normally; Loop Current leaves pickups open and adds a second wrap step."], "b"), recipe("patient-final", "patient-pulse", "raise-the-stakes", "patient-chain", "patient-reverse", ["Patient Chain requires a denser six-pulse ascending route; Patient Reverse instead makes the next valid pickup descend from six to one.", "Patient Reverse accepts pickups only from highest to lowest; Patient Chain accepts them only from lowest to highest."], "a"), recipe("orbit-final", "wild-orbit", "raise-the-stakes", "orbit-double", "orbit-counter", ["Double Orbit rotates commands 180 degrees after every pickup; Counter Orbit rotates them 90 degrees counterclockwise.", "Counter Orbit rotates commands 90 degrees counterclockwise after each pickup; Double Orbit turns them 180 degrees."], "b"), recipe("mirror-final", "mirror-moss", "raise-the-stakes", "mirror-all", "mirror-toggle", ["Full Mirror reverses both horizontal and vertical commands for the whole run; Flicker Mirror changes only horizontal commands and toggles after pickups.", "Flicker Mirror toggles horizontal reversal after each pickup while vertical commands stay literal; Full Mirror always reverses both axes."], "a"), recipe("rhythm-final", "stone-rhythm", "raise-the-stakes", "rhythm-every", "rhythm-third", ["Stone Drum permanently blocks the departed tile after every move; Stone Waltz blocks only after every third move.", "Stone Waltz permanently blocks the departed tile every third move; Stone Drum does so after every move."], "b") ] }; function recipe(id, parentArtifactId, constraintId, a, b, divergenceNotes, tieWinnerSlot) { return { id, parentArtifactId, constraintId, tieWinnerSlot, outcomes: [ { slot: "a", artifactId: a, divergenceNote: divergenceNotes[0] }, { slot: "b", artifactId: b, divergenceNote: divergenceNotes[1] } ] }; } const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; export function validateCatalog(value) { const errors = []; if (!value || !Number.isInteger(value.version)) return { valid: false, errors: ["Catalog version is invalid."] }; const groups = ["artifacts", "constraints", "recipes"]; for (const group of groups) if (!Array.isArray(value[group])) errors.push(`${group} must be an array.`); if (errors.length) return { valid: false, errors }; const allIds = new Map(); for (const group of groups) { for (const item of value[group]) { if (!ID.test(item.id)) errors.push(`Invalid authored ID: ${item.id}`); if (allIds.has(item.id)) errors.push(`Duplicate authored ID: ${item.id}`); allIds.set(item.id, group); } } const artifacts = new Set(value.artifacts.map((item) => item.id)); const constraints = new Set(value.constraints.map((item) => item.id)); const supportedModes = new Set(SIGNAL_GARDEN_MODES); for (const definition of value.artifacts) { if (!definition.title?.trim() || !definition.summary?.trim() || !definition.instructions?.trim()) { errors.push(`Artifact copy is incomplete: ${definition.id}`); } if (!definition.parameters || !supportedModes.has(definition.parameters.mode)) { errors.push(`Unsupported renderer mode: ${definition.id}`); } if (!Number.isInteger(definition.parameters?.target) || definition.parameters.target < 1 || definition.parameters.target > 6) { errors.push(`Artifact target is outside playable bounds: ${definition.id}`); } if (!definition.parameters?.accent?.trim() || !definition.parameters?.rule?.trim()) { errors.push(`Artifact renderer parameters are incomplete: ${definition.id}`); } } if (!artifacts.has(value.seedArtifactId)) errors.push("Seed artifact does not exist."); const pairs = new Set(); for (const entry of value.recipes) { const pair = `${entry.parentArtifactId}:${entry.constraintId}`; if (pairs.has(pair)) errors.push(`Duplicate recipe pair: ${pair}`); pairs.add(pair); if (!artifacts.has(entry.parentArtifactId)) errors.push(`Missing recipe parent: ${entry.parentArtifactId}`); if (!constraints.has(entry.constraintId)) errors.push(`Missing constraint: ${entry.constraintId}`); if (!Array.isArray(entry.outcomes) || entry.outcomes.length !== 2) { errors.push(`Recipe ${entry.id} must have exactly two outcomes.`); continue; } if (entry.outcomes[0].slot !== "a" || entry.outcomes[1].slot !== "b") errors.push(`Recipe ${entry.id} outcome order must be a, b.`); if (entry.outcomes[0].artifactId === entry.outcomes[1].artifactId) errors.push(`Recipe ${entry.id} outcomes must differ.`); for (const outcome of entry.outcomes) { if (!artifacts.has(outcome.artifactId)) errors.push(`Missing outcome artifact: ${outcome.artifactId}`); if (!outcome.divergenceNote?.trim()) errors.push(`Missing divergence note: ${entry.id}:${outcome.slot}`); } if (!["a", "b"].includes(entry.tieWinnerSlot)) errors.push(`Invalid tie slot: ${entry.id}`); const recipeFields = Object.keys(entry).sort(); if (recipeFields.join(",") !== "constraintId,id,outcomes,parentArtifactId,tieWinnerSlot") { errors.push(`Recipe ${entry.id} contains unsupported fields.`); } for (const outcome of entry.outcomes ?? []) { if (Object.keys(outcome).sort().join(",") !== "artifactId,divergenceNote,slot") { errors.push(`Recipe ${entry.id}:${outcome.slot} contains unsupported outcome fields.`); } } } const seedChoices = value.recipes.filter((item) => item.parentArtifactId === value.seedArtifactId); if (seedChoices.length < 3) errors.push("Seed requires at least three constraint choices."); for (const entry of seedChoices) { for (const outcome of entry.outcomes ?? []) { if (!value.recipes.some((next) => next.parentArtifactId === outcome.artifactId)) { errors.push(`Seed outcome requires an authored continuation: ${outcome.artifactId}`); } } } const edges = new Map(value.artifacts.map((item) => [item.id, []])); for (const entry of value.recipes) for (const outcome of entry.outcomes ?? []) edges.get(entry.parentArtifactId)?.push(outcome.artifactId); const visiting = new Set(); const visited = new Set(); const visit = (id) => { if (visiting.has(id)) return false; if (visited.has(id)) return true; visiting.add(id); for (const child of edges.get(id) ?? []) if (!visit(child)) return false; visiting.delete(id); visited.add(id); return true; }; if (!visit(value.seedArtifactId)) errors.push("Catalog graph must be acyclic."); return { valid: errors.length === 0, errors }; } export function getArtifact(source, id) { return source.artifacts.find((item) => item.id === id) ?? null; } export function getConstraint(source, id) { return source.constraints.find((item) => item.id === id) ?? null; } export function getRecipe(source, parentArtifactId, constraintId) { return source.recipes.find((item) => item.parentArtifactId === parentArtifactId && item.constraintId === constraintId) ?? null; } export function constraintsFor(source, artifactId) { return source.recipes.filter((item) => item.parentArtifactId === artifactId).map((recipeEntry) => getConstraint(source, recipeEntry.constraintId)); }