branchjam / src /catalog.js
abhid1234's picture
Upload folder using huggingface_hub
2fe29a7 verified
Raw
History Blame Contribute Delete
13.6 kB
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));
}