From 1ef1720bae7819885d77e8360c3671d5a7bf87e7 Mon Sep 17 00:00:00 2001 From: grabowskil Date: Sun, 16 Aug 2026 14:50:38 +0200 Subject: [PATCH] fixed detour stacking and improper option gating --- .gitignore | 7 + .../Assets/Dialogue/Characters/Bartender.yarn | 38 +-- NightclubArcadia/Assets/Dialogue/Common.yarn | 19 +- .../Assets/Dialogue/Objects/Chair.yarn | 46 ++-- tools/YarnCheck/Program.cs | 251 ++++++++++++++++++ tools/YarnCheck/README.md | 53 ++++ tools/YarnCheck/YarnCheck.csproj | 50 ++++ 7 files changed, 427 insertions(+), 37 deletions(-) create mode 100644 tools/YarnCheck/Program.cs create mode 100644 tools/YarnCheck/README.md create mode 100644 tools/YarnCheck/YarnCheck.csproj diff --git a/.gitignore b/.gitignore index 3aeda1e..b18598c 100644 --- a/.gitignore +++ b/.gitignore @@ -108,6 +108,13 @@ yarn-error.log* __pycache__/ *.py[cod] +# --------------------------------------------------------------------------- +# .NET tooling under tools/ (Unity's own Library/ ignores live in the Unity +# project's .gitignore and don't reach this far up) +# --------------------------------------------------------------------------- +tools/**/bin/ +tools/**/obj/ + # --------------------------------------------------------------------------- # AI / agent tooling # --------------------------------------------------------------------------- diff --git a/NightclubArcadia/Assets/Dialogue/Characters/Bartender.yarn b/NightclubArcadia/Assets/Dialogue/Characters/Bartender.yarn index 0bbb3c4..6ebebc1 100644 --- a/NightclubArcadia/Assets/Dialogue/Characters/Bartender.yarn +++ b/NightclubArcadia/Assets/Dialogue/Characters/Bartender.yarn @@ -1,11 +1,11 @@ // Everything the bartender ever says. // -// These nodes share a title, which makes them a node group. Callers just -// `<>` and Yarn runs the most specific variation whose -// `when:` conditions pass. Adding a new variation later means adding a node, -// not editing a growing <> chain. -// -// Each variation sets $went_inside so the calling scene knows the verdict. +// Stack discipline (the player comes back to the bar, so it matters): +// `Bartender_Talk` is the node DialogueInteractable starts, so nothing is +// waiting for it to return. Its branches therefore <> — they are the last +// thing the node does — and each destination hands control back with +// <> itself. <> is only for sub-conversations that +// have to resume the caller; a <> inside one would clear the return stack. title: Bartender_Talk position: -121,-60 @@ -17,9 +17,9 @@ position: -121,-60 Bartender: Ah, {$player_alias}. A drink? <> -> Yes, please. - <> + <> -> No, I have questions. - <> + <> <> <> Bartender: Ah, {$player_alias}, back again? @@ -41,29 +41,32 @@ position: -121,-60 -> What can you recommend? Bartender: Last time you tooke the Estate. Narrator: You ignore the jab. - <> + <> -> Whatever. - <> + <> -> Yes. I'll take the usual. Narrator: The bartender doesn't even flinch, while taking ingredients from the bar. PROVENANCE: We cannot remember these substances. AMNESTY: Is this really our favorite drink? <> - <> + <> -> I am, and I have a few questions. - <> + <> <> -<> === title: SC101_Hook_Drink_Bartender_Talk position: 116,222 --- +// $had_the_estate_drink is written at the BOTTOM of this node, not here. The whole +// node reads first-glass state — the pour, the taste, and the lean write under the +// answer — and the glass is only recorded once all of it has run. Setting the flag +// up here (as this node used to) leaves the tests below reading a flag this same +// run already flipped, and the lean write can never fire. Bartender: One Estate coming right up. <> Narrator: He puts another amber drink before you. <> - <> Narrator: He puts a deep amber drink before you. HOUSE POUR: It smells like old wood, oak, which went sour with age, like a library shelf that's been rained on once and dried wrong. Narrator: You take a sip. @@ -84,6 +87,8 @@ AMNESTY: Is that the taste of our favorite drink? I cannot remember ... <> HOUSE POUR: Still bitter. <> +<> +<> === title: SC101_Hook_Questions_Bartender_Talk @@ -92,10 +97,11 @@ position: -139,224 Bartender: Sure, what do you want to know? -> Question 1 Bartender: Answer 1 - <> -> Question 2 Bartender: Answer 2 - <> -> Nothing. Bartender: Alright. + <> + <> +<> === diff --git a/NightclubArcadia/Assets/Dialogue/Common.yarn b/NightclubArcadia/Assets/Dialogue/Common.yarn index 39c615e..aceea46 100644 --- a/NightclubArcadia/Assets/Dialogue/Common.yarn +++ b/NightclubArcadia/Assets/Dialogue/Common.yarn @@ -32,9 +32,20 @@ title: Declarations <> // Candidate lean. Three permanently-live readings of who the PC is; they are -// not mutually exclusive and never resolve. Writers <> these; no code -// reads them. There is deliberately NO derived "winning candidate" variable — -// see docs/candidate-system-implementation-plan.md §6. +// not mutually exclusive and never resolve into a canonical identity. +// +// DESIGN DECISION 2026-08-16 — amends the "no code ever reads lean" guardrail in +// docs/candidate-system-implementation-plan.md §6.1: scenes are meant to tilt +// toward one reading and NPCs are meant to act on the current tilt, so that a +// player who thinks they have settled on a path gets challenged on it in the +// next scene. Reading lean through top_skill(...) is therefore sanctioned, as in +// SC101_Asks_Candidate_Chevalier_Cassian_Thal_Talk. top_skill returns its FIRST +// argument on a tie, which is why made_asset is passed first — the Made Asset +// bias on an even score is deliberate, not an accident of argument order. +// +// What the amendment does NOT license: a stored winner. There is still no +// $dominant_candidate, no ground-truth identity field, and nothing in save data +// beyond these three integers. $winner above is scratch, recomputed per use. <> <> <> @@ -102,5 +113,5 @@ title: Declarations <> <> <> -<> +<> === diff --git a/NightclubArcadia/Assets/Dialogue/Objects/Chair.yarn b/NightclubArcadia/Assets/Dialogue/Objects/Chair.yarn index 854cead..f70f775 100644 --- a/NightclubArcadia/Assets/Dialogue/Objects/Chair.yarn +++ b/NightclubArcadia/Assets/Dialogue/Objects/Chair.yarn @@ -1,11 +1,14 @@ // Everything that can be interacted with the chair. // -// These nodes share a title, which makes them a node group. Callers just -// `<>` and Yarn runs the most specific variation whose -// `when:` conditions pass. Adding a new variation later means adding a node, -// not editing a growing <> chain. +// Stack discipline (the player re-enters all of this, so it matters): +// `Chair_Interaction` is the node DialogueInteractable starts, so nothing above +// it is waiting for a return. The menu therefore LOOPS with <>, which +// replaces the current node and pushes nothing, and only sub-conversations that +// have to come back use <>. Never <> out of a detoured node — +// that clears the return stack and the caller never resumes. // -// Each variation sets $went_inside so the calling scene knows the verdict. +// The sit-down line lives in its own node so the menu loop can't replay it, +// while a player who stands up and sits down again still gets it. title: Chair_Interaction position: -334,-601 @@ -13,7 +16,7 @@ position: -334,-601 Narrator: An empty chair with a place card: {$player_name}. -> Sit down. <> - <> + <> -> Not yet. <> === @@ -22,21 +25,22 @@ title: SC101_Hook_Sitting_Chair_Interaction position: -328,-360 --- Narrator: You sit down between two very distinguished gentlemen. +<> +=== + +// The loop target. Every menu variation jumps back here when its sub-conversation +// returns, so which variation shows is re-evaluated after every interaction. +title: SC101_Hook_Menu_Chair_Interaction +position: -328,-205 +--- <> - <> - <> + <> <> - <> - <> + <> <> - <> - <> -<> - <> - <> + <> <> - <> - <> + <> <> === @@ -51,6 +55,8 @@ position: -320,-51 <> -> Stand up. <> + <> +<> === title: SC101_Hook_Left_Not_Right_Chair_Interaction @@ -64,6 +70,8 @@ position: -95,-48 <> -> Stand up. <> + <> +<> === title: SC101_Hook_Right_Not_Left_Chair_Interaction @@ -77,6 +85,8 @@ position: 134,-32 <> -> Stand up. <> + <> +<> === title: SC101_Hook_Both_Chair_Interaction @@ -90,6 +100,8 @@ position: 363,-36 <> -> Stand up. <> + <> +<> === title: SC101_Hook_Place_Card_Chair_Interaction diff --git a/tools/YarnCheck/Program.cs b/tools/YarnCheck/Program.cs new file mode 100644 index 0000000..eeff7d4 --- /dev/null +++ b/tools/YarnCheck/Program.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Yarn; +using Yarn.Compiler; + +namespace YarnCheck +{ + /// + /// Headless compile-and-play harness for the project's Yarn scripts. + /// + /// Compiles every .yarn file under a directory with the same compiler Unity uses + /// (the DLLs shipped in Packages/dev.yarnspinner.unity), and optionally runs a + /// node through the real virtual machine with a scripted list of option picks, + /// printing the line/command/node trace and the final variable state. + /// + /// The point is to answer "does this branch ever actually run" without opening + /// Unity — flag-ordering bugs and broken detour chains show up here in seconds. + /// + static class Program + { + const int LanguageVersion = 3; + + static int Main(string[] args) + { + if (args.Length == 0 || args[0] is "-h" or "--help") + { + Console.WriteLine( + "usage: dotnet run -- [start-node] [option-index ...]\n" + + "\n" + + " directory scanned recursively for *.yarn\n" + + " [start-node] node to run; omit to compile only (exit 1 on error)\n" + + " [option-index] choices to feed the option handler, in order\n" + + "\n" + + " Picks are consumed as the run needs them. If any are left over when\n" + + " the dialogue ends, the node is run a second time with the remainder —\n" + + " that models the player walking away and coming back, which is how the\n" + + " scene is actually played.\n" + + "\n" + + "examples:\n" + + " dotnet run -- ../../NightclubArcadia/Assets/Dialogue\n" + + " dotnet run -- ../../NightclubArcadia/Assets/Dialogue Chair_Interaction 0 0 3\n"); + return args.Length == 0 ? 1 : 0; + } + + var dialogueDir = args[0]; + if (!Directory.Exists(dialogueDir)) + { + Console.Error.WriteLine($"No such directory: {dialogueDir}"); + return 1; + } + + var files = Directory.GetFiles(dialogueDir, "*.yarn", SearchOption.AllDirectories) + .OrderBy(f => f, StringComparer.Ordinal) + .ToList(); + if (files.Count == 0) + { + Console.Error.WriteLine($"No .yarn files under {dialogueDir}"); + return 1; + } + + var job = CompilationJob.CreateFromFiles(files); + job.LanguageVersion = LanguageVersion; + job.Library = ProjectLibrary(); + + var result = Compiler.Compile(job); + + var errors = result.Diagnostics + .Where(d => d.Severity == Diagnostic.DiagnosticSeverity.Error) + .ToList(); + foreach (var d in errors.OrderBy(d => d.FileName).ThenBy(d => d.Range.Start.Line)) + { + Console.Error.WriteLine( + $"[error] {Path.GetFileName(d.FileName)}:{d.Range.Start.Line + 1} {d.Message}"); + } + if (errors.Count > 0) + { + Console.Error.WriteLine($"\n{errors.Count} error(s) in {files.Count} file(s)."); + return 1; + } + + var program = result.Program; + if (program == null) + { + Console.Error.WriteLine("Compilation produced no program."); + return 1; + } + + Console.WriteLine($"compiled ok — {files.Count} file(s), {program.Nodes.Count} node(s)"); + + if (args.Length == 1) + { + return 0; + } + + var startNode = args[1]; + if (!program.Nodes.ContainsKey(startNode)) + { + Console.Error.WriteLine($"No such node: {startNode}"); + return 1; + } + + var picks = new Queue(); + foreach (var raw in args.Skip(2)) + { + if (!int.TryParse(raw, out var pick)) + { + Console.Error.WriteLine($"Not an option index: {raw}"); + return 1; + } + picks.Enqueue(pick); + } + + Play(result, program, startNode, picks); + return 0; + } + + /// + /// The project's own Yarn functions. The compiler type-checks calls against + /// these, and the VM calls them for real, so the stubs mirror the shipping + /// implementations closely enough to follow the same branches. + /// + static Library ProjectLibrary() + { + var library = new Library(); + AddProjectFunctions(library); + return library; + } + + static void AddProjectFunctions(Library library) + { + // SkillFunctions.TopSkill — first argument wins ties, as in the game. + library.RegisterFunction("top_skill", + (Func)((a, aName, b, bName, c, cName) => + a >= b && a >= c ? aName : + b >= a && b >= c ? bName : cName)); + + // AliasNameGenerator.alias_name — the real one is hash-seeded; the trace + // only needs it to be deterministic and obviously a placeholder. + library.RegisterFunction("alias_name", (Func)(name => $"")); + + library.RegisterFunction("skill_rank", (Func)(_ => 0f)); + } + + static void Play(CompilationResult result, Yarn.Program program, string startNode, Queue picks) + { + var storage = new MemoryVariableStore(); + var dialogue = new Dialogue(storage); + AddProjectFunctions(dialogue.Library); + + dialogue.SetProgram(program); + storage.Program = program; + storage.SmartVariableEvaluator = dialogue; + + // Seed declared defaults the way the Unity runtime does from the Yarn project. + // Smart variables (IsInlineExpansion) are computed, never stored. + var declared = result.Declarations + .Where(d => d.DefaultValue != null && !d.IsInlineExpansion) + .ToList(); + foreach (var decl in declared) + { + switch (decl.DefaultValue) + { + case bool b: storage.SetValue(decl.Name, b); break; + case string s: storage.SetValue(decl.Name, s); break; + case float f: storage.SetValue(decl.Name, f); break; + case double d: storage.SetValue(decl.Name, (float)d); break; + case int i: storage.SetValue(decl.Name, i); break; + } + } + + var stringTable = result.StringTable; + string Text(string lineID) => + stringTable != null && stringTable.TryGetValue(lineID, out var entry) + ? entry.text ?? lineID + : lineID; + + // <>, <>, <> and friends are Unity-side + // commands; here they are printed and stepped over. That means checks never + // succeed ($check_result stays false), so a check-gated branch shows its + // failure side — worth remembering when reading a trace. + dialogue.LineHandler = line => Console.WriteLine(" " + Text(line.ID)); + dialogue.CommandHandler = command => + { + Console.WriteLine($" <<{command.Text}>>"); + dialogue.Continue(); + }; + dialogue.NodeStartHandler = node => Console.WriteLine($" [enter {node}]"); + dialogue.NodeCompleteHandler = node => Console.WriteLine($" [exit {node}]"); + dialogue.DialogueCompleteHandler = () => Console.WriteLine(" [dialogue complete]"); + dialogue.OptionsHandler = options => + { + for (var i = 0; i < options.Options.Length; i++) + { + Console.WriteLine($" [{i}] {Text(options.Options[i].Line.ID)}"); + } + + if (picks.Count == 0) + { + Console.WriteLine(" (out of picks — stopping)"); + dialogue.Stop(); + return; + } + + var pick = picks.Dequeue(); + Console.WriteLine($" -> [{pick}]"); + dialogue.SetSelectedOption(pick); + }; + + var visit = 0; + do + { + visit++; + Console.WriteLine($"\n=== visit {visit}: {startNode} ==="); + dialogue.SetNode(startNode); + + // SetNode arms the VM; IsActive only turns true once it is stepping. + var steps = 0; + do + { + dialogue.Continue(); + if (++steps <= 10_000) continue; + Console.WriteLine(" [aborted after 10000 steps — runaway loop?]"); + dialogue.Stop(); + break; + } + while (dialogue.IsActive); + } + while (picks.Count > 0 && visit < 10); + + Console.WriteLine("\n=== variables ==="); + foreach (var decl in declared.OrderBy(d => d.Name, StringComparer.Ordinal)) + { + Console.WriteLine($" {decl.Name} = {Read(storage, decl)}"); + } + } + + /// + /// Reads a variable back using the type it was declared with — asking a + /// MemoryVariableStore for the wrong type coerces silently (a number reads + /// back as "False"), which is exactly how you misread a trace. + /// + static string Read(MemoryVariableStore storage, Declaration decl) => decl.DefaultValue switch + { + bool => storage.TryGetValue(decl.Name, out var b) ? b.ToString() : "", + string => storage.TryGetValue(decl.Name, out var s) ? s : "", + _ => storage.TryGetValue(decl.Name, out var f) ? f.ToString() : "", + }; + } +} diff --git a/tools/YarnCheck/README.md b/tools/YarnCheck/README.md new file mode 100644 index 0000000..1452aac --- /dev/null +++ b/tools/YarnCheck/README.md @@ -0,0 +1,53 @@ +# YarnCheck + +Compiles and plays the project's Yarn scripts from the terminal, without opening Unity. + +It uses the Yarn Spinner assemblies that ship inside +`NightclubArcadia/Packages/dev.yarnspinner.unity/Runtime/DLLs`, so it always compiles against +the exact compiler and virtual machine the game runs — not a NuGet copy that could drift. + +## Why + +The EditMode suite lints Yarn *source* with regexes (`CandidateYarnLintTests`). This runs the +compiled program instead, which catches a different class of bug: a branch that can never be +reached because a flag was written earlier in the same node, a detour chain that never returns, +a menu that recurses instead of looping. Those are invisible to a source lint and expensive to +find by playtesting. + +## Usage + +``` +cd tools/YarnCheck + +# compile everything; exit code 1 and diagnostics on failure +dotnet run -- ../../NightclubArcadia/Assets/Dialogue + +# compile, then play a node, choosing option 0, then 0, then 3 +dotnet run -- ../../NightclubArcadia/Assets/Dialogue Chair_Interaction 0 0 3 +``` + +Picks are consumed as the run needs them. Leftover picks start the node again — that models the +player walking away and coming back, which is how these scenes are actually played: + +``` +# ask the bartender questions, leave, return, and order a drink +dotnet run -- ../../NightclubArcadia/Assets/Dialogue Bartender_Talk 2 2 0 0 +``` + +Output is the line / command / node-enter / node-exit trace, the options at each choice, and +every declared variable's final value. + +## What it does not model + +- **Unity commands are printed, not executed.** `<>`, `<>` and + `<>` are stepped over, so `$check_result` stays `false` and any check-gated branch + shows its failure side. Read traces with that in mind. +- **Yarn functions are stubbed** in `ProjectLibrary()`: `top_skill` mirrors the real tie + behaviour (first argument wins), `alias_name` returns an obvious placeholder, `skill_rank` + returns 0. If a shipping function's behaviour changes in a way that steers dialogue, update + the stub to match. +- **Variables start from their `<>` defaults** every run. There is no save state. + +## Requirements + +.NET 9 SDK. Bump `` in `YarnCheck.csproj` if you move to another version. diff --git a/tools/YarnCheck/YarnCheck.csproj b/tools/YarnCheck/YarnCheck.csproj new file mode 100644 index 0000000..a38f661 --- /dev/null +++ b/tools/YarnCheck/YarnCheck.csproj @@ -0,0 +1,50 @@ + + + + Exe + net9.0 + enable + disable + YarnCheck + yarncheck + + $(MSBuildThisFileDirectory)../../NightclubArcadia/Packages/dev.yarnspinner.unity/Runtime/DLLs + + + + + $(YarnDlls)/YarnSpinner.dll + + + $(YarnDlls)/YarnSpinner.Compiler.dll + + + + $(YarnDlls)/Yarn.Antlr4.Runtime.Standard.dll + + + $(YarnDlls)/Yarn.Google.Protobuf.dll + + + $(YarnDlls)/Yarn.CsvHelper.dll + + + $(YarnDlls)/Yarn.Microsoft.Bcl.AsyncInterfaces.dll + + + $(YarnDlls)/Yarn.Microsoft.Extensions.FileSystemGlobbing.dll + + + $(YarnDlls)/Yarn.System.Runtime.CompilerServices.Unsafe.dll + + + $(YarnDlls)/Yarn.System.Text.Encodings.Web.dll + + + $(YarnDlls)/Yarn.System.Text.Json.dll + + + +