// conditions.js — declarative, serialisable conditions. No eval, ever: // game specs are JSON, saves are JSON, and both must stay inspectable. // // Condition shapes (all JSON): // { "flag": "metOtto" } flag is truthy // { "not": } // { "all": [...] } { "any": [...] } // { "var": "gold", "eq": 3 } eq|ne|lt|lte|gt|gte // { "has": "mug" } object id in inventory // { "at": "tavern" } player is in room id // { "here": "otto" } character is in current room // { "dialogue": "otto1", "node": "start" } dialogue progress // { "seen": "intro" } scene has completed // // null / undefined condition === always true. // Companion keys allowed alongside the type key: var -> op/value, // dialogue -> node. Anything else next to a type key is an error. const COND_TYPES = { flag: [], not: [], all: [], any: [], var: ["op", "value"], has: [], at: [], here: [], dialogue: ["node"], seen: [], }; export function check(cond, world) { if (cond == null) return true; if (typeof cond !== "object" || Array.isArray(cond)) { throw new TypeError(`bad condition: ${JSON.stringify(cond)}`); } const keys = Object.keys(cond); const types = keys.filter((k) => k in COND_TYPES); if (types.length === 0) { throw new TypeError(`unknown condition key: ${keys.join(",")}`); } if (types.length !== 1) { throw new TypeError(`condition must have exactly one key, got: ${keys.join(",")}`); } const k = types[0]; const extras = keys.filter((x) => x !== k && !COND_TYPES[k].includes(x)); if (extras.length) { throw new TypeError(`condition '${k}' got unexpected key(s): ${extras.join(",")}`); } const v = cond[k]; switch (k) { case "flag": return Boolean(world.flags[v]); case "not": return !check(v, world); case "all": return v.every((c) => check(c, world)); case "any": return v.some((c) => check(c, world)); case "var": { const have = world.vars[cond.var] ?? 0; const want = cond.value; switch (cond.op ?? "eq") { case "eq": return have === want; case "ne": return have !== want; case "lt": return have < want; case "lte": return have <= want; case "gt": return have > want; case "gte": return have >= want; default: throw new TypeError(`unknown var op: ${cond.op}`); } } case "has": return world.inventory.includes(v); case "at": return world.room === v; case "here": return world.characters[v]?.room === world.room; case "dialogue": { const st = world.dialogueState[cond.dialogue]; return st != null && (cond.node == null || st.node === cond.node); } case "seen": return Boolean(world.seenScenes[v]); default: throw new TypeError(`unknown condition key: ${k}`); } } // Collect referenced ids for loader validation (dangling-reference checks). export function conditionRefs(cond, refs = { flags: [], vars: [], objects: [], rooms: [], characters: [], dialogues: [], scenes: [] }) { if (cond == null) return refs; if (cond.flag) refs.flags.push(cond.flag); else if (cond.not) conditionRefs(cond.not, refs); else if (cond.all || cond.any) (cond.all ?? cond.any).forEach((c) => conditionRefs(c, refs)); else if (cond.var) refs.vars.push(cond.var); else if (cond.has) refs.objects.push(cond.has); else if (cond.at) refs.rooms.push(cond.at); else if (cond.here) refs.characters.push(cond.here); else if (cond.dialogue) refs.dialogues.push(cond.dialogue); else if (cond.seen) refs.scenes.push(cond.seen); return refs; }