Add BrokkrCommandLedger, the pending-intent rule, with its test project

The rule that decides which Unity command intents still await execution
(an id is answered by any receipt for it; oldest stored-at then key first)
was inline in the mirror and untestable outside Unity. It is now a
stateless ledger over a CultCache in Runtime/, compiled by
brokkr-contracts and pinned by brokkr-contracts.Tests, which also carries
the Imagination probe as an editor-without-Unity scenario harness.

The host snapshot gains agentCommandsEnabled (Key 18; 14-17 are held for
the play-state fields). Nothing in Unity uses the ledger yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 14:03:41 +02:00
commit d498ee37ac
9 changed files with 389 additions and 0 deletions

2
.gitignore vendored
View file

@ -13,3 +13,5 @@ Logs/
UserSettings/
__pycache__/
*.pyc
bin/
obj/

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<CultLibRoot Condition="'$(CultLibRoot)' == ''">..\..\CultLib</CultLibRoot>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\brokkr-contracts\Brokkr.Contracts.csproj" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Mesh\GameCult.Mesh.csproj" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Networking\GameCult.Networking.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,153 @@
using GameCult.Brokkr;
using GameCult.Caching;
namespace Brokkr.Contracts.Tests;
public sealed class BrokkrCommandLedgerTests
{
internal static async Task Answer(CultCache cache, string commandId)
{
await cache.AddAsync(
new BrokkrUnityCommandReceipt { commandId = commandId, status = "accepted" },
new CultRecordHandle<BrokkrUnityCommandReceipt>(BrokkrCommandLedger.ReceiptKey(commandId)));
await cache.FlushAsync();
}
[Fact]
public async Task ExternallyWrittenCommandIsPendingAfterPull()
{
using var project = new ScratchProject();
using var editor = project.OpenCache();
var ledger = new BrokkrCommandLedger(editor);
Assert.False(ledger.TryNextPending(out _));
await project.WriteCommandAsync("written-elsewhere");
Assert.False(ledger.TryNextPending(out _), "an intent is invisible until the editor pulls the store");
await ledger.PullAsync();
Assert.True(ledger.TryNextPending(out var pending));
Assert.Equal("written-elsewhere", pending.commandId);
}
[Fact]
public async Task CommandWithReceiptIsNeverPendingAgain()
{
using var project = new ScratchProject();
await project.WriteCommandAsync("once");
using var editor = project.OpenCache();
var ledger = new BrokkrCommandLedger(editor);
Assert.True(ledger.TryNextPending(out _));
await Answer(editor, "once");
Assert.False(ledger.TryNextPending(out _));
await ledger.PullAsync();
Assert.False(ledger.TryNextPending(out _));
}
[Fact]
public async Task ReceiptWrittenByAnotherProcessRetiresTheCommand()
{
using var project = new ScratchProject();
await project.WriteCommandAsync("answered-elsewhere");
using var editor = project.OpenCache();
var ledger = new BrokkrCommandLedger(editor);
Assert.True(ledger.TryNextPending(out _));
using (var other = project.OpenCache())
await Answer(other, "answered-elsewhere");
await ledger.PullAsync();
Assert.False(ledger.TryNextPending(out _));
}
[Fact]
public async Task PendingOrderIsStoredAtThenKey()
{
using var project = new ScratchProject();
// Written b then a: arrival order, not key order, decides.
await project.WriteCommandAsync("b-first");
await Task.Delay(20);
await project.WriteCommandAsync("a-second");
using var editor = project.OpenCache();
var ledger = new BrokkrCommandLedger(editor);
Assert.True(ledger.TryNextPending(out var first));
Assert.Equal("b-first", first.commandId);
await Answer(editor, "b-first");
Assert.True(ledger.TryNextPending(out var second));
Assert.Equal("a-second", second.commandId);
}
[Fact]
public void EqualStoredAtFallsBackToKeyOrder()
{
using var project = new ScratchProject();
using var writer = project.OpenCache();
// One batch lands both records under one stamp; only the key can order them.
Assert.True(writer.Commit(batch =>
{
batch.Upsert(
new BrokkrUnityCommand { commandId = "z", action = "refreshAssets" },
new CultRecordHandle<BrokkrUnityCommand>(BrokkrCommandLedger.CommandKey("z")));
batch.Upsert(
new BrokkrUnityCommand { commandId = "a", action = "refreshAssets" },
new CultRecordHandle<BrokkrUnityCommand>(BrokkrCommandLedger.CommandKey("a")));
}));
var stamps = writer.AllStoredDocuments
.Where(entry => entry.Document is BrokkrUnityCommand)
.Select(entry => entry.StoredAt)
.Distinct()
.ToArray();
Assert.Single(stamps);
Assert.True(new BrokkrCommandLedger(writer).TryNextPending(out var first));
Assert.Equal("a", first.commandId);
}
[Fact]
public async Task CommandWithoutAnIdIsNeverPending()
{
using var project = new ScratchProject();
await project.WriteCommandAsync(" ");
using var editor = project.OpenCache();
Assert.False(new BrokkrCommandLedger(editor).TryNextPending(out _));
}
[Fact]
public async Task TwoLedgersOverOneStoreExecuteEachIdOnce()
{
// The Unity double drain, at the layer where the rule lives. Two editors take turns over one store; each
// pulls before it looks and answers before it yields, so together they must execute each id exactly once.
// The ledger cannot serialise simultaneous drains; the service being the only owner does that.
using var project = new ScratchProject();
var ids = Enumerable.Range(0, 6).Select(index => $"cmd-{index}").ToArray();
foreach (var id in ids)
{
await project.WriteCommandAsync(id);
await Task.Delay(5);
}
using var first = project.OpenCache();
using var second = project.OpenCache();
var turns = new[]
{
(Cache: first, Ledger: new BrokkrCommandLedger(first)),
(Cache: second, Ledger: new BrokkrCommandLedger(second))
};
var executed = new List<string>();
for (var turn = 0; turn < ids.Length * 2; turn++)
{
var (cache, ledger) = turns[turn % 2];
await ledger.PullAsync();
if (!ledger.TryNextPending(out var command)) continue;
executed.Add(command.commandId);
await Answer(cache, command.commandId);
}
Assert.Equal(ids.OrderBy(id => id), executed.OrderBy(id => id));
using var audit = project.OpenCache();
Assert.Equal(ids.Length, audit.AllStoredDocuments.Count(entry => entry.Document is BrokkrUnityCommandReceipt));
}
}

