updated Candidate System with Voice System
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using NUnit.Framework;
|
||||
using NightclubArcadia.Skills;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NightclubArcadia.Skills.Tests
|
||||
{
|
||||
public class CandidateRegistryTests
|
||||
{
|
||||
const string DatabasePath = "Assets/Candidates/CandidateDatabase.asset";
|
||||
const string SkillDatabasePath = "Assets/Skills/SkillDatabase.asset";
|
||||
const string CommonYarnPath = "Assets/Dialogue/Common.yarn";
|
||||
|
||||
static readonly Regex IdPattern = new Regex(@"^[a-z][a-z0-9_]*$");
|
||||
static readonly Regex UnlockFlagPattern =
|
||||
new Regex(@"^\$(visited|errand)_[a-z][a-z0-9_]*$");
|
||||
|
||||
CandidateDatabase LoadDatabase()
|
||||
{
|
||||
// Assets are checked in (generated by CandidateSystemSetup). Same pattern as
|
||||
// SkillRosterTests — the test assembly cannot reference the Editor setup type.
|
||||
var db = AssetDatabase.LoadAssetAtPath<CandidateDatabase>(DatabasePath);
|
||||
Assert.IsNotNull(db, $"Expected CandidateDatabase at {DatabasePath}");
|
||||
return db;
|
||||
}
|
||||
|
||||
SkillDatabase LoadSkillDatabase()
|
||||
{
|
||||
var db = AssetDatabase.LoadAssetAtPath<SkillDatabase>(SkillDatabasePath);
|
||||
Assert.IsNotNull(db, $"Expected SkillDatabase at {SkillDatabasePath}");
|
||||
return db;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Registry_HasSymmetricClues_UniqueNonBodyIntuitionAxes_AndValidIds()
|
||||
{
|
||||
var db = LoadDatabase();
|
||||
var skills = LoadSkillDatabase();
|
||||
|
||||
Assert.GreaterOrEqual(db.All.Count, 1, "Registry must contain at least one candidate.");
|
||||
|
||||
var seenCandidates = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var seenClues = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var axesUsed = new HashSet<SkillAxis>();
|
||||
|
||||
foreach (var candidate in db.All)
|
||||
{
|
||||
Assert.IsNotNull(candidate);
|
||||
Assert.IsTrue(IdPattern.IsMatch(candidate.Id), $"Bad candidate id '{candidate.Id}'");
|
||||
Assert.IsTrue(seenCandidates.Add(candidate.Id), $"Duplicate candidate id '{candidate.Id}'");
|
||||
|
||||
Assert.AreNotEqual(
|
||||
SkillAxis.Body,
|
||||
candidate.IntuitionAxis,
|
||||
$"Candidate '{candidate.Id}' must not use Body as intuitionAxis.");
|
||||
Assert.AreNotEqual(
|
||||
SkillAxis.Specialist,
|
||||
candidate.IntuitionAxis,
|
||||
$"Candidate '{candidate.Id}' must not use Specialist as intuitionAxis.");
|
||||
Assert.IsTrue(
|
||||
axesUsed.Add(candidate.IntuitionAxis),
|
||||
$"intuitionAxis {candidate.IntuitionAxis} shared by more than one candidate.");
|
||||
|
||||
Assert.IsNotNull(candidate.Clues);
|
||||
var typeCounts = new Dictionary<CandidateEvidenceType, int>();
|
||||
foreach (var clue in candidate.Clues)
|
||||
{
|
||||
Assert.IsNotNull(clue);
|
||||
Assert.IsTrue(IdPattern.IsMatch(clue.Id), $"Bad clue id '{clue.Id}'");
|
||||
Assert.IsTrue(seenClues.Add(clue.Id), $"Duplicate clue id '{clue.Id}'");
|
||||
|
||||
if (!typeCounts.ContainsKey(clue.Type))
|
||||
{
|
||||
typeCounts[clue.Type] = 0;
|
||||
}
|
||||
|
||||
typeCounts[clue.Type]++;
|
||||
|
||||
if (clue.Type == CandidateEvidenceType.Intuition)
|
||||
{
|
||||
Assert.IsFalse(string.IsNullOrEmpty(clue.SkillId),
|
||||
$"Intuition clue '{clue.Id}' needs skillId.");
|
||||
Assert.IsTrue(
|
||||
skills.TryGet(clue.SkillId, out var skill),
|
||||
$"Intuition clue '{clue.Id}' skillId '{clue.SkillId}' missing from SkillDatabase.");
|
||||
Assert.AreEqual(
|
||||
candidate.IntuitionAxis,
|
||||
skill.Axis,
|
||||
$"Intuition clue '{clue.Id}' skill axis mismatch.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (CandidateEvidenceType type in Enum.GetValues(typeof(CandidateEvidenceType)))
|
||||
{
|
||||
typeCounts.TryGetValue(type, out var count);
|
||||
Assert.AreEqual(
|
||||
1,
|
||||
count,
|
||||
$"Candidate '{candidate.Id}' must have exactly one {type} clue.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Registry_UnlockFlags_AreExplorationGated_AndDeclared()
|
||||
{
|
||||
var db = LoadDatabase();
|
||||
var commonYarn = File.ReadAllText(CommonYarnPath);
|
||||
|
||||
foreach (var candidate in db.All)
|
||||
{
|
||||
foreach (var clue in candidate.Clues)
|
||||
{
|
||||
if (string.IsNullOrEmpty(clue.UnlockFlag))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Assert.IsTrue(
|
||||
UnlockFlagPattern.IsMatch(clue.UnlockFlag),
|
||||
$"Clue '{clue.Id}' unlockFlag '{clue.UnlockFlag}' must match " +
|
||||
@"^\$(visited|errand)_[a-z][a-z0-9_]*$.");
|
||||
Assert.IsTrue(
|
||||
commonYarn.Contains($"<<declare {clue.UnlockFlag}"),
|
||||
$"Clue '{clue.Id}' unlockFlag '{clue.UnlockFlag}' is not declared in Common.yarn.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Registry_NoCandidateUnlockFlagSetIsStrictSupersetOfAnother()
|
||||
{
|
||||
// Proxy, not a proof: if candidate A's unlock flags are a strict superset of B's,
|
||||
// A is reliably discoverable only after B. Genuine order coverage is a playtest question.
|
||||
var db = LoadDatabase();
|
||||
var flagSets = new List<(string id, HashSet<string> flags)>();
|
||||
|
||||
foreach (var candidate in db.All)
|
||||
{
|
||||
var flags = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var clue in candidate.Clues)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(clue.UnlockFlag))
|
||||
{
|
||||
flags.Add(clue.UnlockFlag);
|
||||
}
|
||||
}
|
||||
|
||||
flagSets.Add((candidate.Id, flags));
|
||||
}
|
||||
|
||||
for (var i = 0; i < flagSets.Count; i++)
|
||||
{
|
||||
for (var j = 0; j < flagSets.Count; j++)
|
||||
{
|
||||
if (i == j)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var a = flagSets[i];
|
||||
var b = flagSets[j];
|
||||
if (a.flags.Count <= b.flags.Count)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Assert.IsFalse(
|
||||
b.flags.IsSubsetOf(a.flags) && !a.flags.SetEquals(b.flags),
|
||||
$"Candidate '{a.id}' unlock-flag set is a strict superset of '{b.id}' — " +
|
||||
"that makes discovery order reliably stacked.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a525c84fb92f402d863adb1ae811ffd0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using NUnit.Framework;
|
||||
using NightclubArcadia.Skills;
|
||||
using UnityEditor;
|
||||
|
||||
namespace NightclubArcadia.Skills.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Source lint over Assets/Dialogue/**/*.yarn. This is a tripwire, not a proof —
|
||||
/// it catches the easy-and-wrong wirings, not every possible one.
|
||||
/// </summary>
|
||||
public class CandidateYarnLintTests
|
||||
{
|
||||
const string DialogueRoot = "Assets/Dialogue";
|
||||
const string DatabasePath = "Assets/Candidates/CandidateDatabase.asset";
|
||||
|
||||
static readonly Regex LeanWrite = new Regex(
|
||||
@"\$self_lean_([a-z][a-z0-9_]*)",
|
||||
RegexOptions.Compiled);
|
||||
static readonly Regex FoundWrite = new Regex(
|
||||
@"\$found_([a-z][a-z0-9_]*)",
|
||||
RegexOptions.Compiled);
|
||||
static readonly Regex LeanVsLean = new Regex(
|
||||
@"\$self_lean_[a-z][a-z0-9_]*\s*(>=|<=|==|!=|>|<)\s*\$self_lean_[a-z][a-z0-9_]*",
|
||||
RegexOptions.Compiled);
|
||||
static readonly Regex NodeSplit = new Regex(
|
||||
@"^title:\s*(.+)\s*$",
|
||||
RegexOptions.Compiled | RegexOptions.Multiline);
|
||||
|
||||
CandidateDatabase LoadDatabase()
|
||||
{
|
||||
// Assets are checked in (generated by CandidateSystemSetup). Same pattern as
|
||||
// SkillRosterTests — the test assembly cannot reference the Editor setup type.
|
||||
var db = AssetDatabase.LoadAssetAtPath<CandidateDatabase>(DatabasePath);
|
||||
Assert.IsNotNull(db, $"Expected CandidateDatabase at {DatabasePath}");
|
||||
return db;
|
||||
}
|
||||
|
||||
static IEnumerable<string> AllYarnFiles()
|
||||
{
|
||||
return Directory.GetFiles(DialogueRoot, "*.yarn", SearchOption.AllDirectories);
|
||||
}
|
||||
|
||||
static string Read(string path) => File.ReadAllText(path);
|
||||
|
||||
[Test]
|
||||
public void Lint_LeanAndFoundWrites_MatchRegistryIds()
|
||||
{
|
||||
var db = LoadDatabase();
|
||||
var candidateIds = new HashSet<string>(
|
||||
db.All.Select(c => c.Id),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var clueIds = new HashSet<string>(
|
||||
db.All.SelectMany(c => c.Clues).Select(c => c.Id),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var path in AllYarnFiles())
|
||||
{
|
||||
var text = Read(path);
|
||||
foreach (Match match in LeanWrite.Matches(text))
|
||||
{
|
||||
var id = match.Groups[1].Value;
|
||||
Assert.IsTrue(
|
||||
candidateIds.Contains(id),
|
||||
$"{path}: $self_lean_{id} has no matching candidate id in the registry.");
|
||||
}
|
||||
|
||||
foreach (Match match in FoundWrite.Matches(text))
|
||||
{
|
||||
var id = match.Groups[1].Value;
|
||||
Assert.IsTrue(
|
||||
clueIds.Contains(id),
|
||||
$"{path}: $found_{id} has no matching clue id in the registry.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Lint_AuthoredClues_HaveFoundWrites()
|
||||
{
|
||||
var db = LoadDatabase();
|
||||
var allYarn = string.Join("\n", AllYarnFiles().Select(Read));
|
||||
|
||||
foreach (var candidate in db.All)
|
||||
{
|
||||
foreach (var clue in candidate.Clues)
|
||||
{
|
||||
if (clue.Status != CandidateClueStatus.Authored)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var needle = $"<<set $found_{clue.Id} = true>>";
|
||||
Assert.IsTrue(
|
||||
allYarn.Contains(needle),
|
||||
$"Authored clue '{clue.Id}' has no `{needle}` anywhere under {DialogueRoot}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Lint_NoLeanVsLeanComparison()
|
||||
{
|
||||
foreach (var path in AllYarnFiles())
|
||||
{
|
||||
var text = Read(path);
|
||||
Assert.IsFalse(
|
||||
LeanVsLean.IsMatch(text),
|
||||
$"{path}: comparing two $self_lean_* values against each other is forbidden " +
|
||||
"(no derived winner). Comparing one lean against a constant is fine.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Lint_ClueAndLeanWrites_NotSkillGated()
|
||||
{
|
||||
foreach (var path in AllYarnFiles())
|
||||
{
|
||||
var text = Read(path);
|
||||
var matches = NodeSplit.Matches(text);
|
||||
for (var i = 0; i < matches.Count; i++)
|
||||
{
|
||||
var start = matches[i].Index;
|
||||
var end = i + 1 < matches.Count ? matches[i + 1].Index : text.Length;
|
||||
var nodeBody = text.Substring(start, end - start);
|
||||
var title = matches[i].Groups[1].Value.Trim();
|
||||
|
||||
var writesLeanOrFound =
|
||||
nodeBody.Contains("$self_lean_") || nodeBody.Contains("$found_");
|
||||
if (!writesLeanOrFound)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Assert.IsFalse(
|
||||
nodeBody.Contains("skill_rank("),
|
||||
$"{path} node '{title}': a node that writes $self_lean_* / $found_* " +
|
||||
"must not sit behind a skill_rank(...) guard.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Lint_CommentaryNodes_NeverWriteLeanOrFound()
|
||||
{
|
||||
foreach (var path in AllYarnFiles())
|
||||
{
|
||||
var text = Read(path);
|
||||
var matches = NodeSplit.Matches(text);
|
||||
for (var i = 0; i < matches.Count; i++)
|
||||
{
|
||||
var title = matches[i].Groups[1].Value.Trim();
|
||||
if (!title.StartsWith("Commentary_", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var start = matches[i].Index;
|
||||
var end = i + 1 < matches.Count ? matches[i + 1].Index : text.Length;
|
||||
var nodeBody = text.Substring(start, end - start);
|
||||
|
||||
Assert.IsFalse(
|
||||
nodeBody.Contains("$self_lean_") || nodeBody.Contains("$found_"),
|
||||
$"{path} node '{title}': Commentary_* nodes must not write lean or found flags " +
|
||||
"(ambient advocacy only; clue lines are scene-authored).");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4b60d38cd1ee4eccb73aa11b23b94695
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user