From 20ed6da98a140a224f16b0d60387c1a4864342a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 00:35:21 +0000 Subject: [PATCH 1/2] Port the 0.15.0 runtime contract: 207/207 vectors Parlance 0.15.0 changes how a node and its choices are presented, and a 0.14 runtime plays the new shapes wrong with no error. Vectors and schema re-copied from parlance 440369d (the release-prep commit; v0.15.0 is not tagged yet, and PIN says so and must move to the tag commit once it is). - resolve_node is the one skip walk, shared by step_dialogue and advance_node. Only an interstitial gated node (no choices, not isEnd) is skipped; a gated node with choices or isEnd is returned with its line hidden: textHidden true, text "". Both gates are judged on arrival. - step_resolved_node(node, state, project, text_hidden) is the presentation half of the contract's arrival sequence, for a caller that applies onEnter between resolving and presenting. - partition_choices: a fallback is offered only when no non-fallback choice is visible (its own showIf still applies), and lockedChoices lists failing choices whose whenLocked (else rules.choices.whenLockedDefault, else "hide") is "show", with lockedText interpolated. - choose_choice returns {"error": "... not selectable ..."} for a hidden, locked or unoffered-fallback choice, where the reference throws. - Absent node text is tolerated and stays absent; node and choice tags pass through; the engine effect is an explicit no-op in apply_effect. The runner asserts lockedChoiceIds and textHidden (absent = false) on every step vector, text/nodeTags/visibleChoiceTags where stated, and expectedError for choose_choice as for advance. `"project": null` in a vector now maps to {} as the reference harness's asProject does. Mutants on the skippable test, the fallback partition, the selectable check, locked choices, per-choice whenLocked and the blanked hidden text each turn the suite red. Removing the engine case does not: the match has no default arm, so an unknown effect already leaves state unchanged; the case is there to make that a stated decision rather than an accident. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018SVV6KXuhgbARyUvgQKbUP --- README.md | 57 +- addons/parlance/runtime.gd | 290 +++- conformance/PIN | 9 +- conformance/README.md | 53 +- conformance/advance.json | 334 +++++ conformance/apply_effect.json | 87 ++ conformance/choose_choice.json | 2407 ++++++++++++++++++++++--------- conformance/resolve_quests.json | 24 + conformance/step_dialogue.json | 2062 ++++++++++++++++++++++++++ schema/binding.schema.json | 36 + schema/common.schema.json | 26 + schema/dialogue.schema.json | 29 +- schema/rules.schema.json | 74 +- tests/conformance_runner.gd | 96 +- 14 files changed, 4777 insertions(+), 807 deletions(-) create mode 100644 schema/binding.schema.json diff --git a/README.md b/README.md index 4f1e27f..295266b 100644 --- a/README.md +++ b/README.md @@ -11,30 +11,31 @@ git as the single source of truth. This addon reads that JSON and runs it in Godot 4: dialogues, conditions, effects, skill checks (with conditional modifiers), character dialogue offers, -quests, endings. It is verified against Parlance's published conformance +fallback and locked choices, line-only node gates, line and choice tags, engine +commands, quests, endings. It is verified against Parlance's published conformance vectors rather than against its author's confidence. ``` PASS mulberry32 6 vectors PASS evaluate 53 vectors - PASS applyEffect 27 vectors + PASS applyEffect 29 vectors PASS resolveCheck 24 vectors - PASS stepDialogue 11 vectors - PASS chooseChoice 10 vectors - PASS advanceNode 12 vectors + PASS stepDialogue 24 vectors + PASS chooseChoice 17 vectors + PASS advanceNode 14 vectors PASS resolveCharacterDialogue 16 vectors PASS nextContinuations 4 vectors - PASS resolveQuests 6 vectors + PASS resolveQuests 7 vectors PASS progression 13 vectors -182 passed, 0 failed, 0 skipped (not yet ported) +207 passed, 0 failed, 0 skipped (not yet ported) ``` ## Compatibility | parlance-gdscript | Parlance spec | Families | |---|---|---| -| `main` (unreleased) | v0.14.0 — pinned to [`f4a25b0`](conformance/PIN) | 11 of 11 | +| `main` (unreleased) | v0.15.0 (pre-tag) — pinned to [`440369d`](conformance/PIN), the release-prep commit; moves to the tag commit once v0.15.0 is tagged | 11 of 11 | **Versions here are independent of Parlance's**, deliberately, and this table is how the two are tied together. Parlance uses the patch slot itself (`v0.4.3` @@ -42,7 +43,7 @@ exists), so a mirrored version would leave this port no room to release its own fixes without colliding with a spec release. `v1.0.0` means something checkable: complete against the spec, not merely -current with it. As of the v0.14.0 pin every family passes, so `main` meets +current with it. As of the v0.15.0 pin every family passes, so `main` meets that bar; it is not tagged yet. [`conformance/PIN`](conformance/PIN) is the authoritative record of which @@ -68,20 +69,30 @@ const Rng := preload("res://addons/parlance/rng.gd") var project := {"dialogues": {...}, "characters": {...}, "skills": {...}} var state = State.from_dict({}) # or a saved SerializedGameState -# Present a node: filters choices by showIf, interpolates {placeholders}. +# Present a node: skips gated interstitial beats, partitions choices, +# interpolates {placeholders}. Check for "error" (COND-invalid data). var step: Dictionary = Runtime.step_dialogue(dialogue, "node_start", state, project) -print(step["node"]["text"]) -for choice in step["visibleChoices"]: - print(choice["text"]) +var node: Dictionary = step["node"] # may not be the id you asked for +if not step["textHidden"] and node.has("text"): # text is optional on a choice node + print(node["text"]) +for choice in step["visibleChoices"]: # selectable (fallbacks already resolved) + print(choice["text"], choice.get("tags", [])) +for choice in step["lockedChoices"]: # whenLocked "show": grey out, never selectable + print("(locked) ", choice.get("lockedText", choice["text"])) # onEnter effects are RETURNED, not applied. You decide when they fire — -# on first arrival, not on replay. +# on first arrival, not on replay. An `engine` effect changes no state: +# dispatch it yourself, in order among the others. +for effect in step["onEnterEffects"]: + if effect["type"] == "engine": + my_engine.run(effect["command"], effect.get("args", [])) state = Runtime.apply_effects(step["onEnterEffects"], state, project) # Take a choice. Pass a seeded RNG so checks are reproducible. var outcome: Dictionary = Runtime.choose_choice( - dialogue, "node_start", "ch_ask", state, project, Rng.for_step(seed, step_index) + dialogue, node["id"], "ch_ask", state, project, Rng.for_step(seed, step_index) ) +# A hidden, locked or unoffered-fallback choice is refused with "error". state = outcome["newState"] if outcome.has("checkResult"): print(outcome["checkResult"]) # {passed, roll, total, skillValue, dice} @@ -96,6 +107,13 @@ Three things that are easy to get wrong, and are contract rather than style: - **A node with no choices is not necessarily over.** If it has `next`, call `advance_node` — that's a listen-only beat. Treating it as the end silently truncates ambient chains. +- **Resolve a node once per arrival, before its `onEnter`.** `step_dialogue` + judges both node gates (skip an interstitial beat; hide the line of a node + with choices or `isEnd`) against the state it is given. If you apply + `onEnter` before presenting, split it: `resolve_node` and + `node_text_hidden` against the arrival state, apply the effects, then + `step_resolved_node(node, post_state, project, text_hidden)`. Never resolve + again while the player stands on the node. - **Skills start empty by contract.** `createDefaultState` leaves them for the caller to fill from your character's stats or `progression.json`'s `startingSkills`. Skip it and every check rolls at zero. @@ -103,10 +121,15 @@ Three things that are easy to get wrong, and are contract rather than style: ## What's ported Every conformance family: `evaluate`, `applyEffect`, `resolveCheck`, -`stepDialogue`, `chooseChoice`, `advanceNode`, `resolveCharacterDialogue`, +`stepDialogue` (on the shared `resolve_node` walk, with `step_resolved_node` +for the arrival sequence), `chooseChoice`, `advanceNode`, `resolveCharacterDialogue`, `nextContinuations`, `resolveQuests`, the progression functions, and the mulberry32 PRNG. That covers dialogue end to end (gated choices, active skill -checks and their conditional modifiers, effects, character dialogue offers), +checks and their conditional modifiers, effects, character dialogue offers, +fallback choices, locked choices via `whenLocked` / `rules.choices.whenLockedDefault`, +line-only gates reported as `textHidden`, text-less choice nodes, `node.tags` / +`choice.tags` passed through untouched, and `engine` effects returned for the +host to dispatch), what to offer when a scene ends, quest effects, and levelling. `check_bonus`, `passive_check_passes` and `condition_specificity` are exported too: `passive_check_passes` is the passive-check reveal threshold diff --git a/addons/parlance/runtime.gd b/addons/parlance/runtime.gd index 7ada6f6..8033aeb 100644 --- a/addons/parlance/runtime.gd +++ b/addons/parlance/runtime.gd @@ -4,7 +4,8 @@ extends RefCounted ## The Parlance runtime, ported to GDScript. ## ## Ports every conformance family: evaluate, applyEffect, resolveCheck, -## stepDialogue, chooseChoice, advanceNode, resolveCharacterDialogue, +## stepDialogue (with resolveNode and stepResolvedNode), chooseChoice, +## advanceNode, resolveCharacterDialogue, ## nextContinuations, resolveQuests and progression. ## ## `project` is a plain Dictionary in the vectors' MinimalProject shape @@ -12,11 +13,14 @@ extends RefCounted ## mean "no data": an unknown faction applies its delta unclamped, an unknown ## quest is false for every op. Nothing here throws on missing project data. ## -## ERRORS. GDScript has no exceptions, so the one family with a throw contract -## (`advance_node`) returns `{"error": "..."}` instead. Callers must check for -## that key — see the function's own note. `resolve_check` (and so -## `choose_choice`) does the same for a check that declares modifiers but is -## given no project. Every other entry point is total. +## ERRORS. GDScript has no exceptions, so where the reference throws this port +## returns `{"error": "..."}` instead, and callers must check for that key: +## `resolve_node` / `step_dialogue` / `advance_node` on COND-invalid data (a +## missing node, a ring of failing gates, a skipped node with no `next`), +## `advance_node` on a node with no `next`, `choose_choice` on a choice that is +## not selectable (hidden, locked, or an unoffered fallback — contract 0.15.0), +## and `resolve_check` (and so `choose_choice`) on a check that declares +## modifiers but is given no project. Every other entry point is total. const Rng := preload("res://addons/parlance/rng.gd") const State := preload("res://addons/parlance/state.gd") @@ -258,6 +262,12 @@ static func apply_effect(effect, state: State, project := {}) -> State: "set_text": next.texts[effect.get("variable", "")] = effect.get("value", "") + "engine": + # Contract 0.15.0: an engine command changes NO state. It is + # returned in order inside onEnterEffects / a choice's effects and + # the host dispatches on `command`; the runtime never interprets it. + pass + return next @@ -379,81 +389,208 @@ static func parse_dice(notation: String) -> Dictionary: return {"n": 1, "m": 20} +# ------------------------------------------------------------- resolveNode -- + + +## Whether a node may be SKIPPED by a failed `showIf` (contract 0.15.0): an +## interstitial beat, with no choices and not `isEnd`. A gated node with +## choices or `isEnd` is never skipped — skipping it would strand the player — +## and a failed gate there hides only its line (`textHidden`). +static func is_skippable_node(node: Dictionary) -> bool: + var choices = node.get("choices", null) + var has_choices: bool = choices is Array and not choices.is_empty() + return not has_choices and not bool(node.get("isEnd", false)) + + +## Walks past INTERSTITIAL nodes whose `showIf` fails, following `next`, and +## returns the first node actually reached. +## +## THE one place the skip walk lives. Every arrival — `entry`, a `next` +## advance, a choice `goto`, a check's onSuccess/onFailure — goes through +## here, so a conditional node behaves identically however it is reached. A +## skipped node is inert: no text, no onEnter, no transcript entry. +## +## Returns the node Dictionary, or `{"error": ...}` (the reference throws) for +## a missing node, a ring of failing gates, or a skipped node with no `next` — +## all COND-invalid data, reported rather than guessed around. Tell the two +## apart with `result.has("error")`: a real node never carries that key. +static func resolve_node(dialogue: Dictionary, node_id: String, state: State, project := {}) -> Dictionary: + var seen := {} + var current_id := node_id + while true: + var node: Variant = find_node(dialogue, current_id) + if node == null: + return {"error": "node '%s' does not exist in dialogue '%s'" % [current_id, dialogue.get("id", "?")]} + if not node.has("showIf") or not is_skippable_node(node) or evaluate(node["showIf"], state, project): + return node + if seen.has(current_id): + return {"error": "Cycle among conditional nodes in dialogue '%s' at '%s': resolution cannot escape" % [dialogue.get("id", "?"), current_id]} + seen[current_id] = true + if not node.has("next"): + return {"error": "conditional node '%s' has showIf but no 'next' to skip to" % current_id} + current_id = str(node["next"]) + return {} # unreachable; satisfies the parser + + # ----------------------------------------------------------- stepDialogue -- -## Prepares a node for display: filters choices by `showIf` and interpolates -## player-facing text. +## Whether a node's LINE is withheld at this state (contract 0.15.0): a failed +## `showIf` on a node that is not skippable. Judge it against the ARRIVAL +## state — before the node's own onEnter — exactly like the skip gate. +static func node_text_hidden(node: Dictionary, state: State, project := {}) -> bool: + return node.has("showIf") and not is_skippable_node(node) and not evaluate(node["showIf"], state, project) + + +## How a choice whose `showIf` fails is presented: the choice's `whenLocked`, +## else the project's `rules.choices.whenLockedDefault`, else "hide". +static func resolve_when_locked(choice: Dictionary, project := {}) -> String: + if choice.has("whenLocked"): + return str(choice["whenLocked"]) + var rules = project.get("rules", null) + if rules is Dictionary and rules.get("choices", null) is Dictionary and rules["choices"].has("whenLockedDefault"): + return str(rules["choices"]["whenLockedDefault"]) + return "hide" + + +## Partitions a node's choices into the selectable set and the +## locked-but-shown set (contract 0.15.0). THE one place the fallback and +## whenLocked rules live; step_dialogue and choose_choice both read it, so a +## choice can never be selectable in one and not the other. +## +## visibleChoices — passing non-fallback choices; or, ONLY when that set is +## empty, passing fallback choices (a fallback's own showIf +## still applies). Authored order. +## lockedChoices — failing choices whose whenLocked resolves to "show". +## +## Returns the AUTHORED choice Dictionaries (not interpolated copies). +static func partition_choices(node: Dictionary, state: State, project := {}) -> Dictionary: + var all: Array = [] + for c in node.get("choices", []): + if c is Dictionary: + all.append(c) + var passes: Array = [] + var any_primary := false + for c in all: + var p: bool = not c.has("showIf") or evaluate(c["showIf"], state, project) + passes.append(p) + if p and not bool(c.get("fallback", false)): + any_primary = true + var visible: Array = [] + var locked: Array = [] + for i in all.size(): + var c: Dictionary = all[i] + if passes[i]: + if bool(c.get("fallback", false)) != any_primary: + visible.append(c) + elif resolve_when_locked(c, project) == "show": + locked.append(c) + return {"visibleChoices": visible, "lockedChoices": locked} + + +## Prepares a node for display: resolves it (skip walk), partitions its +## choices, applies the line gate and interpolates player-facing text. ## -## Returns `{"node", "visibleChoices", "onEnterEffects", "error"}`. +## Returns `{"node", "visibleChoices", "lockedChoices", "textHidden", +## "onEnterEffects"}`, or `{"error"}` when resolution fails (see resolve_node). +## The returned node may NOT be the one requested — read its id. ## ## CALLER RESPONSIBILITY: `onEnterEffects` are RETURNED, NOT APPLIED. The ## caller decides when they fire (on first arrival; not on replay). Applying -## them here would double-fire every effect on a rewind. +## them here would double-fire every effect on a rewind. A caller that applies +## them before presenting should use resolve_node + step_resolved_node instead. static func step_dialogue(dialogue: Dictionary, node_id: String, state: State, project := {}) -> Dictionary: - var node: Variant = find_node(dialogue, node_id) - if node == null: - return {"error": "node '%s' does not exist in dialogue '%s'" % [node_id, dialogue.get("id", "?")]} + var node := resolve_node(dialogue, node_id, state, project) + if node.has("error"): + return node + return step_resolved_node(node, state, project) - # Node-level showIf skip walk (contract 0.11.0): stepping onto a gated node - # whose gate fails resolves to the node the player actually sees — the same - # skip advance_node performs. A ring of failing gates is COND-invalid and is - # reported rather than looped. - var seen := {} - while node.has("showIf") and not evaluate(node["showIf"], state, project): - var here := str(node.get("id", node_id)) - if seen.has(here): - return {"error": "Cycle among conditional nodes: resolution cannot escape"} - seen[here] = true - if not node.has("next"): - return {"error": "conditional node '%s' has no 'next' to skip to" % here} - var next_id := str(node["next"]) - var next_node: Variant = find_node(dialogue, next_id) - if next_node == null: - return {"error": "next target '%s' does not exist in dialogue '%s'" % [next_id, dialogue.get("id", "?")]} - node = next_node - var visible: Array = [] - for choice in node.get("choices", []): - if not choice is Dictionary: - continue - if choice.has("showIf") and not evaluate(choice["showIf"], state, project): - continue - # Copy only when a placeholder was actually substituted, so callers - # comparing node identity to detect edits keep working. - var text := str(choice.get("text", "")) - var rendered := Interp.interpolate(text, state) - if rendered == text: - visible.append(choice) - else: - var c: Dictionary = choice.duplicate(true) - c["text"] = rendered - visible.append(c) +## The presentation half of an arrival, for a node ALREADY resolved. +## +## The arrival sequence (RUNTIME_CONTRACT "resolve once, and only once"): +## 1. `var node := resolve_node(...)` and +## `var hidden := node_text_hidden(node, arrival_state, project)` — +## both gates, judged once, against the ARRIVAL state; +## 2. apply `node.onEnter` (and any quest resolution it triggers); +## 3. `step_resolved_node(node, post_state, project, hidden)` — choices are +## filtered and text interpolated against the post-onEnter state; +## 4. never resolve again while the player stands on the node. +## +## `text_hidden` is the arrival-state line gate. Pass it whenever `state` is +## not the arrival state: a node whose own onEnter fails its own showIf still +## shows its line. Omitted (null), it is judged against `state` — correct only +## when no effects were applied between resolving and presenting, which is +## what step_dialogue does. +static func step_resolved_node(node: Dictionary, state: State, project := {}, text_hidden = null) -> Dictionary: + var hidden: bool = node_text_hidden(node, state, project) if text_hidden == null else bool(text_hidden) + var parts := partition_choices(node, state, project) + var visible: Array = [] + for c in parts["visibleChoices"]: + visible.append(_interpolate_choice(c, state)) + var locked: Array = [] + for c in parts["lockedChoices"]: + locked.append(_interpolate_choice(c, state)) + + # Copy only when a string actually changes, so callers comparing node + # identity to detect edits keep working. A text-less node (0.15) keeps its + # `text` ABSENT — not "" — and is never textHidden unless gated. var out_node := node - var node_text := str(node.get("text", "")) - var rendered_text := Interp.interpolate(node_text, state) - if rendered_text != node_text: + if hidden: out_node = node.duplicate(true) - out_node["text"] = rendered_text + out_node["text"] = "" + elif node.has("text"): + var node_text := str(node["text"]) + var rendered_text := Interp.interpolate(node_text, state) + if rendered_text != node_text: + out_node = node.duplicate(true) + out_node["text"] = rendered_text return { "node": out_node, "visibleChoices": visible, + "lockedChoices": locked, + "textHidden": hidden, "onEnterEffects": node.get("onEnter", []), } +## A choice with `text` (and `lockedText`) interpolated; the same Dictionary +## when nothing was substituted. Tags and every other key pass through. +static func _interpolate_choice(choice: Dictionary, state: State) -> Dictionary: + var text := str(choice.get("text", "")) + var rendered := Interp.interpolate(text, state) + var locked_changed := false + var locked_rendered := "" + if choice.has("lockedText"): + var lt := str(choice["lockedText"]) + locked_rendered = Interp.interpolate(lt, state) + locked_changed = locked_rendered != lt + if rendered == text and not locked_changed: + return choice + var c: Dictionary = choice.duplicate(true) + c["text"] = rendered + if locked_changed: + c["lockedText"] = locked_rendered + return c + + # ----------------------------------------------------------- chooseChoice -- ## Applies a choice's effects, resolves its check if active, and reports where ## to go next. ## -## Returns `{"nextNodeId", "newState", "checkResult"?, "error"?}`. +## Returns `{"nextNodeId", "newState", "checkResult"?}` or `{"error"}`. ## `nextNodeId` is null for a terminal choice (no goto, no check). ## `checkResult` is present ONLY for an active check — a passive check is a ## plain goto and never rolls. +## +## NOT SELECTABLE IS AN ERROR (contract 0.15.0): a choice whose showIf fails +## (hidden or locked), or a fallback while a non-fallback choice is visible, +## returns `{"error": "... not selectable ..."}` — the reference throws. Same +## partition as step_dialogue, so pass the state you presented from. static func choose_choice(dialogue: Dictionary, node_id: String, choice_id: String, state: State, project := {}, rng := Callable()) -> Dictionary: var node: Variant = find_node(dialogue, node_id) if node == null: @@ -467,6 +604,14 @@ static func choose_choice(dialogue: Dictionary, node_id: String, choice_id: Stri if choice == null: return {"error": "choice '%s' does not exist on node '%s'" % [choice_id, node_id]} + var selectable := false + for c in partition_choices(node, state, project)["visibleChoices"]: + if is_same(c, choice): + selectable = true + break + if not selectable: + return {"error": "Choice '%s' in node '%s' is not selectable at this state (hidden, locked, or an unoffered fallback)" % [choice_id, node_id]} + var next_state := apply_effects(choice.get("effects", []), state, project) var check = choice.get("check", null) @@ -495,20 +640,22 @@ static func choose_choice(dialogue: Dictionary, node_id: String, choice_id: Stri ## Resolves `node.next` — the choiceless counterpart of choose_choice, for ## listen-only beats that advance with no player choice. ## -## THIS IS THE ONE FUNCTION WITH A FAILURE CONTRACT. The reference throws; -## GDScript has no exceptions, so this returns `{"error": "..."}` and the -## conformance runner treats that as the throw. Callers MUST check for it. -## Silence here would hide an upstream bug: the validator's FLOW checks and the -## client both prevent constructing this call on a node with no `next`. +## FAILURE CONTRACT. The reference throws; GDScript has no exceptions, so this +## returns `{"error": "..."}` and the conformance runner treats that as the +## throw. Callers MUST check for it. Silence here would hide an upstream bug: +## the validator's FLOW checks and the client both prevent constructing this +## call on a node with no `next`. ## ## No effects are applied and no check is resolved — `next` carries neither, so ## `newState` is the input state unchanged. Effects live on the TARGET's ## `onEnter` and are the caller's job on arrival, exactly as for a goto. That ## is what makes an advance-arrival and a goto-arrival identical. ## -## Resolves exactly ONE hop. A runtime that auto-chased `next` would collapse a +## Resolves exactly ONE hop, then runs the shared resolve_node walk so the id +## returned is the node the player will actually see (only interstitial gated +## nodes are skipped). A runtime that auto-chased `next` would collapse a ## whole ambient run into one uninterruptible jump. -static func advance_node(dialogue: Dictionary, node_id: String, state: State) -> Dictionary: +static func advance_node(dialogue: Dictionary, node_id: String, state: State, project := {}) -> Dictionary: var node: Variant = find_node(dialogue, node_id) if node == null: return {"error": "node '%s' does not exist in dialogue '%s'" % [node_id, dialogue.get("id", "?")]} @@ -517,28 +664,13 @@ static func advance_node(dialogue: Dictionary, node_id: String, state: State) -> return {"error": "node '%s' has no 'next' to advance from" % node_id} var target_id := str(node["next"]) + if find_node(dialogue, target_id) == null: + return {"error": "node '%s' has next '%s', which does not exist in dialogue '%s'" % [node_id, target_id, dialogue.get("id", "?")]} - # Node-level showIf skip walk (contract 0.11.0): cross gated nodes whose gate - # fails, following each one's `next`, until a shown node is reached. A ring of - # failing gates is COND-invalid data and is reported, not looped. onEnter does - # not fire here — advance is navigation, exactly as the ungated path is. - var visited := {} - while true: - var target_node: Variant = find_node(dialogue, target_id) - if target_node == null: - return {"error": "next target '%s' does not exist in dialogue '%s'" % [target_id, dialogue.get("id", "?")]} - if not target_node.has("showIf"): - break - if evaluate(target_node["showIf"], state, {}): - break - if visited.has(target_id): - return {"error": "Cycle among conditional nodes: resolution cannot escape"} - visited[target_id] = true - if not target_node.has("next"): - return {"error": "conditional node '%s' has no 'next' to skip to" % target_id} - target_id = str(target_node["next"]) - - return {"nextNodeId": target_id, "newState": state} + var resolved := resolve_node(dialogue, target_id, state, project) + if resolved.has("error"): + return resolved + return {"nextNodeId": resolved.get("id", null), "newState": state} # ----------------------------------------- resolveCharacterDialogue (offers) -- diff --git a/conformance/PIN b/conformance/PIN index e10143c..e196c7b 100644 --- a/conformance/PIN +++ b/conformance/PIN @@ -3,7 +3,12 @@ # These vectors are a PINNED COPY, not a fork. Never hand-edit them: they are # generated from and asserted against the reference implementation, so an edit # here makes this port chase a ghost. Re-copy from the named ref instead. -parlanceVersion=v0.14.0 -parlanceCommit=f4a25b0d94ca8569a2897e8354a3f1317f826a82 +# PRE-TAG: v0.15.0 is not tagged yet. The commit below is the release-prep +# commit on parlance branch claude/release-0-15-prep-mi2lm0, whose contract is +# what v0.15.0 will tag. Once the tag exists, move parlanceCommit to the +# v0.15.0 tag commit (re-copy and re-run if the contract moved) and delete +# this comment. +parlanceVersion=v0.15.0 +parlanceCommit=440369d0af4a9f829a525843c16f7762e1d18d32 parlanceRepo=git@github.com:Orbitope/parlance.git # diff --git a/conformance/README.md b/conformance/README.md index 596ef39..c840599 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -17,15 +17,15 @@ Counts below drift every time vectors are added, so treat them as indicative and | File | Function | Vectors | |---|---|---| | `evaluate.json` | `evaluate(condition, state, project)` | 53 | -| `apply_effect.json` | `applyEffect(effect, state, project)` | 27 | +| `apply_effect.json` | `applyEffect(effect, state, project)` | 29 | | `resolve_check.json` | `resolveCheck(check, state, rng, defaultDice?, criticals?, project?)` | 24 | -| `step_dialogue.json` | `stepDialogue(dialogue, nodeId, state, project)` | 11 | -| `choose_choice.json` | `chooseChoice(dialogue, nodeId, choiceId, state, project, rng)` | 10 | -| `advance.json` | `advanceNode(dialogue, nodeId, state, project)` | 12 | +| `step_dialogue.json` | `stepDialogue(dialogue, nodeId, state, project)` | 24 | +| `choose_choice.json` | `chooseChoice(dialogue, nodeId, choiceId, state, project, rng)` | 17 | +| `advance.json` | `advanceNode(dialogue, nodeId, state, project)` | 14 | | `resolveCharacterDialogue.json` | `resolveCharacterDialogue(state, character, project, visited?)` | 16 | | `nextContinuations.json` | `nextContinuations(state, project, visited, currentDialogueId)` — forced routing vs discovery | 4 | | `progression.json` | `levelForXp` / `pointsEarned` / `availablePoints` / `investSkillPoint` / `recomputeSkills` | 13 | -| `resolve_quests.json` | `resolveQuests(state, project)` | 6 | +| `resolve_quests.json` | `resolveQuests(state, project)` | 7 | | `rng.json` | mulberry32(seed) output stream — the seeded PRNG ports must reproduce exactly | 6 | ### `migrate_ladders/` — vectors for the 0.13 → 0.14 ladder migration @@ -53,9 +53,11 @@ agrees. See `validator/README.md` for the case format. Any runtime file may carry failure cases: a vector with `expectedError` (a substring the thrown error's message must contain) instead of `expected` means the call must -throw. `advance.json` carries them for a missing `next` and for a ring of conditional -nodes whose gates all fail — both validator-guaranteed impossibilities, thrown rather -than absorbed because reaching them means the data never passed validation. +throw. Two files carry them. `advance.json` has them for a missing `next` and for a +ring of conditional nodes whose gates all fail — both validator-guaranteed +impossibilities, thrown rather than absorbed because reaching them means the data never +passed validation. `choose_choice.json` (0.15) has them for a choice that is not +selectable: hidden, locked, or an unoffered fallback. ## Vector format @@ -86,7 +88,7 @@ Each file is a JSON array. Each element is a self-contained test vector with the // Expected output — exactly ONE of these two is present: "expected": varies by fn // see per-function sections below - "expectedError": string // advanceNode only — see Error cases below + "expectedError": string // advanceNode / chooseChoice only — see their sections } ``` @@ -194,10 +196,30 @@ below: without it, a runtime that never implements the skip walk still passes ev whose skipped node carries no `onEnter` — the *typical* gated narration line, since a conditional node is choiceless by construction. Older vectors omit the field. -`visibleChoiceIds` is the ordered list of choice ids that pass `showIf` filtering. +`visibleChoiceIds` is the ordered list of SELECTABLE choice ids: those that pass +`showIf`, with a `fallback` choice included only when no non-fallback choice passes. + +`lockedChoiceIds` (0.15, present on **every** vector — `[]` on the older ones) is the +ordered list of choices whose `showIf` failed and whose `whenLocked` resolves to `"show"` +(per-choice, else the vector's `project.rules.choices.whenLockedDefault`, else `"hide"`). +A port that ignores locked choices fails the vectors that expect a non-empty list, which +is the point: `lockedChoices` is opt-in for an ENGINE's presentation, not for conformance. + +`textHidden` (0.15, optional — absent means `false`) is true when the node's own `showIf` +failed on a node with `choices` or `isEnd`: the node is NOT skipped, its line is hidden. +`text`, when present, is the returned `node.text` — `""` for a hidden line, `null` for a +text-less node (whose `text` is absent). Check both: a port that still skips a gated +choice node fails on `nodeId`, and one that shows the hidden line fails on `text`. `onEnterEffects` is `node.onEnter` in order — the effects are returned but **not applied**, which is the caller's responsibility. +Since 0.15 a vector may also state `nodeTags` (the resolved node's `tags`, exactly as +authored) and `visibleChoiceTags` (per visible choice, its `tags` or `null`, in +`visibleChoiceIds` order). Tags are opaque pass-through, so check them when present: +a port that strips, sorts or dedupes them fails. The same vector puts an `engine` +effect among the `onEnterEffects`. It is returned in position like any effect, and +`apply_effect.json` pins that applying it changes nothing. + `onEnterEffectCount` is that list's length, kept for ports already checking it. **Check `onEnterEffects`, not the count.** The count alone is satisfied by returning the *choice's* effects instead of the node's, by returning them in @@ -217,6 +239,10 @@ wrong thing to the player's state while reporting conformance. `checkResult` is only present for active check vectors. `nextNodeId` is `null` for terminal choices (no `goto`, no `check`). +A vector with `expectedError` instead of `expected` (0.15) must THROW with a message +containing that string: choosing a hidden choice, a locked one, or a `fallback` while a +non-fallback choice is visible is not selectable. Same convention as `advance.json`. + ### advanceNode ```json { "expected": { "nextNodeId": "node_end", "newState": SerializedGameState } } @@ -232,8 +258,9 @@ returned `nextNodeId` is **post-skip**: the target is resolved through any faili exactly one *shown* beat — skipped nodes are inert, not beats. **Error cases.** A vector with `expectedError` instead of `expected` means the call must -throw; `expectedError` is a substring the thrown error's message must contain. This is the -one function in the suite with a failure contract — call it only on a node that declares +throw; `expectedError` is a substring the thrown error's message must contain. It is one +of two functions in the suite with a failure contract (the other is `chooseChoice`) — +call it only on a node that declares `next` (the validator's FLOW checks and the client both prevent constructing the call otherwise); reaching it anyway is a bug upstream, not a `Problem`/result-object case. @@ -290,7 +317,7 @@ order; `expected.state.questFired` is the sorted record. 2. For each vector, deserialize `state` by converting `inventory: string[]` to your language's equivalent of `Set`. 3. Call your implementation of the named function with the provided inputs. -4. If the vector carries `expectedError` (advanceNode only): assert the call throws / +4. If the vector carries `expectedError` (advanceNode and chooseChoice only): assert the call throws / returns an error whose message contains that substring, and stop — there is no output state to compare. 5. Otherwise, serialize the output state (if any) back to `{ ..., inventory: string[] }` diff --git a/conformance/advance.json b/conformance/advance.json index 75259c0..d2dc3fe 100644 --- a/conformance/advance.json +++ b/conformance/advance.json @@ -588,5 +588,339 @@ "skills": {}, "xp": 0 } + }, + { + "description": "line-only gate (0.15): advance into an isEnd node whose gate FAILS — it is the node the player lands on (never skipped, never a throw); its line is hidden by stepDialogue", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ], + "id": "node_open", + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "And you've seen the ledger. I can tell." + }, + { + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ], + "id": "node_options" + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "lockedText": "[Requires the key]", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ], + "id": "node_door", + "text": "The door." + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ], + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing." + }, + { + "id": "node_last", + "isEnd": true, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "You think of the cup, still warm." + }, + { + "id": "node_hop", + "next": "node_last", + "text": "A beat." + }, + { + "id": "node_hop_open", + "next": "node_open", + "text": "Another beat." + } + ] + }, + "expected": { + "newState": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + }, + "nextNodeId": "node_last" + }, + "fn": "advanceNode", + "nodeId": "node_hop", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } + }, + { + "description": "line-only gate: advance into a node with choices whose gate FAILS — same: reached, not skipped", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ], + "id": "node_open", + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "And you've seen the ledger. I can tell." + }, + { + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ], + "id": "node_options" + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "lockedText": "[Requires the key]", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ], + "id": "node_door", + "text": "The door." + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ], + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing." + }, + { + "id": "node_last", + "isEnd": true, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "You think of the cup, still warm." + }, + { + "id": "node_hop", + "next": "node_last", + "text": "A beat." + }, + { + "id": "node_hop_open", + "next": "node_open", + "text": "Another beat." + } + ] + }, + "expected": { + "newState": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + }, + "nextNodeId": "node_open" + }, + "fn": "advanceNode", + "nodeId": "node_hop_open", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } } ] diff --git a/conformance/apply_effect.json b/conformance/apply_effect.json index 739cb87..8507e00 100644 --- a/conformance/apply_effect.json +++ b/conformance/apply_effect.json @@ -981,5 +981,92 @@ "npc_a": -3 } } + }, + { + "fn": "applyEffect", + "description": "engine: an engine command changes NO state (the host dispatches on it; the runtime never interprets it)", + "state": { + "flags": { + "seen": true + }, + "reputation": {}, + "skills": {}, + "counters": { + "coins": 3 + }, + "inventory": [ + "key" + ], + "questStages": {}, + "xp": 40, + "skillPointsSpent": {}, + "texts": { + "player_name": "Wren" + }, + "pendingCutscene": "cs_intro" + }, + "effect": { + "type": "engine", + "command": "shake", + "args": [ + 0.5, + "camera_main", + true + ] + }, + "project": { + "factions": {} + }, + "expected": { + "flags": { + "seen": true + }, + "reputation": {}, + "skills": {}, + "counters": { + "coins": 3 + }, + "inventory": [ + "key" + ], + "questStages": {}, + "xp": 40, + "skillPointsSpent": {}, + "texts": { + "player_name": "Wren" + }, + "pendingCutscene": "cs_intro" + } + }, + { + "fn": "applyEffect", + "description": "engine: with no args the state is likewise untouched", + "state": { + "flags": {}, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "effect": { + "type": "engine", + "command": "fade_out" + }, + "project": { + "factions": {} + }, + "expected": { + "flags": {}, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + } } ] diff --git a/conformance/choose_choice.json b/conformance/choose_choice.json index 3d5199c..bb947d1 100644 --- a/conformance/choose_choice.json +++ b/conformance/choose_choice.json @@ -1,716 +1,1791 @@ [ - { - "fn": "chooseChoice", - "description": "goto: advances to target node, state unchanged", - "dialogue": { - "id": "dlg_conformance", - "entry": "node_start", - "nodes": [ - { - "id": "node_start", - "text": "Start.", - "choices": [ - { - "id": "ch_goto", - "text": "Proceed.", - "goto": "node_end" - } - ] - }, - { - "id": "node_end", - "isEnd": true, - "text": "End.", - "choices": [ - { - "id": "ch_terminal", - "text": "Done." + { + "fn": "chooseChoice", + "description": "goto: advances to target node, state unchanged", + "dialogue": { + "id": "dlg_conformance", + "entry": "node_start", + "nodes": [ + { + "id": "node_start", + "text": "Start.", + "choices": [ + { + "id": "ch_goto", + "text": "Proceed.", + "goto": "node_end" + } + ] + }, + { + "id": "node_end", + "isEnd": true, + "text": "End.", + "choices": [ + { + "id": "ch_terminal", + "text": "Done." + } + ] + } + ] + }, + "nodeId": "node_start", + "choiceId": "ch_goto", + "state": { + "flags": {}, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "project": { + "factions": {} + }, + "expected": { + "nextNodeId": "node_end", + "newState": { + "flags": {}, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} } - ] } - ] }, - "nodeId": "node_start", - "choiceId": "ch_goto", - "state": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "project": { - "factions": {} - }, - "expected": { - "nextNodeId": "node_end", - "newState": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - } - } - }, - { - "fn": "chooseChoice", - "description": "goto with effects: effect applied before advancing", - "dialogue": { - "id": "dlg_conformance", - "entry": "node_start", - "nodes": [ - { - "id": "node_start", - "text": "Start.", - "choices": [ - { - "id": "ch_flag_goto", - "text": "Flag and go.", - "goto": "node_end", - "effects": [ - { - "type": "set_flag", - "flag": "took_path", - "value": true + { + "fn": "chooseChoice", + "description": "goto with effects: effect applied before advancing", + "dialogue": { + "id": "dlg_conformance", + "entry": "node_start", + "nodes": [ + { + "id": "node_start", + "text": "Start.", + "choices": [ + { + "id": "ch_flag_goto", + "text": "Flag and go.", + "goto": "node_end", + "effects": [ + { + "type": "set_flag", + "flag": "took_path", + "value": true + } + ] + } + ] + }, + { + "id": "node_end", + "isEnd": true, + "text": "End." } - ] - } - ] + ] + }, + "nodeId": "node_start", + "choiceId": "ch_flag_goto", + "state": { + "flags": {}, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} }, - { - "id": "node_end", - "isEnd": true, - "text": "End." + "project": { + "factions": {} + }, + "expected": { + "nextNodeId": "node_end", + "newState": { + "flags": { + "took_path": true + }, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + } } - ] - }, - "nodeId": "node_start", - "choiceId": "ch_flag_goto", - "state": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} }, - "project": { - "factions": {} - }, - "expected": { - "nextNodeId": "node_end", - "newState": { - "flags": { - "took_path": true - }, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - } - } - }, - { - "fn": "chooseChoice", - "description": "active check pass: rng=0.2, roll=5, wit=5, total=10, DC=10 → success branch", - "dialogue": { - "id": "dlg_conformance", - "entry": "node_start", - "nodes": [ - { - "id": "node_start", - "text": "Start.", - "choices": [ - { - "id": "ch_check", - "text": "Roll.", - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 10, - "onSuccess": "node_win", - "onFailure": "node_lose" - } - } - ] + { + "fn": "chooseChoice", + "description": "active check pass: rng=0.2, roll=5, wit=5, total=10, DC=10 → success branch", + "dialogue": { + "id": "dlg_conformance", + "entry": "node_start", + "nodes": [ + { + "id": "node_start", + "text": "Start.", + "choices": [ + { + "id": "ch_check", + "text": "Roll.", + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 10, + "onSuccess": "node_win", + "onFailure": "node_lose" + } + } + ] + }, + { + "id": "node_win", + "isEnd": true, + "text": "Won." + }, + { + "id": "node_lose", + "isEnd": true, + "text": "Lost." + } + ] + }, + "nodeId": "node_start", + "choiceId": "ch_check", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 5 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} }, - { - "id": "node_win", - "isEnd": true, - "text": "Won." + "project": { + "factions": {} }, - { - "id": "node_lose", - "isEnd": true, - "text": "Lost." + "rng": 0.2, + "expected": { + "nextNodeId": "node_win", + "newState": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 5 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "checkResult": { + "passed": true, + "roll": 5, + "total": 10, + "skillValue": 5, + "dice": "1d20" + } } - ] - }, - "nodeId": "node_start", - "choiceId": "ch_check", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 5 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} }, - "project": { - "factions": {} - }, - "rng": 0.2, - "expected": { - "nextNodeId": "node_win", - "newState": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 5 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "checkResult": { - "passed": true, - "roll": 5, - "total": 10, - "skillValue": 5, - "dice": "1d20" - } - } - }, - { - "fn": "chooseChoice", - "description": "active check fail: rng=0.0, roll=1, wit=0, total=1, DC=10 → failure branch", - "dialogue": { - "id": "dlg_conformance", - "entry": "node_start", - "nodes": [ - { - "id": "node_start", - "text": "Start.", - "choices": [ - { - "id": "ch_check", - "text": "Roll.", - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 10, - "onSuccess": "node_win", - "onFailure": "node_lose" - } - } - ] + { + "fn": "chooseChoice", + "description": "active check fail: rng=0.0, roll=1, wit=0, total=1, DC=10 → failure branch", + "dialogue": { + "id": "dlg_conformance", + "entry": "node_start", + "nodes": [ + { + "id": "node_start", + "text": "Start.", + "choices": [ + { + "id": "ch_check", + "text": "Roll.", + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 10, + "onSuccess": "node_win", + "onFailure": "node_lose" + } + } + ] + }, + { + "id": "node_win", + "isEnd": true, + "text": "Won." + }, + { + "id": "node_lose", + "isEnd": true, + "text": "Lost." + } + ] + }, + "nodeId": "node_start", + "choiceId": "ch_check", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} }, - { - "id": "node_win", - "isEnd": true, - "text": "Won." + "project": { + "factions": {} }, - { - "id": "node_lose", - "isEnd": true, - "text": "Lost." + "rng": 0, + "expected": { + "nextNodeId": "node_lose", + "newState": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "checkResult": { + "passed": false, + "roll": 1, + "total": 1, + "skillValue": 0, + "dice": "1d20" + } } - ] - }, - "nodeId": "node_start", - "choiceId": "ch_check", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 0 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} }, - "project": { - "factions": {} - }, - "rng": 0, - "expected": { - "nextNodeId": "node_lose", - "newState": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 0 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "checkResult": { - "passed": false, - "roll": 1, - "total": 1, - "skillValue": 0, - "dice": "1d20" - } - } - }, - { - "fn": "chooseChoice", - "description": "active check with pre-choice effect: effect applied before roll, skill read from post-effect state", - "dialogue": { - "id": "dlg_conformance", - "entry": "node_start", - "nodes": [ - { - "id": "node_start", - "text": "Start.", - "choices": [ - { - "id": "ch_effect_then_check", - "text": "Buff and roll.", - "effects": [ - { - "type": "set_flag", - "flag": "prepared", - "value": true + { + "fn": "chooseChoice", + "description": "active check with pre-choice effect: effect applied before roll, skill read from post-effect state", + "dialogue": { + "id": "dlg_conformance", + "entry": "node_start", + "nodes": [ + { + "id": "node_start", + "text": "Start.", + "choices": [ + { + "id": "ch_effect_then_check", + "text": "Buff and roll.", + "effects": [ + { + "type": "set_flag", + "flag": "prepared", + "value": true + } + ], + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 10, + "onSuccess": "node_win", + "onFailure": "node_lose" + } + } + ] + }, + { + "id": "node_win", + "isEnd": true, + "text": "Won." + }, + { + "id": "node_lose", + "isEnd": true, + "text": "Lost." } - ], - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 10, - "onSuccess": "node_win", - "onFailure": "node_lose" - } - } - ] + ] }, - { - "id": "node_win", - "isEnd": true, - "text": "Won." + "nodeId": "node_start", + "choiceId": "ch_effect_then_check", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 5 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} }, - { - "id": "node_lose", - "isEnd": true, - "text": "Lost." + "project": { + "factions": {} + }, + "rng": 0.2, + "expected": { + "nextNodeId": "node_win", + "newState": { + "flags": { + "prepared": true + }, + "reputation": {}, + "skills": { + "wit": 5 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "checkResult": { + "passed": true, + "roll": 5, + "total": 10, + "skillValue": 5, + "dice": "1d20" + } } - ] }, - "nodeId": "node_start", - "choiceId": "ch_effect_then_check", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 5 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} + { + "fn": "chooseChoice", + "description": "passive check: treated as plain goto, no roll, no checkResult", + "dialogue": { + "id": "dlg_conformance", + "entry": "node_start", + "nodes": [ + { + "id": "node_start", + "text": "Start.", + "choices": [ + { + "id": "ch_passive", + "text": "Passive.", + "goto": "node_win", + "check": { + "mode": "passive", + "skill": "wit", + "difficulty": 5 + } + } + ] + }, + { + "id": "node_win", + "isEnd": true, + "text": "Won." + } + ] + }, + "nodeId": "node_start", + "choiceId": "ch_passive", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "project": { + "factions": {} + }, + "expected": { + "nextNodeId": "node_win", + "newState": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + } + } }, - "project": { - "factions": {} + { + "fn": "chooseChoice", + "description": "terminal choice (no goto, no check on isEnd node): nextNodeId is null", + "dialogue": { + "id": "dlg_conformance", + "entry": "node_end", + "nodes": [ + { + "id": "node_end", + "isEnd": true, + "text": "End.", + "choices": [ + { + "id": "ch_terminal", + "text": "Done." + } + ] + } + ] + }, + "nodeId": "node_end", + "choiceId": "ch_terminal", + "state": { + "flags": {}, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "project": { + "factions": {} + }, + "expected": { + "nextNodeId": null, + "newState": { + "flags": {}, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + } + } }, - "rng": 0.2, - "expected": { - "nextNodeId": "node_win", - "newState": { - "flags": { - "prepared": true - }, - "reputation": {}, - "skills": { - "wit": 5 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "checkResult": { - "passed": true, - "roll": 5, - "total": 10, - "skillValue": 5, - "dice": "1d20" - } - } - }, - { - "fn": "chooseChoice", - "description": "passive check: treated as plain goto, no roll, no checkResult", - "dialogue": { - "id": "dlg_conformance", - "entry": "node_start", - "nodes": [ - { - "id": "node_start", - "text": "Start.", - "choices": [ - { - "id": "ch_passive", - "text": "Passive.", - "goto": "node_win", - "check": { - "mode": "passive", - "skill": "wit", - "difficulty": 5 - } + { + "fn": "chooseChoice", + "description": "multiple effects threaded in order: flag then reputation", + "dialogue": { + "id": "dlg_conformance", + "entry": "node_start", + "nodes": [ + { + "id": "node_start", + "text": "Start.", + "choices": [ + { + "id": "ch_multi_effect", + "text": "Two effects.", + "goto": "node_end", + "effects": [ + { + "type": "set_flag", + "flag": "done", + "value": true + }, + { + "type": "adjust_reputation", + "faction": "guild", + "delta": 2 + } + ] + } + ] + }, + { + "id": "node_end", + "isEnd": true, + "text": "End." + } + ] + }, + "nodeId": "node_start", + "choiceId": "ch_multi_effect", + "state": { + "flags": {}, + "reputation": { + "guild": 0 + }, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "project": { + "factions": { + "guild": { + "id": "guild", + "name": "Guild", + "summary": "s", + "reputationRange": { + "min": -10, + "max": 10 + } + } } - ] }, - { - "id": "node_win", - "isEnd": true, - "text": "Won." + "expected": { + "nextNodeId": "node_end", + "newState": { + "flags": { + "done": true + }, + "reputation": { + "guild": 2 + }, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + } } - ] - }, - "nodeId": "node_start", - "choiceId": "ch_passive", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 0 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} }, - "project": { - "factions": {} - }, - "expected": { - "nextNodeId": "node_win", - "newState": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 0 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - } - } - }, - { - "fn": "chooseChoice", - "description": "terminal choice (no goto, no check on isEnd node): nextNodeId is null", - "dialogue": { - "id": "dlg_conformance", - "entry": "node_end", - "nodes": [ - { - "id": "node_end", - "isEnd": true, - "text": "End.", - "choices": [ - { - "id": "ch_terminal", - "text": "Done." + { + "fn": "chooseChoice", + "description": "give_item effect: item appears in inventory after choice", + "dialogue": { + "id": "dlg_conformance", + "entry": "node_start", + "nodes": [ + { + "id": "node_start", + "text": "Start.", + "choices": [ + { + "id": "ch_loot", + "text": "Take the key.", + "goto": "node_end", + "effects": [ + { + "type": "give_item", + "item": "key_card" + } + ] + } + ] + }, + { + "id": "node_end", + "isEnd": true, + "text": "End." + } + ] + }, + "nodeId": "node_start", + "choiceId": "ch_loot", + "state": { + "flags": {}, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "project": { + "factions": {} + }, + "expected": { + "nextNodeId": "node_end", + "newState": { + "flags": {}, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [ + "key_card" + ], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} } - ] } - ] }, - "nodeId": "node_end", - "choiceId": "ch_terminal", - "state": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} + { + "fn": "chooseChoice", + "description": "a check modifier flips the branch: roll=5 wit=5 base 10 < DC 15, +6 primed -> 16 -> success", + "dialogue": { + "id": "dlg_conformance", + "entry": "node_start", + "nodes": [ + { + "id": "node_start", + "text": "Start.", + "choices": [ + { + "id": "ch_check", + "text": "Roll.", + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 15, + "onSuccess": "node_win", + "onFailure": "node_lose", + "modifiers": [ + { + "when": { + "type": "flag", + "flag": "primed", + "value": true + }, + "bonus": 6 + } + ] + } + } + ] + }, + { + "id": "node_win", + "isEnd": true, + "text": "Won." + }, + { + "id": "node_lose", + "isEnd": true, + "text": "Lost." + } + ] + }, + "nodeId": "node_start", + "choiceId": "ch_check", + "state": { + "flags": { + "primed": true + }, + "reputation": {}, + "skills": { + "wit": 5 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "project": { + "factions": {} + }, + "rng": 0.2, + "expected": { + "nextNodeId": "node_win", + "newState": { + "flags": { + "primed": true + }, + "reputation": {}, + "skills": { + "wit": 5 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "checkResult": { + "passed": true, + "roll": 5, + "total": 16, + "skillValue": 5, + "dice": "1d20", + "bonus": 6, + "appliedModifiers": [ + 0 + ] + } + } }, - "project": { - "factions": {} + { + "fn": "chooseChoice", + "description": "locked (0.15): choosing a choice whose showIf fails throws — locked (whenLocked show) is never selectable", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "id": "node_open", + "text": "And you've seen the ledger. I can tell.", + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ] + }, + { + "id": "node_options", + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ] + }, + { + "id": "node_door", + "text": "The door.", + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show", + "lockedText": "[Requires the key]" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ] + }, + { + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing.", + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ] + }, + { + "id": "node_last", + "isEnd": true, + "text": "You think of the cup, still warm.", + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ] + }, + { + "id": "node_hop", + "text": "A beat.", + "next": "node_last" + }, + { + "id": "node_hop_open", + "text": "Another beat.", + "next": "node_open" + } + ] + }, + "nodeId": "node_door", + "choiceId": "ch_unlock", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + }, + "project": { + "factions": {} + }, + "expectedError": "not selectable" }, - "expected": { - "nextNodeId": null, - "newState": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - } - } - }, - { - "fn": "chooseChoice", - "description": "multiple effects threaded in order: flag then reputation", - "dialogue": { - "id": "dlg_conformance", - "entry": "node_start", - "nodes": [ - { - "id": "node_start", - "text": "Start.", - "choices": [ - { - "id": "ch_multi_effect", - "text": "Two effects.", - "goto": "node_end", - "effects": [ - { - "type": "set_flag", - "flag": "done", - "value": true - }, - { - "type": "adjust_reputation", - "faction": "guild", - "delta": 2 + { + "fn": "chooseChoice", + "description": "hidden: choosing a choice whose showIf fails throws — same rule whether hidden or locked", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "id": "node_open", + "text": "And you've seen the ledger. I can tell.", + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ] + }, + { + "id": "node_options", + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ] + }, + { + "id": "node_door", + "text": "The door.", + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show", + "lockedText": "[Requires the key]" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ] + }, + { + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing.", + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ] + }, + { + "id": "node_last", + "isEnd": true, + "text": "You think of the cup, still warm.", + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ] + }, + { + "id": "node_hop", + "text": "A beat.", + "next": "node_last" + }, + { + "id": "node_hop_open", + "text": "Another beat.", + "next": "node_open" + } + ] + }, + "nodeId": "node_door", + "choiceId": "ch_force", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + }, + "project": { + "factions": {}, + "rules": { + "choices": { + "whenLockedDefault": "show" } - ] } - ] }, - { - "id": "node_end", - "isEnd": true, - "text": "End." - } - ] + "expectedError": "not selectable" }, - "nodeId": "node_start", - "choiceId": "ch_multi_effect", - "state": { - "flags": {}, - "reputation": { - "guild": 0 - }, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} + { + "fn": "chooseChoice", + "description": "fallback: choosing the fallback while a non-fallback choice is visible throws — it was not offered", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "id": "node_open", + "text": "And you've seen the ledger. I can tell.", + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ] + }, + { + "id": "node_options", + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ] + }, + { + "id": "node_door", + "text": "The door.", + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show", + "lockedText": "[Requires the key]" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ] + }, + { + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing.", + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ] + }, + { + "id": "node_last", + "isEnd": true, + "text": "You think of the cup, still warm.", + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ] + }, + { + "id": "node_hop", + "text": "A beat.", + "next": "node_last" + }, + { + "id": "node_hop_open", + "text": "Another beat.", + "next": "node_open" + } + ] + }, + "nodeId": "node_door", + "choiceId": "ch_walk", + "state": { + "counters": {}, + "flags": { + "has_key": true + }, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + }, + "project": { + "factions": {} + }, + "expectedError": "not selectable" }, - "project": { - "factions": { - "guild": { - "id": "guild", - "name": "Guild", - "summary": "s", - "reputationRange": { - "min": -10, - "max": 10 - } + { + "fn": "chooseChoice", + "description": "fallback: choosing the fallback when nothing else is visible advances normally", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "id": "node_open", + "text": "And you've seen the ledger. I can tell.", + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ] + }, + { + "id": "node_options", + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ] + }, + { + "id": "node_door", + "text": "The door.", + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show", + "lockedText": "[Requires the key]" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ] + }, + { + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing.", + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ] + }, + { + "id": "node_last", + "isEnd": true, + "text": "You think of the cup, still warm.", + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ] + }, + { + "id": "node_hop", + "text": "A beat.", + "next": "node_last" + }, + { + "id": "node_hop_open", + "text": "Another beat.", + "next": "node_open" + } + ] + }, + "nodeId": "node_door", + "choiceId": "ch_walk", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + }, + "project": { + "factions": {} + }, + "expected": { + "nextNodeId": "node_last", + "newState": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } } - } }, - "expected": { - "nextNodeId": "node_end", - "newState": { - "flags": { - "done": true - }, - "reputation": { - "guild": 2 - }, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - } - } - }, - { - "fn": "chooseChoice", - "description": "give_item effect: item appears in inventory after choice", - "dialogue": { - "id": "dlg_conformance", - "entry": "node_start", - "nodes": [ - { - "id": "node_start", - "text": "Start.", - "choices": [ - { - "id": "ch_loot", - "text": "Take the key.", - "goto": "node_end", - "effects": [ - { - "type": "give_item", - "item": "key_card" + { + "fn": "chooseChoice", + "description": "text-less node: choices on a node with no text are chosen like any other", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "id": "node_open", + "text": "And you've seen the ledger. I can tell.", + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ] + }, + { + "id": "node_options", + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ] + }, + { + "id": "node_door", + "text": "The door.", + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show", + "lockedText": "[Requires the key]" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ] + }, + { + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing.", + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ] + }, + { + "id": "node_last", + "isEnd": true, + "text": "You think of the cup, still warm.", + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ] + }, + { + "id": "node_hop", + "text": "A beat.", + "next": "node_last" + }, + { + "id": "node_hop_open", + "text": "Another beat.", + "next": "node_open" } - ] - } - ] + ] + }, + "nodeId": "node_options", + "choiceId": "ch_door", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 }, - { - "id": "node_end", - "isEnd": true, - "text": "End." + "project": { + "factions": {} + }, + "expected": { + "nextNodeId": "node_door", + "newState": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } } - ] - }, - "nodeId": "node_start", - "choiceId": "ch_loot", - "state": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "project": { - "factions": {} }, - "expected": { - "nextNodeId": "node_end", - "newState": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [ - "key_card" - ], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - } - } - }, - { - "fn": "chooseChoice", - "description": "a check modifier flips the branch: roll=5 wit=5 base 10 < DC 15, +6 primed -> 16 -> success", - "dialogue": { - "id": "dlg_conformance", - "entry": "node_start", - "nodes": [ - { - "id": "node_start", - "text": "Start.", - "choices": [ - { - "id": "ch_check", - "text": "Roll.", - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 15, - "onSuccess": "node_win", - "onFailure": "node_lose", - "modifiers": [ - { - "when": { - "type": "flag", - "flag": "primed", - "value": true + { + "fn": "chooseChoice", + "description": "hidden-line node: choices on a node whose line is hidden are chosen like any other (the node was reached, only its line was withheld)", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "id": "node_open", + "text": "And you've seen the ledger. I can tell.", + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true }, - "bonus": 6 - } - ] - } - } - ] + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ] + }, + { + "id": "node_options", + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ] + }, + { + "id": "node_door", + "text": "The door.", + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show", + "lockedText": "[Requires the key]" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ] + }, + { + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing.", + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ] + }, + { + "id": "node_last", + "isEnd": true, + "text": "You think of the cup, still warm.", + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ] + }, + { + "id": "node_hop", + "text": "A beat.", + "next": "node_last" + }, + { + "id": "node_hop_open", + "text": "Another beat.", + "next": "node_open" + } + ] }, - { - "id": "node_win", - "isEnd": true, - "text": "Won." + "nodeId": "node_open", + "choiceId": "ch_ship", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 }, - { - "id": "node_lose", - "isEnd": true, - "text": "Lost." + "project": { + "factions": {} + }, + "expected": { + "nextNodeId": "node_options", + "newState": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } } - ] - }, - "nodeId": "node_start", - "choiceId": "ch_check", - "state": { - "flags": { - "primed": true - }, - "reputation": {}, - "skills": { - "wit": 5 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "project": { - "factions": {} }, - "rng": 0.2, - "expected": { - "nextNodeId": "node_win", - "newState": { - "flags": { - "primed": true - }, - "reputation": {}, - "skills": { - "wit": 5 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "checkResult": { - "passed": true, - "roll": 5, - "total": 16, - "skillValue": 5, - "dice": "1d20", - "bonus": 6, - "appliedModifiers": [ - 0 - ] - } + { + "fn": "chooseChoice", + "description": "engine: a choice's effects apply in order around an engine command, which itself changes nothing", + "dialogue": { + "id": "dlg_conformance", + "entry": "node_start", + "nodes": [ + { + "id": "node_start", + "text": "Start.", + "choices": [ + { + "id": "ch_engine", + "text": "Kick.", + "goto": "node_end", + "effects": [ + { + "type": "set_flag", + "flag": "kicked", + "value": true + }, + { + "type": "engine", + "command": "play_sfx", + "args": [ + "door_kick", + 0.8 + ] + }, + { + "type": "adjust_counter", + "counter": "noise", + "delta": 2 + } + ] + } + ] + }, + { + "id": "node_end", + "isEnd": true, + "text": "End." + } + ] + }, + "nodeId": "node_start", + "choiceId": "ch_engine", + "state": { + "flags": {}, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "project": { + "factions": {} + }, + "expected": { + "nextNodeId": "node_end", + "newState": { + "flags": { + "kicked": true + }, + "reputation": {}, + "skills": {}, + "counters": { + "noise": 2 + }, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + } + } } - } ] diff --git a/conformance/resolve_quests.json b/conformance/resolve_quests.json index d2004d2..bfb6f7a 100644 --- a/conformance/resolve_quests.json +++ b/conformance/resolve_quests.json @@ -116,5 +116,29 @@ "state": { "flags": { "step_one": true, "step_two": true }, "reputation": {}, "skills": {}, "counters": {}, "inventory": [], "questStages": {}, "xp": 50, "skillPointsSpent": {}, "questFired": ["q_a/outcome/out_a", "q_b/outcome/out_b"] }, "firings": [{ "quest": "q_a", "kind": "outcome", "id": "out_a" }, { "quest": "q_b", "kind": "outcome", "id": "out_b" }] } + }, + { + "fn": "resolveQuests", + "description": "cascade needs a second pass: an earlier quest (by id) is satisfied only by a later quest's firing, so a single in-order pass stops short of the fixpoint", + "state": { "flags": { "step_one": true }, "reputation": {}, "skills": {}, "counters": {}, "inventory": [], "questStages": {}, "xp": 0, "skillPointsSpent": {} }, + "project": { + "factions": {}, + "quests": { + "q_a": { + "id": "q_a", "name": "A", "summary": "s", + "stages": [{ "id": "s1", "order": 1, "description": "d" }], + "outcomes": [{ "id": "out_a", "kind": "success", "description": "d", "reachedWhen": { "type": "flag", "flag": "step_two", "value": true }, "effects": [{ "type": "grant_xp", "amount": 50 }] }] + }, + "q_b": { + "id": "q_b", "name": "B", "summary": "s", + "stages": [{ "id": "s1", "order": 1, "description": "d" }], + "outcomes": [{ "id": "out_b", "kind": "success", "description": "d", "reachedWhen": { "type": "flag", "flag": "step_one", "value": true }, "effects": [{ "type": "set_flag", "flag": "step_two", "value": true }] }] + } + } + }, + "expected": { + "state": { "flags": { "step_one": true, "step_two": true }, "reputation": {}, "skills": {}, "counters": {}, "inventory": [], "questStages": {}, "xp": 50, "skillPointsSpent": {}, "questFired": ["q_a/outcome/out_a", "q_b/outcome/out_b"] }, + "firings": [{ "quest": "q_b", "kind": "outcome", "id": "out_b" }, { "quest": "q_a", "kind": "outcome", "id": "out_a" }] + } } ] diff --git a/conformance/step_dialogue.json b/conformance/step_dialogue.json index b7f376a..98b7ef9 100644 --- a/conformance/step_dialogue.json +++ b/conformance/step_dialogue.json @@ -89,6 +89,7 @@ ] }, "expected": { + "lockedChoiceIds": [], "nodeId": "node_start", "onEnterEffectCount": 1, "onEnterEffects": [ @@ -207,6 +208,7 @@ ] }, "expected": { + "lockedChoiceIds": [], "nodeId": "node_start", "onEnterEffectCount": 1, "onEnterEffects": [ @@ -328,6 +330,7 @@ ] }, "expected": { + "lockedChoiceIds": [], "nodeId": "node_start", "onEnterEffectCount": 1, "onEnterEffects": [ @@ -449,6 +452,7 @@ ] }, "expected": { + "lockedChoiceIds": [], "nodeId": "node_start", "onEnterEffectCount": 1, "onEnterEffects": [ @@ -514,6 +518,7 @@ ] }, "expected": { + "lockedChoiceIds": [], "nodeId": "node_end", "onEnterEffectCount": 0, "onEnterEffects": [], @@ -575,6 +580,7 @@ ] }, "expected": { + "lockedChoiceIds": [], "nodeId": "node_start", "onEnterEffectCount": 0, "onEnterEffects": [], @@ -609,6 +615,7 @@ ] }, "expected": { + "lockedChoiceIds": [], "nodeId": "node_only", "onEnterEffectCount": 0, "onEnterEffects": [], @@ -659,6 +666,7 @@ ] }, "expected": { + "lockedChoiceIds": [], "nodeId": "node_enter", "onEnterEffectCount": 2, "onEnterEffects": [ @@ -744,6 +752,7 @@ ] }, "expected": { + "lockedChoiceIds": [], "nodeId": "n_real", "onEnterEffectCount": 1, "onEnterEffects": [ @@ -824,6 +833,7 @@ ] }, "expected": { + "lockedChoiceIds": [], "nodeId": "n_hidden", "onEnterEffectCount": 1, "onEnterEffects": [ @@ -904,6 +914,7 @@ ] }, "expected": { + "lockedChoiceIds": [], "nodeId": "n_real", "onEnterEffectCount": 1, "onEnterEffects": [ @@ -929,5 +940,2056 @@ "skills": {}, "xp": 0 } + }, + { + "description": "line-only gate (0.15): showIf FAILS on a node with choices — node is NOT skipped, its line is hidden (textHidden, text \"\"), its choices are offered and its onEnter is returned", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ], + "id": "node_open", + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "And you've seen the ledger. I can tell." + }, + { + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ], + "id": "node_options" + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "lockedText": "[Requires the key]", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ], + "id": "node_door", + "text": "The door." + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ], + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing." + }, + { + "id": "node_last", + "isEnd": true, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "You think of the cup, still warm." + }, + { + "id": "node_hop", + "next": "node_last", + "text": "A beat." + }, + { + "id": "node_hop_open", + "next": "node_open", + "text": "Another beat." + } + ] + }, + "expected": { + "lockedChoiceIds": [], + "nodeId": "node_open", + "onEnterEffectCount": 1, + "onEnterEffects": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "text": "", + "textHidden": true, + "visibleChoiceIds": [ + "ch_ship", + "ch_leave" + ] + }, + "fn": "stepDialogue", + "nodeId": "node_open", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } + }, + { + "description": "line-only gate: showIf PASSES on a node with choices — the line shows as authored", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ], + "id": "node_open", + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "And you've seen the ledger. I can tell." + }, + { + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ], + "id": "node_options" + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "lockedText": "[Requires the key]", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ], + "id": "node_door", + "text": "The door." + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ], + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing." + }, + { + "id": "node_last", + "isEnd": true, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "You think of the cup, still warm." + }, + { + "id": "node_hop", + "next": "node_last", + "text": "A beat." + }, + { + "id": "node_hop_open", + "next": "node_open", + "text": "Another beat." + } + ] + }, + "expected": { + "lockedChoiceIds": [], + "nodeId": "node_open", + "onEnterEffectCount": 1, + "onEnterEffects": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "text": "And you've seen the ledger. I can tell.", + "textHidden": false, + "visibleChoiceIds": [ + "ch_ship", + "ch_leave" + ] + }, + "fn": "stepDialogue", + "nodeId": "node_open", + "state": { + "counters": {}, + "flags": { + "seen_ledger": true + }, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } + }, + { + "description": "line-only gate: showIf FAILS on an isEnd node — reached (not skipped, not thrown), line hidden, dialogue ends, onEnter returned", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ], + "id": "node_open", + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "And you've seen the ledger. I can tell." + }, + { + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ], + "id": "node_options" + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "lockedText": "[Requires the key]", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ], + "id": "node_door", + "text": "The door." + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ], + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing." + }, + { + "id": "node_last", + "isEnd": true, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "You think of the cup, still warm." + }, + { + "id": "node_hop", + "next": "node_last", + "text": "A beat." + }, + { + "id": "node_hop_open", + "next": "node_open", + "text": "Another beat." + } + ] + }, + "expected": { + "lockedChoiceIds": [], + "nodeId": "node_last", + "onEnterEffectCount": 1, + "onEnterEffects": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ], + "text": "", + "textHidden": true, + "visibleChoiceIds": [] + }, + "fn": "stepDialogue", + "nodeId": "node_last", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } + }, + { + "description": "text-less choice node (0.15): no `text` at all — presents only its options; textHidden is false (nothing was withheld) and text is absent (null here)", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ], + "id": "node_open", + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "And you've seen the ledger. I can tell." + }, + { + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ], + "id": "node_options" + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "lockedText": "[Requires the key]", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ], + "id": "node_door", + "text": "The door." + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ], + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing." + }, + { + "id": "node_last", + "isEnd": true, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "You think of the cup, still warm." + }, + { + "id": "node_hop", + "next": "node_last", + "text": "A beat." + }, + { + "id": "node_hop_open", + "next": "node_open", + "text": "Another beat." + } + ] + }, + "expected": { + "lockedChoiceIds": [], + "nodeId": "node_options", + "onEnterEffectCount": 0, + "onEnterEffects": [], + "text": null, + "textHidden": false, + "visibleChoiceIds": [ + "ch_door", + "ch_end" + ] + }, + "fn": "stepDialogue", + "nodeId": "node_options", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } + }, + { + "description": "fallback (0.15): NOT offered while a non-fallback choice is visible (has_key set); ch_force fails its gate and is hidden (whenLocked defaults to hide)", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ], + "id": "node_open", + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "And you've seen the ledger. I can tell." + }, + { + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ], + "id": "node_options" + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "lockedText": "[Requires the key]", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ], + "id": "node_door", + "text": "The door." + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ], + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing." + }, + { + "id": "node_last", + "isEnd": true, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "You think of the cup, still warm." + }, + { + "id": "node_hop", + "next": "node_last", + "text": "A beat." + }, + { + "id": "node_hop_open", + "next": "node_open", + "text": "Another beat." + } + ] + }, + "expected": { + "lockedChoiceIds": [], + "nodeId": "node_door", + "onEnterEffectCount": 0, + "onEnterEffects": [], + "text": "The door.", + "textHidden": false, + "visibleChoiceIds": [ + "ch_unlock" + ] + }, + "fn": "stepDialogue", + "nodeId": "node_door", + "state": { + "counters": {}, + "flags": { + "has_key": true + }, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } + }, + { + "description": "fallback: offered ONLY when no non-fallback choice is visible; ch_unlock (whenLocked show) is returned locked, ch_force (default hide) is not", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ], + "id": "node_open", + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "And you've seen the ledger. I can tell." + }, + { + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ], + "id": "node_options" + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "lockedText": "[Requires the key]", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ], + "id": "node_door", + "text": "The door." + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ], + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing." + }, + { + "id": "node_last", + "isEnd": true, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "You think of the cup, still warm." + }, + { + "id": "node_hop", + "next": "node_last", + "text": "A beat." + }, + { + "id": "node_hop_open", + "next": "node_open", + "text": "Another beat." + } + ] + }, + "expected": { + "lockedChoiceIds": [ + "ch_unlock" + ], + "nodeId": "node_door", + "onEnterEffectCount": 0, + "onEnterEffects": [], + "text": "The door.", + "textHidden": false, + "visibleChoiceIds": [ + "ch_walk" + ] + }, + "fn": "stepDialogue", + "nodeId": "node_door", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } + }, + { + "description": "locked (0.15): rules.choices.whenLockedDefault=show makes ch_force locked too; lockedChoices keep authored order", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ], + "id": "node_open", + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "And you've seen the ledger. I can tell." + }, + { + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ], + "id": "node_options" + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "lockedText": "[Requires the key]", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ], + "id": "node_door", + "text": "The door." + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ], + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing." + }, + { + "id": "node_last", + "isEnd": true, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "You think of the cup, still warm." + }, + { + "id": "node_hop", + "next": "node_last", + "text": "A beat." + }, + { + "id": "node_hop_open", + "next": "node_open", + "text": "Another beat." + } + ] + }, + "expected": { + "lockedChoiceIds": [ + "ch_unlock", + "ch_force" + ], + "nodeId": "node_door", + "onEnterEffectCount": 0, + "onEnterEffects": [], + "text": "The door.", + "textHidden": false, + "visibleChoiceIds": [ + "ch_walk" + ] + }, + "fn": "stepDialogue", + "nodeId": "node_door", + "project": { + "factions": {}, + "rules": { + "choices": { + "whenLockedDefault": "show" + } + } + }, + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } + }, + { + "description": "locked beside a visible choice: has_key set, whenLockedDefault=show — ch_unlock is selectable, ch_force is locked and must NOT appear in visibleChoices, and the fallback stays hidden", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ], + "id": "node_open", + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "And you've seen the ledger. I can tell." + }, + { + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ], + "id": "node_options" + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "lockedText": "[Requires the key]", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ], + "id": "node_door", + "text": "The door." + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ], + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing." + }, + { + "id": "node_last", + "isEnd": true, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "You think of the cup, still warm." + }, + { + "id": "node_hop", + "next": "node_last", + "text": "A beat." + }, + { + "id": "node_hop_open", + "next": "node_open", + "text": "Another beat." + } + ] + }, + "expected": { + "lockedChoiceIds": [ + "ch_force" + ], + "nodeId": "node_door", + "onEnterEffectCount": 0, + "onEnterEffects": [], + "text": "The door.", + "textHidden": false, + "visibleChoiceIds": [ + "ch_unlock" + ] + }, + "fn": "stepDialogue", + "nodeId": "node_door", + "project": { + "factions": {}, + "rules": { + "choices": { + "whenLockedDefault": "show" + } + } + }, + "state": { + "counters": {}, + "flags": { + "has_key": true + }, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } + }, + { + "description": "gated fallback: its own showIf must pass too — nothing visible when every gate fails (the validator warns on this node)", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ], + "id": "node_open", + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "And you've seen the ledger. I can tell." + }, + { + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ], + "id": "node_options" + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "lockedText": "[Requires the key]", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ], + "id": "node_door", + "text": "The door." + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ], + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing." + }, + { + "id": "node_last", + "isEnd": true, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "You think of the cup, still warm." + }, + { + "id": "node_hop", + "next": "node_last", + "text": "A beat." + }, + { + "id": "node_hop_open", + "next": "node_open", + "text": "Another beat." + } + ] + }, + "expected": { + "lockedChoiceIds": [], + "nodeId": "node_gated_fallback", + "onEnterEffectCount": 0, + "onEnterEffects": [], + "text": "A gated fallback proves nothing.", + "textHidden": false, + "visibleChoiceIds": [] + }, + "fn": "stepDialogue", + "nodeId": "node_gated_fallback", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } + }, + { + "description": "gated fallback: shown when its gate passes and no non-fallback is visible", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ], + "id": "node_open", + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "And you've seen the ledger. I can tell." + }, + { + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ], + "id": "node_options" + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "lockedText": "[Requires the key]", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ], + "id": "node_door", + "text": "The door." + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ], + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing." + }, + { + "id": "node_last", + "isEnd": true, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "You think of the cup, still warm." + }, + { + "id": "node_hop", + "next": "node_last", + "text": "A beat." + }, + { + "id": "node_hop_open", + "next": "node_open", + "text": "Another beat." + } + ] + }, + "expected": { + "lockedChoiceIds": [], + "nodeId": "node_gated_fallback", + "onEnterEffectCount": 0, + "onEnterEffects": [], + "text": "A gated fallback proves nothing.", + "textHidden": false, + "visibleChoiceIds": [ + "ch_walk2" + ] + }, + "fn": "stepDialogue", + "nodeId": "node_gated_fallback", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": { + "might": 7 + }, + "xp": 0 + } + }, + { + "description": "interstitial skip is unchanged: requesting node_hop_open resolves through it? No — node_hop_open has no gate, so it is returned itself; only a GATED interstitial node is skipped", + "dialogue": { + "entry": "node_open", + "id": "dlg_shapes", + "nodes": [ + { + "choices": [ + { + "goto": "node_options", + "id": "ch_ship", + "text": "Ask about the ship." + }, + { + "goto": "node_door", + "id": "ch_leave", + "text": "Leave." + } + ], + "id": "node_open", + "onEnter": [ + { + "flag": "visited_open", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "And you've seen the ledger. I can tell." + }, + { + "choices": [ + { + "goto": "node_door", + "id": "ch_door", + "text": "Try the door." + }, + { + "goto": "node_last", + "id": "ch_end", + "text": "Say goodbye." + } + ], + "id": "node_options" + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock", + "lockedText": "[Requires the key]", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock the door.", + "whenLocked": "show" + }, + { + "goto": "node_last", + "id": "ch_force", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Force it." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk", + "text": "Walk away." + } + ], + "id": "node_door", + "text": "The door." + }, + { + "choices": [ + { + "goto": "node_last", + "id": "ch_unlock2", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "text": "Unlock." + }, + { + "fallback": true, + "goto": "node_last", + "id": "ch_walk2", + "showIf": { + "op": ">=", + "skill": "might", + "type": "skill", + "value": 5 + }, + "text": "Shoulder past." + } + ], + "id": "node_gated_fallback", + "text": "A gated fallback proves nothing." + }, + { + "id": "node_last", + "isEnd": true, + "onEnter": [ + { + "flag": "reached_end", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "seen_ledger", + "type": "flag", + "value": true + }, + "text": "You think of the cup, still warm." + }, + { + "id": "node_hop", + "next": "node_last", + "text": "A beat." + }, + { + "id": "node_hop_open", + "next": "node_open", + "text": "Another beat." + } + ] + }, + "expected": { + "lockedChoiceIds": [], + "nodeId": "node_hop_open", + "onEnterEffectCount": 0, + "onEnterEffects": [], + "text": "Another beat.", + "textHidden": false, + "visibleChoiceIds": [] + }, + "fn": "stepDialogue", + "nodeId": "node_hop_open", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } + }, + { + "description": "tags: node.tags and choice.tags are returned unchanged on the step (opaque pass-through), and an engine effect sits in onEnterEffects in authored order", + "dialogue": { + "entry": "node_tagged", + "id": "dlg_tags", + "nodes": [ + { + "choices": [ + { + "goto": "node_end", + "id": "ch_calm", + "tags": [ + "tone:calm" + ], + "text": "Easy." + }, + { + "goto": "node_end", + "id": "ch_plain", + "text": "Say nothing." + }, + { + "goto": "node_end", + "id": "ch_hidden", + "showIf": { + "flag": "has_key", + "type": "flag", + "value": true + }, + "tags": [ + "tone:sharp" + ], + "text": "Never." + } + ], + "id": "node_tagged", + "onEnter": [ + { + "flag": "visited", + "type": "set_flag", + "value": true + }, + { + "args": [ + 0.5 + ], + "command": "shake", + "type": "engine" + }, + { + "counter": "visits", + "delta": 1, + "type": "adjust_counter" + } + ], + "tags": [ + "mood:angry", + "sfx:door" + ], + "text": "The door slams." + }, + { + "id": "node_end", + "isEnd": true, + "text": "End." + } + ] + }, + "expected": { + "lockedChoiceIds": [], + "nodeId": "node_tagged", + "nodeTags": [ + "mood:angry", + "sfx:door" + ], + "onEnterEffectCount": 3, + "onEnterEffects": [ + { + "flag": "visited", + "type": "set_flag", + "value": true + }, + { + "args": [ + 0.5 + ], + "command": "shake", + "type": "engine" + }, + { + "counter": "visits", + "delta": 1, + "type": "adjust_counter" + } + ], + "visibleChoiceIds": [ + "ch_calm", + "ch_plain" + ], + "visibleChoiceTags": [ + [ + "tone:calm" + ], + null + ] + }, + "fn": "stepDialogue", + "nodeId": "node_tagged", + "project": { + "factions": {} + }, + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } + }, + { + "description": "line-only gate (0.15), self-clearing: the gate is judged ONCE, on ARRIVAL, before onEnter — a node whose own onEnter would fail its own showIf still shows its line (a port that applies onEnter first and then evaluates hides it)", + "dialogue": { + "entry": "node_greet", + "id": "dlg_greet", + "nodes": [ + { + "choices": [ + { + "goto": "node_end", + "id": "ch_bye", + "text": "Bye." + } + ], + "id": "node_greet", + "onEnter": [ + { + "flag": "met_keeper", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "met_keeper", + "type": "flag", + "value": false + }, + "text": "A stranger. Welcome." + }, + { + "id": "node_end", + "isEnd": true, + "text": "Go well." + } + ] + }, + "expected": { + "lockedChoiceIds": [], + "nodeId": "node_greet", + "onEnterEffectCount": 1, + "onEnterEffects": [ + { + "flag": "met_keeper", + "type": "set_flag", + "value": true + } + ], + "text": "A stranger. Welcome.", + "textHidden": false, + "visibleChoiceIds": [ + "ch_bye" + ] + }, + "fn": "stepDialogue", + "nodeId": "node_greet", + "state": { + "counters": {}, + "flags": { + "met_keeper": false + }, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } } ] diff --git a/schema/binding.schema.json b/schema/binding.schema.json new file mode 100644 index 0000000..2df92dc --- /dev/null +++ b/schema/binding.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "binding.schema.json", + "title": "Asset Binding", + "description": "Mapping from asset ids (portraits, VO keys, cutscene assets) to locations per target/profile.", + "type": "object", + "properties": { + "profile": { + "type": "string", + "description": "Identifier for the target profile (e.g., 'godot', 'editor', 'web')." + }, + "portraits": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Mapping of portrait IDs to engine paths or URLs." + }, + "vo": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Mapping of VO keys to engine paths or URLs." + }, + "cutscenes": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Mapping of cutscene asset IDs to engine paths or URLs." + } + }, + "required": ["profile"], + "additionalProperties": false +} diff --git a/schema/common.schema.json b/schema/common.schema.json index 46b1daa..a0c14a3 100644 --- a/schema/common.schema.json +++ b/schema/common.schema.json @@ -473,6 +473,32 @@ "value" ], "additionalProperties": false + }, + { + "description": "An engine command: an opaque name plus literal arguments, handed to the host engine in order among the other effects (camera shake, a sound cue, an animation trigger). The runtime changes NO state for it — it is returned in the step's effect list and the engine decides what it means. 'command' must be lowercase snake_case (validator rule ENGINE); a project may declare its commands in rules.engine.commands so a typo is a warning rather than a silent no-op.", + "properties": { + "type": { + "const": "engine" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": [ + "string", + "number", + "boolean" + ] + } + } + }, + "required": [ + "type", + "command" + ], + "additionalProperties": false } ] }, diff --git a/schema/dialogue.schema.json b/schema/dialogue.schema.json index c542815..3410e6f 100644 --- a/schema/dialogue.schema.json +++ b/schema/dialogue.schema.json @@ -56,7 +56,8 @@ "description": "overrides the dialogue's default speaker for this line — a character or skill id; omit for narration" }, "text": { - "type": "string" + "type": "string", + "description": "The line shown at this node. Optional ONLY on a node with non-empty 'choices' (a text-less choice node presents just its options); every other node must carry it — enforced by the validator (FLOW), since a schema-level conditional would reject valid nodes here." }, "onEnter": { "type": "array", @@ -67,7 +68,7 @@ }, "showIf": { "$ref": "common.schema.json#/definitions/condition", - "description": "Optional gate on this node's DISPLAY. When it fails the node is skipped and resolution continues at 'next': its text is not shown and its onEnter effects DO NOT fire. Requires 'next'; mutually exclusive with 'choices' and 'isEnd', which confines this to interstitial narration and makes skipping unambiguous. A 'next' chain must terminate at a node with no showIf." + "description": "Optional gate on this node's DISPLAY. On an interstitial node (one with 'next', no choices, not isEnd) a failed gate SKIPS the node: resolution continues at 'next', its text is not shown and its onEnter effects DO NOT fire; such a node requires 'next', and a 'next' chain must terminate at a node with no showIf. On a node with 'choices' or 'isEnd' a failed gate hides only the LINE: the node is still reached, its choices are still offered (or the dialogue still ends) and its onEnter effects still fire — the step reports textHidden. A showIf on a node with no text is an error either way (nothing to hide)." }, "isEnd": { "type": "boolean", @@ -104,6 +105,23 @@ "goto": { "$ref": "common.schema.json#/definitions/id", "description": "next node id (omit if check provides onSuccess/onFailure, or if choice ends convo)" + }, + "fallback": { + "type": "boolean", + "description": "true: this choice is offered ONLY when no non-fallback choice on the node is visible (its own showIf, if any, must still pass) — Ink's '* ->', Yarn's fallthrough. A node with an ungated fallback can never strand the player, so the validator drops its 'may be stuck' warning; more than one fallback, or a fallback beside no gated sibling, is a warning." + }, + "whenLocked": { + "type": "string", + "enum": ["hide", "show"], + "description": "What the player sees when this choice's showIf FAILS. 'hide' (the engine default, overridable by rules.choices.whenLockedDefault) drops it from the step entirely; 'show' returns it in stepDialogue's lockedChoices so the engine can present it greyed out. It is never selectable either way." + }, + "lockedText": { + "type": "string", + "description": "Optional replacement text for a choice presented locked (whenLocked 'show'), e.g. '[Requires Engineering 3]'. Localizable, never voiced. Omit to show the choice's own text." + }, + "tags": { + "$ref": "common.schema.json#/definitions/tags", + "description": "Opaque per-choice labels (e.g. 'mood:angry'). Pass-through: the runtime returns them on the visible choice and never reads them." } }, "required": [ @@ -120,11 +138,14 @@ "notes": { "type": "string", "description": "Author-only annotation. Never shown to the player; ignored by the runtime." + }, + "tags": { + "$ref": "common.schema.json#/definitions/tags", + "description": "Opaque per-line labels (e.g. 'mood:angry', 'sfx:door') an engine hangs animation, audio or camera work off. Pass-through: the runtime returns them on the step's node and never reads them; no validator rule reads them either." } }, "required": [ - "id", - "text" + "id" ], "additionalProperties": false } diff --git a/schema/rules.schema.json b/schema/rules.schema.json index db99ac1..030b85a 100644 --- a/schema/rules.schema.json +++ b/schema/rules.schema.json @@ -16,7 +16,7 @@ }, "criticals": { "type": "boolean", - "description": "Critical rolls (snake-eyes / boxcars). When true, every die showing its minimum ALWAYS fails and every die showing its maximum ALWAYS succeeds, regardless of skill or difficulty. Defaults to false \u2014 enabling it changes every check outcome, so it is an explicit per-project opt-in." + "description": "Critical rolls (snake-eyes / boxcars). When true, every die showing its minimum ALWAYS fails and every die showing its maximum ALWAYS succeeds, regardless of skill or difficulty. Defaults to false — enabling it changes every check outcome, so it is an explicit per-project opt-in." } }, "additionalProperties": false @@ -27,11 +27,81 @@ "properties": { "tagVocabulary": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "Controlled vocabulary for Quest.tags. Journal UIs group and prioritise quests from these, so a typo'd tag silently drops out of the grouping — declaring the vocabulary here turns that into an OBJ warning. Omit the field and any tag is accepted." } }, "additionalProperties": false + }, + "choices": { + "type": "object", + "description": "Choice presentation rules.", + "properties": { + "whenLockedDefault": { + "type": "string", + "enum": [ + "hide", + "show" + ], + "description": "Project-wide default for a choice's 'whenLocked' when the choice does not set it. 'hide' (the engine default) keeps the classic behaviour — a choice whose showIf fails is dropped from the step; 'show' returns it in stepDialogue's lockedChoices for greyed-out presentation. A per-choice 'whenLocked' overrides this." + } + }, + "additionalProperties": false + }, + "engine": { + "type": "object", + "description": "Engine-command rules (the 'engine' effect).", + "properties": { + "commands": { + "type": "object", + "description": "Declared engine command names, keyed by command. Declaring the set turns an undeclared command in an 'engine' effect into an ENGINE warning (a typo is otherwise a silent no-op at runtime). Omit the field and any name is accepted.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9_]*$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "args": { + "description": "Expected argument count, or \"any\". When a number, an effect whose args length differs is an ENGINE warning.", + "oneOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "const": "any" + } + ] + }, + "description": { + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "flag": { + "type": "object", + "description": "Flag authoring rules.", + "properties": { + "exclusiveGroups": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 2 + }, + "description": "Lists of flags that are mutually exclusive. A validator rule enforces that no single effect sequence sets more than one flag in a group to true." + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/tests/conformance_runner.gd b/tests/conformance_runner.gd index f55d29a..1d6666c 100644 --- a/tests/conformance_runner.gd +++ b/tests/conformance_runner.gd @@ -150,13 +150,13 @@ func _run_family(file_name: String, label: String, checker: Callable) -> void: func _check_evaluate(v: Dictionary) -> Variant: - var got := Runtime.evaluate(v["condition"], State.from_dict(v["state"]), v.get("project", {})) + var got := Runtime.evaluate(v["condition"], State.from_dict(v["state"]), _project(v)) var want: bool = v["expected"] return "" if got == want else "got %s, want %s" % [got, want] func _check_apply_effect(v: Dictionary) -> Variant: - var out := Runtime.apply_effect(v["effect"], State.from_dict(v["state"]), v.get("project", {})) + var out := Runtime.apply_effect(v["effect"], State.from_dict(v["state"]), _project(v)) return _diff(out.to_dict(), v["expected"]) @@ -181,20 +181,49 @@ func _check_resolve_check(v: Dictionary) -> Variant: func _check_step_dialogue(v: Dictionary) -> Variant: - var out := Runtime.step_dialogue(v["dialogue"], v["nodeId"], State.from_dict(v["state"]), v.get("project", {})) + var out := Runtime.step_dialogue(v["dialogue"], v["nodeId"], State.from_dict(v["state"]), _project(v)) if out.has("error"): return "unexpected error: %s" % out["error"] - var ids: Array = [] - for choice in out["visibleChoices"]: - ids.append(choice.get("id", null)) - - return _diff({ - "nodeId": out["node"].get("id", null), - "visibleChoiceIds": ids, + var want: Dictionary = v["expected"] + # nodeId and lockedChoiceIds are REQUIRED on every vector: a vector missing + # either is a defect in the file and fails here rather than skipping the + # assertion (an optional field is one a port can pass by never implementing). + if not want.has("nodeId") or not want.has("lockedChoiceIds"): + return "vector is missing nodeId or lockedChoiceIds" + + var node: Dictionary = out["node"] + var actual := { + "nodeId": node.get("id", null), + "visibleChoiceIds": _ids(out["visibleChoices"]), + "lockedChoiceIds": _ids(out["lockedChoices"]), "onEnterEffectCount": out["onEnterEffects"].size(), "onEnterEffects": out["onEnterEffects"], - }, v["expected"]) + } + # textHidden: absent from the vector means false, and false is still + # ASSERTED — so a port that skips a gated choice node fails every vector. + var expected := want.duplicate() + expected["textHidden"] = want.get("textHidden", false) + actual["textHidden"] = out["textHidden"] + # The rest are asserted only where the vector states them, as the reference + # harness does. `text: null` pins a text-less node's ABSENT text. + if want.has("text"): + actual["text"] = node.get("text", null) + if want.has("nodeTags"): + actual["nodeTags"] = node.get("tags", null) + if want.has("visibleChoiceTags"): + var tags: Array = [] + for choice in out["visibleChoices"]: + tags.append(choice.get("tags", null)) + actual["visibleChoiceTags"] = tags + return _diff(actual, expected) + + +func _ids(choices: Array) -> Array: + var ids: Array = [] + for choice in choices: + ids.append(choice.get("id", null)) + return ids func _check_choose_choice(v: Dictionary) -> Variant: @@ -203,9 +232,13 @@ func _check_choose_choice(v: Dictionary) -> Variant: v["nodeId"], v["choiceId"], State.from_dict(v["state"]), - v.get("project", {}), + _project(v), _rng_from(v.get("rng", 0)), ) + # 0.15: a hidden, locked or unoffered-fallback choice is not selectable. + var failure: Variant = _expected_error(v, out) + if failure != null: + return failure if out.has("error"): return "unexpected error: %s" % out["error"] @@ -217,19 +250,25 @@ func _check_choose_choice(v: Dictionary) -> Variant: return _diff(actual, v["expected"]) -## The one family with a failure contract. The reference throws; this port -## returns an "error" key (GDScript has no exceptions), and `expectedError` is -## a SUBSTRING the message must contain. -func _check_advance(v: Dictionary) -> Variant: - var out := Runtime.advance_node(v["dialogue"], v["nodeId"], State.from_dict(v["state"])) +## The failure contract. The reference throws; this port returns an "error" +## key (GDScript has no exceptions), and `expectedError` is a SUBSTRING the +## message must contain. Returns null when the vector has no expectedError, +## else the check's verdict ("" for a pass). +func _expected_error(v: Dictionary, out: Dictionary) -> Variant: + if not v.has("expectedError"): + return null + if not out.has("error"): + return "expected an error containing %s, but the call succeeded" % [v["expectedError"]] + if not str(out["error"]).contains(v["expectedError"]): + return "error %s does not contain %s" % [out["error"], v["expectedError"]] + return "" - if v.has("expectedError"): - if not out.has("error"): - return "expected an error containing %s, but the call succeeded" % [v["expectedError"]] - if not str(out["error"]).contains(v["expectedError"]): - return "error %s does not contain %s" % [out["error"], v["expectedError"]] - return "" +func _check_advance(v: Dictionary) -> Variant: + var out := Runtime.advance_node(v["dialogue"], v["nodeId"], State.from_dict(v["state"]), _project(v)) + var failure: Variant = _expected_error(v, out) + if failure != null: + return failure if out.has("error"): return "unexpected error: %s" % out["error"] @@ -240,7 +279,7 @@ func _check_advance(v: Dictionary) -> Variant: ## and `expected` is a dialogue id or null. func _check_character_dialogue(v: Dictionary) -> Variant: var got: Variant = Runtime.resolve_character_dialogue( - State.from_dict(v["state"]), v["character"], v.get("project", {}), v.get("visited", null) + State.from_dict(v["state"]), v["character"], _project(v), v.get("visited", null) ) return _diff(got, v["expected"]) @@ -288,6 +327,15 @@ func _check_progression(v: Dictionary) -> Variant: return _diff(got, v["expected"]) +## The vector's project, or {} when it has none. Some vectors state +## `"project": null` explicitly (their walk reads no project data), which a +## plain `v.get("project", {})` would hand through as null — the reference +## harness maps both to an empty project. +func _project(v: Dictionary) -> Dictionary: + var p = v.get("project", null) + return p if p is Dictionary else {} + + ## A vector's `rng` is a single float for a one-die roll, or an array with one ## value per die consumed IN ORDER. Overrunning returns 0.0 rather than ## erroring — a port that calls rng() too many times should fail on the roll it From 9b463195ce863938e666cbf1a378c48b3dc129d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 01:24:54 +0000 Subject: [PATCH 2/2] Pin to the v0.15.0 tag commit (a3c6454) The contract is unchanged since the release-prep commit this port was built against (440369d): every vendored vector and schema is byte-identical to parlance at a3c6454, which v0.15.0 tags. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018SVV6KXuhgbARyUvgQKbUP --- README.md | 2 +- conformance/PIN | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 295266b..fbf9663 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ vectors rather than against its author's confidence. | parlance-gdscript | Parlance spec | Families | |---|---|---| -| `main` (unreleased) | v0.15.0 (pre-tag) — pinned to [`440369d`](conformance/PIN), the release-prep commit; moves to the tag commit once v0.15.0 is tagged | 11 of 11 | +| `main` (unreleased) | v0.15.0 — pinned to [`a3c6454`](conformance/PIN) | 11 of 11 | **Versions here are independent of Parlance's**, deliberately, and this table is how the two are tied together. Parlance uses the patch slot itself (`v0.4.3` diff --git a/conformance/PIN b/conformance/PIN index e196c7b..129d4df 100644 --- a/conformance/PIN +++ b/conformance/PIN @@ -3,12 +3,7 @@ # These vectors are a PINNED COPY, not a fork. Never hand-edit them: they are # generated from and asserted against the reference implementation, so an edit # here makes this port chase a ghost. Re-copy from the named ref instead. -# PRE-TAG: v0.15.0 is not tagged yet. The commit below is the release-prep -# commit on parlance branch claude/release-0-15-prep-mi2lm0, whose contract is -# what v0.15.0 will tag. Once the tag exists, move parlanceCommit to the -# v0.15.0 tag commit (re-copy and re-run if the contract moved) and delete -# this comment. parlanceVersion=v0.15.0 -parlanceCommit=440369d0af4a9f829a525843c16f7762e1d18d32 +parlanceCommit=a3c6454772823579aa86fedc25fcefa10eea4288 parlanceRepo=git@github.com:Orbitope/parlance.git #