From dab4c301e1ed983c929f27180130fa3d716b86e9 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:09:37 -0500 Subject: [PATCH 01/31] Step 11: add deterministic cardinal pathfinder --- .../Movement/DeterministicPathfinder.cs | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 GenerationArk.Simulation/Movement/DeterministicPathfinder.cs diff --git a/GenerationArk.Simulation/Movement/DeterministicPathfinder.cs b/GenerationArk.Simulation/Movement/DeterministicPathfinder.cs new file mode 100644 index 0000000..a0915f6 --- /dev/null +++ b/GenerationArk.Simulation/Movement/DeterministicPathfinder.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using GenerationArk.Simulation.Map; + +namespace GenerationArk.Simulation.Movement; + +public static class DeterministicPathfinder +{ + public static IReadOnlyList FindPath( + MapState map, + MapCellId start, + MapCellId destination, + Func isWalkable) + { + ArgumentNullException.ThrowIfNull(map); + ArgumentNullException.ThrowIfNull(isWalkable); + + ValidateCell(map, start, nameof(start)); + ValidateCell(map, destination, nameof(destination)); + + if (start == destination) + { + return new[] { start }; + } + if (!isWalkable(destination)) + { + return Array.Empty(); + } + + int[] previous = new int[map.CellCount]; + Array.Fill(previous, -1); + bool[] visited = new bool[map.CellCount]; + var queue = new Queue(); + visited[start.Value] = true; + queue.Enqueue(start); + + while (queue.Count > 0) + { + MapCellId current = queue.Dequeue(); + foreach (MapCellId neighbor in EnumerateNeighborsCanonical(map, current)) + { + if (visited[neighbor.Value] || !isWalkable(neighbor)) + { + continue; + } + + visited[neighbor.Value] = true; + previous[neighbor.Value] = current.Value; + if (neighbor == destination) + { + return Reconstruct(start, destination, previous); + } + queue.Enqueue(neighbor); + } + } + + return Array.Empty(); + } + + private static IEnumerable EnumerateNeighborsCanonical(MapState map, MapCellId cell) + { + GridPosition position = cell.ToPosition(map.Width, map.Height); + Span candidates = stackalloc MapCellId[4]; + int count = 0; + + Add(position.X, position.Y - 1); + Add(position.X - 1, position.Y); + Add(position.X + 1, position.Y); + Add(position.X, position.Y + 1); + + for (int left = 1; left < count; left++) + { + MapCellId value = candidates[left]; + int right = left - 1; + while (right >= 0 && candidates[right].Value > value.Value) + { + candidates[right + 1] = candidates[right]; + right--; + } + candidates[right + 1] = value; + } + + for (int index = 0; index < count; index++) + { + yield return candidates[index]; + } + + void Add(int x, int y) + { + if ((uint)x < (uint)map.Width && (uint)y < (uint)map.Height) + { + candidates[count++] = MapCellId.FromPosition(new GridPosition(x, y), map.Width, map.Height); + } + } + } + + private static IReadOnlyList Reconstruct( + MapCellId start, + MapCellId destination, + IReadOnlyList previous) + { + var reversed = new List { destination }; + int current = destination.Value; + while (current != start.Value) + { + current = previous[current]; + if (current < 0) + { + throw new InvalidOperationException("Path reconstruction encountered an incomplete predecessor chain."); + } + reversed.Add(new MapCellId(current)); + } + reversed.Reverse(); + return reversed; + } + + private static void ValidateCell(MapState map, MapCellId cell, string parameterName) + { + if ((uint)cell.Value >= (uint)map.CellCount) + { + throw new ArgumentOutOfRangeException(parameterName, cell, $"Cell ID must be between 0 and {map.CellCount - 1}."); + } + } +} From 600973c907f4304ff05d366c31e09ef4f5f6f3cf Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:09:57 -0500 Subject: [PATCH 02/31] Fix pathfinder canonical neighbor storage --- .../Movement/DeterministicPathfinder.cs | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/GenerationArk.Simulation/Movement/DeterministicPathfinder.cs b/GenerationArk.Simulation/Movement/DeterministicPathfinder.cs index a0915f6..c2122ee 100644 --- a/GenerationArk.Simulation/Movement/DeterministicPathfinder.cs +++ b/GenerationArk.Simulation/Movement/DeterministicPathfinder.cs @@ -60,25 +60,14 @@ public static IReadOnlyList FindPath( private static IEnumerable EnumerateNeighborsCanonical(MapState map, MapCellId cell) { GridPosition position = cell.ToPosition(map.Width, map.Height); - Span candidates = stackalloc MapCellId[4]; + var candidates = new MapCellId[4]; int count = 0; Add(position.X, position.Y - 1); Add(position.X - 1, position.Y); Add(position.X + 1, position.Y); Add(position.X, position.Y + 1); - - for (int left = 1; left < count; left++) - { - MapCellId value = candidates[left]; - int right = left - 1; - while (right >= 0 && candidates[right].Value > value.Value) - { - candidates[right + 1] = candidates[right]; - right--; - } - candidates[right + 1] = value; - } + Array.Sort(candidates, 0, count); for (int index = 0; index < count; index++) { From db040d2f2382859caa06f818b5deee77b7ef88bf Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:10:23 -0500 Subject: [PATCH 03/31] Step 11: add deterministic pathfinding tests --- .../PathfindingMilestoneTests.cs | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 GenerationArk.Simulation.Tests/PathfindingMilestoneTests.cs diff --git a/GenerationArk.Simulation.Tests/PathfindingMilestoneTests.cs b/GenerationArk.Simulation.Tests/PathfindingMilestoneTests.cs new file mode 100644 index 0000000..255a85f --- /dev/null +++ b/GenerationArk.Simulation.Tests/PathfindingMilestoneTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using GenerationArk.Simulation.Map; +using GenerationArk.Simulation.Movement; + +namespace GenerationArk.Simulation.Tests; + +internal static class PathfindingMilestoneTests +{ + private static readonly MapCellDefinitionId FloorDefinition = new(1); + + public static void CardinalRouteUsesCanonicalTieBreaking() + { + MapState map = CreateMap(3, 3); + IReadOnlyList route = DeterministicPathfinder.FindPath( + map, + Cell(0, 0, map), + Cell(2, 2, map), + static _ => true); + + TestAssert.Equal("0,1,2,5,8", CanonicalRoute(route)); + } + + public static void BlockedCellsAreAvoidedAndBlockedDestinationFails() + { + MapState map = CreateMap(4, 3); + var blocked = new HashSet + { + Cell(1, 0, map), + Cell(1, 1, map) + }; + + IReadOnlyList route = DeterministicPathfinder.FindPath( + map, + Cell(0, 0, map), + Cell(3, 0, map), + cell => !blocked.Contains(cell)); + + TestAssert.Equal("0,4,8,9,10,6,2,3", CanonicalRoute(route)); + + blocked.Add(Cell(3, 0, map)); + TestAssert.Equal( + 0, + DeterministicPathfinder.FindPath( + map, + Cell(0, 0, map), + Cell(3, 0, map), + cell => !blocked.Contains(cell)).Count); + } + + public static void RepathAfterObstructionChangeIsDeterministic() + { + MapState map = CreateMap(5, 3); + var blocked = new HashSet(); + MapCellId start = Cell(0, 1, map); + MapCellId destination = Cell(4, 1, map); + + IReadOnlyList first = DeterministicPathfinder.FindPath( + map, start, destination, cell => !blocked.Contains(cell)); + blocked.Add(Cell(2, 1, map)); + IReadOnlyList second = DeterministicPathfinder.FindPath( + map, start, destination, cell => !blocked.Contains(cell)); + IReadOnlyList repeat = DeterministicPathfinder.FindPath( + map, start, destination, cell => !blocked.Contains(cell)); + + TestAssert.Equal("5,6,7,8,9", CanonicalRoute(first)); + TestAssert.Equal(CanonicalRoute(second), CanonicalRoute(repeat)); + TestAssert.True(!second.Contains(Cell(2, 1, map))); + } + + public static void OneHundredConcurrentRoutesCompleteWithoutDivergence() + { + MapState map = CreateMap(20, 20); + var routes = new ConcurrentBag(); + + Parallel.For(0, 100, _ => + { + IReadOnlyList route = DeterministicPathfinder.FindPath( + map, + Cell(0, 0, map), + Cell(19, 19, map), + static _ => true); + routes.Add(CanonicalRoute(route)); + }); + + TestAssert.Equal(100, routes.Count); + TestAssert.Equal(1, routes.Distinct(StringComparer.Ordinal).Count()); + } + + private static MapState CreateMap(int width, int height) + { + var registry = new MapCellDefinitionRegistry(new[] + { + new MapCellDefinition(FloorDefinition, ParticipatesInRoomTopology: true) + }); + return new MapState(width, height, registry, FloorDefinition); + } + + private static MapCellId Cell(int x, int y, MapState map) => + MapCellId.FromPosition(new GridPosition(x, y), map.Width, map.Height); + + private static string CanonicalRoute(IEnumerable route) => + string.Join(",", route.Select(static cell => cell.Value)); +} From bc4f84d67eb740a08d80613dfb8ece60ca393675 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:11:08 -0500 Subject: [PATCH 04/31] Step 11: register pathfinding milestone tests --- GenerationArk.Simulation.Tests/Program.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/GenerationArk.Simulation.Tests/Program.cs b/GenerationArk.Simulation.Tests/Program.cs index 4d754a1..17821e6 100644 --- a/GenerationArk.Simulation.Tests/Program.cs +++ b/GenerationArk.Simulation.Tests/Program.cs @@ -80,7 +80,11 @@ private static int Main() (nameof(MapTopologyMilestoneTests.RoomTopologyUsesCardinalConnectivityAndStableRoomIds), MapTopologyMilestoneTests.RoomTopologyUsesCardinalConnectivityAndStableRoomIds), (nameof(MapTopologyMilestoneTests.RoomTopologySplitAndMergeRebuildsDeterministically), MapTopologyMilestoneTests.RoomTopologySplitAndMergeRebuildsDeterministically), (nameof(MapTopologyMilestoneTests.MapStateSaveLoadRoundTripIsCanonical), MapTopologyMilestoneTests.MapStateSaveLoadRoundTripIsCanonical), - (nameof(MapTopologyMilestoneTests.MapReplayFramePatternsAndTopologyChurnMatchChecksums), MapTopologyMilestoneTests.MapReplayFramePatternsAndTopologyChurnMatchChecksums) + (nameof(MapTopologyMilestoneTests.MapReplayFramePatternsAndTopologyChurnMatchChecksums), MapTopologyMilestoneTests.MapReplayFramePatternsAndTopologyChurnMatchChecksums), + (nameof(PathfindingMilestoneTests.CardinalRouteUsesCanonicalTieBreaking), PathfindingMilestoneTests.CardinalRouteUsesCanonicalTieBreaking), + (nameof(PathfindingMilestoneTests.BlockedCellsAreAvoidedAndBlockedDestinationFails), PathfindingMilestoneTests.BlockedCellsAreAvoidedAndBlockedDestinationFails), + (nameof(PathfindingMilestoneTests.RepathAfterObstructionChangeIsDeterministic), PathfindingMilestoneTests.RepathAfterObstructionChangeIsDeterministic), + (nameof(PathfindingMilestoneTests.OneHundredConcurrentRoutesCompleteWithoutDivergence), PathfindingMilestoneTests.OneHundredConcurrentRoutesCompleteWithoutDivergence) }; int failures = 0; From c11172659b346de32b3463764f7a48f6d4e3856e Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:16:01 -0500 Subject: [PATCH 05/31] Add canonical movement agent component state --- .../Movement/MovementAgentState.cs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 GenerationArk.Simulation/Movement/MovementAgentState.cs diff --git a/GenerationArk.Simulation/Movement/MovementAgentState.cs b/GenerationArk.Simulation/Movement/MovementAgentState.cs new file mode 100644 index 0000000..0910a3c --- /dev/null +++ b/GenerationArk.Simulation/Movement/MovementAgentState.cs @@ -0,0 +1,54 @@ +using System; +using System.Globalization; +using GenerationArk.Simulation.Diagnostics; +using GenerationArk.Simulation.Map; +using GenerationArk.Simulation.State; + +namespace GenerationArk.Simulation.Movement; + +public sealed record MovementAgentState( + MapCellId CurrentCell, + MapCellId DestinationCell, + ulong RouteRevision) +{ + public static readonly ComponentTypeId ComponentTypeId = new("movement-agent"); + + public static ComponentRegistration CreateRegistration() + => ComponentRegistration.Create( + ComponentTypeId, + Serialize, + Deserialize, + WriteChecksum); + + public static string Serialize(MovementAgentState state) + { + ArgumentNullException.ThrowIfNull(state); + return string.Create( + CultureInfo.InvariantCulture, + $"{state.CurrentCell.Value}:{state.DestinationCell.Value}:{state.RouteRevision}"); + } + + public static MovementAgentState Deserialize(string payload) + { + ArgumentNullException.ThrowIfNull(payload); + string[] fields = payload.Split(':', StringSplitOptions.None); + if (fields.Length != 3 + || !int.TryParse(fields[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int current) + || !int.TryParse(fields[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int destination) + || !ulong.TryParse(fields[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out ulong revision)) + { + throw new InvalidOperationException("Movement agent payload must be '::'."); + } + + return new MovementAgentState(new MapCellId(current), new MapCellId(destination), revision); + } + + public static void WriteChecksum(StateChecksumWriter writer, MovementAgentState state) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(state); + writer.AddInt32(state.CurrentCell.Value); + writer.AddInt32(state.DestinationCell.Value); + writer.AddUInt64(state.RouteRevision); + } +} From 87c61f2652df0a9d2b0ddd392e3526e79d7d6254 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:16:12 -0500 Subject: [PATCH 06/31] Add deterministic authoritative movement planner --- .../Movement/AuthoritativeMovementPlanner.cs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 GenerationArk.Simulation/Movement/AuthoritativeMovementPlanner.cs diff --git a/GenerationArk.Simulation/Movement/AuthoritativeMovementPlanner.cs b/GenerationArk.Simulation/Movement/AuthoritativeMovementPlanner.cs new file mode 100644 index 0000000..7154a11 --- /dev/null +++ b/GenerationArk.Simulation/Movement/AuthoritativeMovementPlanner.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using GenerationArk.Simulation.Map; + +namespace GenerationArk.Simulation.Movement; + +public static class AuthoritativeMovementPlanner +{ + public static MovementAgentState PlanNext( + MapState map, + MovementAgentState current, + Func isWalkable) + { + ArgumentNullException.ThrowIfNull(map); + ArgumentNullException.ThrowIfNull(current); + ArgumentNullException.ThrowIfNull(isWalkable); + + IReadOnlyList route = DeterministicPathfinder.FindPath( + map, + current.CurrentCell, + current.DestinationCell, + isWalkable); + + if (route.Count <= 1) + { + return current; + } + + return current with + { + CurrentCell = route[1], + RouteRevision = checked(current.RouteRevision + 1UL) + }; + } +} From 4e60c361248ef49933fa0b30cf67a6c254993363 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:16:46 -0500 Subject: [PATCH 07/31] Test canonical movement state and authoritative planning --- .../PathfindingMilestoneTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/GenerationArk.Simulation.Tests/PathfindingMilestoneTests.cs b/GenerationArk.Simulation.Tests/PathfindingMilestoneTests.cs index 255a85f..4604f42 100644 --- a/GenerationArk.Simulation.Tests/PathfindingMilestoneTests.cs +++ b/GenerationArk.Simulation.Tests/PathfindingMilestoneTests.cs @@ -3,8 +3,10 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using GenerationArk.Simulation.Diagnostics; using GenerationArk.Simulation.Map; using GenerationArk.Simulation.Movement; +using GenerationArk.Simulation.State; namespace GenerationArk.Simulation.Tests; @@ -90,6 +92,33 @@ public static void OneHundredConcurrentRoutesCompleteWithoutDivergence() TestAssert.Equal(1, routes.Distinct(StringComparer.Ordinal).Count()); } + public static void MovementAgentStateSerializationAndChecksumAreCanonical() + { + var state = new MovementAgentState(new MapCellId(3), new MapCellId(9), 7UL); + string payload = MovementAgentState.Serialize(state); + MovementAgentState restored = MovementAgentState.Deserialize(payload); + TestAssert.Equal("3:9:7", payload); + TestAssert.Equal(state, restored); + + ComponentRegistration registration = MovementAgentState.CreateRegistration(); + var first = new StateChecksumWriter(); + var second = new StateChecksumWriter(); + registration.WriteChecksum(first, state); + registration.WriteChecksum(second, restored); + TestAssert.Equal(first.Value, second.Value); + } + + public static void AuthoritativePlannerAdvancesOneCanonicalCellPerCommitIntent() + { + MapState map = CreateMap(3, 2); + var initial = new MovementAgentState(Cell(0, 0, map), Cell(2, 1, map), 0UL); + MovementAgentState next = AuthoritativeMovementPlanner.PlanNext(map, initial, static _ => true); + + TestAssert.Equal(Cell(1, 0, map), next.CurrentCell); + TestAssert.Equal(initial.DestinationCell, next.DestinationCell); + TestAssert.Equal(1UL, next.RouteRevision); + } + private static MapState CreateMap(int width, int height) { var registry = new MapCellDefinitionRegistry(new[] From 2f97e09723f77ad8b053033d55195cbc14ee0f86 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:17:31 -0500 Subject: [PATCH 08/31] Register movement state and planner tests --- GenerationArk.Simulation.Tests/Program.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/GenerationArk.Simulation.Tests/Program.cs b/GenerationArk.Simulation.Tests/Program.cs index 17821e6..1bfdc44 100644 --- a/GenerationArk.Simulation.Tests/Program.cs +++ b/GenerationArk.Simulation.Tests/Program.cs @@ -84,7 +84,9 @@ private static int Main() (nameof(PathfindingMilestoneTests.CardinalRouteUsesCanonicalTieBreaking), PathfindingMilestoneTests.CardinalRouteUsesCanonicalTieBreaking), (nameof(PathfindingMilestoneTests.BlockedCellsAreAvoidedAndBlockedDestinationFails), PathfindingMilestoneTests.BlockedCellsAreAvoidedAndBlockedDestinationFails), (nameof(PathfindingMilestoneTests.RepathAfterObstructionChangeIsDeterministic), PathfindingMilestoneTests.RepathAfterObstructionChangeIsDeterministic), - (nameof(PathfindingMilestoneTests.OneHundredConcurrentRoutesCompleteWithoutDivergence), PathfindingMilestoneTests.OneHundredConcurrentRoutesCompleteWithoutDivergence) + (nameof(PathfindingMilestoneTests.OneHundredConcurrentRoutesCompleteWithoutDivergence), PathfindingMilestoneTests.OneHundredConcurrentRoutesCompleteWithoutDivergence), + (nameof(PathfindingMilestoneTests.MovementAgentStateSerializationAndChecksumAreCanonical), PathfindingMilestoneTests.MovementAgentStateSerializationAndChecksumAreCanonical), + (nameof(PathfindingMilestoneTests.AuthoritativePlannerAdvancesOneCanonicalCellPerCommitIntent), PathfindingMilestoneTests.AuthoritativePlannerAdvancesOneCanonicalCellPerCommitIntent) }; int failures = 0; From f87776e230f60eb10a1a569cc01505c271ce7c55 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:20:39 -0500 Subject: [PATCH 09/31] Add deterministic component replacement primitive --- GenerationArk.Simulation/State/ComponentStore.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/GenerationArk.Simulation/State/ComponentStore.cs b/GenerationArk.Simulation/State/ComponentStore.cs index 7b8241a..e6b07a5 100644 --- a/GenerationArk.Simulation/State/ComponentStore.cs +++ b/GenerationArk.Simulation/State/ComponentStore.cs @@ -38,6 +38,17 @@ public void Add(EntityId entityId, object value) } } + public void Replace(EntityId entityId, object value) + { + _registration.ValidateRuntimeType(value); + if (!_values.ContainsKey(entityId)) + { + throw new InvalidOperationException( + $"Entity {entityId} does not have component {ComponentTypeId}."); + } + _values[entityId] = value; + } + public void Remove(EntityId entityId) { if (!_values.Remove(entityId)) From 30165155ca48d450554ba5d14351be590ea3d63d Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:21:03 -0500 Subject: [PATCH 10/31] Expose authoritative component replacement to Commit pipeline --- .../State/ComponentRegistry.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/GenerationArk.Simulation/State/ComponentRegistry.cs b/GenerationArk.Simulation/State/ComponentRegistry.cs index 7c40eb2..a59d82e 100644 --- a/GenerationArk.Simulation/State/ComponentRegistry.cs +++ b/GenerationArk.Simulation/State/ComponentRegistry.cs @@ -80,6 +80,23 @@ internal void Add(EntityRegistry entities, EntityId entityId, ComponentValue com GetStore(component.ComponentTypeId).Add(entityId, component.Value); } + internal void Replace(EntityRegistry entities, EntityId entityId, ComponentValue component) + { + ArgumentNullException.ThrowIfNull(entities); + ArgumentNullException.ThrowIfNull(component); + if (!entities.Contains(entityId)) + { + throw new InvalidOperationException( + $"Cannot replace component {component.ComponentTypeId} on missing entity {entityId}."); + } + + ComponentStore store = _stores.TryGetValue(component.ComponentTypeId, out ComponentStore? registered) + ? registered + : throw new InvalidOperationException( + $"Unknown component type ID {component.ComponentTypeId}."); + store.Replace(entityId, component.Value); + } + internal void Remove(EntityRegistry entities, EntityId entityId, ComponentTypeId componentTypeId) { ArgumentNullException.ThrowIfNull(entities); From 5362fa497b0a419507904f8fb49aa2ff38ee2b6c Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:22:18 -0500 Subject: [PATCH 11/31] Add deterministic component replacement mutation kind --- GenerationArk.Simulation/State/EntityMutationKind.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/GenerationArk.Simulation/State/EntityMutationKind.cs b/GenerationArk.Simulation/State/EntityMutationKind.cs index 59302c9..ac4cdfb 100644 --- a/GenerationArk.Simulation/State/EntityMutationKind.cs +++ b/GenerationArk.Simulation/State/EntityMutationKind.cs @@ -5,5 +5,6 @@ public enum EntityMutationKind : byte CreateEntity = 1, DestroyEntity = 2, AddComponent = 3, - RemoveComponent = 4 + RemoveComponent = 4, + ReplaceComponent = 5 } From f52c5f0ed35e822e399598b13b8cc1b18d061f3f Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:24:37 -0500 Subject: [PATCH 12/31] Add buffered deterministic component replacement --- .../State/MutationBuffer.cs | 63 +++++++++++++++---- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/GenerationArk.Simulation/State/MutationBuffer.cs b/GenerationArk.Simulation/State/MutationBuffer.cs index e9f1c2a..1d8b70f 100644 --- a/GenerationArk.Simulation/State/MutationBuffer.cs +++ b/GenerationArk.Simulation/State/MutationBuffer.cs @@ -71,6 +71,19 @@ public EntityMutation EnqueueRemove(EntityId entityId, ComponentTypeId component initialComponents: null)); } + public EntityMutation EnqueueReplace(EntityId entityId, ComponentValue component) + { + RequireEntityId(entityId); + ArgumentNullException.ThrowIfNull(component); + return Add(new EntityMutation( + AllocateSequence(), + EntityMutationKind.ReplaceComponent, + entityId, + component, + component.ComponentTypeId, + initialComponents: null)); + } + public MapCellMutation EnqueueSetCellDefinition( MapCellId cell, MapCellDefinitionId definition) @@ -165,9 +178,7 @@ internal MutationCommitResult Commit( } case EntityMutationKind.AddComponent: { - ComponentValue component = mutation.Component - ?? throw new InvalidOperationException( - $"Mutation {mutation.MutationSequence} is missing its component value."); + ComponentValue component = RequireComponent(mutation); world.Components.Add(world.Entities, mutation.EntityId, component); world.RecordLifecycleEvent( mutation.MutationSequence, @@ -193,6 +204,19 @@ internal MutationCommitResult Commit( "component-removed"); break; } + case EntityMutationKind.ReplaceComponent: + { + ComponentValue component = RequireComponent(mutation); + world.Components.Replace(world.Entities, mutation.EntityId, component); + world.RecordLifecycleEvent( + mutation.MutationSequence, + tick, + EntityLifecycleEventKind.ComponentAdded, + mutation.EntityId, + component.ComponentTypeId, + "component-replaced"); + break; + } default: throw new InvalidOperationException( $"Unknown entity mutation kind {(byte)mutation.Kind}."); @@ -276,9 +300,7 @@ private Dictionary ValidateBatch( case EntityMutationKind.AddComponent: { RequireSimulatedEntity(simulatedEntities, mutation); - ComponentValue component = mutation.Component - ?? throw new InvalidOperationException( - $"Mutation {mutation.MutationSequence} is missing its component value."); + ComponentValue component = RequireComponent(mutation); ValidateComponent(world, component); if (!simulatedComponents.Add((mutation.EntityId, component.ComponentTypeId))) { @@ -305,6 +327,18 @@ private Dictionary ValidateBatch( } break; } + case EntityMutationKind.ReplaceComponent: + { + RequireSimulatedEntity(simulatedEntities, mutation); + ComponentValue component = RequireComponent(mutation); + ValidateComponent(world, component); + if (!simulatedComponents.Contains((mutation.EntityId, component.ComponentTypeId))) + { + throw new InvalidOperationException( + $"Entity {mutation.EntityId} does not have component {component.ComponentTypeId} to replace."); + } + break; + } default: throw new InvalidOperationException( $"Unknown entity mutation kind {(byte)mutation.Kind}."); @@ -334,7 +368,9 @@ private static IReadOnlyList FindConflicts(EntityMutation[] orde .Where(static mutation => mutation.Kind == EntityMutationKind.DestroyEntity) .ToArray(); EntityMutation[] componentChanges = group - .Where(static mutation => mutation.Kind is EntityMutationKind.AddComponent or EntityMutationKind.RemoveComponent) + .Where(static mutation => mutation.Kind is EntityMutationKind.AddComponent + or EntityMutationKind.RemoveComponent + or EntityMutationKind.ReplaceComponent) .ToArray(); if (destroys.Length > 1 || (destroys.Length > 0 && componentChanges.Length > 0)) { @@ -346,16 +382,16 @@ private static IReadOnlyList FindConflicts(EntityMutation[] orde } foreach (IGrouping<(EntityId EntityId, ComponentTypeId ComponentTypeId), EntityMutation> group in ordered - .Where(static mutation => mutation.Kind is EntityMutationKind.AddComponent or EntityMutationKind.RemoveComponent) + .Where(static mutation => mutation.Kind is EntityMutationKind.AddComponent + or EntityMutationKind.RemoveComponent + or EntityMutationKind.ReplaceComponent) .GroupBy(static mutation => ( mutation.EntityId, mutation.ComponentTypeId ?? mutation.Component?.ComponentTypeId ?? default))) { - int additions = group.Count(static mutation => mutation.Kind == EntityMutationKind.AddComponent); - int removals = group.Count(static mutation => mutation.Kind == EntityMutationKind.RemoveComponent); - if (additions > 1 || removals > 1 || (additions > 0 && removals > 0)) + if (group.Count() > 1) { foreach (EntityMutation mutation in group) { @@ -390,6 +426,11 @@ private void Reject( conflicts); } + private static ComponentValue RequireComponent(EntityMutation mutation) + => mutation.Component + ?? throw new InvalidOperationException( + $"Mutation {mutation.MutationSequence} is missing its component value."); + private static void ValidateComponent(WorldState world, ComponentValue component) { if (!world.Components.IsRegistered(component.ComponentTypeId)) From 93bb5b54f41db8b2ad09919d33d511322764016c Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:26:08 -0500 Subject: [PATCH 13/31] test: validate buffered component replacement --- .../ComponentReplacementMilestoneTests.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 GenerationArk.Simulation.Tests/ComponentReplacementMilestoneTests.cs diff --git a/GenerationArk.Simulation.Tests/ComponentReplacementMilestoneTests.cs b/GenerationArk.Simulation.Tests/ComponentReplacementMilestoneTests.cs new file mode 100644 index 0000000..d30102e --- /dev/null +++ b/GenerationArk.Simulation.Tests/ComponentReplacementMilestoneTests.cs @@ -0,0 +1,97 @@ +using System; +using GenerationArk.Simulation.Core; +using GenerationArk.Simulation.Diagnostics; +using GenerationArk.Simulation.Map; +using GenerationArk.Simulation.Movement; +using GenerationArk.Simulation.Scheduling; +using GenerationArk.Simulation.State; + +namespace GenerationArk.Simulation.Tests; + +internal static class ComponentReplacementMilestoneTests +{ + public static void ReplacementRemainsInvisibleUntilCommitAndThenApplies() + { + WorldState world = CreateWorld(); + DeterministicScheduler scheduler = CreateScheduler(); + EntityId entityId = CreateMovementEntity(world, scheduler); + var original = (MovementAgentState)world.Components.Get(entityId, MovementAgentState.ComponentTypeId); + var replacement = new MovementAgentState(new MapCellId(1), new MapCellId(3), 1); + + world.Mutations.EnqueueReplace( + entityId, + new ComponentValue(MovementAgentState.ComponentTypeId, replacement)); + + TestAssert.Equal(original, world.Components.Get(entityId, MovementAgentState.ComponentTypeId)); + world.CommitMutations(scheduler, new SimTick(2)); + TestAssert.Equal(replacement, world.Components.Get(entityId, MovementAgentState.ComponentTypeId)); + } + + public static void ConflictingReplacementsRejectAtomically() + { + WorldState world = CreateWorld(); + DeterministicScheduler scheduler = CreateScheduler(); + EntityId entityId = CreateMovementEntity(world, scheduler); + var original = (MovementAgentState)world.Components.Get(entityId, MovementAgentState.ComponentTypeId); + + world.Mutations.EnqueueReplace( + entityId, + new ComponentValue( + MovementAgentState.ComponentTypeId, + new MovementAgentState(new MapCellId(1), new MapCellId(3), 1))); + world.Mutations.EnqueueReplace( + entityId, + new ComponentValue( + MovementAgentState.ComponentTypeId, + new MovementAgentState(new MapCellId(2), new MapCellId(3), 2))); + + TestAssert.Throws( + () => world.CommitMutations(scheduler, new SimTick(2))); + TestAssert.Equal(original, world.Components.Get(entityId, MovementAgentState.ComponentTypeId)); + } + + public static void MovementReplacementChangesCanonicalChecksum() + { + WorldState world = CreateWorld(); + DeterministicScheduler scheduler = CreateScheduler(); + EntityId entityId = CreateMovementEntity(world, scheduler); + ulong before = StateChecksum.Compute(new SimTick(1), world, scheduler); + + world.Mutations.EnqueueReplace( + entityId, + new ComponentValue( + MovementAgentState.ComponentTypeId, + new MovementAgentState(new MapCellId(1), new MapCellId(3), 1))); + world.CommitMutations(scheduler, new SimTick(2)); + ulong after = StateChecksum.Compute(new SimTick(2), world, scheduler); + + TestAssert.True(before != after, "Movement replacement must change the canonical checksum."); + } + + private static EntityId CreateMovementEntity(WorldState world, DeterministicScheduler scheduler) + { + world.Mutations.EnqueueCreate(new[] + { + new ComponentValue( + MovementAgentState.ComponentTypeId, + new MovementAgentState(new MapCellId(0), new MapCellId(3), 0)) + }); + MutationCommitResult result = world.CommitMutations(scheduler, new SimTick(1)); + return result.CreatedEntityIds[0]; + } + + private static WorldState CreateWorld() + { + var definitions = new MapCellDefinitionRegistry(new[] + { + new MapCellDefinition(new MapCellDefinitionId(1), ParticipatesInRoomTopology: true) + }); + var map = new MapState(4, 1, definitions, new MapCellDefinitionId(1)); + return new WorldState( + componentRegistrations: new[] { MovementAgentState.CreateRegistration() }, + map: map); + } + + private static DeterministicScheduler CreateScheduler() => + new(new ScheduledEventHandlerRegistry(Array.Empty())); +} From 480e3cc47de32c13ed1f7c86b31ec72c5f369c02 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:27:13 -0500 Subject: [PATCH 14/31] test: register component replacement validation --- GenerationArk.Simulation.Tests/Program.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/GenerationArk.Simulation.Tests/Program.cs b/GenerationArk.Simulation.Tests/Program.cs index 1bfdc44..31f054b 100644 --- a/GenerationArk.Simulation.Tests/Program.cs +++ b/GenerationArk.Simulation.Tests/Program.cs @@ -86,7 +86,10 @@ private static int Main() (nameof(PathfindingMilestoneTests.RepathAfterObstructionChangeIsDeterministic), PathfindingMilestoneTests.RepathAfterObstructionChangeIsDeterministic), (nameof(PathfindingMilestoneTests.OneHundredConcurrentRoutesCompleteWithoutDivergence), PathfindingMilestoneTests.OneHundredConcurrentRoutesCompleteWithoutDivergence), (nameof(PathfindingMilestoneTests.MovementAgentStateSerializationAndChecksumAreCanonical), PathfindingMilestoneTests.MovementAgentStateSerializationAndChecksumAreCanonical), - (nameof(PathfindingMilestoneTests.AuthoritativePlannerAdvancesOneCanonicalCellPerCommitIntent), PathfindingMilestoneTests.AuthoritativePlannerAdvancesOneCanonicalCellPerCommitIntent) + (nameof(PathfindingMilestoneTests.AuthoritativePlannerAdvancesOneCanonicalCellPerCommitIntent), PathfindingMilestoneTests.AuthoritativePlannerAdvancesOneCanonicalCellPerCommitIntent), + (nameof(ComponentReplacementMilestoneTests.ReplacementRemainsInvisibleUntilCommitAndThenApplies), ComponentReplacementMilestoneTests.ReplacementRemainsInvisibleUntilCommitAndThenApplies), + (nameof(ComponentReplacementMilestoneTests.ConflictingReplacementsRejectAtomically), ComponentReplacementMilestoneTests.ConflictingReplacementsRejectAtomically), + (nameof(ComponentReplacementMilestoneTests.MovementReplacementChangesCanonicalChecksum), ComponentReplacementMilestoneTests.MovementReplacementChangesCanonicalChecksum) }; int failures = 0; From 29261bddfe5b012636dbb12582ce3bbefb971501 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:28:48 -0500 Subject: [PATCH 15/31] Add Step 11 Release validation workflow --- .github/workflows/step11-validation.yml | 35 +++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/step11-validation.yml diff --git a/.github/workflows/step11-validation.yml b/.github/workflows/step11-validation.yml new file mode 100644 index 0000000..5cbd0d2 --- /dev/null +++ b/.github/workflows/step11-validation.yml @@ -0,0 +1,35 @@ +name: Step 11 Validation + +on: + pull_request: + branches: + - main + push: + branches: + - step11/deterministic-pathfinding-movement + +permissions: + contents: read + +jobs: + release-validation: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore + run: dotnet restore GenerationArk.Simulation.Tests/GenerationArk.Simulation.Tests.csproj + + - name: Build Release + run: dotnet build GenerationArk.Simulation.Tests/GenerationArk.Simulation.Tests.csproj --configuration Release --no-restore + + - name: Run exact harness + run: dotnet run --project GenerationArk.Simulation.Tests/GenerationArk.Simulation.Tests.csproj --configuration Release --no-build From 576783f6047e32d9e752f6bded26132e4b4edad1 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:30:54 -0500 Subject: [PATCH 16/31] Fix replacement test typing and checksum isolation --- .../ComponentReplacementMilestoneTests.cs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/GenerationArk.Simulation.Tests/ComponentReplacementMilestoneTests.cs b/GenerationArk.Simulation.Tests/ComponentReplacementMilestoneTests.cs index d30102e..db283d9 100644 --- a/GenerationArk.Simulation.Tests/ComponentReplacementMilestoneTests.cs +++ b/GenerationArk.Simulation.Tests/ComponentReplacementMilestoneTests.cs @@ -22,9 +22,13 @@ public static void ReplacementRemainsInvisibleUntilCommitAndThenApplies() entityId, new ComponentValue(MovementAgentState.ComponentTypeId, replacement)); - TestAssert.Equal(original, world.Components.Get(entityId, MovementAgentState.ComponentTypeId)); + TestAssert.Equal( + original, + (MovementAgentState)world.Components.Get(entityId, MovementAgentState.ComponentTypeId)); world.CommitMutations(scheduler, new SimTick(2)); - TestAssert.Equal(replacement, world.Components.Get(entityId, MovementAgentState.ComponentTypeId)); + TestAssert.Equal( + replacement, + (MovementAgentState)world.Components.Get(entityId, MovementAgentState.ComponentTypeId)); } public static void ConflictingReplacementsRejectAtomically() @@ -47,7 +51,9 @@ public static void ConflictingReplacementsRejectAtomically() TestAssert.Throws( () => world.CommitMutations(scheduler, new SimTick(2))); - TestAssert.Equal(original, world.Components.Get(entityId, MovementAgentState.ComponentTypeId)); + TestAssert.Equal( + original, + (MovementAgentState)world.Components.Get(entityId, MovementAgentState.ComponentTypeId)); } public static void MovementReplacementChangesCanonicalChecksum() @@ -55,15 +61,16 @@ public static void MovementReplacementChangesCanonicalChecksum() WorldState world = CreateWorld(); DeterministicScheduler scheduler = CreateScheduler(); EntityId entityId = CreateMovementEntity(world, scheduler); - ulong before = StateChecksum.Compute(new SimTick(1), world, scheduler); + SimTick checksumTick = new(2); + ulong before = StateChecksum.Compute(checksumTick, world, scheduler); world.Mutations.EnqueueReplace( entityId, new ComponentValue( MovementAgentState.ComponentTypeId, new MovementAgentState(new MapCellId(1), new MapCellId(3), 1))); - world.CommitMutations(scheduler, new SimTick(2)); - ulong after = StateChecksum.Compute(new SimTick(2), world, scheduler); + world.CommitMutations(scheduler, checksumTick); + ulong after = StateChecksum.Compute(checksumTick, world, scheduler); TestAssert.True(before != after, "Movement replacement must change the canonical checksum."); } From bf52cf887e4208ab9e6087b2855f265723e6d3d4 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:31:58 -0500 Subject: [PATCH 17/31] CI: surface concise Step 11 compiler diagnostics --- .github/workflows/step11-validation.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/step11-validation.yml b/.github/workflows/step11-validation.yml index 5cbd0d2..9baf63f 100644 --- a/.github/workflows/step11-validation.yml +++ b/.github/workflows/step11-validation.yml @@ -26,10 +26,10 @@ jobs: dotnet-version: 8.0.x - name: Restore - run: dotnet restore GenerationArk.Simulation.Tests/GenerationArk.Simulation.Tests.csproj + run: dotnet restore GenerationArk.Simulation.Tests/GenerationArk.Simulation.Tests.csproj --verbosity quiet - name: Build Release - run: dotnet build GenerationArk.Simulation.Tests/GenerationArk.Simulation.Tests.csproj --configuration Release --no-restore + run: dotnet build GenerationArk.Simulation.Tests/GenerationArk.Simulation.Tests.csproj --configuration Release --no-restore --verbosity quiet - name: Run exact harness run: dotnet run --project GenerationArk.Simulation.Tests/GenerationArk.Simulation.Tests.csproj --configuration Release --no-build From 3ba80240c2859fd45674dd3f34c6bd3d12467d1f Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:33:12 -0500 Subject: [PATCH 18/31] ci: upload Step 11 build diagnostics --- .github/workflows/step11-validation.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/step11-validation.yml b/.github/workflows/step11-validation.yml index 9baf63f..a1a505d 100644 --- a/.github/workflows/step11-validation.yml +++ b/.github/workflows/step11-validation.yml @@ -29,7 +29,22 @@ jobs: run: dotnet restore GenerationArk.Simulation.Tests/GenerationArk.Simulation.Tests.csproj --verbosity quiet - name: Build Release - run: dotnet build GenerationArk.Simulation.Tests/GenerationArk.Simulation.Tests.csproj --configuration Release --no-restore --verbosity quiet + id: build + shell: bash + run: | + set +e + dotnet build GenerationArk.Simulation.Tests/GenerationArk.Simulation.Tests.csproj --configuration Release --no-restore --verbosity minimal > step11-build.log 2>&1 + status=$? + cat step11-build.log + exit $status + + - name: Upload build diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: step11-build-diagnostics + path: step11-build.log + if-no-files-found: error - name: Run exact harness run: dotnet run --project GenerationArk.Simulation.Tests/GenerationArk.Simulation.Tests.csproj --configuration Release --no-build From 9badf518f0b12c875ccb7642d510593b548fa1a5 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:35:21 -0500 Subject: [PATCH 19/31] Fix array reverse overload resolution in tests --- .../ArrayEnumerableExtensions.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 GenerationArk.Simulation.Tests/ArrayEnumerableExtensions.cs diff --git a/GenerationArk.Simulation.Tests/ArrayEnumerableExtensions.cs b/GenerationArk.Simulation.Tests/ArrayEnumerableExtensions.cs new file mode 100644 index 0000000..0f2bf54 --- /dev/null +++ b/GenerationArk.Simulation.Tests/ArrayEnumerableExtensions.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenerationArk.Simulation.Tests; + +internal static class ArrayEnumerableExtensions +{ + public static IEnumerable Reverse(this T[] source) + { + ArgumentNullException.ThrowIfNull(source); + return Enumerable.Reverse(source); + } +} From 5ecaabd4eaee3561a865c20a36dd4b336810a8f5 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:39:23 -0500 Subject: [PATCH 20/31] docs: add Step 11 movement core CI-validated handoff --- ...ndoff-Step11-Movement-Core-CI-Validated.md | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 Generation-Ark-Session-Handoff-Step11-Movement-Core-CI-Validated.md diff --git a/Generation-Ark-Session-Handoff-Step11-Movement-Core-CI-Validated.md b/Generation-Ark-Session-Handoff-Step11-Movement-Core-CI-Validated.md new file mode 100644 index 0000000..b6f707e --- /dev/null +++ b/Generation-Ark-Session-Handoff-Step11-Movement-Core-CI-Validated.md @@ -0,0 +1,239 @@ +# Generation Ark — Session Handoff: Step 11 Movement Core CI Validated + +## Status + +Step 11 movement-core implementation is active on a draft pull request. The current branch has a successful Release build and an exact 81/81 test-harness result in GitHub Actions. Movement persistence, replay continuity, frame-pattern equivalence, dynamic-obstruction soak, and owner validation are not yet complete. + +This file is the authoritative baseline for the next Step 11 phase. + +## Repository and branch + +- Repository: `MerverliPy/Generation-Ark` +- Base branch: `main` +- Active branch: `step11/deterministic-pathfinding-movement` +- Draft pull request: `#3` +- Validated branch head: `9badf518f0b12c875ccb7642d510593b548fa1a5` +- Step 10 validated main commit: `f6be88e0c7c273899263661e31fb6c9639b006f8` +- Step 10 source commit: `03ac1cfd8cab11011c205bedf4ee3bae88265784` + +## Validation evidence + +- Workflow: `Step 11 Validation` +- Workflow run: `29697124585` +- Job: `release-validation` +- Restore: passed +- Release build: passed +- Warnings-as-errors gate: passed +- Exact console harness: passed +- Test result: `81/81` +- Runner OS: Ubuntu 24.04 +- Target framework: .NET 8 + +The pull request must remain draft and unmerged until all remaining Step 11 gates and owner validation are complete. + +## Preserved backup + +Do not remove or overwrite: + +`/home/calvin/Generation-Ark/generation-ark-clock-before-git-sync-20260719T164917Z.tar.gz` + +## Completed Step 11 movement core + +### Deterministic pathfinding + +- Deterministic cardinal pathfinding is implemented. +- Neighbor traversal and tie-breaking use canonical `MapCellId` ordering. +- Blocked cells are avoided. +- Blocked destinations fail deterministically. +- Repathing after obstruction changes is deterministic. +- A 100-agent concurrent-route test is registered and passing. + +### Movement state + +`MovementAgentState` is the authoritative movement component and includes: + +- current cell +- destination cell +- movement revision +- canonical registration +- canonical serialization and deserialization +- checksum participation + +### Movement planning + +`AuthoritativeMovementPlanner`: + +- advances exactly one canonical cell per planning call +- does not mutate authoritative world state directly +- produces Commit-bound replacement intent +- reuses the existing world, mutation, Commit, save, replay, and checksum architecture + +## Component replacement infrastructure + +Replacement support was added to the existing component and mutation pipeline. + +### Component store + +`ComponentStore.Replace(EntityId, object)`: + +- rejects null values +- requires the exact registered runtime type +- requires the component to already exist +- replaces the stored value without changing component identity + +### Component registry + +`ComponentRegistry.Replace(EntityRegistry, EntityId, ComponentValue)`: + +- validates arguments +- requires the entity to exist +- delegates to the registered component store + +### Mutation kind + +`EntityMutationKind.ReplaceComponent = 5` + +Existing enum numeric values were preserved: + +- `CreateEntity = 1` +- `DestroyEntity = 2` +- `AddComponent = 3` +- `RemoveComponent = 4` +- `ReplaceComponent = 5` + +### Mutation buffer semantics + +`MutationBuffer` now supports `EnqueueReplace` and applies replacements through the existing Commit path. + +Validation requires: + +- the entity exists +- the component type is registered +- the replacement runtime type exactly matches the registration +- the entity already owns the component + +Conflict rules reject atomically: + +- replace + replace for the same entity/component +- add + replace for the same entity/component +- remove + replace for the same entity/component +- destroy + replace for the same entity +- any multiple structural component mutations targeting the same entity/component in one batch + +Replacement remains invisible until Commit. + +## Focused replacement tests + +The following tests are registered and passing: + +- `ReplacementRemainsInvisibleUntilCommitAndThenApplies` +- `ConflictingReplacementsRejectAtomically` +- `MovementReplacementChangesCanonicalChecksum` + +The checksum test compares states at the same tick so divergence is attributable to movement state rather than clock state. + +## CI infrastructure + +`.github/workflows/step11-validation.yml` runs on the Step 11 branch and pull request. + +It performs: + +1. checkout +2. .NET 8 setup +3. restore +4. Release build +5. build-diagnostics artifact upload +6. exact console harness execution + +The workflow preserves the real build exit status and uploads `step11-build.log` even when compilation fails. + +A small test-namespace array `Reverse` extension delegates explicitly to LINQ to avoid .NET array overload resolution selecting the in-place `void` overload in existing map tests. + +## Current test count + +The authoritative registered test count is: + +`81/81` + +This supersedes the Step 10 `72/72` baseline for the active Step 11 branch only. It does not complete Step 11. + +## Next authoritative work: movement persistence and replay continuity + +Continue from validated head `9badf518f0b12c875ccb7642d510593b548fa1a5`. + +Implement and validate movement continuity through the existing persistence and replay pipelines. Do not create parallel infrastructure. + +Required next gates: + +### 1. Save/load continuity + +- Persist `MovementAgentState` through the existing entity/component snapshot pipeline. +- Restore authoritative current cell, destination, and revision exactly. +- Verify canonical serialized bytes or canonical restored state. +- Verify uninterrupted execution and save/load-resumed execution produce identical final checksums. + +### 2. Replay equivalence + +- Record movement-driving commands or deterministic movement inputs through the existing replay log. +- Re-run from the same seed and initial state. +- Verify checkpoint and final checksum equivalence. +- Ensure replacement mutation sequencing remains stable across replay. + +### 3. Frame-pattern equivalence + +- Run the same movement scenario under multiple frame budgets and frame patterns. +- Verify identical tick outcomes, movement positions, revisions, and canonical checksums. +- Confirm no frame-rate-dependent path planning or movement advancement exists. + +### 4. Dynamic-obstruction deterministic soak + +- Include at least 100 concurrently moving agents. +- Apply deterministic obstruction changes over a long run. +- Require deterministic repathing and completion/failure behavior. +- Compare repeated runs and varied frame patterns. +- Retain bounded checkpoints. +- Verify final checksum equivalence. + +### 5. Owner validation + +After CI is green for all Step 11 gates, owner validation must still produce retained evidence showing: + +- exact branch and commit SHA +- .NET SDK version +- Release build result +- zero warnings and zero errors +- exact final test count +- soak and continuity evidence +- backup preservation + +Do not mark the PR ready, merge it, close issue `#1`, or claim Step 11 complete before owner evidence exists. + +## Explicit exclusions + +Do not begin or introduce: + +- jobs or toils +- reservation systems +- needs or routines +- atmosphere simulation +- Unity presentation work +- a second simulation runner +- a second simulation clock +- a second world root +- a parallel Commit pipeline +- a parallel save/load format +- a parallel checksum pipeline + +## Guardrails + +- Preserve deterministic ordering everywhere. +- Preserve all existing enum numeric values. +- Preserve the Step 10 validated behavior and tests. +- Reuse the existing mutation, Commit, persistence, replay, scheduler, and checksum systems. +- Treat GitHub CI as development evidence, not owner validation. +- Keep PR #3 draft and unmerged until every remaining gate passes. +- Keep the preserved backup intact. + +## Immediate next step + +Add movement save/load continuity tests and the minimum persistence integration needed to make them pass. Then run the existing `Step 11 Validation` workflow and act only on concrete build or test evidence. From ecb0944b43a1abef525ee269fc4031cd5c3cb50d Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:45:21 -0500 Subject: [PATCH 21/31] test: add movement save load continuity coverage --- .../MovementSaveLoadContinuityTests.cs | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 GenerationArk.Simulation.Tests/MovementSaveLoadContinuityTests.cs diff --git a/GenerationArk.Simulation.Tests/MovementSaveLoadContinuityTests.cs b/GenerationArk.Simulation.Tests/MovementSaveLoadContinuityTests.cs new file mode 100644 index 0000000..9eb5ee1 --- /dev/null +++ b/GenerationArk.Simulation.Tests/MovementSaveLoadContinuityTests.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using GenerationArk.Simulation.Core; +using GenerationArk.Simulation.Diagnostics; +using GenerationArk.Simulation.Map; +using GenerationArk.Simulation.Movement; +using GenerationArk.Simulation.Persistence; +using GenerationArk.Simulation.Scheduling; +using GenerationArk.Simulation.State; + +namespace GenerationArk.Simulation.Tests; + +internal static class MovementSaveLoadContinuityTests +{ + private static readonly MapCellDefinitionId FloorDefinition = new(1); + + public static void MovementEntityRestorePreservesCanonicalComponentState() + { + IReadOnlyList registrations = Registrations(); + WorldState world = CreateWorld(registrations); + DeterministicScheduler scheduler = CreateScheduler(); + var expected = new MovementAgentState(new MapCellId(2), new MapCellId(7), 11UL); + EntityId entityId = CreateMovementEntity(world, scheduler, expected); + + byte[] beforeBytes = EntityStateSerializer.ToUtf8(world); + WorldState restored = EntityStateSerializer.Restore(beforeBytes, registrations); + byte[] afterBytes = EntityStateSerializer.ToUtf8(restored); + var actual = (MovementAgentState)restored.Components.Get( + entityId, + MovementAgentState.ComponentTypeId); + + TestAssert.Equal(expected.CurrentCell, actual.CurrentCell); + TestAssert.Equal(expected.DestinationCell, actual.DestinationCell); + TestAssert.Equal(expected.RouteRevision, actual.RouteRevision); + TestAssert.Equal(expected, actual); + TestAssert.Equal(Convert.ToHexString(beforeBytes), Convert.ToHexString(afterBytes)); + TestAssert.Equal( + StateChecksum.Compute(new SimTick(1), world), + StateChecksum.Compute(new SimTick(1), restored)); + } + + public static void SaveLoadResumedMovementMatchesUninterruptedMovement() + { + IReadOnlyList registrations = Registrations(); + MapState map = CreateMap(6, 2); + var initial = new MovementAgentState( + Cell(0, 0, map), + Cell(5, 1, map), + 0UL); + const int midpointTick = 3; + const int finalTick = 6; + + WorldState uninterruptedWorld = CreateWorld(registrations); + DeterministicScheduler uninterruptedScheduler = CreateScheduler(); + EntityId uninterruptedEntity = CreateMovementEntity( + uninterruptedWorld, + uninterruptedScheduler, + initial); + AdvanceMovement( + uninterruptedWorld, + uninterruptedScheduler, + uninterruptedEntity, + map, + firstTick: 2, + finalTick); + + MovementAgentState uninterruptedState = GetMovement(uninterruptedWorld, uninterruptedEntity); + byte[] uninterruptedBytes = EntityStateSerializer.ToUtf8(uninterruptedWorld); + ulong uninterruptedChecksum = StateChecksum.Compute( + new SimTick(finalTick), + uninterruptedWorld, + uninterruptedScheduler); + + WorldState resumedWorld = CreateWorld(registrations); + DeterministicScheduler resumedScheduler = CreateScheduler(); + EntityId resumedEntity = CreateMovementEntity(resumedWorld, resumedScheduler, initial); + AdvanceMovement( + resumedWorld, + resumedScheduler, + resumedEntity, + map, + firstTick: 2, + midpointTick); + + byte[] midpointBytes = EntityStateSerializer.ToUtf8(resumedWorld); + resumedWorld = EntityStateSerializer.Restore(midpointBytes, registrations); + resumedScheduler = CreateScheduler(); + AdvanceMovement( + resumedWorld, + resumedScheduler, + resumedEntity, + map, + firstTick: midpointTick + 1, + finalTick); + + MovementAgentState resumedState = GetMovement(resumedWorld, resumedEntity); + byte[] resumedBytes = EntityStateSerializer.ToUtf8(resumedWorld); + ulong resumedChecksum = StateChecksum.Compute( + new SimTick(finalTick), + resumedWorld, + resumedScheduler); + + TestAssert.Equal(uninterruptedState.CurrentCell, resumedState.CurrentCell); + TestAssert.Equal(uninterruptedState.DestinationCell, resumedState.DestinationCell); + TestAssert.Equal(uninterruptedState.RouteRevision, resumedState.RouteRevision); + TestAssert.Equal(uninterruptedState, resumedState); + TestAssert.Equal( + Convert.ToHexString(uninterruptedBytes), + Convert.ToHexString(resumedBytes)); + TestAssert.Equal(uninterruptedChecksum, resumedChecksum); + } + + private static void AdvanceMovement( + WorldState world, + DeterministicScheduler scheduler, + EntityId entityId, + MapState map, + int firstTick, + int finalTick) + { + for (int tick = firstTick; tick <= finalTick; tick++) + { + MovementAgentState current = GetMovement(world, entityId); + MovementAgentState next = AuthoritativeMovementPlanner.PlanNext( + map, + current, + static _ => true); + world.Mutations.EnqueueReplace( + entityId, + new ComponentValue(MovementAgentState.ComponentTypeId, next)); + world.CommitMutations(scheduler, new SimTick(tick)); + } + } + + private static MovementAgentState GetMovement(WorldState world, EntityId entityId) => + (MovementAgentState)world.Components.Get(entityId, MovementAgentState.ComponentTypeId); + + private static EntityId CreateMovementEntity( + WorldState world, + DeterministicScheduler scheduler, + MovementAgentState state) + { + world.Mutations.EnqueueCreate(new[] + { + new ComponentValue(MovementAgentState.ComponentTypeId, state) + }); + return world.CommitMutations(scheduler, new SimTick(1)).CreatedEntityIds[0]; + } + + private static IReadOnlyList Registrations() => + new[] { MovementAgentState.CreateRegistration() }; + + private static WorldState CreateWorld(IReadOnlyList registrations) => + new(registrations); + + private static MapState CreateMap(int width, int height) + { + var registry = new MapCellDefinitionRegistry(new[] + { + new MapCellDefinition(FloorDefinition, ParticipatesInRoomTopology: true) + }); + return new MapState(width, height, registry, FloorDefinition); + } + + private static MapCellId Cell(int x, int y, MapState map) => + MapCellId.FromPosition(new GridPosition(x, y), map.Width, map.Height); + + private static DeterministicScheduler CreateScheduler() => + new(new ScheduledEventHandlerRegistry(Array.Empty())); +} From 3bcc9ce6717cf63641b535c5e89e25b6c50fce9c Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:46:10 -0500 Subject: [PATCH 22/31] test: register movement save load continuity tests --- GenerationArk.Simulation.Tests/Program.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/GenerationArk.Simulation.Tests/Program.cs b/GenerationArk.Simulation.Tests/Program.cs index 31f054b..21e85e9 100644 --- a/GenerationArk.Simulation.Tests/Program.cs +++ b/GenerationArk.Simulation.Tests/Program.cs @@ -89,7 +89,9 @@ private static int Main() (nameof(PathfindingMilestoneTests.AuthoritativePlannerAdvancesOneCanonicalCellPerCommitIntent), PathfindingMilestoneTests.AuthoritativePlannerAdvancesOneCanonicalCellPerCommitIntent), (nameof(ComponentReplacementMilestoneTests.ReplacementRemainsInvisibleUntilCommitAndThenApplies), ComponentReplacementMilestoneTests.ReplacementRemainsInvisibleUntilCommitAndThenApplies), (nameof(ComponentReplacementMilestoneTests.ConflictingReplacementsRejectAtomically), ComponentReplacementMilestoneTests.ConflictingReplacementsRejectAtomically), - (nameof(ComponentReplacementMilestoneTests.MovementReplacementChangesCanonicalChecksum), ComponentReplacementMilestoneTests.MovementReplacementChangesCanonicalChecksum) + (nameof(ComponentReplacementMilestoneTests.MovementReplacementChangesCanonicalChecksum), ComponentReplacementMilestoneTests.MovementReplacementChangesCanonicalChecksum), + (nameof(MovementSaveLoadContinuityTests.MovementEntityRestorePreservesCanonicalComponentState), MovementSaveLoadContinuityTests.MovementEntityRestorePreservesCanonicalComponentState), + (nameof(MovementSaveLoadContinuityTests.SaveLoadResumedMovementMatchesUninterruptedMovement), MovementSaveLoadContinuityTests.SaveLoadResumedMovementMatchesUninterruptedMovement) }; int failures = 0; From fd76da5ea866dc42476cffe4a0902cb9b56ebb20 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:51:06 -0500 Subject: [PATCH 23/31] Add movement replay equivalence coverage --- .../MovementReplayEquivalenceTests.cs | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs diff --git a/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs b/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs new file mode 100644 index 0000000..513836d --- /dev/null +++ b/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs @@ -0,0 +1,115 @@ +using System; +using GenerationArk.Simulation.Core; +using GenerationArk.Simulation.Diagnostics; +using GenerationArk.Simulation.Map; +using GenerationArk.Simulation.Movement; +using GenerationArk.Simulation.Persistence; +using GenerationArk.Simulation.Replay; +using GenerationArk.Simulation.Scheduling; +using GenerationArk.Simulation.State; + +namespace GenerationArk.Simulation.Tests; + +internal static class MovementReplayEquivalenceTests +{ + private const ulong Seed = 0x4D4F56454D454E54UL; + private const string BuildVersion = "step11-movement-replay-r1"; + + public static void MovementReplayMatchesRecordedCheckpoints() + { + var factory = new MovementScenarioFactory(); + IReplaySimulationSession baselineSession = factory.CreateNew(); + HeadlessRunResult baseline = new HeadlessSimulationRunner().RunToTick( + baselineSession, + finalTick: 6, + commands: Array.Empty(), + checkpointTicks: new long[] { 1, 2, 3, 4, 5, 6 }); + + var log = new ReplayLog( + ReplayLog.CurrentFormatVersion, + Seed, + BuildVersion, + finalTick: 6, + commands: Array.Empty(), + checkpoints: baseline.Checkpoints); + + ReplayRunResult replay = new ReplayRunner().Run(factory.CreateNew(), log); + + TestAssert.True(replay.Succeeded, "Movement replay diverged from recorded checkpoints."); + TestAssert.Equal(baseline.FinalChecksum, replay.FinalChecksum); + + var baselineMovement = ((MovementScenarioSession)baselineSession).Movement; + TestAssert.Equal(new MapCellId(5), baselineMovement.CurrentCell); + TestAssert.Equal(new MapCellId(5), baselineMovement.DestinationCell); + TestAssert.Equal(5, baselineMovement.RouteRevision); + } + + private sealed class MovementScenarioFactory : IReplaySimulationFactory + { + public IReplaySimulationSession CreateNew() => new MovementScenarioSession(); + + public IReplaySimulationSession Load(SimulationSaveEnvelope save) => + throw new NotSupportedException("Movement replay equivalence does not load saves."); + } + + private sealed class MovementScenarioSession : IReplaySimulationSession + { + private readonly WorldState _world; + private readonly DeterministicScheduler _scheduler; + private readonly EntityId _entityId; + + public MovementScenarioSession() + { + var definitions = new MapCellDefinitionRegistry(new[] + { + new MapCellDefinition(new MapCellDefinitionId(1), ParticipatesInRoomTopology: true) + }); + var map = new MapState(6, 1, definitions, new MapCellDefinitionId(1)); + _world = new WorldState( + componentRegistrations: new[] { MovementAgentState.CreateRegistration() }, + map: map); + _scheduler = new DeterministicScheduler( + new ScheduledEventHandlerRegistry(Array.Empty())); + _world.Mutations.EnqueueCreate(new[] + { + new ComponentValue( + MovementAgentState.ComponentTypeId, + new MovementAgentState(new MapCellId(0), new MapCellId(5), 0)) + }); + _entityId = _world.CommitMutations(_scheduler, new SimTick(0)).CreatedEntityIds[0]; + } + + public long CurrentTick { get; private set; } + + public MovementAgentState Movement => + (MovementAgentState)_world.Components.Get(_entityId, MovementAgentState.ComponentTypeId); + + public void SubmitCommand(ReplayCommand command) + { + ArgumentNullException.ThrowIfNull(command); + throw new InvalidOperationException("This deterministic movement scenario accepts no replay commands."); + } + + public void RunOneTick() + { + CurrentTick++; + MovementAgentState next = AuthoritativeMovementPlanner.PlanNext( + _world.Map, + Movement, + static _ => true); + if (next != Movement) + { + _world.Mutations.EnqueueReplace( + _entityId, + new ComponentValue(MovementAgentState.ComponentTypeId, next)); + _world.CommitMutations(_scheduler, new SimTick(CurrentTick)); + } + } + + public ulong CaptureChecksum() => + StateChecksum.Compute(new SimTick(CurrentTick), _world, _scheduler); + + public SimulationSaveEnvelope CaptureSave() => + throw new NotSupportedException("Movement replay equivalence does not capture saves."); + } +} From 95f2e9c4ca5de6b437648d01ac82cf09ca768111 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:52:26 -0500 Subject: [PATCH 24/31] Register movement replay equivalence test --- GenerationArk.Simulation.Tests/Program.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/GenerationArk.Simulation.Tests/Program.cs b/GenerationArk.Simulation.Tests/Program.cs index 21e85e9..ac4238b 100644 --- a/GenerationArk.Simulation.Tests/Program.cs +++ b/GenerationArk.Simulation.Tests/Program.cs @@ -91,7 +91,8 @@ private static int Main() (nameof(ComponentReplacementMilestoneTests.ConflictingReplacementsRejectAtomically), ComponentReplacementMilestoneTests.ConflictingReplacementsRejectAtomically), (nameof(ComponentReplacementMilestoneTests.MovementReplacementChangesCanonicalChecksum), ComponentReplacementMilestoneTests.MovementReplacementChangesCanonicalChecksum), (nameof(MovementSaveLoadContinuityTests.MovementEntityRestorePreservesCanonicalComponentState), MovementSaveLoadContinuityTests.MovementEntityRestorePreservesCanonicalComponentState), - (nameof(MovementSaveLoadContinuityTests.SaveLoadResumedMovementMatchesUninterruptedMovement), MovementSaveLoadContinuityTests.SaveLoadResumedMovementMatchesUninterruptedMovement) + (nameof(MovementSaveLoadContinuityTests.SaveLoadResumedMovementMatchesUninterruptedMovement), MovementSaveLoadContinuityTests.SaveLoadResumedMovementMatchesUninterruptedMovement), + (nameof(MovementReplayEquivalenceTests.MovementReplayMatchesRecordedCheckpoints), MovementReplayEquivalenceTests.MovementReplayMatchesRecordedCheckpoints) }; int failures = 0; From aa6f7ad95df756ad2bd42a69d7b1f012bc7b2e5d Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 12:54:29 -0500 Subject: [PATCH 25/31] Fix movement replay CI compile errors --- .../MovementReplayEquivalenceTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs b/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs index 513836d..47ca58a 100644 --- a/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs +++ b/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs @@ -21,9 +21,9 @@ public static void MovementReplayMatchesRecordedCheckpoints() IReplaySimulationSession baselineSession = factory.CreateNew(); HeadlessRunResult baseline = new HeadlessSimulationRunner().RunToTick( baselineSession, - finalTick: 6, - commands: Array.Empty(), - checkpointTicks: new long[] { 1, 2, 3, 4, 5, 6 }); + 6, + Array.Empty(), + new long[] { 1, 2, 3, 4, 5, 6 }); var log = new ReplayLog( ReplayLog.CurrentFormatVersion, @@ -36,7 +36,7 @@ public static void MovementReplayMatchesRecordedCheckpoints() ReplayRunResult replay = new ReplayRunner().Run(factory.CreateNew(), log); TestAssert.True(replay.Succeeded, "Movement replay diverged from recorded checkpoints."); - TestAssert.Equal(baseline.FinalChecksum, replay.FinalChecksum); + TestAssert.Equal(baseline.FinalChecksum, replay.Run.FinalChecksum); var baselineMovement = ((MovementScenarioSession)baselineSession).Movement; TestAssert.Equal(new MapCellId(5), baselineMovement.CurrentCell); From f8bf9933ee6e5515b1ef8aef939d8680694cd146 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 13:00:45 -0500 Subject: [PATCH 26/31] Add movement frame-pattern checksum equivalence test --- .../MovementReplayEquivalenceTests.cs | 75 ++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs b/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs index 47ca58a..faaf14c 100644 --- a/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs +++ b/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using GenerationArk.Simulation.Core; using GenerationArk.Simulation.Diagnostics; using GenerationArk.Simulation.Map; @@ -44,9 +45,81 @@ public static void MovementReplayMatchesRecordedCheckpoints() TestAssert.Equal(5, baselineMovement.RouteRevision); } + public static void MovementFramePatternsProduceIdenticalCheckpointsAndFinalState() + { + var baselineFactory = new MovementScenarioFactory(); + HeadlessRunResult baseline = new HeadlessSimulationRunner().RunToTick( + baselineFactory.CreateNew(), + 6, + Array.Empty(), + new long[] { 1, 2, 3, 4, 5, 6 }); + var log = new ReplayLog( + ReplayLog.CurrentFormatVersion, + Seed, + BuildVersion, + finalTick: 6, + commands: Array.Empty(), + checkpoints: baseline.Checkpoints); + var patterns = new[] + { + new FramePattern("stable-30", new[] + { + new FramePatternStep(1.0 / 30.0, SimulationSpeedProfile.Normal) + }), + new FramePattern("stable-144", new[] + { + new FramePatternStep(1.0 / 144.0, SimulationSpeedProfile.Normal) + }), + new FramePattern("stalls-speed-pause-step", new[] + { + new FramePatternStep(1.0 / 60.0, SimulationSpeedProfile.Normal), + new FramePatternStep(1.0 / 240.0, SimulationSpeedProfile.Fast), + new FramePatternStep(0.0, SimulationSpeedProfile.Paused, manualSteps: 1), + new FramePatternStep(0.25, SimulationSpeedProfile.Paused), + new FramePatternStep(0.005, SimulationSpeedProfile.VeryFast), + new FramePatternStep(0.10, SimulationSpeedProfile.Normal) + }) + }; + var factory = new MovementScenarioFactory(); + + IReadOnlyList results = + new FramePatternDeterminismValidator().Validate(factory, log, patterns); + + TestAssert.Equal(patterns.Length, results.Count); + TestAssert.Equal(patterns.Length, factory.CreatedSessions.Count); + MovementAgentState expected = factory.CreatedSessions[0].Movement; + for (int index = 0; index < results.Count; index++) + { + FramePatternRunResult result = results[index]; + MovementAgentState actual = factory.CreatedSessions[index].Movement; + TestAssert.Equal(log.FinalTick, result.FinalTick); + TestAssert.Equal(baseline.FinalChecksum, result.FinalChecksum); + TestAssert.Equal(log.Checkpoints.Count, result.Checkpoints.Count); + for (int checkpointIndex = 0; checkpointIndex < log.Checkpoints.Count; checkpointIndex++) + { + TestAssert.Equal(log.Checkpoints[checkpointIndex].Tick, result.Checkpoints[checkpointIndex].Tick); + TestAssert.Equal(log.Checkpoints[checkpointIndex].Checksum, result.Checkpoints[checkpointIndex].Checksum); + } + TestAssert.Equal(expected.CurrentCell, actual.CurrentCell); + TestAssert.Equal(expected.DestinationCell, actual.DestinationCell); + TestAssert.Equal(expected.RouteRevision, actual.RouteRevision); + } + + TestAssert.Equal(new MapCellId(5), expected.CurrentCell); + TestAssert.Equal(new MapCellId(5), expected.DestinationCell); + TestAssert.Equal(5, expected.RouteRevision); + } + private sealed class MovementScenarioFactory : IReplaySimulationFactory { - public IReplaySimulationSession CreateNew() => new MovementScenarioSession(); + public List CreatedSessions { get; } = new(); + + public IReplaySimulationSession CreateNew() + { + var session = new MovementScenarioSession(); + CreatedSessions.Add(session); + return session; + } public IReplaySimulationSession Load(SimulationSaveEnvelope save) => throw new NotSupportedException("Movement replay equivalence does not load saves."); From 5b60f889e81180f6fab0624860898b8f4ef8ce1e Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 13:01:40 -0500 Subject: [PATCH 27/31] Register movement frame-pattern equivalence test --- GenerationArk.Simulation.Tests/Program.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/GenerationArk.Simulation.Tests/Program.cs b/GenerationArk.Simulation.Tests/Program.cs index ac4238b..f62a1a2 100644 --- a/GenerationArk.Simulation.Tests/Program.cs +++ b/GenerationArk.Simulation.Tests/Program.cs @@ -92,7 +92,8 @@ private static int Main() (nameof(ComponentReplacementMilestoneTests.MovementReplacementChangesCanonicalChecksum), ComponentReplacementMilestoneTests.MovementReplacementChangesCanonicalChecksum), (nameof(MovementSaveLoadContinuityTests.MovementEntityRestorePreservesCanonicalComponentState), MovementSaveLoadContinuityTests.MovementEntityRestorePreservesCanonicalComponentState), (nameof(MovementSaveLoadContinuityTests.SaveLoadResumedMovementMatchesUninterruptedMovement), MovementSaveLoadContinuityTests.SaveLoadResumedMovementMatchesUninterruptedMovement), - (nameof(MovementReplayEquivalenceTests.MovementReplayMatchesRecordedCheckpoints), MovementReplayEquivalenceTests.MovementReplayMatchesRecordedCheckpoints) + (nameof(MovementReplayEquivalenceTests.MovementReplayMatchesRecordedCheckpoints), MovementReplayEquivalenceTests.MovementReplayMatchesRecordedCheckpoints), + (nameof(MovementReplayEquivalenceTests.MovementFramePatternsProduceIdenticalCheckpointsAndFinalState), MovementReplayEquivalenceTests.MovementFramePatternsProduceIdenticalCheckpointsAndFinalState) }; int failures = 0; From c8aa1d32b8d9a5f4afe62790c9e1f52210a13c73 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 13:04:16 -0500 Subject: [PATCH 28/31] Fix movement frame-pattern speed profile import --- GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs b/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs index faaf14c..0cdf4c5 100644 --- a/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs +++ b/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs @@ -8,6 +8,7 @@ using GenerationArk.Simulation.Replay; using GenerationArk.Simulation.Scheduling; using GenerationArk.Simulation.State; +using GenerationArk.Simulation.UnityAdapter; namespace GenerationArk.Simulation.Tests; From c793eaa009f890f270fa6ad112e846c4e9331a44 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 13:08:19 -0500 Subject: [PATCH 29/31] Add deterministic dynamic-obstruction movement soak --- .../MovementReplayEquivalenceTests.cs | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs b/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs index 0cdf4c5..5e38364 100644 --- a/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs +++ b/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs @@ -111,6 +111,18 @@ public static void MovementFramePatternsProduceIdenticalCheckpointsAndFinalState TestAssert.Equal(5, expected.RouteRevision); } + public static void DynamicObstructionMovementSoakRepeatsCheckpointAndFinalChecksums() + { + SoakRunResult result = new DeterministicSoakRunner().RunTwice( + new DynamicObstructionScenarioFactory(), + totalTicks: 20_000, + checkpointInterval: 1_000); + + TestAssert.True(result.Succeeded, "Dynamic-obstruction movement soak diverged."); + TestAssert.Equal(20, result.CheckpointCount); + TestAssert.Equal(result.FirstFinalChecksum, result.SecondFinalChecksum); + } + private sealed class MovementScenarioFactory : IReplaySimulationFactory { public List CreatedSessions { get; } = new(); @@ -186,4 +198,111 @@ public ulong CaptureChecksum() => public SimulationSaveEnvelope CaptureSave() => throw new NotSupportedException("Movement replay equivalence does not capture saves."); } + + private sealed class DynamicObstructionScenarioFactory : IReplaySimulationFactory + { + public IReplaySimulationSession CreateNew() => new DynamicObstructionScenarioSession(); + + public IReplaySimulationSession Load(SimulationSaveEnvelope save) => + throw new NotSupportedException("Dynamic-obstruction movement soak does not load saves."); + } + + private sealed class DynamicObstructionScenarioSession : IReplaySimulationSession + { + private static readonly MapCellDefinitionId FloorDefinition = new(1); + private static readonly MapCellDefinitionId BlockedDefinition = new(2); + private readonly WorldState _world; + private readonly DeterministicScheduler _scheduler; + private readonly EntityId _entityId; + + public DynamicObstructionScenarioSession() + { + var definitions = new MapCellDefinitionRegistry(new[] + { + new MapCellDefinition(FloorDefinition, ParticipatesInRoomTopology: true), + new MapCellDefinition(BlockedDefinition, ParticipatesInRoomTopology: false) + }); + var map = new MapState(9, 3, definitions, FloorDefinition); + _world = new WorldState( + componentRegistrations: new[] { MovementAgentState.CreateRegistration() }, + map: map); + _scheduler = new DeterministicScheduler( + new ScheduledEventHandlerRegistry(Array.Empty())); + _world.Mutations.EnqueueCreate(new[] + { + new ComponentValue( + MovementAgentState.ComponentTypeId, + new MovementAgentState(Cell(0, 1), Cell(8, 1), 0)) + }); + _entityId = _world.CommitMutations(_scheduler, new SimTick(0)).CreatedEntityIds[0]; + } + + public long CurrentTick { get; private set; } + + private MovementAgentState Movement => + (MovementAgentState)_world.Components.Get(_entityId, MovementAgentState.ComponentTypeId); + + public void SubmitCommand(ReplayCommand command) + { + ArgumentNullException.ThrowIfNull(command); + throw new InvalidOperationException("Dynamic-obstruction movement soak accepts no replay commands."); + } + + public void RunOneTick() + { + CurrentTick++; + + if (CurrentTick % 11 == 0) + { + int obstacleX = 2 + (int)((CurrentTick / 11) % 5); + MapCellId obstacle = Cell(obstacleX, 1); + MapCellDefinitionId nextDefinition = + _world.Map.GetCellDefinition(obstacle) == FloorDefinition + ? BlockedDefinition + : FloorDefinition; + _world.Mutations.EnqueueSetCellDefinition(obstacle, nextDefinition); + } + + MovementAgentState current = Movement; + if (current.CurrentCell == current.DestinationCell) + { + MapCellId destination = current.DestinationCell == Cell(8, 1) + ? Cell(0, 1) + : Cell(8, 1); + current = new MovementAgentState( + current.CurrentCell, + destination, + checked(current.RouteRevision + 1)); + _world.Mutations.EnqueueReplace( + _entityId, + new ComponentValue(MovementAgentState.ComponentTypeId, current)); + } + + if (_world.Mutations.Count > 0) + { + _world.CommitMutations(_scheduler, new SimTick(CurrentTick)); + } + + MovementAgentState next = AuthoritativeMovementPlanner.PlanNext( + _world.Map, + Movement, + cell => _world.Map.GetCellDefinition(cell) == FloorDefinition); + if (next != Movement) + { + _world.Mutations.EnqueueReplace( + _entityId, + new ComponentValue(MovementAgentState.ComponentTypeId, next)); + _world.CommitMutations(_scheduler, new SimTick(CurrentTick)); + } + } + + public ulong CaptureChecksum() => + StateChecksum.Compute(new SimTick(CurrentTick), _world, _scheduler); + + public SimulationSaveEnvelope CaptureSave() => + throw new NotSupportedException("Dynamic-obstruction movement soak does not capture saves."); + + private static MapCellId Cell(int x, int y) => + MapCellId.FromPosition(new GridPosition(x, y), width: 9, height: 3); + } } From db02e64300bd14614e93f019913080bbd3655590 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 13:09:11 -0500 Subject: [PATCH 30/31] Register dynamic-obstruction movement soak test --- GenerationArk.Simulation.Tests/Program.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/GenerationArk.Simulation.Tests/Program.cs b/GenerationArk.Simulation.Tests/Program.cs index f62a1a2..a527c8f 100644 --- a/GenerationArk.Simulation.Tests/Program.cs +++ b/GenerationArk.Simulation.Tests/Program.cs @@ -93,7 +93,8 @@ private static int Main() (nameof(MovementSaveLoadContinuityTests.MovementEntityRestorePreservesCanonicalComponentState), MovementSaveLoadContinuityTests.MovementEntityRestorePreservesCanonicalComponentState), (nameof(MovementSaveLoadContinuityTests.SaveLoadResumedMovementMatchesUninterruptedMovement), MovementSaveLoadContinuityTests.SaveLoadResumedMovementMatchesUninterruptedMovement), (nameof(MovementReplayEquivalenceTests.MovementReplayMatchesRecordedCheckpoints), MovementReplayEquivalenceTests.MovementReplayMatchesRecordedCheckpoints), - (nameof(MovementReplayEquivalenceTests.MovementFramePatternsProduceIdenticalCheckpointsAndFinalState), MovementReplayEquivalenceTests.MovementFramePatternsProduceIdenticalCheckpointsAndFinalState) + (nameof(MovementReplayEquivalenceTests.MovementFramePatternsProduceIdenticalCheckpointsAndFinalState), MovementReplayEquivalenceTests.MovementFramePatternsProduceIdenticalCheckpointsAndFinalState), + (nameof(MovementReplayEquivalenceTests.DynamicObstructionMovementSoakRepeatsCheckpointAndFinalChecksums), MovementReplayEquivalenceTests.DynamicObstructionMovementSoakRepeatsCheckpointAndFinalChecksums) }; int failures = 0; From 524187775458e70ef769b05fe5f12abf21dcb970 Mon Sep 17 00:00:00 2001 From: Merverli Date: Sun, 19 Jul 2026 13:32:03 -0500 Subject: [PATCH 31/31] Trigger Step 11 validation after soak registration ref correction --- GenerationArk.Simulation.Tests/Step11WorkflowTrigger.cs | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 GenerationArk.Simulation.Tests/Step11WorkflowTrigger.cs diff --git a/GenerationArk.Simulation.Tests/Step11WorkflowTrigger.cs b/GenerationArk.Simulation.Tests/Step11WorkflowTrigger.cs new file mode 100644 index 0000000..330f99d --- /dev/null +++ b/GenerationArk.Simulation.Tests/Step11WorkflowTrigger.cs @@ -0,0 +1,7 @@ +namespace GenerationArk.Simulation.Tests; + +// Behavior-neutral source marker used to emit a pull-request synchronize event +// after the Step 11 soak registration branch ref was corrected. +internal static class Step11WorkflowTrigger +{ +}