diff --git a/.github/workflows/step11-validation.yml b/.github/workflows/step11-validation.yml new file mode 100644 index 0000000..a1a505d --- /dev/null +++ b/.github/workflows/step11-validation.yml @@ -0,0 +1,50 @@ +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 --verbosity quiet + + - name: Build Release + 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 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. 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); + } +} diff --git a/GenerationArk.Simulation.Tests/ComponentReplacementMilestoneTests.cs b/GenerationArk.Simulation.Tests/ComponentReplacementMilestoneTests.cs new file mode 100644 index 0000000..db283d9 --- /dev/null +++ b/GenerationArk.Simulation.Tests/ComponentReplacementMilestoneTests.cs @@ -0,0 +1,104 @@ +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, + (MovementAgentState)world.Components.Get(entityId, MovementAgentState.ComponentTypeId)); + world.CommitMutations(scheduler, new SimTick(2)); + TestAssert.Equal( + replacement, + (MovementAgentState)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, + (MovementAgentState)world.Components.Get(entityId, MovementAgentState.ComponentTypeId)); + } + + public static void MovementReplacementChangesCanonicalChecksum() + { + WorldState world = CreateWorld(); + DeterministicScheduler scheduler = CreateScheduler(); + EntityId entityId = CreateMovementEntity(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, checksumTick); + ulong after = StateChecksum.Compute(checksumTick, 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())); +} diff --git a/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs b/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs new file mode 100644 index 0000000..5e38364 --- /dev/null +++ b/GenerationArk.Simulation.Tests/MovementReplayEquivalenceTests.cs @@ -0,0 +1,308 @@ +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.Replay; +using GenerationArk.Simulation.Scheduling; +using GenerationArk.Simulation.State; +using GenerationArk.Simulation.UnityAdapter; + +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, + 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); + + ReplayRunResult replay = new ReplayRunner().Run(factory.CreateNew(), log); + + TestAssert.True(replay.Succeeded, "Movement replay diverged from recorded checkpoints."); + TestAssert.Equal(baseline.FinalChecksum, replay.Run.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); + } + + 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); + } + + 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(); + + 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."); + } + + 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."); + } + + 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); + } +} 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())); +} diff --git a/GenerationArk.Simulation.Tests/PathfindingMilestoneTests.cs b/GenerationArk.Simulation.Tests/PathfindingMilestoneTests.cs new file mode 100644 index 0000000..4604f42 --- /dev/null +++ b/GenerationArk.Simulation.Tests/PathfindingMilestoneTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Concurrent; +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; + +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()); + } + + 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[] + { + 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)); +} diff --git a/GenerationArk.Simulation.Tests/Program.cs b/GenerationArk.Simulation.Tests/Program.cs index 4d754a1..a527c8f 100644 --- a/GenerationArk.Simulation.Tests/Program.cs +++ b/GenerationArk.Simulation.Tests/Program.cs @@ -80,7 +80,21 @@ 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), + (nameof(PathfindingMilestoneTests.MovementAgentStateSerializationAndChecksumAreCanonical), PathfindingMilestoneTests.MovementAgentStateSerializationAndChecksumAreCanonical), + (nameof(PathfindingMilestoneTests.AuthoritativePlannerAdvancesOneCanonicalCellPerCommitIntent), PathfindingMilestoneTests.AuthoritativePlannerAdvancesOneCanonicalCellPerCommitIntent), + (nameof(ComponentReplacementMilestoneTests.ReplacementRemainsInvisibleUntilCommitAndThenApplies), ComponentReplacementMilestoneTests.ReplacementRemainsInvisibleUntilCommitAndThenApplies), + (nameof(ComponentReplacementMilestoneTests.ConflictingReplacementsRejectAtomically), ComponentReplacementMilestoneTests.ConflictingReplacementsRejectAtomically), + (nameof(ComponentReplacementMilestoneTests.MovementReplacementChangesCanonicalChecksum), ComponentReplacementMilestoneTests.MovementReplacementChangesCanonicalChecksum), + (nameof(MovementSaveLoadContinuityTests.MovementEntityRestorePreservesCanonicalComponentState), MovementSaveLoadContinuityTests.MovementEntityRestorePreservesCanonicalComponentState), + (nameof(MovementSaveLoadContinuityTests.SaveLoadResumedMovementMatchesUninterruptedMovement), MovementSaveLoadContinuityTests.SaveLoadResumedMovementMatchesUninterruptedMovement), + (nameof(MovementReplayEquivalenceTests.MovementReplayMatchesRecordedCheckpoints), MovementReplayEquivalenceTests.MovementReplayMatchesRecordedCheckpoints), + (nameof(MovementReplayEquivalenceTests.MovementFramePatternsProduceIdenticalCheckpointsAndFinalState), MovementReplayEquivalenceTests.MovementFramePatternsProduceIdenticalCheckpointsAndFinalState), + (nameof(MovementReplayEquivalenceTests.DynamicObstructionMovementSoakRepeatsCheckpointAndFinalChecksums), MovementReplayEquivalenceTests.DynamicObstructionMovementSoakRepeatsCheckpointAndFinalChecksums) }; int failures = 0; 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 +{ +} 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) + }; + } +} diff --git a/GenerationArk.Simulation/Movement/DeterministicPathfinder.cs b/GenerationArk.Simulation/Movement/DeterministicPathfinder.cs new file mode 100644 index 0000000..c2122ee --- /dev/null +++ b/GenerationArk.Simulation/Movement/DeterministicPathfinder.cs @@ -0,0 +1,113 @@ +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); + 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); + Array.Sort(candidates, 0, count); + + 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}."); + } + } +} 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); + } +} 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); 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)) 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 } 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))