In the previous update, the legacy map adapter was gone and DomainState had become a real part of the application. Most of turn finalization already used the new state, but one old section remained in the middle: the economy phase still converted the canonical snapshot to PersistentGameState, processed the turn, and converted the result back.
That legacy island is now gone.
The complete simultaneous turn now stays on canonical or persistence-neutral types, and the old persistent implementation of simultaneous finalization has been removed. At the same time, the same migration pattern is spreading through normal player commands, where local play, the multiplayer server, and AI previously had separate versions of similar rules.
The Complete Turn Is Now Canonical
The economy phase was the last large part of turn processing that depended on PersistentGameState. It handled city production, growth, workers, research, artifacts, resource trades, stability, objectives, fog, science, and generated events, so moving it in one large rewrite would have been risky.
Instead, the old processor was divided into smaller parts working on explicit economy state and context. Thin adapters can still use this logic from legacy state when needed, but CanonicalTurnPipeline now processes combat and economy directly through DomainState, then passes the result to movement, diplomacy, victory, session, and event finalization.
flowchart LR
Input["CanonicalGameSnapshot"]
Combat["Combat<br/>DomainState"]
Economy["Economy<br/>DomainState"]
Movement["Movement and fog"]
Tail["Diplomacy, victory,<br/>session and events"]
Output["CanonicalGameSnapshot"]
Input --> Combat
Combat --> Economy
Economy --> Movement
Movement --> Tail
Tail --> Output
There is no compatibility adapter between these phases anymore. The canonical pipeline receives one snapshot, completes the whole simultaneous turn, and returns another snapshot together with ordered events and a renderer-neutral movement delta. It also validates that every finalized or skipped player belongs to the correct participant scope.
After this change, the old PersistentTurnPipeline.simultaneousFinalize and its temporary request, result, and movement types were removed. The persistent pipeline now keeps only a smaller single-player compatibility path that still has a real caller.
The local game and server still convert at their outer boundaries because current saves and multiplayer wire snapshots use the existing schema. The important difference is that these conversions now happen before and after the complete operation, not between individual parts of one turn.
One Rule Instead of Several Similar Implementations
The next step was to reduce duplication outside turn finalization. I did not solve this by converting a complete snapshot for every small command, because changing one worked hex or selecting one technology should not require copying the full game state twice.
Instead, each migrated command family receives a small state-neutral rule kernel. It takes only the information required by the rule, such as cities, units, research, artifacts, the map view, the actor, and the ruleset. It then returns acceptance, an optional rejection reason, events, and the changed immutable collections.
flowchart TB
Local["Local reducer"]
Server["Server command handler"]
AI["AI and simulations"]
Domain["DomainState adapter"]
Persistent["Persistent adapter"]
Kernel["Shared rule kernel"]
Result["Accepted or rejected<br/>changed state slices<br/>ordered events"]
Local --> Kernel
Server --> Kernel
AI --> Kernel
Domain --> Kernel
Persistent --> Kernel
Kernel --> Result
The local reducer can still handle selection, previews, animations, and HUD effects. The server can still handle saves, wire responses, and rejection messages. AI can use a smaller simulation view. The important part is that all of them can now ask the same implementation whether a command is valid and what it changes.
This pattern now covers worked hexes, research selection, troop detachment, artifact commands, basic unit actions, merchant routing, city expansion, worker commands, city founding, buildings, projects, specialization, wonders, unit production, and rush production. The city-production kernel, for example, receives explicit city, research, map, pace, resource, supply, and ruleset inputs without depending on GameState, PersistentGameState, or DomainState.
The old persistent resolvers have not all disappeared yet, but their role is becoming much smaller. They map the old state container to the shared rule and map the result back instead of owning another copy of the gameplay logic.
Parity Comes Before Extraction
Every command family is characterized before its implementation is moved. The parity fixtures are not generated from either the local reducer or the server reducer. They are committed, reviewed inputs and expected outputs that act as a third oracle.
Both runtime paths execute the same fixture and compare the complete save, state, acceptance result, rejection reason, and ordered events. Each fixture also runs with normal and reversed JSON object order, and every variant is repeated to detect unstable behaviour.
This process is slower than extracting a common helper and trusting the existing tests, but it answers the important question:
Did I only move the rule, or did I also change it?
The fixtures cover more than simple success cases. They preserve rejection precedence, wrong actors, map requirements, queue replacement, overflow, resource imports, coast requirements, supply limits, blocked unit spawning, wonder refunds, interaction cleanup, and accepted no-op behaviour.
Architecture tests then record the exact allowed call sites for each shared kernel. A migrated rule cannot quietly return to a persistent adapter through an alias, helper, or tear-off.
The Migration Still Found Real Problems
The worker flow exposed a replay problem. Selecting an improvement while a worker dialog is open is only a local preview, but the same command type can also represent a direct authoritative action outside that flow. Replay logging previously looked only at the command type, so temporary preview changes could enter the command history.
Replay logging is now state-aware. Preview selections and cancellations stay local, while confirmation records one complete authoritative command containing the final improvement. Replaying the game therefore starts the job once instead of repeating the UI conversation that led to it.
Rush production revealed another difference. A unit completed normally could receive experience from a stored artifact, while a unit completed through rush production did not. Both paths now apply the same artifact experience rule.
At the same time, rush production is still too complex for the lightweight MCTS state because it can change gold, units, research, wonders, cities, and events together. Instead of pretending that the action is simulated correctly, MCTS now rejects it at the candidate boundary until the simulation can represent the complete result.
Structural sharing also became part of the contract. Rejected commands preserve their original collections, and accepted commands that change nothing can preserve the complete local state identity. This reduces unnecessary projection, provider, and renderer work while making a real state change easier to distinguish from a no-op.
What Still Remains
This is not yet the final GameEngine.
The server still decodes multiplayer snapshots into GameSave and PersistentGameState, and some command families still use persistent resolvers, including normal movement and direct combat. The current wire format is written back in the same legacy shape after the command finishes.
The remaining work includes moving movement, combat, diplomacy, and the map-dependent part of unit actions to shared kernels, then introducing one canonical command transition that can update DomainState, session state, and persisted interaction together.
After that, local play, the server, and AI can enter through one command engine, while save and wire conversion remain only at infrastructure boundaries. UI actions such as selecting, tapping, previewing, or focusing also still need a clearer separation from authoritative domain commands.
flowchart LR
Wire["Existing save or wire format"]
AdapterIn["Compatibility boundary"]
Engine["Future single GameEngine"]
State["DomainState + session<br/>+ persisted interaction"]
AdapterOut["Compatibility boundary"]
Wire --> AdapterIn
AdapterIn --> Engine
Engine --> State
State --> AdapterOut
AdapterOut --> Wire
The previous post ended with one visible legacy island inside the turn pipeline. This post ends with that island removed and many normal commands already sharing their rules between local play, the server, and selected AI paths.
The project still has more than one outer state container, but there are fewer places where the same gameplay decision can quietly produce two different answers.
The question from the original refactor plan remains useful:
Does the next feature require fewer unrelated changes than the previous one?
For turn finalization, the answer is now yes. For city production, workers, artifacts, research, merchant routing, and several other command families, it is becoming yes as well.
The engine is not finished, but its shared rules are already running.
Leave a Reply