252 lines
10 KiB
C#
252 lines
10 KiB
C#
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>",
|
|
};
|
|
}
|
|
}
|