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