In the original refactor post, I described the target architecture and a plan for reaching it without rewriting the whole game. In the previous progress update, the project already had stronger quality gates and one indexed map model, but the migration was not complete because two production conversions still existed, the legacy map adapter was still alive, and DomainState was mostly a target shown on a diagram rather than a type used by real gameplay paths.
That is no longer the current shape of the project. The last map adapter has been deleted, the local reducer uses read-only map contracts, several old state models now own their collections instead of borrowing mutable lists and maps, and a real DomainState exists together with a canonical snapshot that separates game rules, match session data, metadata, persisted interaction, and the event offset. Turn combat and turn movement now run directly on this canonical state, while only the economy phase still uses the old persistent model.
The refactor is still not complete, but the remaining legacy part is now small enough to draw as one clear box instead of describing it as a large group of mixed responsibilities.
The Map Migration Ended With a Deletion
At the end of the previous update, LegacyWorldMapAdapter had only two production jobs left: the server map cache and MapDraft.freeze(). The server cache now loads and validates the map, builds one indexed MapReadView, and keeps that view instead of storing another raw representation next to it, which means combat, resource trade, city production, turn finalization, and outcome detection can all reuse the same indexed object.
MapDraft.freeze() also stopped converting through MapData. It now calls WorldMap.fromTileViews() directly, and this factory copies representation-neutral tile values into immutable WorldTile objects, validates the complete map, and builds the coordinate index. After these two changes, the adapter had no production callers, so I removed the implementation, its export, its dedicated tests, and the architecture allowlist that had permitted its temporary use.
An architecture test now works as a tombstone for the old boundary, which means that adding the removed adapter symbol or import again will fail the gate. This is the result I wanted from the migration: not an unused adapter with a comment saying that it should be removed later, but an actual deletion.
flowchart LR
Draft["MapDraft"] --> Freeze["WorldMap.fromTileViews"]
Freeze --> World["Immutable WorldMap"]
World --> WorldView["WorldMapReadView"]
Files["MapData<br/>JSON and IO boundary"] --> Indexed["indexedReadView()"]
WorldView --> View["MapReadView"]
Indexed --> View
View --> Local["Local reducers"]
View --> Server["Server"]
View --> AI["AI and MCTS"]
View --> Replay["Replay and tools"]
MapData still exists because the editor, codecs, repositories, several tools, and parts of presentation still have valid reasons to know about the persistence representation. Removing every remaining use is a separate cleanup, while the important result of this stage is that gameplay rules no longer convert the complete world between two competing domain models.
The map adapter is gone, but compatibility with existing saves and files is still protected. These are separate goals, and removing an unnecessary map conversion does not mean that an active persistence format should disappear at the same time.
Small Map Contracts Became the Normal API
The migration did not replace every MapData parameter with WorldMap, because that would still give many classes more access than they need. Instead, the code now uses three small read-only contracts: MapTileLookup reads one tile, MapTraversalView adds dimensions for movement and pathfinding, and MapReadView adds the complete read-only catalog, survey data, and map objectives.
A city rule that checks one controlled tile does not need the full map catalog, a pathfinder needs dimensions but does not need objectives, and a domination rule needs survey information but does not need a mutable persistence DTO. This pattern moved through most of the local game graph, including selection, worker actions, city founding, research, unit detachment, city expansion, combat, merchant routes, manual movement, auto-explore, turn reset, city production, worked hexes, and end-turn processing.
The last important step was moving GameStateReducer and ReducerEnvironment to one MapReadView. Before that change, the leaf rules were clean, but the root of the reducer graph still owned the old representation, so the architecture could still return to linear lookups or raw DTO access through a central dependency. The composition roots now create the indexed view once and pass the same instance through the reducer, without keeping a second raw-map field beside it and without hiding the linear MapData.tileAt() implementation behind an interface.
The Refactor Continued to Find Real Behaviour Problems
Many commits in this stage looked like type migrations, but the work still exposed real gameplay problems because moving data through explicit boundaries makes missing context much easier to see.
MCTS Artifact Privacy
The MCTS opponent simulation needs enough information to score another player, but it should not receive hidden artifacts owned by the active player. The opponent index now builds artifact ownership from the current cities and units, so an artifact stored in a city belongs to that city’s owner, while an artifact carried by a unit belongs to that unit’s owner.
Artifacts placed on the map, currently under excavation, or pointing to an unknown city or unit are not placed inside any player bucket. Each simulated opponent receives only its own stored and carried artifacts, while the buckets remain immutable and preserve their original order.
This was not only a scoring correction because it also made the information model explicit: the AI should not become stronger because a projection accidentally exposes information that the player represented by that simulation should not know.
Food Artifacts and Unit Supply
The old unit-supply calculation did not include food artifacts, so this required a clear gameplay decision instead of a silent implementation change. The new rule says that a food artifact stored in one of the player’s cities contributes to that city’s net food and can increase raw unit supply before the global map cap is applied.
A carried artifact does not count, an artifact owned by another player does not count, and an artifact that points to a missing city does not count. Normal food consumption and the map-level supply cap can still absorb the bonus, so the artifact does not guarantee a larger final limit in every situation.
The local reducer, persistent rules, server path, UI, AI, MCTS, and telemetry now pass the same artifact context into the supply calculation. This is a small gameplay rule, but before the refactor, implementing it correctly required finding many separate projections that calculated similar values with slightly different inputs.
Stability in City Projections
The selected-city panel also had drift because some paths showed raw city output, other paths included artifacts or wonders, and fresh projections did not include stability. CitySelectionProjector now receives one GameRuleset, reads the cached stability value for the city owner, and applies the correct stability modifier to the projected economy.
It does not calculate relative standing again because the turn pipeline has already produced the cached stability value, and repeating the calculation during UI projection could apply the same effect twice. The main city summary and the detailed breakdown now come from one atomic snapshot, and the same context is used by production bubbles and rush production.
This removes a class of problems where the selected city could show one value while the production system used another value, even though both were describing the same city at the same moment.
A Movement Preview Could Become Stale
One of the more interesting bugs appeared during the movement migration. The game correctly recalculated a route when the player confirmed a movement preview, but it still used the old preview to decide whether the movement was partial and to build the animation steps and queued path.
When another unit appeared or disappeared between preview and confirmation, the final position, the animation, and the stored route could disagree. Confirmation now uses one current movement plan for every decision, including whether the move is full or partial, which destination is reached, how many movement points are spent, which animation steps are shown, which queued path is saved, and whether movement targeting remains active.
The tests cover both directions, including a new blocker appearing and an old blocker disappearing. This is a useful example of why behaviour tests remain important during architectural migrations, because the type checker cannot detect that an animation still follows a route calculated before the game state changed.
Immutability Started Before DomainState
I did not begin the state migration by renaming PersistentGameState, because that would only create a new name around the same ownership problems. The first step was fixing existing save round-trips, where the local reader could lose wonderRegistry, current visibleHexes, and scienceOverflow in some paths.
After those fields were protected, I started freezing the existing graph from the inside. FogOfWarState, WonderRegistry, and DiplomacyState now own their collections, and diplomacy also copies nested score-history lists. GameCity owns its controlled hexes, worked hexes, buildings, and wonders, while GameRuntimeState became a hand-written final value object because the old Freezed model looked immutable through its API but could still store references to mutable collections supplied by callers.
PersistentGameState now creates owned snapshots and structurally shares branches that do not change during copyWith. MatchRules.balance is also deeply owned JSON, and it rejects cycles, non-finite numbers, and values that do not belong in a valid JSON payload.
Only after these smaller values were safe to place inside a larger root did I introduce DomainState. This made the migration a sequence of small, tested ownership changes rather than a large rename followed by a long list of hidden aliasing problems.
The New State Has Clear Owners
DomainState now contains the complete rule state of a running match, including the turn, match rules, ordered participants, gold, stability, units, cities, artifacts, fog, research, wonders, diplomacy, trades, intended attacks, and victory progress. Match lifecycle, client interaction, presentation state, and persistence metadata do not belong inside it.
Player colours and countries are derived from the ordered participant list rather than accepted as separate public inputs that could silently disagree with the roster. The larger game snapshot is composed from several independent parts, with each part responsible for one kind of information.
flowchart TB
Snapshot["CanonicalGameSnapshot"]
Snapshot --> Domain["DomainState<br/>complete game-rule truth"]
Snapshot --> Session["MatchSessionState<br/>submitted players, timeouts,<br/>AFK, kicks, turn start"]
Snapshot --> Metadata["GameSnapshotMetadata<br/>id, schema, world,<br/>camera, saved time"]
Snapshot --> Interaction["PersistedInteractionState<br/>multi-step interaction data"]
Snapshot --> Offset["Event log offset"]
Domain --> PlayerView["PlayerViewState<br/>redacted for one recipient"]
PlayerView --> Wire["Wire codec"]
This split resolves several old ambiguities. A submitted player and a finished player are not always the same thing because a resigned player can be finished without submitting a turn, while a camera position belongs to snapshot metadata rather than game rules.
A pending city-founding action may need to survive a save and reload, but it is not part of the authoritative economy or combat state. A multiplayer timeout belongs to the match session, while diplomacy, units, research, and victory progress belong to the domain.
The old structures often allowed these concepts to travel together simply because they lived in one large object. The new boundaries make their ownership visible and make it harder to update one category of state by accident while changing another.
A Redacted View Is Not Game State
The multiplayer server still needs to send different information to different players because fog of war and hidden data mean that a client cannot always receive the full canonical state. This responsibility now has its own type, PlayerViewState.
The type is intentionally difficult to misuse. It can carry a redacted snapshot to the network edge, and its dedicated wire codec can serialize it, but the projected persistent state remains private, so application code cannot easily pass the redacted view back into canonical reducers as if it were the complete game state.
The direction of the boundary is simple: full state can create a player view, but a player view cannot become full state. This is a small API decision, but it protects the server from treating a recipient-specific projection as an authoritative source.
Old Saves Still Use the Existing Schema
Adding a canonical snapshot did not require an immediate change to the save format. One compatibility boundary, LegacyGameSnapshotAdapter, maps the old combination of GameSave and PersistentGameState to CanonicalGameSnapshot and back again.
The adapter preserves the current save and wire shapes while keeping the old-data precedence rules in one place. Roster order comes from the save, persistent colour and country values win when legacy sources disagree, missing player IDs are added deterministically, and the event offset crosses the boundary without becoming part of the domain rules.
For multiplayer snapshots without turnStartedAt, the adapter materializes savedAt once during canonicalization. Hot-seat saves keep a missing start time as null, because the absence of a multiplayer timeout clock has a different meaning there.
This adapter is different from the deleted map adapter. The map adapter had no remaining production job, while the snapshot adapter still protects an active compatibility contract. A temporary adapter is useful when it has one owner, one purpose, focused tests, and a clear condition for deletion.
Turn Finalization Now Has One Legacy Island
The first version of CanonicalTurnPipeline was a new facade around the complete old turn kernel. That was already useful because local play, the server, and the performance workload gained one public entry point, but the facade would not provide much architectural value until its internal steps started operating on DomainState.
Combat moved first. The old persistent combat resolver was split into a state-neutral combat kernel with two small adapters, one for PersistentGameState and one for DomainState, and the canonical turn pipeline now runs combat directly on the domain state.
Movement and the end of the turn moved next. Movement reset, merchant routes, queued paths, fog updates, and auto-explore now work through a neutral movement kernel, while the canonical turn suffix handles diplomacy, domination progress, cultural victory progress, session state, timeout streaks, timestamps, and final event ordering.
The current pipeline has one remaining legacy section:
flowchart LR
Input["CanonicalGameSnapshot"] --> Combat["Combat<br/>DomainState"]
Combat --> ToLegacy["Compatibility adapter"]
ToLegacy --> Economy["Economy<br/>PersistentGameState"]
Economy --> ToCanonical["Compatibility adapter"]
ToCanonical --> Movement["Movement and fog<br/>DomainState"]
Movement --> Tail["Diplomacy, victory,<br/>session and events"]
Tail --> Output["CanonicalGameSnapshot"]
Economy -. remaining legacy island .-> Economy
Combat receives request.snapshot.domain, after which the pipeline converts to the old persistent state before running economy. When economy is complete, the result is converted back into a canonical snapshot, and the rest of turn finalization remains canonical.
The suffix increments the turn, clears intended attacks, updates diplomacy and victory hold counters, resets submitted players, updates timeout streaks, stores the new turn start, and produces the final ordered event list. The old PersistentTurnPipeline no longer has production callers.
An AST-based architecture test requires the local game, server, and performance workload to call the canonical facade. It also limits where snapshot conversions can occur and prevents the core domain from importing the compatibility or application layers.
This is not yet one complete GameEngine, but it is the first production path where both local play and the server enter through a shared canonical state boundary and complete most of their work using the same state model.
The Tests Had to Move With the Architecture
The reducer parity corpus now performs 234 local runs and 234 server runs, covering accepted commands, semantic rejection, wrong actors, canonical map order, reversed map order, and repeated deterministic execution. Combat and movement also have dedicated canonical-versus-persistent parity tests, which compare the old wrappers with the new state-neutral kernels.
The performance gate includes turn finalization for 100, 1,000, and 10,000 entities, and the benchmark now contains a real combat instead of ending the combat phase through an early return. Architecture baselines were reduced only after old debt disappeared, and they were not increased simply to make the refactor pass.
The old persistent combat and movement classes lost hundreds of lines because their behaviour moved into neutral kernels, leaving the old classes as thin compatibility adapters. This is a more useful result than adding new classes while allowing the old implementations to remain unchanged.
The new code did not only create another route through the application. It made the old route smaller and removed its production consumers.
What Is Still Missing
The next turn milestone is clear because the economy processor still operates on PersistentGameState. Moving economy to DomainState will remove the two internal snapshot conversions from CanonicalTurnPipeline, after which the local and server composition roots can become canonical without converting at their outer boundaries.
Several larger goals still remain. GameIntent must be separated from authoritative DomainCommand, one GameEngine must execute command families outside turn finalization, interaction and render state still need clearer separation, and remaining presentation and renderer consumers should eventually move away from MapData.
Local event-offset lookup and persistence still need performance work, while the delivery side still needs immutable artifact promotion, reliable rollback, monitoring, alerting, and tested restore procedures. These tasks remain large, but they are no longer mixed with an unfinished map migration or an undefined state boundary.
Where the Refactor Stands Now
The previous progress update ended with an almost completed map migration, while this one ends with no legacy map adapter, a real canonical state, a compatibility-safe snapshot, a protected player-specific projection, and a turn pipeline where combat and movement already operate on DomainState.
Only the economy phase still crosses the old state model during turn finalization. That is a much smaller and clearer problem than having several client and server versions of the same concepts spread across the codebase.
The remaining legacy boundary is visible, measured, and has a clear deletion condition. For map-based rules, the answer to the original refactor question is already improving, and for turn finalization, it is beginning to improve as well:
Does the next feature require fewer unrelated changes than the previous one?
The game still works, old saves still work, local and server behaviour is still compared, and each large migration leaves less old code behind instead of creating a second permanent implementation.
Leave a Reply