diff --git a/.gitignore b/.gitignore index 2ebe090..6c11419 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ Logs/ UserSettings/ __pycache__/ *.pyc +bin/ +obj/ diff --git a/brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj b/brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj new file mode 100644 index 0000000..d3939e6 --- /dev/null +++ b/brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + false + ..\..\CultLib + + + + + + + + + + + + + + + diff --git a/brokkr-contracts.Tests/BrokkrCommandLedgerTests.cs b/brokkr-contracts.Tests/BrokkrCommandLedgerTests.cs new file mode 100644 index 0000000..09228fa --- /dev/null +++ b/brokkr-contracts.Tests/BrokkrCommandLedgerTests.cs @@ -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(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(BrokkrCommandLedger.CommandKey("z"))); + batch.Upsert( + new BrokkrUnityCommand { commandId = "a", action = "refreshAssets" }, + new CultRecordHandle(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(); + 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)); + } +} diff --git a/brokkr-contracts.Tests/EditorProbe.cs b/brokkr-contracts.Tests/EditorProbe.cs new file mode 100644 index 0000000..133c504 --- /dev/null +++ b/brokkr-contracts.Tests/EditorProbe.cs @@ -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 Executed { get; } = new(); + + private EditorProbe(CultMeshNode node) + { + this.node = node; + ledger = new BrokkrCommandLedger(node.Cache); + } + + internal static async Task 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: /.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(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) { } + } +} diff --git a/brokkr-contracts.Tests/EditorScenarioTests.cs b/brokkr-contracts.Tests/EditorScenarioTests.cs new file mode 100644 index 0000000..c34ef44 --- /dev/null +++ b/brokkr-contracts.Tests/EditorScenarioTests.cs @@ -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().ToArray(); + Assert.Equal(ids.OrderBy(id => id), receipts.Select(receipt => receipt.commandId).OrderBy(id => id)); + } +} diff --git a/brokkr-contracts/Brokkr.Contracts.csproj b/brokkr-contracts/Brokkr.Contracts.csproj new file mode 100644 index 0000000..7413d56 --- /dev/null +++ b/brokkr-contracts/Brokkr.Contracts.csproj @@ -0,0 +1,16 @@ + + + netstandard2.1 + enable + enable + latest + false + ..\..\CultLib + + + + + + + + diff --git a/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrCommandLedger.cs b/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrCommandLedger.cs new file mode 100644 index 0000000..c448642 --- /dev/null +++ b/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrCommandLedger.cs @@ -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( + stored.Select(entry => entry.Document).OfType() + .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; + } + } +} diff --git a/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrCommandLedger.cs.meta b/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrCommandLedger.cs.meta new file mode 100644 index 0000000..8f66202 --- /dev/null +++ b/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrCommandLedger.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: diff --git a/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs b/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs index 2814cae..49204bf 100644 --- a/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs +++ b/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs @@ -23,6 +23,9 @@ namespace GameCult.Brokkr [Key(11)] public string[] capabilities = Array.Empty(); [Key(12)] public BrokkrGameObjectSnapshot[] sceneObjects = Array.Empty(); [Key(13)] public BrokkrAssetSnapshot[] assets = Array.Empty(); + // 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")]