Aetheria/Assets/Scripts/ServerShared/VerseGrammar.cs
Meta Vens 972848cb5f 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>
2026-10-10 14:02:10 +02:00

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.");
}
}
}