VerseGrammar: Open refuses a role named actor and two verbs with one name; edge and command-update pins

ValidateAll (one owner, called by AetheriaStores.Open) refuses duplicate verb names; Validate refuses the reserved role
name actor. Tests pin the name-class edges (a z 0 9 _), a null role element, the starter verb replaced when only its
description or render path differs, the grammar global updated in place, and the fixture's second allegiance and zero-role
speak. The test's scoped registry is TestCatalog.Registry(). Builds; unverified until the full run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 14:02:10 +02:00
commit 972848cb5f
3 changed files with 96 additions and 7 deletions

View file

@ -40,7 +40,7 @@ public static class AetheriaStores
// cut.md), stat modifiers, role usage (Cut 7). Fails loudly, naming the item.
foreach (var data in cache.GetAll<EquippableItemData>()) CultRecordRefs.Validate(data);
foreach (var data in cache.GetAll<ConsumableItemData>()) CultRecordRefs.Validate(data);
foreach (var verb in cache.GetAll<VerseVerb>()) VerseGrammarValidation.Validate(verb);
VerseGrammarValidation.ValidateAll(cache.GetAll<VerseVerb>());
return cache;
}
catch

View file

@ -57,6 +57,9 @@ public static class VerseGrammarValidation
public const int VerbNameMaxLength = 48;
public const int RoleNameMaxLength = 32;
// The invoking subject, never a role (Ghostlight refuses a world affordance that names it: ReservedRole).
public const string ActorRole = "actor";
// Canonical: a lower-case letter, then lower-case letters, digits and underscores, at most maxLength characters.
private static bool Canonical(string name, int maxLength)
{
@ -67,6 +70,18 @@ public static class VerseGrammarValidation
return true;
}
// Every verb valid and no two sharing a name: a verb is found by name (Ghostlight refuses DuplicateAffordanceKind).
public static void ValidateAll(IEnumerable<VerseVerb> verbs)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var verb in verbs)
{
Validate(verb);
if (!seen.Add(verb.Name))
throw new InvalidOperationException($"VerseVerb \"{verb.Name}\": duplicate verb name.");
}
}
// Canonical names, unique role names, defined enum values. A failure names the field and the rule, and the verb once
// its name is known canonical; it never prints a value the author wrote that is itself the fault.
public static void Validate(VerseVerb verb)
@ -82,6 +97,8 @@ public static class VerseGrammarValidation
{
if (role == null || !Canonical(role.Name, RoleNameMaxLength))
throw new InvalidOperationException($"VerseVerb \"{verb.Name}\" Roles.Name: not a canonical role name ([a-z][a-z0-9_], 1 to {RoleNameMaxLength} characters).");
if (role.Name == ActorRole)
throw new InvalidOperationException($"VerseVerb \"{verb.Name}\" Roles.Name: reserved for the invoking subject, never a role.");
if (!seen.Add(role.Name))
throw new InvalidOperationException($"VerseVerb \"{verb.Name}\" Roles.Name: duplicate role name.");
if (!Enum.IsDefined(typeof(VerseReferentKind), role.Binds))

View file

@ -49,10 +49,7 @@ public sealed class VerseGrammarTests : IDisposable
private static CultCache OpenScoped(string path, bool readOnly)
{
var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes()
.Where(t => t is { IsAbstract: false, IsInterface: false })
.Where(t => t.GetCustomAttribute<CultDocumentAttribute>() != null));
var cache = new CultCache(registry);
var cache = new CultCache(TestCatalog.Registry());
cache.AddBackingStore(new SingleFileMessagePackBackingStore(path, readOnly), AetheriaStores.CatalogTypes);
return cache;
}
@ -105,6 +102,10 @@ public sealed class VerseGrammarTests : IDisposable
new object[] { "duplicate role name", (Action<VerseVerb>)(v => v.Roles = new[] { Good("x").Roles[0], Good("x").Roles[0] }), "duplicate role name", null },
new object[] { "role name with a capital", (Action<VerseVerb>)(v => v.Roles[0].Name = "Site"), "Roles.Name", "Site" },
new object[] { "role name one past 32 characters", (Action<VerseVerb>)(v => v.Roles[0].Name = "r" + new string('s', 32)), "Roles.Name", new string('s', 32) },
new object[] { "a role named for the actor", (Action<VerseVerb>)(v => v.Roles[1].Name = "actor"), "reserved", null },
new object[] { "a null role element", (Action<VerseVerb>)(v => v.Roles = new VerseRole[] { null }), "Roles.Name", null },
new object[] { "name starting with an underscore", (Action<VerseVerb>)(v => v.Name = "_haul"), "VerseVerb.Name", "_haul" },
new object[] { "role name starting with a digit", (Action<VerseVerb>)(v => v.Roles[0].Name = "0site"), "Roles.Name", "0site" },
new object[] { "referent kind zero", (Action<VerseVerb>)(v => v.Roles[0].Binds = 0), "Roles.Binds", null },
new object[] { "undefined referent kind", (Action<VerseVerb>)(v => v.Roles[0].Binds = (VerseReferentKind)99), "Roles.Binds", null },
};
@ -138,6 +139,35 @@ public sealed class VerseGrammarTests : IDisposable
Assert.Contains("VerseVerb.Name", Assert.Throws<InvalidOperationException>(() => VerseGrammarValidation.Validate(verb)).Message);
}
// The class edges: a and z, 0 and 9, and the underscore, in the first, middle and last position of a verb and a role.
[Theory]
[InlineData("z")]
[InlineData("a")]
[InlineData("zz")]
[InlineData("a0")]
[InlineData("a9")]
[InlineData("a_")]
[InlineData("a_b")]
[InlineData("z0z9_az")]
public void Names_at_the_edges_of_the_canonical_class_are_accepted(string name)
{
var verb = Good(name);
verb.Roles[0].Name = name;
var path = Author("edges-" + name, new VerseGrammar { Revision = 1 }, verb);
using var cache = AetheriaStores.Open(path);
Assert.Equal(name, cache.GetAll<VerseVerb>().Single().Name);
}
// A verb is found by name: two with one name are refused wherever they sit.
[Fact]
public void Open_refuses_two_verbs_with_one_name()
{
var path = Author("twins", new VerseGrammar { Revision = 1 }, Good("alpha"), Good("twin"), Good("beta"), Good("twin"));
var error = Assert.Throws<InvalidOperationException>(() => AetheriaStores.Open(path));
Assert.Contains("duplicate verb name", error.Message);
Assert.Contains("\"twin\"", error.Message);
}
[Fact]
public void The_longest_canonical_names_are_accepted()
{
@ -153,7 +183,10 @@ public sealed class VerseGrammarTests : IDisposable
// A scratch root holding a copy of the shipped catalog with its verse grammar removed: the catalog as it was before the
// command ran. The catalog is stripped and read through a registry scoped to the shipped assembly, as
// PiratesFactionCommandTests does.
private string RootWithoutGrammar(params VerseVerb[] preexisting)
private string RootWithoutGrammar(params VerseVerb[] preexisting) => RootWith(null, preexisting);
// The same, holding `grammar` (when given) and the preexisting verbs.
private string RootWith(VerseGrammar grammar, params VerseVerb[] preexisting)
{
var gameData = Path.Combine(_root, "cmd", "GameData");
Directory.CreateDirectory(gameData);
@ -163,6 +196,7 @@ public sealed class VerseGrammarTests : IDisposable
{
foreach (var grammar in cache.GetAll<VerseGrammar>().ToArray()) Assert.True(cache.Remove(cache.RefOf(grammar).Key));
foreach (var verb in cache.GetAll<VerseVerb>().ToArray()) Assert.True(cache.Remove(cache.RefOf(verb).Key));
if (grammar != null) cache.Upsert(grammar);
foreach (var verb in preexisting) cache.Upsert(verb);
cache.FlushAsync().Wait();
}
@ -229,6 +263,39 @@ public sealed class VerseGrammarTests : IDisposable
Assert.Equal(1, cache.GetAll<VerseVerb>().Count(verb => verb.Name == "mine"));
}
// A starter verb that differs in only its description, or only its render path, is replaced too (one record each).
[Theory]
[InlineData("description")]
[InlineData("render path")]
public void Apply_replaces_a_starter_verb_that_differs_in_one_field(string field)
{
var starter = VerseGrammarCommand.StarterVerbs().Single(v => v.Name == "haul");
var stale = new VerseVerb { Name = starter.Name, RenderPath = starter.RenderPath, Description = starter.Description, Roles = starter.Roles };
if (field == "description") stale.Description = "An older description.";
else stale.RenderPath = VerseRenderPath.Conversation;
var root = RootWithoutGrammar(stale);
Assert.Equal(0, VerseGrammarCommand.Run(apply: true, root: root));
using var cache = OpenScoped(CatalogOf(root), true);
Assert.Equal(1, cache.GetAll<VerseVerb>().Count(verb => verb.Name == "haul"));
Assert.True(VerseGrammarCommand.Same(starter, cache.GetByName<VerseVerb>("haul")));
}
// The grammar global is updated in place (one record) when its revision or its description is not the starter's.
[Theory]
[InlineData(0, VerseGrammarCommand.StarterDescription)]
[InlineData(VerseGrammarCommand.StarterRevision, "An older description.")]
public void Apply_updates_a_grammar_global_that_is_not_the_starter_in_place(int revision, string description)
{
var root = RootWith(new VerseGrammar { Revision = revision, Description = description });
Assert.Equal(0, VerseGrammarCommand.Run(apply: true, root: root));
using var cache = OpenScoped(CatalogOf(root), true);
var grammar = Assert.Single(cache.GetAll<VerseGrammar>());
Assert.Equal(VerseGrammarCommand.StarterRevision, grammar.Revision);
Assert.Equal(VerseGrammarCommand.StarterDescription, grammar.Description);
}
// The shipped catalog carries the command's output.
[Fact]
public void The_shipped_catalog_holds_revision_one_and_the_seven_starter_verbs()
@ -318,6 +385,11 @@ public sealed class VerseGrammarTests : IDisposable
Assert.Equal(2, factions.Length);
Assert.All(factions, faction => Assert.Single(faction.Allegiance));
var adrasteia = cache.GetByName<Faction>("Adrasteia");
Assert.Equal(.75f, adrasteia.Allegiance[cache.RefOf(cache.GetByName<Faction>("Brannoch"))]);
var brannoch = cache.GetByName<Faction>("Brannoch");
Assert.Equal(.75f, adrasteia.Allegiance[cache.RefOf(brannoch)]);
Assert.Equal(.25f, brannoch.Allegiance[cache.RefOf(adrasteia)]);
var speak = cache.GetByName<VerseVerb>("speak");
Assert.Equal(VerseRenderPath.Conversation, speak.RenderPath);
Assert.Empty(speak.Roles);
}
}