diff --git a/addons/parlance/runtime.gd b/addons/parlance/runtime.gd index bfd3219..ba20137 100644 --- a/addons/parlance/runtime.gd +++ b/addons/parlance/runtime.gd @@ -320,6 +320,24 @@ static func step_dialogue(dialogue: Dictionary, node_id: String, state: State, p if node == null: return {"error": "node '%s' does not exist in dialogue '%s'" % [node_id, dialogue.get("id", "?")]} + # 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: @@ -422,8 +440,26 @@ 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": "next target '%s' does not exist in dialogue '%s'" % [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} diff --git a/conformance/PIN b/conformance/PIN index 71d6dd8..e7aa472 100644 --- a/conformance/PIN +++ b/conformance/PIN @@ -3,11 +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. -parlanceVersion=v0.9.0-unreleased -parlanceCommit=1a4e6572e68064b3b6def073413660ef27648ae2 +parlanceVersion=v0.12.0 +parlanceCommit=a45908cf66b87c5dd1e6db68be88d90a734ea117 parlanceRepo=git@github.com:Orbitope/parlance.git # -# v0.9.0 is prepared but NOT YET TAGGED upstream (blocked on Actions billing). -# INTEGRATION.md requires pinning an exact tag, so this pin is provisional and -# MUST be moved to v0.9.0 once that tag exists. Recorded rather than silently -# pinned to a moving branch. diff --git a/conformance/advance.json b/conformance/advance.json index a4de209..75259c0 100644 --- a/conformance/advance.json +++ b/conformance/advance.json @@ -1,15 +1,14 @@ [ { - "fn": "advanceNode", "description": "advances to the node's next target; state unchanged (advanceNode applies nothing itself)", "dialogue": { - "id": "dlg_conformance", "entry": "node_start", + "id": "dlg_conformance", "nodes": [ { "id": "node_start", - "text": "Start.", - "next": "node_end" + "next": "node_end", + "text": "Start." }, { "id": "node_end", @@ -18,143 +17,143 @@ } ] }, - "nodeId": "node_start", - "state": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, "expected": { - "nextNodeId": "node_end", "newState": { - "flags": {}, - "reputation": {}, - "skills": {}, "counters": {}, + "flags": {}, "inventory": [], "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - } + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + }, + "nextNodeId": "node_end" + }, + "fn": "advanceNode", + "nodeId": "node_start", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 } }, { - "fn": "advanceNode", - "description": "target's onEnter is NOT applied by advanceNode itself \u2014 newState is unchanged even though node_end has onEnter effects; the caller (playSession.buildArrivalStep) applies them on arrival, identically to a goto arrival", + "description": "target's onEnter is NOT applied by advanceNode itself — newState is unchanged even though node_end has onEnter effects; the caller (playSession.buildArrivalStep) applies them on arrival, identically to a goto arrival", "dialogue": { - "id": "dlg_conformance", "entry": "node_start", + "id": "dlg_conformance", "nodes": [ { "id": "node_start", - "text": "Start.", - "next": "node_end" + "next": "node_end", + "text": "Start." }, { "id": "node_end", "isEnd": true, - "text": "End.", "onEnter": [ { - "type": "set_flag", "flag": "arrived", + "type": "set_flag", "value": true } - ] + ], + "text": "End." } ] }, - "nodeId": "node_start", - "state": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, "expected": { - "nextNodeId": "node_end", "newState": { - "flags": {}, - "reputation": {}, - "skills": {}, "counters": {}, + "flags": {}, "inventory": [], "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - } + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + }, + "nextNodeId": "node_end" + }, + "fn": "advanceNode", + "nodeId": "node_start", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 } }, { - "fn": "advanceNode", - "description": "advances into a node that itself has choices \u2014 advanceNode only resolves the pointer, it doesn't care what the target looks like", + "description": "advances into a node that itself has choices — advanceNode only resolves the pointer, it doesn't care what the target looks like", "dialogue": { - "id": "dlg_conformance", "entry": "node_start", + "id": "dlg_conformance", "nodes": [ { "id": "node_start", - "text": "Start.", - "next": "node_choice" + "next": "node_choice", + "text": "Start." }, { - "id": "node_choice", - "text": "Choose.", "choices": [ { + "goto": "node_start", "id": "c", - "text": "go", - "goto": "node_start" + "text": "go" } - ] + ], + "id": "node_choice", + "text": "Choose." } ] }, - "nodeId": "node_start", - "state": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, "expected": { - "nextNodeId": "node_choice", "newState": { - "flags": {}, - "reputation": {}, - "skills": {}, "counters": {}, + "flags": {}, "inventory": [], "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - } + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + }, + "nextNodeId": "node_choice" + }, + "fn": "advanceNode", + "nodeId": "node_start", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 } }, { - "fn": "advanceNode", "description": "advances into a node with isEnd:true", "dialogue": { - "id": "dlg_conformance", "entry": "node_start", + "id": "dlg_conformance", "nodes": [ { "id": "node_start", - "text": "Start.", - "next": "node_end" + "next": "node_end", + "text": "Start." }, { "id": "node_end", @@ -163,47 +162,47 @@ } ] }, - "nodeId": "node_start", - "state": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, "expected": { - "nextNodeId": "node_end", "newState": { - "flags": {}, - "reputation": {}, - "skills": {}, "counters": {}, + "flags": {}, "inventory": [], "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - } + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + }, + "nextNodeId": "node_end" + }, + "fn": "advanceNode", + "nodeId": "node_start", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 } }, { - "fn": "advanceNode", "description": "3-node next chain, hop 1 of 3: node_a -> node_b (one call = one step, D3)", "dialogue": { - "id": "dlg_conformance", "entry": "node_a", + "id": "dlg_conformance", "nodes": [ { "id": "node_a", - "text": "Beat one.", - "next": "node_b" + "next": "node_b", + "text": "Beat one." }, { "id": "node_b", - "text": "Beat two.", - "next": "node_c" + "next": "node_c", + "text": "Beat two." }, { "id": "node_c", @@ -212,47 +211,47 @@ } ] }, - "nodeId": "node_a", - "state": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, "expected": { - "nextNodeId": "node_b", "newState": { - "flags": {}, - "reputation": {}, - "skills": {}, "counters": {}, + "flags": {}, "inventory": [], "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - } + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + }, + "nextNodeId": "node_b" + }, + "fn": "advanceNode", + "nodeId": "node_a", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 } }, { - "fn": "advanceNode", "description": "3-node next chain, hop 2 of 3: node_b -> node_c", "dialogue": { - "id": "dlg_conformance", "entry": "node_a", + "id": "dlg_conformance", "nodes": [ { "id": "node_a", - "text": "Beat one.", - "next": "node_b" + "next": "node_b", + "text": "Beat one." }, { "id": "node_b", - "text": "Beat two.", - "next": "node_c" + "next": "node_c", + "text": "Beat two." }, { "id": "node_c", @@ -261,47 +260,47 @@ } ] }, - "nodeId": "node_b", - "state": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, "expected": { - "nextNodeId": "node_c", "newState": { - "flags": {}, - "reputation": {}, - "skills": {}, "counters": {}, + "flags": {}, "inventory": [], "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - } + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + }, + "nextNodeId": "node_c" + }, + "fn": "advanceNode", + "nodeId": "node_b", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 } }, { - "fn": "advanceNode", - "description": "3-node next chain, past the end: node_c is the chain's terminal (no next) \u2014 advanceNode never auto-chases, so calling it a third time here is the error case, not a hop", + "description": "3-node next chain, past the end: node_c is the chain's terminal (no next) — advanceNode never auto-chases, so calling it a third time here is the error case, not a hop", "dialogue": { - "id": "dlg_conformance", "entry": "node_a", + "id": "dlg_conformance", "nodes": [ { "id": "node_a", - "text": "Beat one.", - "next": "node_b" + "next": "node_b", + "text": "Beat one." }, { "id": "node_b", - "text": "Beat two.", - "next": "node_c" + "next": "node_c", + "text": "Beat two." }, { "id": "node_c", @@ -310,25 +309,25 @@ } ] }, + "expectedError": "no 'next'", + "fn": "advanceNode", "nodeId": "node_c", "state": { - "flags": {}, - "reputation": {}, - "skills": {}, "counters": {}, + "flags": {}, "inventory": [], "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "expectedError": "no 'next'" + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } }, { - "fn": "advanceNode", "description": "throws when the node has no 'next' to advance from", "dialogue": { - "id": "dlg_conformance", "entry": "node_start", + "id": "dlg_conformance", "nodes": [ { "id": "node_start", @@ -337,44 +336,257 @@ } ] }, + "expectedError": "no 'next'", + "fn": "advanceNode", "nodeId": "node_start", "state": { - "flags": {}, - "reputation": {}, - "skills": {}, "counters": {}, + "flags": {}, "inventory": [], "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "expectedError": "no 'next'" + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } }, { - "fn": "advanceNode", "description": "throws when next targets a node id that does not exist in this dialogue", "dialogue": { - "id": "dlg_conformance", "entry": "node_start", + "id": "dlg_conformance", "nodes": [ { "id": "node_start", - "text": "Start.", - "next": "node_missing" + "next": "node_missing", + "text": "Start." } ] }, + "expectedError": "does not exist", + "fn": "advanceNode", "nodeId": "node_start", "state": { + "counters": {}, "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } + }, + { + "description": "advance where the next node's gate PASSES: one hop, no skip", + "dialogue": { + "entry": "n_hidden", + "id": "dlg_skip", + "nodes": [ + { + "id": "n_hidden", + "next": "n_mid", + "onEnter": [ + { + "flag": "ghost", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "cooperated", + "type": "flag", + "value": true + }, + "text": "SECRET: gated line." + }, + { + "id": "n_mid", + "next": "n_real", + "showIf": { + "flag": "cooperated", + "type": "flag", + "value": true + }, + "text": "Second gated line." + }, + { + "choices": [ + { + "goto": "n_hidden", + "id": "c_go", + "text": "Go." + } + ], + "id": "n_real", + "onEnter": [ + { + "flag": "arrived", + "type": "set_flag", + "value": true + } + ], + "text": "Shown line." + } + ] + }, + "expected": { + "newState": { + "counters": {}, + "flags": { + "cooperated": true + }, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + }, + "nextNodeId": "n_mid" + }, + "fn": "advanceNode", + "nodeId": "n_hidden", + "project": null, + "state": { + "counters": {}, + "flags": { + "cooperated": true + }, + "inventory": [], + "questStages": {}, "reputation": {}, + "skillPointsSpent": {}, "skills": {}, + "xp": 0 + } + }, + { + "description": "advance where the next node's gate FAILS: the skip walk crosses it and returns the node the player will see", + "dialogue": { + "entry": "n_hidden", + "id": "dlg_skip", + "nodes": [ + { + "id": "n_hidden", + "next": "n_mid", + "onEnter": [ + { + "flag": "ghost", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "cooperated", + "type": "flag", + "value": true + }, + "text": "SECRET: gated line." + }, + { + "id": "n_mid", + "next": "n_real", + "showIf": { + "flag": "cooperated", + "type": "flag", + "value": true + }, + "text": "Second gated line." + }, + { + "choices": [ + { + "goto": "n_hidden", + "id": "c_go", + "text": "Go." + } + ], + "id": "n_real", + "onEnter": [ + { + "flag": "arrived", + "type": "set_flag", + "value": true + } + ], + "text": "Shown line." + } + ] + }, + "expected": { + "newState": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + }, + "nextNodeId": "n_real" + }, + "fn": "advanceNode", + "nodeId": "n_hidden", + "project": null, + "state": { "counters": {}, + "flags": {}, "inventory": [], "questStages": {}, - "xp": 0, - "skillPointsSpent": {} + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } + }, + { + "description": "advance into a ring of conditional nodes whose gates all fail: throws — COND-invalid data, resolution cannot escape", + "dialogue": { + "entry": "n_a", + "id": "dlg_ring", + "nodes": [ + { + "id": "n_a", + "next": "n_b", + "text": "Start." + }, + { + "id": "n_b", + "next": "n_c", + "showIf": { + "flag": "cooperated", + "type": "flag", + "value": true + }, + "text": "Gated." + }, + { + "id": "n_c", + "next": "n_b", + "showIf": { + "flag": "cooperated", + "type": "flag", + "value": true + }, + "text": "Also gated." + } + ] }, - "expectedError": "does not exist" + "expectedError": "Cycle among conditional nodes", + "fn": "advanceNode", + "nodeId": "n_a", + "project": null, + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } } ] diff --git a/conformance/step_dialogue.json b/conformance/step_dialogue.json index d984e6f..b7f376a 100644 --- a/conformance/step_dialogue.json +++ b/conformance/step_dialogue.json @@ -1,81 +1,80 @@ [ { - "fn": "stepDialogue", "description": "base state (wit=0, has_key absent): showIf-gated choices hidden", "dialogue": { - "id": "dlg_conformance", "entry": "node_start", + "id": "dlg_conformance", "nodes": [ { - "id": "node_start", - "text": "Start.", - "onEnter": [ - { - "type": "set_flag", - "flag": "visited_start", - "value": true - } - ], "choices": [ { + "goto": "node_end", "id": "ch_goto", - "text": "Proceed.", - "goto": "node_end" + "text": "Proceed." }, { - "id": "ch_skill_req", - "text": "Need wit≥5.", "goto": "node_end", + "id": "ch_skill_req", "showIf": { - "type": "skill", - "skill": "wit", "op": ">=", + "skill": "wit", + "type": "skill", "value": 5 - } + }, + "text": "Need wit≥5." }, { - "id": "ch_flag_req", - "text": "Need key.", "goto": "node_end", + "id": "ch_flag_req", "showIf": { - "type": "flag", "flag": "has_key", + "type": "flag", "value": true - } + }, + "text": "Need key." }, { - "id": "ch_check", - "text": "Roll.", "check": { - "mode": "active", - "skill": "wit", "difficulty": 10, + "mode": "active", + "onFailure": "node_lose", "onSuccess": "node_win", - "onFailure": "node_lose" - } + "skill": "wit" + }, + "id": "ch_check", + "text": "Roll." }, { - "id": "ch_passive", - "text": "Passive.", - "goto": "node_win", "check": { + "difficulty": 5, "mode": "passive", - "skill": "wit", - "difficulty": 5 - } + "skill": "wit" + }, + "goto": "node_win", + "id": "ch_passive", + "text": "Passive." } - ] + ], + "id": "node_start", + "onEnter": [ + { + "flag": "visited_start", + "type": "set_flag", + "value": true + } + ], + "text": "Start." }, { - "id": "node_end", - "isEnd": true, - "text": "End.", "choices": [ { "id": "ch_terminal", "text": "Done." } - ] + ], + "id": "node_end", + "isEnd": true, + "text": "End." }, { "id": "node_win", @@ -89,103 +88,111 @@ } ] }, - "nodeId": "node_start", - "state": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, "expected": { + "nodeId": "node_start", + "onEnterEffectCount": 1, + "onEnterEffects": [ + { + "flag": "visited_start", + "type": "set_flag", + "value": true + } + ], "visibleChoiceIds": [ "ch_goto", "ch_check", "ch_passive" - ], - "onEnterEffectCount": 1 + ] + }, + "fn": "stepDialogue", + "nodeId": "node_start", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 } }, { - "fn": "stepDialogue", "description": "wit=5 makes ch_skill_req visible", "dialogue": { - "id": "dlg_conformance", "entry": "node_start", + "id": "dlg_conformance", "nodes": [ { - "id": "node_start", - "text": "Start.", - "onEnter": [ - { - "type": "set_flag", - "flag": "visited_start", - "value": true - } - ], "choices": [ { + "goto": "node_end", "id": "ch_goto", - "text": "Proceed.", - "goto": "node_end" + "text": "Proceed." }, { - "id": "ch_skill_req", - "text": "Need wit≥5.", "goto": "node_end", + "id": "ch_skill_req", "showIf": { - "type": "skill", - "skill": "wit", "op": ">=", + "skill": "wit", + "type": "skill", "value": 5 - } + }, + "text": "Need wit≥5." }, { - "id": "ch_flag_req", - "text": "Need key.", "goto": "node_end", + "id": "ch_flag_req", "showIf": { - "type": "flag", "flag": "has_key", + "type": "flag", "value": true - } + }, + "text": "Need key." }, { - "id": "ch_check", - "text": "Roll.", "check": { - "mode": "active", - "skill": "wit", "difficulty": 10, + "mode": "active", + "onFailure": "node_lose", "onSuccess": "node_win", - "onFailure": "node_lose" - } + "skill": "wit" + }, + "id": "ch_check", + "text": "Roll." }, { - "id": "ch_passive", - "text": "Passive.", - "goto": "node_win", "check": { + "difficulty": 5, "mode": "passive", - "skill": "wit", - "difficulty": 5 - } + "skill": "wit" + }, + "goto": "node_win", + "id": "ch_passive", + "text": "Passive." + } + ], + "id": "node_start", + "onEnter": [ + { + "flag": "visited_start", + "type": "set_flag", + "value": true } - ] + ], + "text": "Start." }, { - "id": "node_end", - "isEnd": true, - "text": "End.", "choices": [ { "id": "ch_terminal", "text": "Done." } - ] + ], + "id": "node_end", + "isEnd": true, + "text": "End." }, { "id": "node_win", @@ -199,106 +206,114 @@ } ] }, + "expected": { + "nodeId": "node_start", + "onEnterEffectCount": 1, + "onEnterEffects": [ + { + "flag": "visited_start", + "type": "set_flag", + "value": true + } + ], + "visibleChoiceIds": [ + "ch_goto", + "ch_skill_req", + "ch_check", + "ch_passive" + ] + }, + "fn": "stepDialogue", "nodeId": "node_start", "state": { + "counters": {}, "flags": {}, + "inventory": [], + "questStages": {}, "reputation": {}, + "skillPointsSpent": {}, "skills": { "wit": 5 }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "expected": { - "visibleChoiceIds": [ - "ch_goto", - "ch_skill_req", - "ch_check", - "ch_passive" - ], - "onEnterEffectCount": 1 + "xp": 0 } }, { - "fn": "stepDialogue", "description": "has_key=true makes ch_flag_req visible", "dialogue": { - "id": "dlg_conformance", "entry": "node_start", + "id": "dlg_conformance", "nodes": [ { - "id": "node_start", - "text": "Start.", - "onEnter": [ - { - "type": "set_flag", - "flag": "visited_start", - "value": true - } - ], "choices": [ { + "goto": "node_end", "id": "ch_goto", - "text": "Proceed.", - "goto": "node_end" + "text": "Proceed." }, { - "id": "ch_skill_req", - "text": "Need wit≥5.", "goto": "node_end", + "id": "ch_skill_req", "showIf": { - "type": "skill", - "skill": "wit", "op": ">=", + "skill": "wit", + "type": "skill", "value": 5 - } + }, + "text": "Need wit≥5." }, { - "id": "ch_flag_req", - "text": "Need key.", "goto": "node_end", + "id": "ch_flag_req", "showIf": { - "type": "flag", "flag": "has_key", + "type": "flag", "value": true - } + }, + "text": "Need key." }, { - "id": "ch_check", - "text": "Roll.", "check": { - "mode": "active", - "skill": "wit", "difficulty": 10, + "mode": "active", + "onFailure": "node_lose", "onSuccess": "node_win", - "onFailure": "node_lose" - } + "skill": "wit" + }, + "id": "ch_check", + "text": "Roll." }, { - "id": "ch_passive", - "text": "Passive.", - "goto": "node_win", "check": { + "difficulty": 5, "mode": "passive", - "skill": "wit", - "difficulty": 5 - } + "skill": "wit" + }, + "goto": "node_win", + "id": "ch_passive", + "text": "Passive." } - ] + ], + "id": "node_start", + "onEnter": [ + { + "flag": "visited_start", + "type": "set_flag", + "value": true + } + ], + "text": "Start." }, { - "id": "node_end", - "isEnd": true, - "text": "End.", "choices": [ { "id": "ch_terminal", "text": "Done." } - ] + ], + "id": "node_end", + "isEnd": true, + "text": "End." }, { "id": "node_win", @@ -312,106 +327,114 @@ } ] }, + "expected": { + "nodeId": "node_start", + "onEnterEffectCount": 1, + "onEnterEffects": [ + { + "flag": "visited_start", + "type": "set_flag", + "value": true + } + ], + "visibleChoiceIds": [ + "ch_goto", + "ch_flag_req", + "ch_check", + "ch_passive" + ] + }, + "fn": "stepDialogue", "nodeId": "node_start", "state": { + "counters": {}, "flags": { "has_key": true }, - "reputation": {}, - "skills": {}, - "counters": {}, "inventory": [], "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "expected": { - "visibleChoiceIds": [ - "ch_goto", - "ch_flag_req", - "ch_check", - "ch_passive" - ], - "onEnterEffectCount": 1 + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 } }, { - "fn": "stepDialogue", "description": "all conditions met: all choices visible", "dialogue": { - "id": "dlg_conformance", "entry": "node_start", + "id": "dlg_conformance", "nodes": [ { - "id": "node_start", - "text": "Start.", - "onEnter": [ - { - "type": "set_flag", - "flag": "visited_start", - "value": true - } - ], "choices": [ { + "goto": "node_end", "id": "ch_goto", - "text": "Proceed.", - "goto": "node_end" + "text": "Proceed." }, { - "id": "ch_skill_req", - "text": "Need wit≥5.", "goto": "node_end", + "id": "ch_skill_req", "showIf": { - "type": "skill", - "skill": "wit", "op": ">=", + "skill": "wit", + "type": "skill", "value": 5 - } + }, + "text": "Need wit≥5." }, { - "id": "ch_flag_req", - "text": "Need key.", "goto": "node_end", + "id": "ch_flag_req", "showIf": { - "type": "flag", "flag": "has_key", + "type": "flag", "value": true - } + }, + "text": "Need key." }, { - "id": "ch_check", - "text": "Roll.", "check": { - "mode": "active", - "skill": "wit", "difficulty": 10, + "mode": "active", + "onFailure": "node_lose", "onSuccess": "node_win", - "onFailure": "node_lose" - } + "skill": "wit" + }, + "id": "ch_check", + "text": "Roll." }, { - "id": "ch_passive", - "text": "Passive.", - "goto": "node_win", "check": { + "difficulty": 5, "mode": "passive", - "skill": "wit", - "difficulty": 5 - } + "skill": "wit" + }, + "goto": "node_win", + "id": "ch_passive", + "text": "Passive." } - ] - }, - { - "id": "node_end", - "isEnd": true, - "text": "End.", - "choices": [ - { - "id": "ch_terminal", + ], + "id": "node_start", + "onEnter": [ + { + "flag": "visited_start", + "type": "set_flag", + "value": true + } + ], + "text": "Start." + }, + { + "choices": [ + { + "id": "ch_terminal", "text": "Done." } - ] + ], + "id": "node_end", + "isEnd": true, + "text": "End." }, { "id": "node_win", @@ -425,114 +448,124 @@ } ] }, + "expected": { + "nodeId": "node_start", + "onEnterEffectCount": 1, + "onEnterEffects": [ + { + "flag": "visited_start", + "type": "set_flag", + "value": true + } + ], + "visibleChoiceIds": [ + "ch_goto", + "ch_skill_req", + "ch_flag_req", + "ch_check", + "ch_passive" + ] + }, + "fn": "stepDialogue", "nodeId": "node_start", "state": { + "counters": {}, "flags": { "has_key": true }, + "inventory": [], + "questStages": {}, "reputation": {}, + "skillPointsSpent": {}, "skills": { "wit": 5 }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "expected": { - "visibleChoiceIds": [ - "ch_goto", - "ch_skill_req", - "ch_flag_req", - "ch_check", - "ch_passive" - ], - "onEnterEffectCount": 1 + "xp": 0 } }, { - "fn": "stepDialogue", "description": "isEnd node: terminal choice visible, no onEnter effects", "dialogue": { - "id": "dlg_conformance", "entry": "node_start", + "id": "dlg_conformance", "nodes": [ { - "id": "node_start", - "text": "Start.", "choices": [ { + "goto": "node_end", "id": "ch_goto", - "text": "Proceed.", - "goto": "node_end" + "text": "Proceed." } - ] + ], + "id": "node_start", + "text": "Start." }, { - "id": "node_end", - "isEnd": true, - "text": "End.", "choices": [ { "id": "ch_terminal", "text": "Done." } - ] + ], + "id": "node_end", + "isEnd": true, + "text": "End." } ] }, + "expected": { + "nodeId": "node_end", + "onEnterEffectCount": 0, + "onEnterEffects": [], + "visibleChoiceIds": [ + "ch_terminal" + ] + }, + "fn": "stepDialogue", "nodeId": "node_end", "state": { - "flags": {}, - "reputation": {}, - "skills": {}, "counters": {}, + "flags": {}, "inventory": [], "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "expected": { - "visibleChoiceIds": [ - "ch_terminal" - ], - "onEnterEffectCount": 0 + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 } }, { - "fn": "stepDialogue", "description": "all showIf conditions fail: no visible choices", "dialogue": { - "id": "dlg_conformance", "entry": "node_start", + "id": "dlg_conformance", "nodes": [ { - "id": "node_start", - "text": "Blocked.", "choices": [ { - "id": "ch_need_flag", - "text": "Hidden A.", "goto": "node_end", + "id": "ch_need_flag", "showIf": { - "type": "flag", "flag": "unlocked", + "type": "flag", "value": true - } + }, + "text": "Hidden A." }, { - "id": "ch_need_skill", - "text": "Hidden B.", "goto": "node_end", + "id": "ch_need_skill", "showIf": { - "type": "skill", - "skill": "wit", "op": ">=", + "skill": "wit", + "type": "skill", "value": 10 - } + }, + "text": "Hidden B." } - ] + ], + "id": "node_start", + "text": "Blocked." }, { "id": "node_end", @@ -541,30 +574,32 @@ } ] }, + "expected": { + "nodeId": "node_start", + "onEnterEffectCount": 0, + "onEnterEffects": [], + "visibleChoiceIds": [] + }, + "fn": "stepDialogue", "nodeId": "node_start", "state": { + "counters": {}, "flags": {}, + "inventory": [], + "questStages": {}, "reputation": {}, + "skillPointsSpent": {}, "skills": { "wit": 5 }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "expected": { - "visibleChoiceIds": [], - "onEnterEffectCount": 0 + "xp": 0 } }, { - "fn": "stepDialogue", "description": "node with no choices field: visibleChoices is empty", "dialogue": { - "id": "dlg_conformance", "entry": "node_only", + "id": "dlg_conformance", "nodes": [ { "id": "node_only", @@ -573,70 +608,326 @@ } ] }, + "expected": { + "nodeId": "node_only", + "onEnterEffectCount": 0, + "onEnterEffects": [], + "visibleChoiceIds": [] + }, + "fn": "stepDialogue", "nodeId": "node_only", "state": { - "flags": {}, - "reputation": {}, - "skills": {}, "counters": {}, + "flags": {}, "inventory": [], "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "expected": { - "visibleChoiceIds": [], - "onEnterEffectCount": 0 + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 } }, { - "fn": "stepDialogue", "description": "onEnter effects returned but NOT applied to state (caller responsibility)", "dialogue": { - "id": "dlg_conformance", "entry": "node_enter", + "id": "dlg_conformance", "nodes": [ { + "choices": [ + { + "goto": "node_enter", + "id": "ch_go", + "text": "Go." + } + ], "id": "node_enter", - "text": "Enter.", "onEnter": [ { - "type": "set_flag", "flag": "arrived", + "type": "set_flag", "value": true }, { - "type": "adjust_counter", "counter": "steps", - "delta": 1 + "delta": 1, + "type": "adjust_counter" } ], + "text": "Enter." + } + ] + }, + "expected": { + "nodeId": "node_enter", + "onEnterEffectCount": 2, + "onEnterEffects": [ + { + "flag": "arrived", + "type": "set_flag", + "value": true + }, + { + "counter": "steps", + "delta": 1, + "type": "adjust_counter" + } + ], + "visibleChoiceIds": [ + "ch_go" + ] + }, + "fn": "stepDialogue", + "nodeId": "node_enter", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } + }, + { + "description": "node showIf FAILS: entry resolves through a two-skip chain to n_real; the skipped nodes' onEnter never appears — only the RESOLVED node's effects are returned", + "dialogue": { + "entry": "n_hidden", + "id": "dlg_skip", + "nodes": [ + { + "id": "n_hidden", + "next": "n_mid", + "onEnter": [ + { + "flag": "ghost", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "cooperated", + "type": "flag", + "value": true + }, + "text": "SECRET: gated line." + }, + { + "id": "n_mid", + "next": "n_real", + "showIf": { + "flag": "cooperated", + "type": "flag", + "value": true + }, + "text": "Second gated line." + }, + { "choices": [ { - "id": "ch_go", - "text": "Go.", - "goto": "node_enter" + "goto": "n_hidden", + "id": "c_go", + "text": "Go." + } + ], + "id": "n_real", + "onEnter": [ + { + "flag": "arrived", + "type": "set_flag", + "value": true } - ] + ], + "text": "Shown line." } ] }, - "nodeId": "node_enter", + "expected": { + "nodeId": "n_real", + "onEnterEffectCount": 1, + "onEnterEffects": [ + { + "flag": "arrived", + "type": "set_flag", + "value": true + } + ], + "visibleChoiceIds": [ + "c_go" + ] + }, + "fn": "stepDialogue", + "nodeId": "n_hidden", "state": { + "counters": {}, "flags": {}, + "inventory": [], + "questStages": {}, "reputation": {}, + "skillPointsSpent": {}, "skills": {}, + "xp": 0 + } + }, + { + "description": "node showIf PASSES: the gated entry node is shown as itself, id and effects intact", + "dialogue": { + "entry": "n_hidden", + "id": "dlg_skip", + "nodes": [ + { + "id": "n_hidden", + "next": "n_mid", + "onEnter": [ + { + "flag": "ghost", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "cooperated", + "type": "flag", + "value": true + }, + "text": "SECRET: gated line." + }, + { + "id": "n_mid", + "next": "n_real", + "showIf": { + "flag": "cooperated", + "type": "flag", + "value": true + }, + "text": "Second gated line." + }, + { + "choices": [ + { + "goto": "n_hidden", + "id": "c_go", + "text": "Go." + } + ], + "id": "n_real", + "onEnter": [ + { + "flag": "arrived", + "type": "set_flag", + "value": true + } + ], + "text": "Shown line." + } + ] + }, + "expected": { + "nodeId": "n_hidden", + "onEnterEffectCount": 1, + "onEnterEffects": [ + { + "flag": "ghost", + "type": "set_flag", + "value": true + } + ], + "visibleChoiceIds": [] + }, + "fn": "stepDialogue", + "nodeId": "n_hidden", + "state": { "counters": {}, + "flags": { + "cooperated": true + }, "inventory": [], "questStages": {}, - "xp": 0, - "skillPointsSpent": {} + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 + } + }, + { + "description": "goto arrival at a gated node resolves the same way entry does — one skip walk, every arrival site", + "dialogue": { + "entry": "n_hidden", + "id": "dlg_skip", + "nodes": [ + { + "id": "n_hidden", + "next": "n_mid", + "onEnter": [ + { + "flag": "ghost", + "type": "set_flag", + "value": true + } + ], + "showIf": { + "flag": "cooperated", + "type": "flag", + "value": true + }, + "text": "SECRET: gated line." + }, + { + "id": "n_mid", + "next": "n_real", + "showIf": { + "flag": "cooperated", + "type": "flag", + "value": true + }, + "text": "Second gated line." + }, + { + "choices": [ + { + "goto": "n_hidden", + "id": "c_go", + "text": "Go." + } + ], + "id": "n_real", + "onEnter": [ + { + "flag": "arrived", + "type": "set_flag", + "value": true + } + ], + "text": "Shown line." + } + ] }, "expected": { - "visibleChoiceIds": [ - "ch_go" + "nodeId": "n_real", + "onEnterEffectCount": 1, + "onEnterEffects": [ + { + "flag": "arrived", + "type": "set_flag", + "value": true + } ], - "onEnterEffectCount": 2 + "visibleChoiceIds": [ + "c_go" + ] + }, + "fn": "stepDialogue", + "nodeId": "n_mid", + "state": { + "counters": {}, + "flags": {}, + "inventory": [], + "questStages": {}, + "reputation": {}, + "skillPointsSpent": {}, + "skills": {}, + "xp": 0 } } ] diff --git a/tests/conformance_runner.gd b/tests/conformance_runner.gd index 5357a95..f604611 100644 --- a/tests/conformance_runner.gd +++ b/tests/conformance_runner.gd @@ -180,8 +180,10 @@ func _check_step_dialogue(v: Dictionary) -> Variant: ids.append(choice.get("id", null)) return _diff({ + "nodeId": out["node"].get("id", null), "visibleChoiceIds": ids, "onEnterEffectCount": out["onEnterEffects"].size(), + "onEnterEffects": out["onEnterEffects"], }, v["expected"])