View file

@ -0,0 +1,93 @@
using GameCult.Brokkr;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using GameCult.Mesh;
using GameCult.Networking;
namespace Brokkr.Contracts.Tests;
// The scenario harness: an editor without Unity. It opens the store exactly as BrokkrCultMeshMirror does (a CultMesh
// node with StartServer=false over a directory store and durable shard logs) and runs one service tick in the
// service order: pull, drain pending intents through the ledger, receipt, soft flush, host snapshot, soft flush.
// "Executing" an intent only records its id, so the scenarios measure delivery and once-only execution, not Unity.
internal sealed class EditorProbe : IDisposable
{
private readonly CultMeshNode node;
private readonly BrokkrCommandLedger ledger;
internal List<string> Executed { get; } = new();
private EditorProbe(CultMeshNode node)
{
this.node = node;
ledger = new BrokkrCommandLedger(node.Cache);
}
internal static async Task<EditorProbe> StartAsync(string cachePath)
{
Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
var node = await CultMesh.CreateNodeAsync(cachePath, new CultMeshNodeOptions
{
StartServer = false,
CacheOptions = new CultCacheOpenOptions { UseDirectoryStore = true },
EnableDurableShardLogs = true,
DatabaseOptions = new CultNetDatabaseOptions { RuntimeId = "brokkr-unity-editor" }
});
return new EditorProbe(node);
}
internal async Task TickAsync()
{
await ledger.PullAsync();
while (ledger.TryNextPending(out var command))
{
Executed.Add(command.commandId);
await node.Database.PutAsync(
BrokkrCommandLedger.ReceiptKey(command.commandId),
new BrokkrUnityCommandReceipt
{
commandId = command.commandId,
status = "accepted",
message = "probe",
observedAt = DateTime.UtcNow.ToString("O")
});
await node.FlushAsync(soft: true);
}
await node.Database.PutAsync(
new CultRecordKey("unity/host/current"),
new BrokkrHostSnapshot { observedAt = DateTime.UtcNow.ToString("O"), projectPath = "probe" });
await node.FlushAsync(soft: true);
}
public void Dispose() => node.Dispose();
}
// A store on disk laid out the way a Unity project lays it out: <project>/.brokkr/unity-editor.ccmp.
internal sealed class ScratchProject : IDisposable
{
internal string Root { get; } = Path.Combine(Path.GetTempPath(), "brokkr-tests-" + Guid.NewGuid().ToString("N"));
internal string StorePath => Path.Combine(Root, ".brokkr", "unity-editor.ccmp");
// What brokkr-command does: open the same directory store, write one intent, flush.
internal async Task WriteCommandAsync(string commandId, string action = "refreshAssets")
{
using var writer = OpenCache();
await writer.AddAsync(
new BrokkrUnityCommand { commandId = commandId, action = action },
new CultRecordHandle<BrokkrUnityCommand>(BrokkrCommandLedger.CommandKey(commandId)));
await writer.FlushAsync();
}
internal CultCache OpenCache()
{
Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!);
return CultCacheMessagePack.Create(StorePath, new CultCacheOpenOptions { UseDirectoryStore = true });
}
public void Dispose()
{
try { Directory.Delete(Root, recursive: true); } catch (IOException) { }
}
}

View file

