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:
parent
15c735c45f
commit
d498ee37ac
9 changed files with 389 additions and 0 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -13,3 +13,5 @@ Logs/
|
|||
UserSettings/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
bin/
|
||||
obj/
|
||||
|
|
|
|||
22
brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj
Normal file
22
brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj
Normal 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>
|
||||
153
brokkr-contracts.Tests/BrokkrCommandLedgerTests.cs
Normal file
153
brokkr-contracts.Tests/BrokkrCommandLedgerTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
93
brokkr-contracts.Tests/EditorProbe.cs
Normal file
93
brokkr-contracts.Tests/EditorProbe.cs
Normal 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) { }
|
||||
}
|
||||
}
|
||||
46
brokkr-contracts.Tests/EditorScenarioTests.cs
Normal file
46
brokkr-contracts.Tests/EditorScenarioTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
16
brokkr-contracts/Brokkr.Contracts.csproj
Normal file
16
brokkr-contracts/Brokkr.Contracts.csproj
Normal 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid:
|
||||
|
|
@ -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")]
|
||||
|
|
|
|||
Loading…
Reference in a new issue