fixed detour stacking and improper option gating
This commit is contained in:
@@ -108,6 +108,13 @@ yarn-error.log*
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.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
|
# AI / agent tooling
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// Everything the bartender ever says.
|
// Everything the bartender ever says.
|
||||||
//
|
//
|
||||||
// These nodes share a title, which makes them a node group. Callers just
|
// Stack discipline (the player comes back to the bar, so it matters):
|
||||||
// `<<detour Bartender_Talk>>` and Yarn runs the most specific variation whose
|
// `Bartender_Talk` is the node DialogueInteractable starts, so nothing is
|
||||||
// `when:` conditions pass. Adding a new variation later means adding a node,
|
// waiting for it to return. Its branches therefore <<jump>> — they are the last
|
||||||
// not editing a growing <<if>> chain.
|
// thing the node does — and each destination hands control back with
|
||||||
//
|
// <<enter_environment>> itself. <<detour>> is only for sub-conversations that
|
||||||
// Each variation sets $went_inside so the calling scene knows the verdict.
|
// have to resume the caller; a <<jump>> inside one would clear the return stack.
|
||||||
|
|
||||||
title: Bartender_Talk
|
title: Bartender_Talk
|
||||||
position: -121,-60
|
position: -121,-60
|
||||||
@@ -17,9 +17,9 @@ position: -121,-60
|
|||||||
Bartender: Ah, {$player_alias}. A drink?
|
Bartender: Ah, {$player_alias}. A drink?
|
||||||
<<endif>>
|
<<endif>>
|
||||||
-> Yes, please.
|
-> Yes, please.
|
||||||
<<detour SC101_Hook_Drink_Bartender_Talk>>
|
<<jump SC101_Hook_Drink_Bartender_Talk>>
|
||||||
-> No, I have questions.
|
-> No, I have questions.
|
||||||
<<detour SC101_Hook_Questions_Bartender_Talk>>
|
<<jump SC101_Hook_Questions_Bartender_Talk>>
|
||||||
<<else>>
|
<<else>>
|
||||||
<<set $spoke_to_bartender = true>>
|
<<set $spoke_to_bartender = true>>
|
||||||
Bartender: Ah, {$player_alias}, back again?
|
Bartender: Ah, {$player_alias}, back again?
|
||||||
@@ -41,29 +41,32 @@ position: -121,-60
|
|||||||
-> What can you recommend?
|
-> What can you recommend?
|
||||||
Bartender: Last time you tooke the Estate.
|
Bartender: Last time you tooke the Estate.
|
||||||
Narrator: You ignore the jab.
|
Narrator: You ignore the jab.
|
||||||
<<detour SC101_Hook_Drink_Bartender_Talk>>
|
<<jump SC101_Hook_Drink_Bartender_Talk>>
|
||||||
-> Whatever.
|
-> Whatever.
|
||||||
<<detour SC101_Hook_Drink_Bartender_Talk>>
|
<<jump SC101_Hook_Drink_Bartender_Talk>>
|
||||||
-> Yes. I'll take the usual.
|
-> Yes. I'll take the usual.
|
||||||
Narrator: The bartender doesn't even flinch, while taking ingredients from the bar.
|
Narrator: The bartender doesn't even flinch, while taking ingredients from the bar.
|
||||||
PROVENANCE: We cannot remember these substances.
|
PROVENANCE: We cannot remember these substances.
|
||||||
AMNESTY: Is this really our favorite drink?
|
AMNESTY: Is this really our favorite drink?
|
||||||
<<set $self_lean_inheritor += 1>>
|
<<set $self_lean_inheritor += 1>>
|
||||||
<<detour SC101_Hook_Drink_Bartender_Talk>>
|
<<jump SC101_Hook_Drink_Bartender_Talk>>
|
||||||
-> I am, and I have a few questions.
|
-> I am, and I have a few questions.
|
||||||
<<detour SC101_Hook_Questions_Bartender_Talk>>
|
<<jump SC101_Hook_Questions_Bartender_Talk>>
|
||||||
<<endif>>
|
<<endif>>
|
||||||
<<enter_environment>>
|
|
||||||
===
|
===
|
||||||
|
|
||||||
title: SC101_Hook_Drink_Bartender_Talk
|
title: SC101_Hook_Drink_Bartender_Talk
|
||||||
position: 116,222
|
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.
|
Bartender: One Estate coming right up.
|
||||||
<<if $had_the_estate_drink>>
|
<<if $had_the_estate_drink>>
|
||||||
Narrator: He puts another amber drink before you.
|
Narrator: He puts another amber drink before you.
|
||||||
<<else>>
|
<<else>>
|
||||||
<<set $had_the_estate_drink = true>>
|
|
||||||
Narrator: He puts a deep 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.
|
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.
|
Narrator: You take a sip.
|
||||||
@@ -84,6 +87,8 @@ AMNESTY: Is that the taste of our favorite drink? I cannot remember ...
|
|||||||
<<else>>
|
<<else>>
|
||||||
HOUSE POUR: Still bitter.
|
HOUSE POUR: Still bitter.
|
||||||
<<endif>>
|
<<endif>>
|
||||||
|
<<set $had_the_estate_drink = true>>
|
||||||
|
<<enter_environment>>
|
||||||
===
|
===
|
||||||
|
|
||||||
title: SC101_Hook_Questions_Bartender_Talk
|
title: SC101_Hook_Questions_Bartender_Talk
|
||||||
@@ -92,10 +97,11 @@ position: -139,224
|
|||||||
Bartender: Sure, what do you want to know?
|
Bartender: Sure, what do you want to know?
|
||||||
-> Question 1
|
-> Question 1
|
||||||
Bartender: Answer 1
|
Bartender: Answer 1
|
||||||
<<detour SC101_Hook_Questions_Bartender_Talk>>
|
|
||||||
-> Question 2
|
-> Question 2
|
||||||
Bartender: Answer 2
|
Bartender: Answer 2
|
||||||
<<detour SC101_Hook_Questions_Bartender_Talk>>
|
|
||||||
-> Nothing.
|
-> Nothing.
|
||||||
Bartender: Alright.
|
Bartender: Alright.
|
||||||
|
<<enter_environment>>
|
||||||
|
<<stop>>
|
||||||
|
<<jump SC101_Hook_Questions_Bartender_Talk>>
|
||||||
===
|
===
|
||||||
|
|||||||
@@ -32,9 +32,20 @@ title: Declarations
|
|||||||
<<declare $previous_winner = "" as string>>
|
<<declare $previous_winner = "" as string>>
|
||||||
|
|
||||||
// Candidate lean. Three permanently-live readings of who the PC is; they are
|
// Candidate lean. Three permanently-live readings of who the PC is; they are
|
||||||
// not mutually exclusive and never resolve. Writers <<set>> these; no code
|
// not mutually exclusive and never resolve into a canonical identity.
|
||||||
// reads them. There is deliberately NO derived "winning candidate" variable —
|
//
|
||||||
// see docs/candidate-system-implementation-plan.md §6.
|
// 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.
|
||||||
<<declare $self_lean_made_asset = 0 as number>>
|
<<declare $self_lean_made_asset = 0 as number>>
|
||||||
<<declare $self_lean_journalist = 0 as number>>
|
<<declare $self_lean_journalist = 0 as number>>
|
||||||
<<declare $self_lean_inheritor = 0 as number>>
|
<<declare $self_lean_inheritor = 0 as number>>
|
||||||
@@ -102,5 +113,5 @@ title: Declarations
|
|||||||
<<declare $spoke_to_chevalier_cassian_thal = false as bool>>
|
<<declare $spoke_to_chevalier_cassian_thal = false as bool>>
|
||||||
<<declare $spoke_to_bradford_kane = false as bool>>
|
<<declare $spoke_to_bradford_kane = false as bool>>
|
||||||
<<declare $inquired_about_cyprus = false as bool>>
|
<<declare $inquired_about_cyprus = false as bool>>
|
||||||
<<declare $declared_candidate_to_chevalier_cassian_thal = true as bool>>
|
<<declare $declared_candidate_to_chevalier_cassian_thal = false as bool>>
|
||||||
===
|
===
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
// Everything that can be interacted with the chair.
|
// Everything that can be interacted with the chair.
|
||||||
//
|
//
|
||||||
// These nodes share a title, which makes them a node group. Callers just
|
// Stack discipline (the player re-enters all of this, so it matters):
|
||||||
// `<<detour Chair_Interaction>>` and Yarn runs the most specific variation whose
|
// `Chair_Interaction` is the node DialogueInteractable starts, so nothing above
|
||||||
// `when:` conditions pass. Adding a new variation later means adding a node,
|
// it is waiting for a return. The menu therefore LOOPS with <<jump>>, which
|
||||||
// not editing a growing <<if>> chain.
|
// replaces the current node and pushes nothing, and only sub-conversations that
|
||||||
|
// have to come back use <<detour>>. Never <<jump>> 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
|
title: Chair_Interaction
|
||||||
position: -334,-601
|
position: -334,-601
|
||||||
@@ -13,7 +16,7 @@ position: -334,-601
|
|||||||
Narrator: An empty chair with a place card: {$player_name}.
|
Narrator: An empty chair with a place card: {$player_name}.
|
||||||
-> Sit down.
|
-> Sit down.
|
||||||
<<set $sat_down_at_table = true>>
|
<<set $sat_down_at_table = true>>
|
||||||
<<detour SC101_Hook_Sitting_Chair_Interaction>>
|
<<jump SC101_Hook_Sitting_Chair_Interaction>>
|
||||||
-> Not yet.
|
-> Not yet.
|
||||||
<<enter_environment>>
|
<<enter_environment>>
|
||||||
===
|
===
|
||||||
@@ -22,21 +25,22 @@ title: SC101_Hook_Sitting_Chair_Interaction
|
|||||||
position: -328,-360
|
position: -328,-360
|
||||||
---
|
---
|
||||||
Narrator: You sit down between two very distinguished gentlemen.
|
Narrator: You sit down between two very distinguished gentlemen.
|
||||||
|
<<jump SC101_Hook_Menu_Chair_Interaction>>
|
||||||
|
===
|
||||||
|
|
||||||
|
// 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
|
||||||
|
---
|
||||||
<<if $spoke_to_chevalier_cassian_thal eq true and $spoke_to_bradford_kane eq true>>
|
<<if $spoke_to_chevalier_cassian_thal eq true and $spoke_to_bradford_kane eq true>>
|
||||||
<<detour SC101_Hook_Both_Chair_Interaction>>
|
<<jump SC101_Hook_Both_Chair_Interaction>>
|
||||||
<<detour SC101_Hook_Sitting_Chair_Interaction>>
|
|
||||||
<<elseif $spoke_to_chevalier_cassian_thal eq true and $spoke_to_bradford_kane eq false>>
|
<<elseif $spoke_to_chevalier_cassian_thal eq true and $spoke_to_bradford_kane eq false>>
|
||||||
<<detour SC101_Hook_Left_Not_Right_Chair_Interaction>>
|
<<jump SC101_Hook_Left_Not_Right_Chair_Interaction>>
|
||||||
<<detour SC101_Hook_Sitting_Chair_Interaction>>
|
|
||||||
<<elseif $spoke_to_chevalier_cassian_thal eq false and $spoke_to_bradford_kane eq true>>
|
<<elseif $spoke_to_chevalier_cassian_thal eq false and $spoke_to_bradford_kane eq true>>
|
||||||
<<detour SC101_Hook_Right_Not_Left_Chair_Interaction>>
|
<<jump SC101_Hook_Right_Not_Left_Chair_Interaction>>
|
||||||
<<detour SC101_Hook_Sitting_Chair_Interaction>>
|
|
||||||
<<elseif $spoke_to_chevalier_cassian_thal eq true and $spoke_to_bradford_kane eq true>>
|
|
||||||
<<detour SC101_Hook_Both_Chair_Interaction>>
|
|
||||||
<<detour SC101_Hook_Sitting_Chair_Interaction>>
|
|
||||||
<<else>>
|
<<else>>
|
||||||
<<detour SC101_Hook_First_Sitting_Chair_Interaction>>
|
<<jump SC101_Hook_First_Sitting_Chair_Interaction>>
|
||||||
<<detour SC101_Hook_Sitting_Chair_Interaction>>
|
|
||||||
<<endif>>
|
<<endif>>
|
||||||
===
|
===
|
||||||
|
|
||||||
@@ -51,6 +55,8 @@ position: -320,-51
|
|||||||
<<detour Bradford_Kane_Talk>>
|
<<detour Bradford_Kane_Talk>>
|
||||||
-> Stand up.
|
-> Stand up.
|
||||||
<<enter_environment>>
|
<<enter_environment>>
|
||||||
|
<<stop>>
|
||||||
|
<<jump SC101_Hook_Menu_Chair_Interaction>>
|
||||||
===
|
===
|
||||||
|
|
||||||
title: SC101_Hook_Left_Not_Right_Chair_Interaction
|
title: SC101_Hook_Left_Not_Right_Chair_Interaction
|
||||||
@@ -64,6 +70,8 @@ position: -95,-48
|
|||||||
<<detour Bradford_Kane_Talk>>
|
<<detour Bradford_Kane_Talk>>
|
||||||
-> Stand up.
|
-> Stand up.
|
||||||
<<enter_environment>>
|
<<enter_environment>>
|
||||||
|
<<stop>>
|
||||||
|
<<jump SC101_Hook_Menu_Chair_Interaction>>
|
||||||
===
|
===
|
||||||
|
|
||||||
title: SC101_Hook_Right_Not_Left_Chair_Interaction
|
title: SC101_Hook_Right_Not_Left_Chair_Interaction
|
||||||
@@ -77,6 +85,8 @@ position: 134,-32
|
|||||||
<<detour Bradford_Kane_Talk>>
|
<<detour Bradford_Kane_Talk>>
|
||||||
-> Stand up.
|
-> Stand up.
|
||||||
<<enter_environment>>
|
<<enter_environment>>
|
||||||
|
<<stop>>
|
||||||
|
<<jump SC101_Hook_Menu_Chair_Interaction>>
|
||||||
===
|
===
|
||||||
|
|
||||||
title: SC101_Hook_Both_Chair_Interaction
|
title: SC101_Hook_Both_Chair_Interaction
|
||||||
@@ -90,6 +100,8 @@ position: 363,-36
|
|||||||
<<detour Bradford_Kane_Talk>>
|
<<detour Bradford_Kane_Talk>>
|
||||||
-> Stand up.
|
-> Stand up.
|
||||||
<<enter_environment>>
|
<<enter_environment>>
|
||||||
|
<<stop>>
|
||||||
|
<<jump SC101_Hook_Menu_Chair_Interaction>>
|
||||||
===
|
===
|
||||||
|
|
||||||
title: SC101_Hook_Place_Card_Chair_Interaction
|
title: SC101_Hook_Place_Card_Chair_Interaction
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Yarn;
|
||||||
|
using Yarn.Compiler;
|
||||||
|
|
||||||
|
namespace YarnCheck
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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 -- <dialogue-dir> [start-node] [option-index ...]\n" +
|
||||||
|
"\n" +
|
||||||
|
" <dialogue-dir> 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<int>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<float, string, float, string, float, string, string>)((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<string, string>)(name => $"<alias:{name}>"));
|
||||||
|
|
||||||
|
library.RegisterFunction("skill_rank", (Func<string, float>)(_ => 0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Play(CompilationResult result, Yarn.Program program, string startNode, Queue<int> 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;
|
||||||
|
|
||||||
|
// <<check>>, <<enter_environment>>, <<skill_mod>> 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)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
static string Read(MemoryVariableStore storage, Declaration decl) => decl.DefaultValue switch
|
||||||
|
{
|
||||||
|
bool => storage.TryGetValue<bool>(decl.Name, out var b) ? b.ToString() : "<unset>",
|
||||||
|
string => storage.TryGetValue<string>(decl.Name, out var s) ? s : "<unset>",
|
||||||
|
_ => storage.TryGetValue<float>(decl.Name, out var f) ? f.ToString() : "<unset>",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.** `<<check>>`, `<<enter_environment>>` and
|
||||||
|
`<<skill_mod>>` 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 `<<declare>>` defaults** every run. There is no save state.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
.NET 9 SDK. Bump `<TargetFramework>` in `YarnCheck.csproj` if you move to another version.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
|
<RootNamespace>YarnCheck</RootNamespace>
|
||||||
|
<AssemblyName>yarncheck</AssemblyName>
|
||||||
|
<!-- The Yarn Spinner assemblies ship with the Unity package; this tool always
|
||||||
|
compiles against the exact version the game runs, never a NuGet copy that
|
||||||
|
could drift from it. -->
|
||||||
|
<YarnDlls>$(MSBuildThisFileDirectory)../../NightclubArcadia/Packages/dev.yarnspinner.unity/Runtime/DLLs</YarnDlls>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="YarnSpinner">
|
||||||
|
<HintPath>$(YarnDlls)/YarnSpinner.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="YarnSpinner.Compiler">
|
||||||
|
<HintPath>$(YarnDlls)/YarnSpinner.Compiler.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<!-- Vendored dependencies of the two above; needed at runtime. -->
|
||||||
|
<Reference Include="Yarn.Antlr4.Runtime.Standard">
|
||||||
|
<HintPath>$(YarnDlls)/Yarn.Antlr4.Runtime.Standard.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Yarn.Google.Protobuf">
|
||||||
|
<HintPath>$(YarnDlls)/Yarn.Google.Protobuf.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Yarn.CsvHelper">
|
||||||
|
<HintPath>$(YarnDlls)/Yarn.CsvHelper.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Yarn.Microsoft.Bcl.AsyncInterfaces">
|
||||||
|
<HintPath>$(YarnDlls)/Yarn.Microsoft.Bcl.AsyncInterfaces.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Yarn.Microsoft.Extensions.FileSystemGlobbing">
|
||||||
|
<HintPath>$(YarnDlls)/Yarn.Microsoft.Extensions.FileSystemGlobbing.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Yarn.System.Runtime.CompilerServices.Unsafe">
|
||||||
|
<HintPath>$(YarnDlls)/Yarn.System.Runtime.CompilerServices.Unsafe.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Yarn.System.Text.Encodings.Web">
|
||||||
|
<HintPath>$(YarnDlls)/Yarn.System.Text.Encodings.Web.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Yarn.System.Text.Json">
|
||||||
|
<HintPath>$(YarnDlls)/Yarn.System.Text.Json.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user