diff --git a/README.md b/README.md index a1179fb..23a6fce 100644 --- a/README.md +++ b/README.md @@ -10,30 +10,31 @@ human-readable JSON directly in your repo. No database, no import/export step, git as the single source of truth. This addon reads that JSON and runs it in Godot 4: dialogues, conditions, -effects, skill checks, character dialogue ladders, quests, endings. It is -verified against Parlance's published conformance vectors rather than against -its author's confidence. +effects, skill checks (with conditional modifiers), character dialogue offers, +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 resolveCheck 18 vectors - PASS stepDialogue 8 vectors - PASS chooseChoice 9 vectors - PASS advanceNode 9 vectors - PASS resolveCharacterDialogue 6 vectors + PASS resolveCheck 24 vectors + PASS stepDialogue 11 vectors + PASS chooseChoice 10 vectors + PASS advanceNode 12 vectors + PASS resolveCharacterDialogue 16 vectors SKIP resolveQuests 6 vectors — not ported yet SKIP progression 13 vectors — not ported yet + SKIP nextContinuations 4 vectors — not ported yet -136 passed, 0 failed, 19 skipped (not yet ported) +159 passed, 0 failed, 23 skipped (not yet ported) ``` ## Compatibility | parlance-gdscript | Parlance spec | Families | |---|---|---| -| `main` (unreleased) | v0.9.0 — pre-tag, pinned to [`1a4e657`](conformance/PIN) | 7 of 10 | +| `main` (unreleased) | v0.14.0 — pinned to [`f4a25b0`](conformance/PIN) | 8 of 11 | **Versions here are independent of Parlance's**, deliberately, and this table is how the two are tied together. Two reasons not to mirror the upstream number: @@ -41,9 +42,10 @@ how the two are tied together. Two reasons not to mirror the upstream number: - Parlance uses the patch slot itself (`v0.4.3` exists), so a mirrored version leaves this port no room to release its own fixes without colliding with a spec release. -- `v0.9.0` on this repo would read as "implements Parlance 0.9.0", and it does - not — `resolveQuests` and `progression` are missing. A version number is the - most visible claim a project makes and it should not be the least accurate. +- `v0.14.0` on this repo would read as "implements Parlance 0.14.0", and it does + not — `resolveQuests`, `progression` and `nextContinuations` are missing. A + version number is the most visible claim a project makes and it should not be + the least accurate. So this stays in `0.x` while families are missing. `v1.0.0` will mean something checkable: complete against the spec, not merely current with it. @@ -107,11 +109,15 @@ Three things that are easy to get wrong, and are contract rather than style: `evaluate`, `applyEffect`, `resolveCheck`, `stepDialogue`, `chooseChoice`, `advanceNode`, `resolveCharacterDialogue`, and the mulberry32 PRNG — enough to -run dialogue end to end, including gated choices, active skill checks, effects, -and character ladders. - -**Not ported:** `resolveQuests` and `progression`. They report as SKIP rather -than passing by omission. Note that quest *conditions* still work: `questOutcome` +run dialogue end to end, including gated choices, active skill checks and their +conditional modifiers, effects, and character dialogue offers. `check_bonus`, +`passive_check_passes` and `condition_specificity` are exported too: +`passive_check_passes` is the passive-check reveal threshold +(`skill + Σbonus >= difficulty`) an engine should use to show or hide a passive +choice, so a modifier means the same thing in both modes. + +**Not ported:** `resolveQuests`, `progression` and `nextContinuations`. They +report as SKIP rather than passing by omission. Note that quest *conditions* still work: `questOutcome` re-evaluates an outcome's own `reachedWhen` against current state rather than reading a fired-record, so endings gated on outcomes resolve without them. @@ -132,8 +138,8 @@ the failure mode this runner exists to avoid. To confirm the suite can actually fail, break something on purpose: change a `>=` to `>` in `_compare` in `addons/parlance/runtime.gd` and re-run. You should -get 5 failures across `evaluate` and `stepDialogue`. If it stays green, the -suite is lying and that is worth an issue. +get 6 failures across `evaluate`, `resolveCheck` and `stepDialogue`. If it stays +green, the suite is lying and that is worth an issue. CI runs the same command on every push. diff --git a/addons/parlance/runtime.gd b/addons/parlance/runtime.gd index ba20137..931a4af 100644 --- a/addons/parlance/runtime.gd +++ b/addons/parlance/runtime.gd @@ -4,9 +4,9 @@ extends RefCounted ## The Parlance runtime, ported to GDScript. ## ## Ported families: evaluate, applyEffect, resolveCheck, stepDialogue, -## chooseChoice, advanceNode. NOT ported: resolveQuests, progression, -## resolveCharacterDialogue. The conformance runner reports those as SKIP -## rather than passing them by omission. +## chooseChoice, advanceNode, resolveCharacterDialogue. NOT ported: +## resolveQuests, progression, nextContinuations. The conformance runner +## reports those as SKIP rather than passing them by omission. ## ## `project` is a plain Dictionary in the vectors' MinimalProject shape ## (`{"factions": {...}, "quests": {...}}`). Missing sub-objects are legal and @@ -15,7 +15,9 @@ extends RefCounted ## ## 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. Every other entry point is total. +## 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. const Rng := preload("res://addons/parlance/rng.gd") const State := preload("res://addons/parlance/state.gd") @@ -33,6 +35,38 @@ static func evaluate(condition, state: State, project := {}) -> bool: ## `visiting` carries the questOutcome reference cycle guard. An outcome whose ## `reachedWhen` leads back to itself is false rather than a stack overflow — ## the suite has a vector for exactly that. +## How SPECIFIC a condition is — the "most specific offer wins" tiebreak in +## resolve_character_dialogue. Lives beside evaluate because the reference does. +## +## absent (a fallback offer) -> 0 any leaf -> 1 +## all -> SUM of members any -> MIN of members (0 if empty) +## not -> its operand's +## +## `any` is MIN, not a clause count: any(a, b, c) is only as specific as its +## weakest branch, and a count would rank it above `a` alone. Total and pure. +static func condition_specificity(condition) -> int: + if not condition is Dictionary: + return 0 + match condition.get("type", ""): + "all": + var total := 0 + for c in condition.get("of", []): + total += condition_specificity(c) + return total + "any": + var members: Array = condition.get("of", []) + if members.is_empty(): + return 0 + var lowest := condition_specificity(members[0]) + for c in members: + lowest = mini(lowest, condition_specificity(c)) + return lowest + "not": + return condition_specificity(condition.get("of", null)) + _: + return 1 + + static func _evaluate(condition, state: State, project: Dictionary, visiting: Dictionary) -> bool: if not condition is Dictionary: return false @@ -212,9 +246,9 @@ static func apply_effect(effect, state: State, project := {}) -> State: next.xp += float(effect.get("amount", 0)) "set_active_dialogue": - # The feed model: a flag, not a separate map. The character's ladder - # carries a high-priority rung gated on it, and `dialogue` is - # metadata for tooling. Clear it with an ordinary set_flag false. + # The feed model: a flag, not a separate map. The forced dialogue + # carries a tier-1 offer gated on it, and `dialogue` is metadata + # for tooling. Clear it with an ordinary set_flag false. next.flags["active_dialogue__" + str(effect.get("character", ""))] = true "play_cutscene": @@ -247,7 +281,19 @@ static func apply_effects(effects, state: State, project := {}) -> State: ## ## `default_dice` is the project's `rules.check.dice`. Precedence is ## check.dice > default_dice > 1d20. -static func resolve_check(check: Dictionary, state: State, rng: Callable, default_dice = null, criticals := false) -> Dictionary: +## +## MODIFIERS (contract 0.14.0): total = roll + skill + check_bonus. Evaluating a +## modifier's `when` needs the project (quest conditions read stage order), so +## a check that declares modifiers and is given no project returns +## `{"error": ...}` — the reference throws there rather than read those +## conditions as silently false. `bonus` and `appliedModifiers` appear in the +## result ONLY when the check declares a modifier, so an unmodified check's +## result is byte-identical to the pre-0.14 one. +static func resolve_check(check: Dictionary, state: State, rng: Callable, default_dice = null, criticals := false, project = null) -> Dictionary: + var has_modifiers: bool = check.get("modifiers", null) is Array and not check["modifiers"].is_empty() + if has_modifiers and not project is Dictionary: + return {"error": "resolve_check: check has modifiers; pass project"} + var notation := str(check.get("dice", default_dice if default_dice != null else "1d20")) var spec := parse_dice(notation) @@ -260,7 +306,8 @@ static func resolve_check(check: Dictionary, state: State, rng: Callable, defaul roll += face var skill_value := float(state.skills.get(check.get("skill", ""), 0)) - var total := float(roll) + skill_value + var mod := check_bonus(check, state, project) if has_modifiers else {} + var total := float(roll) + skill_value + float(mod.get("bonus", 0)) var passed := total >= float(check.get("difficulty", 0)) var result := { @@ -270,6 +317,9 @@ static func resolve_check(check: Dictionary, state: State, rng: Callable, defaul "skillValue": skill_value, "dice": "%dd%d" % [spec.n, spec.m], } + if has_modifiers: + result["bonus"] = mod["bonus"] + result["appliedModifiers"] = mod["appliedModifiers"] # Criticals are judged on individual FACES, never the sum: 7 on 2d6 is 1+6 # or 3+4, and neither is critical. Default off — enabling changes every @@ -292,6 +342,32 @@ static func resolve_check(check: Dictionary, state: State, rng: Callable, defaul return result +## Σ of every modifier's `bonus` whose `when` holds, plus the indices that +## contributed, in array order: `{"bonus": float, "appliedModifiers": [int]}`. +## THE one place modifiers are summed, so the roll and the passive reveal can +## never disagree. Total and pure. +static func check_bonus(check: Dictionary, state: State, project = {}) -> Dictionary: + var proj: Dictionary = project if project is Dictionary else {} + var bonus := 0.0 + var applied: Array = [] + var mods = check.get("modifiers", null) + if mods is Array: + for i in mods.size(): + var m = mods[i] + if m is Dictionary and evaluate(m.get("when", null), state, proj): + bonus += float(m.get("bonus", 0)) + applied.append(i) + return {"bonus": bonus, "appliedModifiers": applied} + + +## Whether a PASSIVE check reveals its choice: skill + Σbonus >= difficulty. +## Passive checks do not roll; this is the threshold an engine applies to show +## or hide the option, so a modifier means the same thing in both modes. +static func passive_check_passes(check: Dictionary, state: State, project := {}) -> bool: + var skill_value := float(state.skills.get(check.get("skill", ""), 0)) + return skill_value + float(check_bonus(check, state, project)["bonus"]) >= float(check.get("difficulty", 0)) + + ## "NdM" -> {n, m}. Falls back to 1d20 on anything unparseable rather than ## throwing; the validator is what tells an author their notation is wrong. static func parse_dice(notation: String) -> Dictionary: @@ -397,7 +473,9 @@ static func choose_choice(dialogue: Dictionary, node_id: String, choice_id: Stri var check = choice.get("check", null) if check is Dictionary and check.get("mode", "") == "active": var rules: Dictionary = project.get("rules", {}).get("check", {}) if project.get("rules", null) is Dictionary else {} - var result := resolve_check(check, next_state, rng, rules.get("dice", null), bool(rules.get("criticals", false))) + var result := resolve_check(check, next_state, rng, rules.get("dice", null), bool(rules.get("criticals", false)), project) + if result.has("error"): + return result return { "nextNodeId": check.get("onSuccess", null) if result["passed"] else check.get("onFailure", null), "newState": next_state, @@ -464,30 +542,80 @@ static func advance_node(dialogue: Dictionary, node_id: String, state: State) -> return {"nextNodeId": target_id, "newState": state} -# ------------------------------------------- resolveCharacterDialogue (feed) -- +# ----------------------------------------- resolveCharacterDialogue (offers) -- -## Which dialogue this character offers right now, or null. +## Which dialogue this character offers right now, or null (contract 0.14.0). ## -## Walks `character.dialogues` — the ladder — IN ORDER and returns the first -## rung whose `showIf` passes; a rung with no `showIf` always passes. Array -## order is the whole mechanism: the specific, conditional rungs sit above the -## general fallback, so first-match-wins reads as "the most specific thing this -## character has to say today". +## A dialogue opts in by carrying an `offer` object — its PRESENCE is the +## opt-in, so `"offer": {}` is a fallback and a dialogue with no `offer` is +## never a candidate. It is offered by `offer.character ?? speakerId`. Among +## this character's offers, those whose `offer.when` passes (absent = always) +## are eligible, minus — when `visited` is given — any non-`replayable` one +## already seen. The winner is the most salient eligible offer: ## -## Returns null when the ladder is absent, empty, or nothing matches. +## 1. priority tier descending (offer.priority, default 0) +## 2. specificity descending (condition_specificity(offer.when)) +## 3. id ascending (ordinal code-unit compare, NOT locale) +## +## There is no array order anywhere: which file a dialogue lives in, or where +## it sits in `project.dialogues`, never changes the answer. ## ## The feed model: there is no `activeDialogues` map. `set_active_dialogue` -## sets the flag `active_dialogue__{character}`, and a high-priority rung gated -## on that flag is what pins a character to one conversation. -static func resolve_character_dialogue(state: State, character: Dictionary, project := {}) -> Variant: - for rung in character.get("dialogues", []): - if not rung is Dictionary: +## sets the flag `active_dialogue__{character}`, and the forced dialogue carries +## a tier-1 offer gated on it, which out-ranks every tier-0 offer. +## +## `visited` is an Array (or Dictionary keyed by id) of dialogue ids the host +## has shown; omit it for the pure state answer. +static func resolve_character_dialogue(state: State, character: Dictionary, project := {}, visited = null) -> Variant: + var character_id = character.get("id", null) + var dialogues = project.get("dialogues", {}) + if not dialogues is Dictionary: + return null + + var best = null + for dialogue in dialogues.values(): + if not dialogue is Dictionary: continue - if rung.has("showIf") and not evaluate(rung["showIf"], state, project): + # `is Dictionary`, not `has`: hand-edited data can carry `offer: null`, + # which the validator reports but the runtime must survive. + var offer = dialogue.get("offer", null) + if not offer is Dictionary: continue - return rung.get("dialogue", null) - return null + if offer.get("character", dialogue.get("speakerId", null)) != character_id: + continue + if offer.has("when") and not evaluate(offer["when"], state, project): + continue + if visited != null and dialogue.get("replayable", false) != true and _visited_has(visited, dialogue.get("id", null)): + continue + if best == null or _better_offer(dialogue, best): + best = dialogue + + return best.get("id", null) if best != null else null + + +## True if offer `a` outranks `b`: higher tier, then higher specificity, then +## the lower id. GDScript's String `<` compares code points, which orders +## exactly like the reference's UTF-16 compare for every id outside the astral +## planes — and ids are ASCII by schema. +static func _better_offer(a: Dictionary, b: Dictionary) -> bool: + var pa := int(a["offer"].get("priority", 0)) + var pb := int(b["offer"].get("priority", 0)) + if pa != pb: + return pa > pb + var sa := condition_specificity(a["offer"].get("when", null)) + var sb := condition_specificity(b["offer"].get("when", null)) + if sa != sb: + return sa > sb + return str(a.get("id", "")) < str(b.get("id", "")) + + +static func _visited_has(visited, id) -> bool: + if visited is Dictionary: + return visited.has(id) + if visited is Array: + return visited.has(id) + return false # ------------------------------------------------------ speaker / portrait -- @@ -512,8 +640,8 @@ static func effective_speaker_id(dialogue: Dictionary, node: Dictionary) -> Vari ## {"kind": "narration"} | {"kind": "character", "character": {...}} ## | {"kind": "skill", "skill": {...}} ## -## A dialogue-level speakerId is character-only (it doubles as ownership for -## ladder resolution); only the NODE level may name a skill — that is the +## A dialogue-level speakerId is character-only (it doubles as the default +## `offer.character` for offer resolution); only the NODE level may name a skill — that is the ## skill-voiced beat, an inner voice speaking a line. ## ## "No speakerId" and "a dangling speakerId" BOTH resolve to narration here. diff --git a/conformance/PIN b/conformance/PIN index e7aa472..e10143c 100644 --- a/conformance/PIN +++ b/conformance/PIN @@ -3,7 +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.12.0 -parlanceCommit=a45908cf66b87c5dd1e6db68be88d90a734ea117 +parlanceVersion=v0.14.0 +parlanceCommit=f4a25b0d94ca8569a2897e8354a3f1317f826a82 parlanceRepo=git@github.com:Orbitope/parlance.git # diff --git a/conformance/README.md b/conformance/README.md index 0c1466e..596ef39 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -18,18 +18,44 @@ Counts below drift every time vectors are added, so treat them as indicative and |---|---|---| | `evaluate.json` | `evaluate(condition, state, project)` | 53 | | `apply_effect.json` | `applyEffect(effect, state, project)` | 27 | -| `resolve_check.json` | `resolveCheck(check, state, rng, defaultDice?, criticals?)` | 18 | -| `step_dialogue.json` | `stepDialogue(dialogue, nodeId, state, project)` | 8 | -| `choose_choice.json` | `chooseChoice(dialogue, nodeId, choiceId, state, project, rng)` | 9 | -| `advance.json` | `advanceNode(dialogue, nodeId, state)` | 9 | -| `resolveCharacterDialogue.json` | `resolveCharacterDialogue(state, character, project)` | 6 | +| `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 | +| `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 | | `rng.json` | mulberry32(seed) output stream — the seeded PRNG ports must reproduce exactly | 6 | -`advance.json` is the one file with a failure case: some vectors carry `expectedError` (a substring -the thrown error must contain) instead of `expected` — see Error cases below. Every other file is -`expected` + deep-equality only. +### `migrate_ladders/` — vectors for the 0.13 → 0.14 ladder migration + +`migrate_ladders/vectors.json` holds input projects (characters with the retired +`dialogues` ladder, dialogues with the retired `availableWhen`) beside the exact +`offer` objects the reference migration writes and the report lines it prints. +`tooling/scripts/migrate_ladders.py` (published beside the validator as +`validate/migrate_ladders.py`) is the reference; `editor/core/src/migrateLadders.ts` +is held to the same vectors. A port that converts 0.13 projects can replay them; +one that only reads 0.14 projects can ignore this directory. + +### `validator/` — cases for the validator, not the runtime + +The JSON files above pin *runtime* behavior. `validator/cases/` pins the +**validation rules** instead: each case is a small complete project with one +seeded defect and an `expected.json` saying which issue codes must (and must +not) be reported. They exist because this repo has two validators — the +reference one in `tooling/validate.py` and the one inside the editor — and two +implementations of one rule set drift silently: a rule that stops firing looks +exactly like data with no defects. + +A port that ships its own validator can vendor these the same way, and check it +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. ## Vector format @@ -55,6 +81,8 @@ Each file is a JSON array. Each element is a self-contained test vector with the "choiceId": string // chooseChoice only "rng": number | number[] // resolveCheck and chooseChoice (active checks). Array = one value per die, in order "defaultDice": string // resolveCheck only, optional — project rules.check.dice (absent ⇒ 1d20) + // resolveCheck also takes "project" (optional) — required only when the + // check declares modifiers whose `when` reads quest/questOutcome state // Expected output — exactly ONE of these two is present: "expected": varies by fn // see per-function sections below @@ -142,16 +170,39 @@ carried through unchanged from the input state. ### resolveCheck ```json -{ "expected": { "passed": true, "roll": 5, "total": 10, "skillValue": 5 } } +{ "expected": { "passed": true, "roll": 5, "total": 10, "skillValue": 5, "dice": "1d20" } } ``` +`bonus` and `appliedModifiers` are present **iff the check declares at least one +modifier** (0 / `[]` when none applied), so a check without modifiers produces the +same result it did before the feature existed. `total = roll + skillValue + Σbonus` +over every modifier whose `when` holds; a check with modifiers must be resolved with +a `project`. `critical` is present only on a critical roll (see the roll model). ### stepDialogue ```json -{ "expected": { "visibleChoiceIds": ["ch_goto", "ch_check"], "onEnterEffectCount": 1 } } +{ "expected": { + "nodeId": "n_real", + "visibleChoiceIds": ["ch_goto", "ch_check"], + "onEnterEffectCount": 1, + "onEnterEffects": [{ "type": "set_flag", "flag": "visited_start", "value": true }] +} } ``` +`nodeId` is the **resolved** node's id — `stepDialogue` walks past nodes whose own +`showIf` fails (see `resolveNode` in `RUNTIME_CONTRACT.md`), so the node returned may not +be the one requested. **Check it when present**, by the same reasoning as the effects note +below: without it, a runtime that never implements the skip walk still passes every vector +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. -`onEnterEffectCount` is the count of effects in `node.onEnter` (they are returned but -**not applied** — that is the caller's responsibility). +`onEnterEffects` is `node.onEnter` in order — the effects are returned but **not +applied**, which is the caller's responsibility. + +`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 +reverse, or by returning the right number of nulls — all of which apply the +wrong thing to the player's state while reporting conformance. ### chooseChoice ```json @@ -175,8 +226,10 @@ Resolves `node.next` — the choiceless counterpart of `chooseChoice`, for liste **no effects are applied and no check is resolved** — `next` carries neither — so `newState` always equals the input `state`, byte-for-byte, even when the TARGET node has `onEnter` effects: those are the caller's responsibility on arrival, exactly as with -`stepDialogue`'s `onEnterEffects` / a `goto` arrival, never `advanceNode`'s own job. Does -not chase further `next` pointers — one call resolves exactly one hop. +`stepDialogue`'s `onEnterEffects` / a `goto` arrival, never `advanceNode`'s own job. The +returned `nextNodeId` is **post-skip**: the target is resolved through any failing node +`showIf` gates, so it names the node the player will actually see. One call still reveals +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 @@ -186,18 +239,23 @@ otherwise); reaching it anyway is a bug upstream, not a `Problem`/result-object ### resolveCharacterDialogue ```json -{ "character": Character, "expected": "d_first_meeting" } // or null +{ "character": Character, "project": { "dialogues": {…} }, "visited": ["d_x"], "expected": "d_confront" } // or null ``` -Walk `character.dialogues` (the ladder) in order and return the first rung whose `showIf` -passes (absent `showIf` = always). Return `null` if the ladder is empty/absent or nothing -matches. Array order is significant — first match wins. The vector carries the full -`Character` (with its `dialogues` ladder) as an input field. - -> **Feed model (contract change).** `activeDialogues` was removed from -> `SerializedGameState`. `set_active_dialogue` now sets the flag -> `active_dialogue__{character}` (see `apply_effect.json`), and dialogue resolution is -> always the ladder via `resolveCharacterDialogue`. Older serialized states that carry an -> `activeDialogues` field should ignore it. +Saliency model (ws 17). Gather the dialogues whose `offer.character ?? speakerId` equals +`character.id`; the presence of `offer` is the opt-in (a dialogue without it is never a +candidate). Keep the ones whose `offer.when` passes (absent = always); when a `visited` set +is given, drop the non-replayable ones already in it. Pick the winner by: + +1. `offer.priority ?? 0` — descending (tier) +2. condition specificity of `offer.when` — descending (leaf 1, `all` sum, `any` **min**, `not` operand, absent 0) +3. `id` — ascending, ordinal / UTF-16 code-unit compare (NOT locale collation) + +Return `null` if no offer is eligible. The vector carries the offered dialogues under +`project.dialogues` and an optional `visited` list; array order is NOT significant anywhere. + +> **Feed model.** `set_active_dialogue` sets the flag `active_dialogue__{character}` (see +> `apply_effect.json`); a forced dialogue is a tier-1 offer whose `when` reads that flag. +> There is no `activeDialogues` map in `SerializedGameState`; states carrying one ignore it. ### progression Each vector carries a `config` (the `progression.json` shape) and dispatches on `fn`: diff --git a/conformance/choose_choice.json b/conformance/choose_choice.json index 77a1e7d..3d5199c 100644 --- a/conformance/choose_choice.json +++ b/conformance/choose_choice.json @@ -1,621 +1,716 @@ [ - { - "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": {} + { + "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" + } + ] }, - "expected": { - "nextNodeId": "node_end", - "newState": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} + { + "id": "node_end", + "isEnd": true, + "text": "End.", + "choices": [ + { + "id": "ch_terminal", + "text": "Done." } + ] } + ] }, - { - "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 - } - ] - } - ] - }, + "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": [ { - "id": "node_end", - "isEnd": true, - "text": "End." + "type": "set_flag", + "flag": "took_path", + "value": true } - ] - }, - "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": {} + ] } + ] + }, + { + "id": "node_end", + "isEnd": true, + "text": "End." } + ] }, - { - "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_flag_goto", + "state": { + "flags": {}, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "project": { + "factions": {} + }, + "expected": { + "nextNodeId": "node_end", + "newState": { + "flags": { + "took_path": true }, - "nodeId": "node_start", - "choiceId": "ch_check", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 5 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} + "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" + } + } + ] }, - "project": { - "factions": {} + { + "id": "node_win", + "isEnd": true, + "text": "Won." }, - "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" - } + { + "id": "node_lose", + "isEnd": true, + "text": "Lost." } + ] }, - { - "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": 5 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "project": { + "factions": {} + }, + "rng": 0.2, + "expected": { + "nextNodeId": "node_win", + "newState": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 5 }, - "nodeId": "node_start", - "choiceId": "ch_check", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 0 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} + "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" + } + } + ] }, - "project": { - "factions": {} + { + "id": "node_win", + "isEnd": true, + "text": "Won." }, - "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" - } + { + "id": "node_lose", + "isEnd": true, + "text": "Lost." } + ] }, - { - "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." - }, + "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": [ { - "id": "node_lose", - "isEnd": true, - "text": "Lost." + "type": "set_flag", + "flag": "prepared", + "value": true } - ] - }, - "nodeId": "node_start", - "choiceId": "ch_effect_then_check", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 5 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} + ], + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 10, + "onSuccess": "node_win", + "onFailure": "node_lose" + } + } + ] }, - "project": { - "factions": {} + { + "id": "node_win", + "isEnd": true, + "text": "Won." }, - "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" - } + { + "id": "node_lose", + "isEnd": true, + "text": "Lost." } + ] }, - { - "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": {} + "nodeId": "node_start", + "choiceId": "ch_effect_then_check", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 5 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "project": { + "factions": {} + }, + "rng": 0.2, + "expected": { + "nextNodeId": "node_win", + "newState": { + "flags": { + "prepared": true }, - "project": { - "factions": {} + "reputation": {}, + "skills": { + "wit": 5 }, - "expected": { - "nextNodeId": "node_win", - "newState": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 0 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} + "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 + } } + ] + }, + { + "id": "node_win", + "isEnd": true, + "text": "Won." } + ] }, - { - "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": {} + "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 }, - "expected": { - "nextNodeId": null, - "newState": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} + "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." } + ] } + ] + }, + "nodeId": "node_end", + "choiceId": "ch_terminal", + "state": { + "flags": {}, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "project": { + "factions": {} }, - { - "fn": "chooseChoice", - "description": "multiple effects threaded in order: flag then reputation", - "dialogue": { - "id": "dlg_conformance", - "entry": "node_start", - "nodes": [ + "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": [ { - "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 - } - ] - } - ] + "type": "set_flag", + "flag": "done", + "value": true }, { - "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 - } + "type": "adjust_reputation", + "faction": "guild", + "delta": 2 } + ] } + ] }, - "expected": { - "nextNodeId": "node_end", - "newState": { - "flags": { - "done": true - }, - "reputation": { - "guild": 2 - }, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - } + { + "id": "node_end", + "isEnd": true, + "text": "End." } + ] }, - { - "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" - } - ] - } - ] - }, + "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 + } + } + } + }, + "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": [ { - "id": "node_end", - "isEnd": true, - "text": "End." + "type": "give_item", + "item": "key_card" } - ] + ] + } + ] }, - "nodeId": "node_start", - "choiceId": "ch_loot", - "state": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} + { + "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": {} + } + } + }, + { + "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 + } + ] + } + } + ] }, - "project": { - "factions": {} + { + "id": "node_win", + "isEnd": true, + "text": "Won." }, - "expected": { - "nextNodeId": "node_end", - "newState": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [ - "key_card" - ], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - } + { + "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 + ] + } } + } ] diff --git a/conformance/nextContinuations.json b/conformance/nextContinuations.json new file mode 100644 index 0000000..46d2f36 --- /dev/null +++ b/conformance/nextContinuations.json @@ -0,0 +1,207 @@ +[ + { + "fn": "nextContinuations", + "description": "forced routing wins over discovery and ignores the visited set: the flag-gated tier-1 offer is queued even though it was visited, and no other character is offered", + "state": { + "flags": { "active_dialogue__npc_x": true }, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {} + }, + "visited": ["d_forced"], + "currentDialogueId": "d_other", + "project": { + "characters": { + "npc_x": { "id": "npc_x", "name": "X" }, + "npc_y": { "id": "npc_y", "name": "Y" } + }, + "dialogues": { + "d_forced": { + "id": "d_forced", + "entry": "n", + "nodes": [ + { "id": "n", "text": "…", "isEnd": true } + ], + "speakerId": "npc_x", + "offer": { + "priority": 1, + "when": { "type": "flag", "flag": "active_dialogue__npc_x", "value": true } + } + }, + "d_x_default": { + "id": "d_x_default", + "entry": "n", + "nodes": [ + { "id": "n", "text": "…", "isEnd": true } + ], + "speakerId": "npc_x", + "offer": {} + }, + "d_y_default": { + "id": "d_y_default", + "entry": "n", + "nodes": [ + { "id": "n", "text": "…", "isEnd": true } + ], + "speakerId": "npc_y", + "offer": {} + } + } + }, + "expected": [ + { "kind": "dialogue", "characterId": "npc_x", "dialogue": "d_forced", "queued": true } + ] + }, + { + "fn": "nextContinuations", + "description": "a routed character whose winning offer does not read the flag is NOT forced: no queued continuation, discovery runs for everyone, the visited filter applies", + "state": { + "flags": { "active_dialogue__npc_x": true }, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {} + }, + "visited": ["d_x_default"], + "currentDialogueId": "d_other", + "project": { + "characters": { + "npc_x": { "id": "npc_x", "name": "X" }, + "npc_y": { "id": "npc_y", "name": "Y" } + }, + "dialogues": { + "d_forced": { + "id": "d_forced", + "entry": "n", + "nodes": [ + { "id": "n", "text": "…", "isEnd": true } + ], + "speakerId": "npc_x", + "offer": { + "priority": 1, + "when": { + "type": "all", + "of": [ + { "type": "flag", "flag": "active_dialogue__npc_x", "value": true }, + { "type": "item", "item": "key", "has": true } + ] + } + } + }, + "d_x_default": { + "id": "d_x_default", + "entry": "n", + "nodes": [ + { "id": "n", "text": "…", "isEnd": true } + ], + "speakerId": "npc_x", + "offer": {} + }, + "d_y_default": { + "id": "d_y_default", + "entry": "n", + "nodes": [ + { "id": "n", "text": "…", "isEnd": true } + ], + "speakerId": "npc_y", + "offer": {} + } + } + }, + "expected": [ + { "kind": "dialogue", "characterId": "npc_y", "dialogue": "d_y_default", "queued": false } + ] + }, + { + "fn": "nextContinuations", + "description": "no flag set: discovery offers each character's best eligible offer, unqueued, dropping visited one-shots and the current dialogue", + "state": { "flags": {}, "reputation": {}, "skills": {}, "counters": {}, "inventory": [], "questStages": {} }, + "visited": ["d_x_default"], + "currentDialogueId": "d_y_default", + "project": { + "characters": { + "npc_x": { "id": "npc_x", "name": "X" }, + "npc_y": { "id": "npc_y", "name": "Y" } + }, + "dialogues": { + "d_forced": { + "id": "d_forced", + "entry": "n", + "nodes": [ + { "id": "n", "text": "…", "isEnd": true } + ], + "speakerId": "npc_x", + "offer": { + "priority": 1, + "when": { "type": "flag", "flag": "active_dialogue__npc_x", "value": true } + } + }, + "d_x_default": { + "id": "d_x_default", + "entry": "n", + "nodes": [ + { "id": "n", "text": "…", "isEnd": true } + ], + "speakerId": "npc_x", + "offer": {} + }, + "d_x_replay": { + "id": "d_x_replay", + "entry": "n", + "nodes": [ + { "id": "n", "text": "…", "isEnd": true } + ], + "speakerId": "npc_x", + "replayable": true, + "offer": { + "when": { "type": "flag", "flag": "seen", "value": true } + } + }, + "d_y_default": { + "id": "d_y_default", + "entry": "n", + "nodes": [ + { "id": "n", "text": "…", "isEnd": true } + ], + "speakerId": "npc_y", + "offer": {} + } + } + }, + "expected": [] + }, + { + "fn": "nextContinuations", + "description": "a pending cutscene is listed before any dialogue continuation", + "state": { "flags": {}, "reputation": {}, "skills": {}, "counters": {}, "inventory": [], "questStages": {}, "pendingCutscene": "cs_intro" }, + "visited": [], + "currentDialogueId": "d_other", + "project": { + "characters": { + "npc_x": { "id": "npc_x", "name": "X" }, + "npc_y": { "id": "npc_y", "name": "Y" } + }, + "dialogues": { + "d_x_default": { + "id": "d_x_default", + "entry": "n", + "nodes": [ + { "id": "n", "text": "…", "isEnd": true } + ], + "speakerId": "npc_x", + "offer": {} + } + }, + "cutscenes": { + "cs_intro": { "id": "cs_intro", "name": "Intro" } + } + }, + "expected": [ + { "kind": "cutscene", "cutscene": "cs_intro" }, + { "kind": "dialogue", "characterId": "npc_x", "dialogue": "d_x_default", "queued": false } + ] + } +] diff --git a/conformance/resolveCharacterDialogue.json b/conformance/resolveCharacterDialogue.json index aef2acc..e2222d7 100644 --- a/conformance/resolveCharacterDialogue.json +++ b/conformance/resolveCharacterDialogue.json @@ -1,129 +1,461 @@ [ { "fn": "resolveCharacterDialogue", - "description": "first-match wins: highest passing rung is returned even when lower rungs also pass", + "description": "most specific eligible offer wins over a less specific one", "state": { - "flags": { "betrayed": true, "met": true }, + "flags": { "met": true, "betrayed": true }, "reputation": {}, "skills": {}, "counters": {}, "inventory": [], "questStages": {} }, - "character": { - "id": "char_wren", - "name": "Wren", - "archetype": "operative", - "dialogues": [ - { "dialogue": "d_confront", "showIf": { "type": "flag", "flag": "betrayed", "value": true } }, - { "dialogue": "d_post_meeting", "showIf": { "type": "flag", "flag": "met", "value": true } }, - { "dialogue": "d_first_meeting" } - ] + "character": { "id": "char_wren", "name": "Wren" }, + "project": { + "dialogues": { + "d_greeting": { "id": "d_greeting", "speakerId": "char_wren", "offer": {} }, + "d_met": { + "id": "d_met", + "speakerId": "char_wren", + "offer": { + "when": { "type": "flag", "flag": "met", "value": true } + } + }, + "d_confront": { + "id": "d_confront", + "speakerId": "char_wren", + "offer": { + "when": { + "type": "all", + "of": [ + { "type": "flag", "flag": "met", "value": true }, + { "type": "flag", "flag": "betrayed", "value": true } + ] + } + } + } + } }, "expected": "d_confront" }, { "fn": "resolveCharacterDialogue", - "description": "unconditional rung above shadows a later matching rung", + "description": "priority tier beats a higher-specificity offer", "state": { - "flags": { "met": true }, + "flags": { "met": true, "urgent": true }, "reputation": {}, "skills": {}, "counters": {}, "inventory": [], "questStages": {} }, - "character": { - "id": "char_wren", - "name": "Wren", - "archetype": "operative", - "dialogues": [ - { "dialogue": "d_idle" }, - { "dialogue": "d_post_meeting", "showIf": { "type": "flag", "flag": "met", "value": true } } - ] + "character": { "id": "char_wren", "name": "Wren" }, + "project": { + "dialogues": { + "d_specific": { + "id": "d_specific", + "speakerId": "char_wren", + "offer": { + "when": { + "type": "all", + "of": [ + { "type": "flag", "flag": "met", "value": true }, + { "type": "flag", "flag": "urgent", "value": true } + ] + } + } + }, + "d_urgent": { + "id": "d_urgent", + "speakerId": "char_wren", + "offer": { + "when": { "type": "flag", "flag": "urgent", "value": true }, + "priority": 1 + } + } + } + }, + "expected": "d_urgent" + }, + { + "fn": "resolveCharacterDialogue", + "description": "id breaks a tie by ordinal (UTF-16 code unit) order, not locale collation: d_Z before d_a", + "state": { "flags": {}, "reputation": {}, "skills": {}, "counters": {}, "inventory": [], "questStages": {} }, + "character": { "id": "char_wren", "name": "Wren" }, + "project": { + "dialogues": { + "d_Z": { "id": "d_Z", "speakerId": "char_wren", "offer": {} }, + "d_a": { "id": "d_a", "speakerId": "char_wren", "offer": {} } + } }, - "expected": "d_idle" + "expected": "d_Z" }, { "fn": "resolveCharacterDialogue", - "description": "fallthrough: rung with no showIf is selected when nothing above matches", + "description": "fallback (no when) is offered when nothing more specific is eligible", "state": { - "flags": { "betrayed": false, "met": false }, + "flags": { "met": false }, "reputation": {}, "skills": {}, "counters": {}, "inventory": [], "questStages": {} }, - "character": { - "id": "char_wren", - "name": "Wren", - "archetype": "operative", - "dialogues": [ - { "dialogue": "d_confront", "showIf": { "type": "flag", "flag": "betrayed", "value": true } }, - { "dialogue": "d_post_meeting", "showIf": { "type": "flag", "flag": "met", "value": true } }, - { "dialogue": "d_first_meeting" } - ] - }, - "expected": "d_first_meeting" + "character": { "id": "char_wren", "name": "Wren" }, + "project": { + "dialogues": { + "d_met": { + "id": "d_met", + "speakerId": "char_wren", + "offer": { + "when": { "type": "flag", "flag": "met", "value": true } + } + }, + "d_greeting": { "id": "d_greeting", "speakerId": "char_wren", "offer": {} } + } + }, + "expected": "d_greeting" }, { "fn": "resolveCharacterDialogue", - "description": "empty ladder resolves to null", + "description": "an any offer scores the MINIMUM of its branches, so a two-clause all offer outranks it", "state": { - "flags": {}, + "flags": { "a": true, "b": true, "c": true }, "reputation": {}, "skills": {}, "counters": {}, "inventory": [], "questStages": {} }, - "character": { - "id": "char_mute", - "name": "Mute", - "archetype": "operative", - "dialogues": [] + "character": { "id": "char_wren", "name": "Wren" }, + "project": { + "dialogues": { + "d_aaa": { + "id": "d_aaa", + "speakerId": "char_wren", + "offer": { + "when": { + "type": "any", + "of": [ + { "type": "flag", "flag": "a", "value": true }, + { "type": "flag", "flag": "b", "value": true } + ] + } + } + }, + "d_zzz": { + "id": "d_zzz", + "speakerId": "char_wren", + "offer": { + "when": { + "type": "all", + "of": [ + { "type": "flag", "flag": "a", "value": true }, + { "type": "flag", "flag": "c", "value": true } + ] + } + } + } + } }, - "expected": null + "expected": "d_zzz" }, { "fn": "resolveCharacterDialogue", - "description": "absent ladder resolves to null", + "description": "a not offer scores its operand's specificity (1), beating a bare fallback", "state": { - "flags": {}, + "flags": { "a": false }, "reputation": {}, "skills": {}, "counters": {}, "inventory": [], "questStages": {} }, - "character": { - "id": "char_mute", - "name": "Mute", - "archetype": "operative" + "character": { "id": "char_wren", "name": "Wren" }, + "project": { + "dialogues": { + "d_not": { + "id": "d_not", + "speakerId": "char_wren", + "offer": { + "when": { + "type": "not", + "of": { "type": "flag", "flag": "a", "value": true } + } + } + }, + "d_fallback": { "id": "d_fallback", "speakerId": "char_wren", "offer": {} } + } + }, + "expected": "d_not" + }, + { + "fn": "resolveCharacterDialogue", + "description": "a character with no eligible offer resolves to null", + "state": { "flags": {}, "reputation": {}, "skills": {}, "counters": {}, "inventory": [], "questStages": {} }, + "character": { "id": "char_wren", "name": "Wren" }, + "project": { + "dialogues": { + "d_gated": { + "id": "d_gated", + "speakerId": "char_wren", + "offer": { + "when": { "type": "flag", "flag": "never", "value": true } + } + } + } }, "expected": null }, { "fn": "resolveCharacterDialogue", - "description": "no rung matches (all gated, none satisfied) resolves to null", + "description": "a dialogue without an offer is not a candidate even when its speakerId matches", + "state": { "flags": {}, "reputation": {}, "skills": {}, "counters": {}, "inventory": [], "questStages": {} }, + "character": { "id": "char_wren", "name": "Wren" }, + "project": { + "dialogues": { + "d_scene": { "id": "d_scene", "speakerId": "char_wren" } + } + }, + "expected": null + }, + { + "fn": "resolveCharacterDialogue", + "description": "offer.character offers a dialogue for a character other than its speaker", + "state": { "flags": {}, "reputation": {}, "skills": {}, "counters": {}, "inventory": [], "questStages": {} }, + "character": { "id": "player", "name": "Player" }, + "project": { + "dialogues": { + "d_trial": { + "id": "d_trial", + "offer": { "character": "player" } + } + } + }, + "expected": "d_trial" + }, + { + "fn": "resolveCharacterDialogue", + "description": "a visited non-replayable offer is dropped and the next-best offer plays", "state": { - "flags": { "betrayed": false, "met": false }, + "flags": { "met": true }, "reputation": {}, "skills": {}, "counters": {}, "inventory": [], "questStages": {} }, - "character": { - "id": "char_wren", - "name": "Wren", - "archetype": "operative", - "dialogues": [ - { "dialogue": "d_confront", "showIf": { "type": "flag", "flag": "betrayed", "value": true } }, - { "dialogue": "d_post_meeting", "showIf": { "type": "flag", "flag": "met", "value": true } } - ] + "character": { "id": "char_wren", "name": "Wren" }, + "project": { + "dialogues": { + "d_intro": { + "id": "d_intro", + "speakerId": "char_wren", + "offer": { + "when": { "type": "flag", "flag": "met", "value": true } + } + }, + "d_greeting": { "id": "d_greeting", "speakerId": "char_wren", "offer": {} } + } }, - "expected": null + "visited": ["d_intro"], + "expected": "d_greeting" + }, + { + "fn": "resolveCharacterDialogue", + "description": "a visited replayable offer is kept", + "state": { + "flags": { "met": true }, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {} + }, + "character": { "id": "char_wren", "name": "Wren" }, + "project": { + "dialogues": { + "d_intro": { + "id": "d_intro", + "speakerId": "char_wren", + "replayable": true, + "offer": { + "when": { "type": "flag", "flag": "met", "value": true } + } + }, + "d_greeting": { "id": "d_greeting", "speakerId": "char_wren", "offer": {} } + } + }, + "visited": ["d_intro"], + "expected": "d_intro" + }, + { + "fn": "resolveCharacterDialogue", + "description": "forced routing: with active_dialogue__ set, the tier-1 offer gated on it beats a more specific tier-0 offer", + "state": { + "flags": { "active_dialogue__char_wren": true, "met": true, "urgent": true }, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {} + }, + "character": { "id": "char_wren", "name": "Wren" }, + "project": { + "dialogues": { + "d_queued": { + "id": "d_queued", + "speakerId": "char_wren", + "offer": { + "priority": 1, + "when": { "type": "flag", "flag": "active_dialogue__char_wren", "value": true } + } + }, + "d_specific": { + "id": "d_specific", + "speakerId": "char_wren", + "offer": { + "when": { + "type": "all", + "of": [ + { "type": "flag", "flag": "met", "value": true }, + { "type": "flag", "flag": "urgent", "value": true } + ] + } + } + }, + "d_default": { "id": "d_default", "speakerId": "char_wren", "offer": {} } + } + }, + "expected": "d_queued" + }, + { + "fn": "resolveCharacterDialogue", + "description": "forced routing: once the flag is cleared the same state resolves to the ordinary winner", + "state": { + "flags": { "active_dialogue__char_wren": false, "met": true, "urgent": true }, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {} + }, + "character": { "id": "char_wren", "name": "Wren" }, + "project": { + "dialogues": { + "d_queued": { + "id": "d_queued", + "speakerId": "char_wren", + "offer": { + "priority": 1, + "when": { "type": "flag", "flag": "active_dialogue__char_wren", "value": true } + } + }, + "d_specific": { + "id": "d_specific", + "speakerId": "char_wren", + "offer": { + "when": { + "type": "all", + "of": [ + { "type": "flag", "flag": "met", "value": true }, + { "type": "flag", "flag": "urgent", "value": true } + ] + } + } + }, + "d_default": { "id": "d_default", "speakerId": "char_wren", "offer": {} } + } + }, + "expected": "d_specific" + }, + { + "fn": "resolveCharacterDialogue", + "description": "offer.character overrides a present speakerId: the dialogue is a candidate for the offer's character, not the speaker (wren side)", + "state": { + "flags": { "met": true }, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {} + }, + "character": { "id": "char_wren", "name": "Wren" }, + "project": { + "dialogues": { + "d_handoff": { + "id": "d_handoff", + "speakerId": "char_guide", + "offer": { + "character": "char_wren", + "when": { "type": "flag", "flag": "met", "value": true } + } + }, + "d_guide_default": { "id": "d_guide_default", "speakerId": "char_guide", "offer": {} }, + "d_wren_default": { "id": "d_wren_default", "speakerId": "char_wren", "offer": {} } + } + }, + "expected": "d_handoff" + }, + { + "fn": "resolveCharacterDialogue", + "description": "offer.character overrides a present speakerId: the speaker does not get the dialogue (guide side)", + "state": { + "flags": { "met": true }, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {} + }, + "character": { "id": "char_guide", "name": "Guide" }, + "project": { + "dialogues": { + "d_handoff": { + "id": "d_handoff", + "speakerId": "char_guide", + "offer": { + "character": "char_wren", + "when": { "type": "flag", "flag": "met", "value": true } + } + }, + "d_guide_default": { "id": "d_guide_default", "speakerId": "char_guide", "offer": {} }, + "d_wren_default": { "id": "d_wren_default", "speakerId": "char_wren", "offer": {} } + } + }, + "expected": "d_guide_default" + }, + { + "fn": "resolveCharacterDialogue", + "description": "an explicit priority 0 is the same tier as an absent priority: equal specificity, so the lower id wins", + "state": { + "flags": { "met": true }, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {} + }, + "character": { "id": "char_wren", "name": "Wren" }, + "project": { + "dialogues": { + "d_b_explicit": { + "id": "d_b_explicit", + "speakerId": "char_wren", + "offer": { + "priority": 0, + "when": { "type": "flag", "flag": "met", "value": true } + } + }, + "d_a_absent": { + "id": "d_a_absent", + "speakerId": "char_wren", + "offer": { + "when": { "type": "flag", "flag": "met", "value": true } + } + } + } + }, + "expected": "d_a_absent" } ] diff --git a/conformance/resolve_check.json b/conformance/resolve_check.json index 5bfba77..f953f7c 100644 --- a/conformance/resolve_check.json +++ b/conformance/resolve_check.json @@ -1,584 +1,884 @@ [ - { - "fn": "resolveCheck", - "description": "minimum roll: rng=0.0 \u2192 roll=1, wit=0, DC=1 \u2192 pass (1 >= 1)", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 0 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 1, - "onSuccess": "node_win", - "onFailure": "node_lose" - }, - "rng": 0, - "expected": { - "passed": true, - "roll": 1, - "total": 1, - "skillValue": 0, - "dice": "1d20" - } + { + "fn": "resolveCheck", + "description": "minimum roll: rng=0.0 → roll=1, wit=0, DC=1 → pass (1 >= 1)", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} }, - { - "fn": "resolveCheck", - "description": "minimum roll: rng=0.0 \u2192 roll=1, wit=0, DC=2 \u2192 fail (1 < 2)", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 0 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 2, - "onSuccess": "node_win", - "onFailure": "node_lose" - }, - "rng": 0, - "expected": { - "passed": false, - "roll": 1, - "total": 1, - "skillValue": 0, - "dice": "1d20" - } + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 1, + "onSuccess": "node_win", + "onFailure": "node_lose" }, - { - "fn": "resolveCheck", - "description": "maximum roll: rng=0.95 \u2192 roll=20, wit=0, DC=20 \u2192 pass (20 >= 20)", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 0 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 20, - "onSuccess": "node_win", - "onFailure": "node_lose" - }, - "rng": 0.95, - "expected": { - "passed": true, - "roll": 20, - "total": 20, - "skillValue": 0, - "dice": "1d20" - } + "rng": 0, + "expected": { + "passed": true, + "roll": 1, + "total": 1, + "skillValue": 0, + "dice": "1d20" + } + }, + { + "fn": "resolveCheck", + "description": "minimum roll: rng=0.0 → roll=1, wit=0, DC=2 → fail (1 < 2)", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} }, - { - "fn": "resolveCheck", - "description": "impossible: rng=0.95 \u2192 roll=20, wit=0, DC=21 \u2192 fail (20 < 21)", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 0 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 21, - "onSuccess": "node_win", - "onFailure": "node_lose" - }, - "rng": 0.95, - "expected": { - "passed": false, - "roll": 20, - "total": 20, - "skillValue": 0, - "dice": "1d20" - } + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 2, + "onSuccess": "node_win", + "onFailure": "node_lose" }, - { - "fn": "resolveCheck", - "description": "exact threshold with skill: rng=0.2 \u2192 roll=5, wit=5, DC=10 \u2192 pass (10 >= 10)", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 5 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 10, - "onSuccess": "node_win", - "onFailure": "node_lose" - }, - "rng": 0.2, - "expected": { - "passed": true, - "roll": 5, - "total": 10, - "skillValue": 5, - "dice": "1d20" - } + "rng": 0, + "expected": { + "passed": false, + "roll": 1, + "total": 1, + "skillValue": 0, + "dice": "1d20" + } + }, + { + "fn": "resolveCheck", + "description": "maximum roll: rng=0.95 → roll=20, wit=0, DC=20 → pass (20 >= 20)", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} }, - { - "fn": "resolveCheck", - "description": "one below threshold: rng=0.2 \u2192 roll=5, wit=4, DC=10 \u2192 fail (9 < 10)", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 4 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 10, - "onSuccess": "node_win", - "onFailure": "node_lose" - }, - "rng": 0.2, - "expected": { - "passed": false, - "roll": 5, - "total": 9, - "skillValue": 4, - "dice": "1d20" - } + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 20, + "onSuccess": "node_win", + "onFailure": "node_lose" }, - { - "fn": "resolveCheck", - "description": "missing skill key defaults to 0: rng=0.5 \u2192 roll=11, wit missing, DC=10 \u2192 pass (11 >= 10)", - "state": { - "flags": {}, - "reputation": {}, - "skills": {}, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 10, - "onSuccess": "node_win", - "onFailure": "node_lose" - }, - "rng": 0.5, - "expected": { - "passed": true, - "roll": 11, - "total": 11, - "skillValue": 0, - "dice": "1d20" - } + "rng": 0.95, + "expected": { + "passed": true, + "roll": 20, + "total": 20, + "skillValue": 0, + "dice": "1d20" + } + }, + { + "fn": "resolveCheck", + "description": "impossible: rng=0.95 → roll=20, wit=0, DC=21 → fail (20 < 21)", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} }, - { - "fn": "resolveCheck", - "description": "skill adds to roll: rng=0.5 \u2192 roll=11, wit=3, DC=14 \u2192 pass (14 >= 14)", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 3 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 14, - "onSuccess": "node_win", - "onFailure": "node_lose" - }, - "rng": 0.5, - "expected": { - "passed": true, - "roll": 11, - "total": 14, - "skillValue": 3, - "dice": "1d20" - } + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 21, + "onSuccess": "node_win", + "onFailure": "node_lose" }, - { - "fn": "resolveCheck", - "description": "per-check 2d6: rng=[0.99,0.99] -> roll=12, wit=2, DC=8 -> pass", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 2 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 8, - "onSuccess": "node_win", - "onFailure": "node_lose", - "dice": "2d6" - }, - "rng": [ - 0.99, - 0.99 - ], - "expected": { - "passed": true, - "roll": 12, - "total": 14, - "skillValue": 2, - "dice": "2d6" - } + "rng": 0.95, + "expected": { + "passed": false, + "roll": 20, + "total": 20, + "skillValue": 0, + "dice": "1d20" + } + }, + { + "fn": "resolveCheck", + "description": "exact threshold with skill: rng=0.2 → roll=5, wit=5, DC=10 → pass (10 >= 10)", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 5 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} }, - { - "fn": "resolveCheck", - "description": "per-check 1d100: rng=0.5 -> roll=51, wit=0, DC=51 -> pass (51>=51)", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 0 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 51, - "onSuccess": "node_win", - "onFailure": "node_lose", - "dice": "1d100" - }, - "rng": 0.5, - "expected": { - "passed": true, - "roll": 51, - "total": 51, - "skillValue": 0, - "dice": "1d100" - } + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 10, + "onSuccess": "node_win", + "onFailure": "node_lose" }, - { - "fn": "resolveCheck", - "description": "project defaultDice 2d6 (no check.dice): rng=[0.0,0.5] -> roll=5, wit=0, DC=10 -> fail", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 0 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 10, - "onSuccess": "node_win", - "onFailure": "node_lose" - }, - "rng": [ - 0, - 0.5 - ], - "defaultDice": "2d6", - "expected": { - "passed": false, - "roll": 5, - "total": 5, - "skillValue": 0, - "dice": "2d6" - } + "rng": 0.2, + "expected": { + "passed": true, + "roll": 5, + "total": 10, + "skillValue": 5, + "dice": "1d20" + } + }, + { + "fn": "resolveCheck", + "description": "one below threshold: rng=0.2 → roll=5, wit=4, DC=10 → fail (9 < 10)", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 4 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} }, - { - "fn": "resolveCheck", - "description": "criticals off (default): 2d6 snake eyes still just sums \u2014 2 + 20 skill beats DC 10", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 20 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 10, - "dice": "2d6" - }, - "rng": [ - 0.0, - 0.0 - ], - "expected": { - "passed": true, - "roll": 2, - "total": 22, - "skillValue": 20, - "dice": "2d6" + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 10, + "onSuccess": "node_win", + "onFailure": "node_lose" + }, + "rng": 0.2, + "expected": { + "passed": false, + "roll": 5, + "total": 9, + "skillValue": 4, + "dice": "1d20" + } + }, + { + "fn": "resolveCheck", + "description": "missing skill key defaults to 0: rng=0.5 → roll=11, wit missing, DC=10 → pass (11 >= 10)", + "state": { + "flags": {}, + "reputation": {}, + "skills": {}, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 10, + "onSuccess": "node_win", + "onFailure": "node_lose" + }, + "rng": 0.5, + "expected": { + "passed": true, + "roll": 11, + "total": 11, + "skillValue": 0, + "dice": "1d20" + } + }, + { + "fn": "resolveCheck", + "description": "skill adds to roll: rng=0.5 → roll=11, wit=3, DC=14 → pass (14 >= 14)", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 3 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 14, + "onSuccess": "node_win", + "onFailure": "node_lose" + }, + "rng": 0.5, + "expected": { + "passed": true, + "roll": 11, + "total": 14, + "skillValue": 3, + "dice": "1d20" + } + }, + { + "fn": "resolveCheck", + "description": "per-check 2d6: rng=[0.99,0.99] -> roll=12, wit=2, DC=8 -> pass", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 2 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 8, + "onSuccess": "node_win", + "onFailure": "node_lose", + "dice": "2d6" + }, + "rng": [ + 0.99, + 0.99 + ], + "expected": { + "passed": true, + "roll": 12, + "total": 14, + "skillValue": 2, + "dice": "2d6" + } + }, + { + "fn": "resolveCheck", + "description": "per-check 1d100: rng=0.5 -> roll=51, wit=0, DC=51 -> pass (51>=51)", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 51, + "onSuccess": "node_win", + "onFailure": "node_lose", + "dice": "1d100" + }, + "rng": 0.5, + "expected": { + "passed": true, + "roll": 51, + "total": 51, + "skillValue": 0, + "dice": "1d100" + } + }, + { + "fn": "resolveCheck", + "description": "project defaultDice 2d6 (no check.dice): rng=[0.0,0.5] -> roll=5, wit=0, DC=10 -> fail", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 10, + "onSuccess": "node_win", + "onFailure": "node_lose" + }, + "rng": [ + 0, + 0.5 + ], + "defaultDice": "2d6", + "expected": { + "passed": false, + "roll": 5, + "total": 5, + "skillValue": 0, + "dice": "2d6" + } + }, + { + "fn": "resolveCheck", + "description": "criticals off (default): 2d6 snake eyes still just sums — 2 + 20 skill beats DC 10", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 20 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 10, + "dice": "2d6" + }, + "rng": [ + 0.0, + 0.0 + ], + "expected": { + "passed": true, + "roll": 2, + "total": 22, + "skillValue": 20, + "dice": "2d6" + } + }, + { + "fn": "resolveCheck", + "description": "criticals on: 2d6 snake eyes ALWAYS fails, even at skill 20 vs DC 10", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 20 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 10, + "dice": "2d6" + }, + "rng": [ + 0.0, + 0.0 + ], + "criticals": true, + "expected": { + "passed": false, + "roll": 2, + "total": 22, + "skillValue": 20, + "dice": "2d6", + "critical": "failure" + } + }, + { + "fn": "resolveCheck", + "description": "criticals on: 2d6 boxcars ALWAYS succeeds, even at skill 0 vs DC 99", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 99, + "dice": "2d6" + }, + "rng": [ + 0.99, + 0.99 + ], + "criticals": true, + "expected": { + "passed": true, + "roll": 12, + "total": 12, + "skillValue": 0, + "dice": "2d6", + "critical": "success" + } + }, + { + "fn": "resolveCheck", + "description": "criticals on: a mixed roll summing to the same total is NOT critical (1+6 vs 6+6)", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 99, + "dice": "2d6" + }, + "rng": [ + 0.0, + 0.99 + ], + "criticals": true, + "expected": { + "passed": false, + "roll": 7, + "total": 7, + "skillValue": 0, + "dice": "2d6" + } + }, + { + "fn": "resolveCheck", + "description": "criticals on: only SOME dice on the extreme is not critical (6,6,1 on 3d6)", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 5, + "dice": "3d6" + }, + "rng": [ + 0.99, + 0.99, + 0.0 + ], + "criticals": true, + "expected": { + "passed": true, + "roll": 13, + "total": 13, + "skillValue": 0, + "dice": "3d6" + } + }, + { + "fn": "resolveCheck", + "description": "criticals on: 1d20 natural 20 always succeeds", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 99 + }, + "rng": 0.99, + "criticals": true, + "expected": { + "passed": true, + "roll": 20, + "total": 20, + "skillValue": 0, + "dice": "1d20", + "critical": "success" + } + }, + { + "fn": "resolveCheck", + "description": "criticals on: 1d20 natural 1 always fails", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 50 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 5 + }, + "rng": 0.0, + "criticals": true, + "expected": { + "passed": false, + "roll": 1, + "total": 51, + "skillValue": 50, + "dice": "1d20", + "critical": "failure" + } + }, + { + "fn": "resolveCheck", + "description": "one modifier applies: +3 folded into total, bonus and appliedModifiers reported", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [ + "key_card" + ], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 4, + "modifiers": [ + { + "when": { + "type": "item", + "item": "key_card", + "has": true + }, + "bonus": 3, + "label": "Key card" } + ] }, - { - "fn": "resolveCheck", - "description": "criticals on: 2d6 snake eyes ALWAYS fails, even at skill 20 vs DC 10", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 20 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 10, - "dice": "2d6" - }, - "rng": [ - 0.0, - 0.0 - ], - "criticals": true, - "expected": { - "passed": false, - "roll": 2, - "total": 22, - "skillValue": 20, - "dice": "2d6", - "critical": "failure" + "rng": 0.0, + "expected": { + "passed": true, + "roll": 1, + "total": 4, + "skillValue": 0, + "dice": "1d20", + "bonus": 3, + "appliedModifiers": [ + 0 + ] + } + }, + { + "fn": "resolveCheck", + "description": "two modifiers, only the one whose when holds is summed", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [ + "key_card" + ], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 3, + "modifiers": [ + { + "when": { + "type": "item", + "item": "key_card", + "has": true + }, + "bonus": 2 + }, + { + "when": { + "type": "item", + "item": "lockpick", + "has": true + }, + "bonus": 5 } + ] }, - { - "fn": "resolveCheck", - "description": "criticals on: 2d6 boxcars ALWAYS succeeds, even at skill 0 vs DC 99", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 0 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 99, - "dice": "2d6" - }, - "rng": [ - 0.99, - 0.99 - ], - "criticals": true, - "expected": { - "passed": true, - "roll": 12, - "total": 12, - "skillValue": 0, - "dice": "2d6", - "critical": "success" + "rng": 0.0, + "expected": { + "passed": true, + "roll": 1, + "total": 3, + "skillValue": 0, + "dice": "1d20", + "bonus": 2, + "appliedModifiers": [ + 0 + ] + } + }, + { + "fn": "resolveCheck", + "description": "a negative modifier turns a passing total into a failure", + "state": { + "flags": { + "drunk": true + }, + "reputation": {}, + "skills": { + "wit": 5 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 10, + "modifiers": [ + { + "when": { + "type": "flag", + "flag": "drunk", + "value": true + }, + "bonus": -20 } + ] }, - { - "fn": "resolveCheck", - "description": "criticals on: a mixed roll summing to the same total is NOT critical (1+6 vs 6+6)", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 0 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 99, - "dice": "2d6" - }, - "rng": [ - 0.0, - 0.99 - ], - "criticals": true, - "expected": { - "passed": false, - "roll": 7, - "total": 7, - "skillValue": 0, - "dice": "2d6" + "rng": 0.2, + "expected": { + "passed": false, + "roll": 5, + "total": -10, + "skillValue": 5, + "dice": "1d20", + "bonus": -20, + "appliedModifiers": [ + 0 + ] + } + }, + { + "fn": "resolveCheck", + "description": "modifiers declared but none apply: bonus 0, appliedModifiers empty", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 5, + "modifiers": [ + { + "when": { + "type": "item", + "item": "absent", + "has": true + }, + "bonus": 4 } + ] }, - { - "fn": "resolveCheck", - "description": "criticals on: only SOME dice on the extreme is not critical (6,6,1 on 3d6)", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 0 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 5, - "dice": "3d6" - }, - "rng": [ - 0.99, - 0.99, - 0.0 - ], - "criticals": true, - "expected": { - "passed": true, - "roll": 13, - "total": 13, - "skillValue": 0, - "dice": "3d6" + "rng": 0.9, + "expected": { + "passed": true, + "roll": 19, + "total": 19, + "skillValue": 0, + "dice": "1d20", + "bonus": 0, + "appliedModifiers": [] + } + }, + { + "fn": "resolveCheck", + "description": "a quest-gated modifier needs project.quests to evaluate (stage order)", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [], + "questStages": { + "q_test": "stg_mid" + }, + "xp": 0, + "skillPointsSpent": {} + }, + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 4, + "modifiers": [ + { + "when": { + "type": "quest", + "quest": "q_test", + "op": ">=", + "stage": "stg_mid" + }, + "bonus": 3 } + ] }, - { - "fn": "resolveCheck", - "description": "criticals on: 1d20 natural 20 always succeeds", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 0 + "rng": 0.0, + "project": { + "quests": { + "q_test": { + "id": "q_test", + "name": "T", + "summary": "s", + "stages": [ + { + "id": "stg_start", + "order": 1, + "description": "d" }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 99 - }, - "rng": 0.99, - "criticals": true, - "expected": { - "passed": true, - "roll": 20, - "total": 20, - "skillValue": 0, - "dice": "1d20", - "critical": "success" + { + "id": "stg_mid", + "order": 2, + "description": "d" + } + ] } + } }, - { - "fn": "resolveCheck", - "description": "criticals on: 1d20 natural 1 always fails", - "state": { - "flags": {}, - "reputation": {}, - "skills": { - "wit": 50 - }, - "counters": {}, - "inventory": [], - "questStages": {}, - "xp": 0, - "skillPointsSpent": {} - }, - "check": { - "mode": "active", - "skill": "wit", - "difficulty": 5 - }, - "rng": 0.0, - "criticals": true, - "expected": { - "passed": false, - "roll": 1, - "total": 51, - "skillValue": 50, - "dice": "1d20", - "critical": "failure" + "expected": { + "passed": true, + "roll": 1, + "total": 4, + "skillValue": 0, + "dice": "1d20", + "bonus": 3, + "appliedModifiers": [ + 0 + ] + } + }, + { + "fn": "resolveCheck", + "description": "criticals override the modifier: all-minimum roll fails despite +10", + "state": { + "flags": {}, + "reputation": {}, + "skills": { + "wit": 0 + }, + "counters": {}, + "inventory": [ + "key_card" + ], + "questStages": {}, + "xp": 0, + "skillPointsSpent": {} + }, + "check": { + "mode": "active", + "skill": "wit", + "difficulty": 1, + "dice": "1d20", + "modifiers": [ + { + "when": { + "type": "item", + "item": "key_card", + "has": true + }, + "bonus": 10 } + ] + }, + "rng": 0.0, + "criticals": true, + "expected": { + "passed": false, + "roll": 1, + "total": 11, + "skillValue": 0, + "dice": "1d20", + "critical": "failure", + "bonus": 10, + "appliedModifiers": [ + 0 + ] } + } ] diff --git a/schema/character.schema.json b/schema/character.schema.json index 1b7057a..15e5847 100644 --- a/schema/character.schema.json +++ b/schema/character.schema.json @@ -4,44 +4,50 @@ "title": "Character / NPC", "type": "object", "properties": { - "id": { "$ref": "common.schema.json#/definitions/id" }, - "name": { "type": "string" }, - "archetype": { "type": "string", "description": "Project-defined grouping for this character — role, class, species, rank, whatever axis the project sorts its cast by. Free-form on purpose: the taxonomy is per-project, not a Parlance concept. Drives the editor's group-by-archetype view." }, - "factionId": { "$ref": "common.schema.json#/definitions/id" }, - "role": { "type": "string" }, - "description": { "type": "string" }, - "dialogueStyle": { "type": "string", "description": "Notes on how they speak — vocabulary, cadence, tics." }, + "id": { + "$ref": "common.schema.json#/definitions/id" + }, + "name": { + "type": "string" + }, + "archetype": { + "type": "string", + "description": "Project-defined grouping for this character — role, class, species, rank, whatever axis the project sorts its cast by. Free-form on purpose: the taxonomy is per-project, not a Parlance concept. Drives the editor's group-by-archetype view." + }, + "factionId": { + "$ref": "common.schema.json#/definitions/id" + }, + "role": { + "type": "string" + }, + "description": { + "type": "string" + }, + "dialogueStyle": { + "type": "string", + "description": "Notes on how they speak — vocabulary, cadence, tics." + }, "stats": { "type": "object", "description": "Optional NPC stats keyed by skill id (for opposed checks later).", - "additionalProperties": { "type": "integer" } - }, - "dialogues": { - "type": "array", - "description": "Ordered dialogue ladder (first-match-wins). Resolution walks top-to-bottom and returns the first rung whose showIf passes; an absent showIf always matches (unconditional fallthrough). ARRAY ORDER IS SIGNIFICANT. Replaces the old defaultDialogue field.", - "items": { - "type": "object", - "properties": { - "dialogue": { - "$ref": "common.schema.json#/definitions/id", - "description": "Dialogue id presented when this rung wins." - }, - "showIf": { - "$ref": "common.schema.json#/definitions/condition", - "description": "Gate for this rung. Omitted = always matches (fallthrough). By convention the last rung omits it." - } - }, - "required": ["dialogue"], - "additionalProperties": false + "additionalProperties": { + "type": "integer" } }, "portrait": { "$ref": "common.schema.json#/definitions/id", "description": "Portrait registry id (data/portraits.json), e.g. portrait_wren." }, - "loreRef": { "$ref": "common.schema.json#/definitions/loreRef" }, - "tags": { "$ref": "common.schema.json#/definitions/tags" } + "loreRef": { + "$ref": "common.schema.json#/definitions/loreRef" + }, + "tags": { + "$ref": "common.schema.json#/definitions/tags" + } }, - "required": ["id", "name"], + "required": [ + "id", + "name" + ], "additionalProperties": false } diff --git a/schema/common.schema.json b/schema/common.schema.json index a620aeb..46b1daa 100644 --- a/schema/common.schema.json +++ b/schema/common.schema.json @@ -515,6 +515,13 @@ "acknowledgedLockout": { "type": "boolean", "description": "Only meaningful with kind:'oneshot'. Declares that content reachable only by passing this check is INTENDED to be unreachable on a fail — an identity moment the player meets as whoever they are. Suppresses the REACH oneshot-lockout warning for the nodes behind it, so a project with a legitimate one-shot fork can still run --strict." + }, + "modifiers": { + "type": "array", + "items": { + "$ref": "#/definitions/checkModifier" + }, + "description": "Conditional bonuses, summed over every modifier whose `when` holds and added to the check total (active: roll + skill + Σbonus ≥ difficulty; passive: skill + Σbonus ≥ difficulty). `difficulty` stays the fixed DC. Per-check, not global." } }, "required": [ @@ -524,6 +531,28 @@ ], "additionalProperties": false }, + "checkModifier": { + "type": "object", + "description": "One conditional adjustment to a check: `bonus` is added to the total when `when` holds (evaluated against state AFTER the choice's effects).", + "properties": { + "when": { + "$ref": "#/definitions/condition" + }, + "bonus": { + "type": "integer", + "description": "Added to the check total. Negative = harder." + }, + "label": { + "type": "string", + "description": "Author-facing name for odds breakdowns. Not player-facing." + } + }, + "required": [ + "when", + "bonus" + ], + "additionalProperties": false + }, "gameState": { "type": "object", "description": "THE serialized runtime save-state contract (RUNTIME_CONTRACT.md §SerializedGameState). Shared by snapshots and engine save files — define it here so the two can never drift.", diff --git a/schema/dialogue.schema.json b/schema/dialogue.schema.json index 38b687f..c542815 100644 --- a/schema/dialogue.schema.json +++ b/schema/dialogue.schema.json @@ -5,8 +5,12 @@ "description": "A conversation as a graph of nodes. Each node shows text and offers choices. Choices may be gated by conditions, wrapped in skill checks, and may apply effects and route to other nodes.", "type": "object", "properties": { - "id": { "$ref": "common.schema.json#/definitions/id" }, - "title": { "type": "string" }, + "id": { + "$ref": "common.schema.json#/definitions/id" + }, + "title": { + "type": "string" + }, "speakerId": { "$ref": "common.schema.json#/definitions/id", "description": "default character speaking in this dialogue" @@ -15,9 +19,24 @@ "type": "boolean", "description": "true if the player can re-enter this dialogue after first completion; once-only choices (showIf flag gates) will simply not appear" }, - "availableWhen": { - "$ref": "common.schema.json#/definitions/condition", - "description": "optional pull-based gate; prefer set_active_dialogue effects for scene switches — use this only for corner-case overrides" + "offer": { + "type": "object", + "description": "Self-declared candidacy for being OFFERED to the player (presented on approach, or discovered as a continuation). The PRESENCE of this object is the opt-in — a dialogue without it is never a candidate and is reached only by goto/entersDialogue/route/world placement. Resolution gathers a character's offers, drops those whose when fails (and visited non-replayable ones), and picks by priority tier, then condition specificity (leaf 1, all sum, any min, not operand), then ordinal id. Replaces the character dialogues ladder.", + "properties": { + "character": { + "$ref": "common.schema.json#/definitions/id", + "description": "The character this dialogue is offered BY, when that is not its speakerId (e.g. a narrated protagonist trial). Defaults to speakerId." + }, + "when": { + "$ref": "common.schema.json#/definitions/condition", + "description": "Gate. Absent = the fallback (specificity 0), offered whenever nothing more specific or higher-tier is eligible." + }, + "priority": { + "type": "integer", + "description": "Priority TIER, compared before specificity (default 0). A higher tier beats every lower-tier candidate however specific. Forced routing uses a tier-1 offer gated on active_dialogue__{character}." + } + }, + "additionalProperties": false }, "entry": { "$ref": "common.schema.json#/definitions/id", @@ -29,18 +48,31 @@ "items": { "type": "object", "properties": { - "id": { "$ref": "common.schema.json#/definitions/id" }, + "id": { + "$ref": "common.schema.json#/definitions/id" + }, "speakerId": { "$ref": "common.schema.json#/definitions/id", "description": "overrides the dialogue's default speaker for this line — a character or skill id; omit for narration" }, - "text": { "type": "string" }, + "text": { + "type": "string" + }, "onEnter": { "type": "array", - "items": { "$ref": "common.schema.json#/definitions/effect" }, + "items": { + "$ref": "common.schema.json#/definitions/effect" + }, "description": "effects applied when this node is reached" }, - "isEnd": { "type": "boolean", "description": "true if this node ends the conversation" }, + "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." + }, + "isEnd": { + "type": "boolean", + "description": "true if this node ends the conversation" + }, "next": { "$ref": "common.schema.json#/definitions/id", "description": "choiceless advance to another node id in this same dialogue, for listen-only beats. Mutually exclusive with a non-empty 'choices' and with isEnd — enforced by the validator (FLOW), not expressible here since additionalProperties:false plus all-optional fields makes a schema-level oneOf reject valid nodes." @@ -50,23 +82,34 @@ "items": { "type": "object", "properties": { - "id": { "$ref": "common.schema.json#/definitions/id" }, - "text": { "type": "string" }, + "id": { + "$ref": "common.schema.json#/definitions/id" + }, + "text": { + "type": "string" + }, "showIf": { "$ref": "common.schema.json#/definitions/condition", "description": "choice is hidden unless this condition passes" }, - "check": { "$ref": "common.schema.json#/definitions/check" }, + "check": { + "$ref": "common.schema.json#/definitions/check" + }, "effects": { "type": "array", - "items": { "$ref": "common.schema.json#/definitions/effect" } + "items": { + "$ref": "common.schema.json#/definitions/effect" + } }, "goto": { "$ref": "common.schema.json#/definitions/id", "description": "next node id (omit if check provides onSuccess/onFailure, or if choice ends convo)" } }, - "required": ["id", "text"], + "required": [ + "id", + "text" + ], "additionalProperties": false } }, @@ -79,13 +122,24 @@ "description": "Author-only annotation. Never shown to the player; ignored by the runtime." } }, - "required": ["id", "text"], + "required": [ + "id", + "text" + ], "additionalProperties": false } }, - "loreRef": { "$ref": "common.schema.json#/definitions/loreRef" }, - "tags": { "$ref": "common.schema.json#/definitions/tags" } + "loreRef": { + "$ref": "common.schema.json#/definitions/loreRef" + }, + "tags": { + "$ref": "common.schema.json#/definitions/tags" + } }, - "required": ["id", "entry", "nodes"], + "required": [ + "id", + "entry", + "nodes" + ], "additionalProperties": false } diff --git a/schema/location.schema.json b/schema/location.schema.json index dec62f2..7ffb2a8 100644 --- a/schema/location.schema.json +++ b/schema/location.schema.json @@ -71,7 +71,7 @@ }, "character": { "$ref": "common.schema.json#/definitions/id", - "description": "For npc: runtime resolves the character's dialogue ladder (resolveCharacterDialogue)." + "description": "For npc: runtime resolves the character's offered dialogue (resolveCharacterDialogue)." }, "showIf": { "$ref": "common.schema.json#/definitions/condition" }, "trigger": { diff --git a/schema/route.schema.json b/schema/route.schema.json index 892c4a8..6dd5d38 100644 --- a/schema/route.schema.json +++ b/schema/route.schema.json @@ -135,7 +135,7 @@ }, { "type": "object", - "description": "Walk N consecutive next-advances (choiceless, N2) from the current node — collapses a long ambient/narration run into one step instead of one synthetic-choice step per beat. Fails if the current node has no 'next' at any hop.", + "description": "Walk N consecutive next-advances (choiceless, N2) from the current node — collapses a long ambient/narration run into one step instead of one synthetic-choice step per beat. Fails if the current node has no 'next' at any hop. One hop means one SHOWN beat: nodes whose showIf fails are skipped inside a hop and do not count toward N, so on state-dependent paths the same N can cross different numbers of authored nodes.", "properties": { "advance": { "type": "integer", diff --git a/schema/snapshot.schema.json b/schema/snapshot.schema.json index 2d26558..3be1736 100644 --- a/schema/snapshot.schema.json +++ b/schema/snapshot.schema.json @@ -14,7 +14,13 @@ "name": { "type": "string" }, "description": { "type": "string" }, "tags": { "$ref": "common.schema.json#/definitions/tags" }, - "state": { "$ref": "common.schema.json#/definitions/gameState" } + "state": { "$ref": "common.schema.json#/definitions/gameState" }, + "visitedDialogueIds": { + "type": "array", + "description": "Dialogues already seen when this baseline was captured. Host bookkeeping, not game state — the runtime asks for this set but SerializedGameState has no field for it — so it lives on the envelope. A route starting from this snapshot seeds its visited set from here, which is what keeps a spent one-shot spent. Sorted for stable diffs; omitted when empty.", + "items": { "$ref": "common.schema.json#/definitions/id" }, + "uniqueItems": true + } }, "required": ["schemaVersion", "id", "name", "state"], "additionalProperties": false diff --git a/tests/conformance_runner.gd b/tests/conformance_runner.gd index f604611..844972e 100644 --- a/tests/conformance_runner.gd +++ b/tests/conformance_runner.gd @@ -41,6 +41,7 @@ func _init() -> void: _run_family("resolveCharacterDialogue.json", "resolveCharacterDialogue", _check_character_dialogue) _skip_family("resolve_quests.json", "resolveQuests") _skip_family("progression.json", "progression") + _skip_family("nextContinuations.json", "nextContinuations") _report() @@ -160,13 +161,22 @@ func _check_apply_effect(v: Dictionary) -> Variant: func _check_resolve_check(v: Dictionary) -> Variant: + # A check with modifiers requires a project, as a real caller always has + # one. Use the vector's when it carries one (quest state), else {} — the + # reference harness does exactly this. + var project = v.get("project", null) + if project == null and v["check"].get("modifiers", []).size() > 0: + project = {} var got := Runtime.resolve_check( v["check"], State.from_dict(v["state"]), _rng_from(v.get("rng", 0)), v.get("defaultDice", null), bool(v.get("criticals", false)), + project, ) + if got.has("error"): + return "unexpected error: %s" % got["error"] return _diff(got, v["expected"]) @@ -230,7 +240,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", {}) + State.from_dict(v["state"]), v["character"], v.get("project", {}), v.get("visited", null) ) return _diff(got, v["expected"])