@ -0,0 +1,46 @@
using GameCult.Brokkr;
using GameCult.Caching;
namespace Brokkr.Contracts.Tests;
// The Imagination probe, kept: many writers, one editor, the real store. Scaled to run in seconds; the probe
// measured 24 commands at a 1000 ms tick and 90 at a 20 ms tick, three writers at a time, with no loss.
public sealed class EditorScenarioTests
{
[Theory]
[InlineData(24, 200)]
[InlineData(90, 20)]
public async Task ManyWritersOneEditorExecuteEveryIntentOnceAndAnswerEachId(int commands, int tickMilliseconds)
{
using var project = new ScratchProject();
using var editor = await EditorProbe.StartAsync(project.StorePath);
var ids = Enumerable.Range(0, commands).Select(index => $"scenario-{index}").ToArray();
using var stop = new CancellationTokenSource();
var editorLoop = Task.Run(async () =>
{
while (!stop.IsCancellationRequested)
{
await editor.TickAsync();
await Task.Delay(tickMilliseconds);
}
});
foreach (var wave in ids.Chunk(3))
await Task.WhenAll(wave.Select(id => project.WriteCommandAsync(id)));
var deadline = DateTime.UtcNow.AddSeconds(60);
while (DateTime.UtcNow < deadline && editor.Executed.Count < commands)
await Task.Delay(50);
// One more tick after the last receipt, so a duplicate execution would have had its chance.
await Task.Delay(tickMilliseconds * 3);
stop.Cancel();
await editorLoop;
Assert.Equal(commands, editor.Executed.Count);
Assert.Equal(ids.OrderBy(id => id), editor.Executed.OrderBy(id => id));
using var audit = project.OpenCache();
var receipts = audit.AllStoredDocuments.Select(entry => entry.Document).OfType<BrokkrUnityCommandReceipt>().ToArray();
Assert.Equal(ids.OrderBy(id => id), receipts.Select(receipt => receipt.commandId).OrderBy(id => id));
}
}

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<CultLibRoot Condition="'$(CultLibRoot)' == ''">..\..\CultLib</CultLibRoot>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\surfaces\unity\Packages\com.gamecult.brokkr\Runtime\*.cs" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching\GameCult.Caching.csproj" />
<PackageReference Include="MessagePack" Version="3.1.7" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,52 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using GameCult.Caching;
namespace GameCult.Brokkr
{
// Decides which Unity command intents still await execution. A command id is single-use: any receipt that
// answers an id retires it for good, whatever is later written under that id. The ledger holds no state of its
// own; the answer is derived from the cache on every call, so there is no second copy that could drift.
public sealed class BrokkrCommandLedger
{
public const string CommandKeyPrefix = "unity/commands/";
public const string ReceiptKeyPrefix = "unity/receipts/";
private readonly CultCache cache;
public BrokkrCommandLedger(CultCache cache)
{
this.cache = cache ?? throw new ArgumentNullException(nameof(cache));
}
public static CultRecordKey CommandKey(string commandId) => new CultRecordKey(CommandKeyPrefix + commandId);
public static CultRecordKey ReceiptKey(string commandId) => new CultRecordKey(ReceiptKeyPrefix + commandId);
// Loads writes that other processes have landed in the backing store.
public Task PullAsync() => cache.PullAllBackingStoresAsync();
// The oldest unanswered intent: stored-at order, then key order.
public bool TryNextPending(out BrokkrUnityCommand command)
{
var stored = cache.AllStoredDocuments.ToArray();
var answered = new HashSet<string>(
stored.Select(entry => entry.Document).OfType<BrokkrUnityCommandReceipt>()
.Select(receipt => receipt.commandId)
.Where(id => !string.IsNullOrWhiteSpace(id)),
StringComparer.Ordinal);
command = stored
.Where(entry => entry.Document is BrokkrUnityCommand)
.OrderBy(entry => entry.StoredAt, StringComparer.Ordinal)
.ThenBy(entry => entry.Key.Value, StringComparer.Ordinal)
.Select(entry => (BrokkrUnityCommand)entry.Document)
.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate.commandId)
&& !answered.Contains(candidate.commandId));
return command != null;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid:

View file

@ -23,6 +23,9 @@ namespace GameCult.Brokkr
[Key(11)] public string[] capabilities = Array.Empty<string>();
[Key(12)] public BrokkrGameObjectSnapshot[] sceneObjects = Array.Empty<BrokkrGameObjectSnapshot>();
[Key(13)] public BrokkrAssetSnapshot[] assets = Array.Empty<BrokkrAssetSnapshot>();
// Keys 14-17 are reserved for the editor play-state fields (A4).
// False means the editor executes no agent commands: the operator has not enabled the sink.
[Key(18)] public bool agentCommandsEnabled;
}
[CultDocument("brokkr.unity.snapshot_receipt", "brokkr.unity.snapshot_receipt.v0")]