File size: 13,618 Bytes
2fe29a7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | 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));
}
|