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>
108 lines
4.6 KiB
C#
108 lines
4.6 KiB
C#
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using GameCult.Caching;
|
|
using MessagePack;
|
|
|
|
// The verse grammar (aetheria-release, cut verse-grammar; ghostlight-verse rulings verse-grammar-home-ruled and
|
|
// verse-verbs-two-render-paths): the verbs a Ghostlight-simulated Aetheria world may use, each rendering by exactly one
|
|
// path the game already has, with typed participant roles. Aetheria owns which verbs exist and how each renders;
|
|
// Ghostlight owns what each does to world state. Nothing in the game runtime reads these documents.
|
|
[CultDocument("aetheria.verse_grammar", "1"), CultGlobal, MessagePackObject]
|
|
public class VerseGrammar
|
|
{
|
|
[Key(1)] public int Revision;
|
|
[Key(2)] public string Description;
|
|
}
|
|
|
|
// How a verb is shown: filmed in-engine as a ship action, or presented as an Aetheria conversation.
|
|
public enum VerseRenderPath
|
|
{
|
|
ShipAction = 1,
|
|
Conversation = 2,
|
|
}
|
|
|
|
// What kind of thing a role binds.
|
|
public enum VerseReferentKind
|
|
{
|
|
Person = 1,
|
|
Faction = 2,
|
|
Population = 3,
|
|
Place = 4,
|
|
Cargo = 5,
|
|
}
|
|
|
|
[MessagePackObject]
|
|
public class VerseRole
|
|
{
|
|
[Key(1)] public string Name;
|
|
[Key(2)] public VerseReferentKind Binds;
|
|
}
|
|
|
|
// The actor is the invoking subject and is never a role.
|
|
[CultDocument("aetheria.verse_verb", "1"), MessagePackObject]
|
|
public class VerseVerb
|
|
{
|
|
[CultName, Key(1)] public string Name;
|
|
[Key(2)] public VerseRenderPath RenderPath;
|
|
[Key(3)] public VerseRole[] Roles = Array.Empty<VerseRole>();
|
|
[Key(4)] public string Description;
|
|
}
|
|
|
|
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)
|
|
{
|
|
if (name == null || name.Length == 0 || name.Length > maxLength) return false;
|
|
if (name[0] < 'a' || name[0] > 'z') return false;
|
|
foreach (var c in name)
|
|
if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_')) return false;
|
|
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)
|
|
{
|
|
if (!Canonical(verb.Name, VerbNameMaxLength))
|
|
throw new InvalidOperationException($"VerseVerb.Name: not a canonical verb name ([a-z][a-z0-9_], 1 to {VerbNameMaxLength} characters).");
|
|
if (!Enum.IsDefined(typeof(VerseRenderPath), verb.RenderPath))
|
|
throw new InvalidOperationException($"VerseVerb \"{verb.Name}\" RenderPath: not a defined render path.");
|
|
if (verb.Roles == null)
|
|
throw new InvalidOperationException($"VerseVerb \"{verb.Name}\" Roles: missing.");
|
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (var role in verb.Roles)
|
|
{
|
|
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))
|
|
throw new InvalidOperationException($"VerseVerb \"{verb.Name}\" Roles.Binds: not a defined referent kind.");
|
|
}
|
|
}
|
|
}
|