# Theriapolis — Phase 4 + Save/Load — Design & Implementation Plan Status: Proposed. Targets the codebase state as of 2026-04-20 (worldgen phases 0–3 complete; `ENABLE_RAIL=false`; 256×256 world; 83 tests green). Governing docs: `theriapolis-rpg-implementation-plan.md` §§5, 6, 11, 12 (binding); `theriapolis-rpg-procgen.md` / `-addendum-a.md` (hard constraints). --- ## 1. Goals & non-goals ### Goals 1. **Make the world inhabitable.** A single player actor can stand somewhere in the world, be seen on the map, and move. 2. **Deliver the seamless-zoom promise.** Smooth, continuous transition between a bird's-eye world map and a walking-distance tactical view, through one camera in world-pixel space. 3. **Stream tactical detail on demand.** Tactical chunks are generated deterministically from `(seed, chunkX, chunkY)` on first access, cached while in view, and evicted when the player leaves. Never pre-baked; never fully persisted. 4. **Persist what matters, derive the rest.** Save = seed + sparse deltas + actor/flag state. Load re-runs the pipeline to reconstruct the world. 5. **Preserve every invariant we have.** Determinism tests, polyline-as-source-of-truth, Core-has-no-MonoGame, Addendum A §1/§2, 60-second worldgen budget. ### Non-goals (explicit) - **Combat resolver** — Phase 5. - **Character creation, stats, inventory semantics** — Phase 5. Phase 4 uses a placeholder actor record with just `Position`, `FacingDir`, `Speed`. - **NPCs, quest dialogue, reputation logic** — Phase 6. Save schema leaves hooks; code is stubbed. - **PoI interiors / dungeon generation** — Phase 7. - **Weather / seasons / time-of-day lighting** — Phase 8. A `WorldClock` is introduced (Phase 4 needs travel time) but weather tables are not. - **Art pass.** Tactical placeholders follow the established flat-color + letter convention. - **Rail re-enable.** The flag stays `false` for the duration of Phase 4. Rail wye code remains exercised by determinism tests when the flag is flipped in future. --- ## 2. Current-state inventory (what we can build on) Confirmed by reading the tree on 2026-04-20: | Piece | File | Notes | |---|---|---| | 23-stage worldgen pipeline | [WorldGenerator.cs](Theriapolis.Core/World/Generation/WorldGenerator.cs) | Deterministic, per-stage FNV-1a hashes in `WorldState.StageHashes` | | Camera with continuous zoom | [Camera2D.cs](Theriapolis.Game/Rendering/Camera2D.cs) | `TacticalThreshold = 0.8f` already wired; `Mode` flips as zoom crosses threshold | | View abstraction | [IMapView.cs](Theriapolis.Game/Rendering/IMapView.cs) | Single-method interface; `WorldMapRenderer` implements it; tactical renderer slots in | | Polylines in world-pixel space | [Polyline.cs](Theriapolis.Core/World/Polylines/Polyline.cs) | Rendered by [LineFeatureRenderer.cs](Theriapolis.Game/Rendering/LineFeatureRenderer.cs) — reusable at both zoom levels | | Tactical constants already defined | [Constants.cs:17-19](Theriapolis.Core/Constants.cs) | `TACTICAL_PER_WORLD_TILE=32`, `TACTICAL_CHUNK_SIZE=64`, `TACTICAL_WINDOW_WORLD_TILES=3` | | Stage-hash infrastructure | [WorldState.cs:45](Theriapolis.Core/World/WorldState.cs) | Save integrity check is already populated for every stage | | Screen stack | [ScreenManager.cs](Theriapolis.Game/Screens/ScreenManager.cs) | Title → WorldGenProgress → WorldMap; new screen(s) plug in | | Seeded RNG with sub-streams | [SeededRng.cs](Theriapolis.Core/Util/SeededRng.cs) | Need new sub-streams for tactical gen and actor ID assignment | Two facts that materially simplify Phase 4: - **The zoom crossover is already plumbed.** `Camera2D.AdjustZoom` toggles `Mode` at 0.8f. Screens just need to pick the right renderer based on `camera.Mode`. - **Map is now 256×256.** Total world span is 8192×8192 world pixels (vs 16384×16384 before). Every constant-in-pixels we pick for tactical behavior should be sanity-checked against that. --- ## 3. Phase 4 architecture ### 3.1 Coordinate systems (recap) One canonical space — **world pixels**. Origin top-left, +Y down, no rotation. ``` world tile (X,Y) = region of 32×32 world pixels at [X*32, Y*32, (X+1)*32, (Y+1)*32) tactical tile (x,y) = 1 world pixel (TACTICAL_PER_WORLD_TILE = 32 tactical per world tile) tactical chunk (cx,cy)= region of 64×64 tactical tiles = 64×64 world pixels = 2×2 world tiles macro cell = 8×8 world tiles = 256×256 world pixels (at 256-tile world) ``` Nothing except rendering converts to screen space; that's Camera2D's job. ### 3.2 Module layout Phase 4 adds these directories (all greenfield): ``` Theriapolis.Core/ Tactical/ TacticalTile.cs struct — surface class, decoration, walkability, etc TacticalChunk.cs class — 64×64 TacticalTile[,] plus spawn list TacticalChunkGen.cs static — hashed per-chunk generator (deterministic) ChunkCoord.cs struct — (cx, cy) key ChunkStreamer.cs manages in-memory chunk cache & delta overlay IChunkDeltaStore.cs interface — save-layer hook Entities/ Actor.cs base — Id, Position, FacingDir, Speed PlayerActor.cs player-only fields (saved explicitly) ActorManager.cs owns live actors; tick their state Persistence/ SaveHeader.cs JSON-serializable: version, seed, stage hashes, play time, save slot meta SaveFile.cs header + binary body combined file SaveCodec.cs MessagePack body encode/decode IPersistable.cs contract each save-worthy module implements SaveMigrations/ ISaveMigration.cs Migrations.cs registry Time/ WorldClock.cs in-game time advancement (used by travel) Theriapolis.Game/ Rendering/ TacticalRenderer.cs IMapView; reads ChunkStreamer PlayerSprite.cs draws player actor in whichever view is active Screens/ PlayScreen.cs replaces WorldMapScreen once the game is in-play mode SaveLoadScreen.cs save-slot picker (from title and in-game pause) Input/ PlayerController.cs world-travel + tactical-step input Platform/ SavePaths.cs OS-aware save directory resolution ``` **`Theriapolis.Core` must not gain a MonoGame dependency.** The architecture test asserts it. Tactical generation, chunk streaming, actor model, and persistence are all Core-only. Rendering, input, and platform paths are Game-only. ### 3.3 View model — how PlayScreen drives rendering `PlayScreen` owns: camera, input, chunk streamer, actor manager, both renderers. ```csharp public void Draw(GameTime gt, SpriteBatch _) { _chunks.EnsureLoadedAround(_player.Position, radius: C.TACTICAL_WINDOW_WORLD_TILES); var view = _camera.Mode == ViewMode.WorldMap ? _worldView : _tacticalView; view.Draw(_sb, _camera, gt); _playerSprite.Draw(_sb, _camera, _player); // drawn in either mode _overlay.Render(); } ``` Because both renderers use the same camera and world-pixel space, the same polylines, the same player position — crossing the zoom threshold is just a renderer swap. No camera repositioning. No state hand-off. This is the seamless-zoom model working as specified in Section 5. A cross-fade is a future polish; an abrupt swap is acceptable for Phase 4. ### 3.4 Chunk streaming #### Cache policy The canonical window is a 3×3 grid of **world tiles** around the player (`TACTICAL_WINDOW_WORLD_TILES=3`). A world tile is 2×2 tactical chunks (32 tactical tiles per world tile ÷ 64 tactical tiles per chunk side → 0.5; actually: 32/64 = ½, so 1 world tile overlaps 1 chunk on each axis, sometimes 2 if straddled). Recomputing: at `TACTICAL_PER_WORLD_TILE=32` and `TACTICAL_CHUNK_SIZE=64`, **each chunk covers 2×2 world tiles**. A 3×3 world-tile window therefore touches at most a 3×3 arrangement of chunks (rounded out from wherever the window aligns). So the steady-state in-memory set is ≤ **9 chunks × 64×64 tiles = 36,864 tactical tiles**. If each `TacticalTile` is 16 bytes, that's ~600 KB. Negligible. - **Load:** on first access, `ChunkStreamer.Get(cc)` calls `TacticalChunkGen.Generate(seed, cc, worldState)` then applies any `IChunkDeltaStore` overlay for the chunk. - **Evict:** any chunk not covered by the current window is dropped. Before dropping, if the chunk has been modified relative to its deterministic baseline, the modified fields are flushed to `IChunkDeltaStore`. - **Pre-warm:** when the player crosses into a new world tile, the streamer kicks a background `Task` to `Get` the chunks that will be in range after the move. Caller never blocks on generation; the main thread calls `Get` and gets either the cached result or a fresh-generation that took <1 ms on 64×64 (budgeted in §6 below). #### Generation algorithm (deterministic, Core-only) `TacticalChunkGen.Generate(ulong worldSeed, ChunkCoord cc, WorldState world) → TacticalChunk` Inputs: the chunk's covered world tiles (2×2), polylines intersecting the chunk's world-pixel AABB, settlement footprints intersecting the AABB, and `SubSeed(RNG_TACTICAL, cc.X, cc.Y)`. Steps: 1. **Ground layer.** For each tactical tile `(tx, ty)`: - Resolve parent world tile `(wx, wy)`. - Read its biome, elevation, moisture, feature flags. - Pick a ground variant deterministically per biome: e.g. Grassland → `{ shortgrass, tallgrass, dirt, flower }` weighted by a hash of `(wx, wy, tx, ty)`. - Bias by elevation gradient (rockier near mountain edges). 2. **Polyline burn-in.** For every polyline (river, road) intersecting the chunk: - Walk its segments; for each segment, rasterize a width-aware stroke into the chunk. Rivers carve water tiles; roads stamp gravel/cobble. Reuse `PolylineBuilder.RasterizeToTileFlags`-style math but at tactical resolution (1 tile = 1 world pixel). 3. **Settlement burn-in.** If a settlement footprint intersects, stamp its building templates. A Tier-5 hamlet is a couple of cottages + a well. Tier-1 Millhaven gets a preset layout from `Content/Data/settlement_layouts/`. No procedural buildings in Phase 4 — templates only. 4. **Scatter pass.** Per biome, sprinkle decorations (trees, rocks, bushes) using a blue-noise-ish Poisson sample seeded from `SubSeed(RNG_TACTICAL, cc.X, cc.Y, 1)`. Walkability of these tiles depends on the decoration type (trees block, bushes slow). 5. **Spawn list.** A deterministic encounter roll driven by `world.EncounterDensity[wx, wy]` and the same sub-seed. Phase 4 stores the spawn list but does not actually spawn NPCs — Phase 5/6 reads it. All four passes must be pure functions of `(worldSeed, cc, world)`. No ambient RNG. Test: generate the same chunk on two machines; assert byte-identical. #### Determinism caveats specific to tactical - The player moving around must not change what the world contains. Chunk generation is referentially transparent. The delta store only captures **player-caused** changes (chopped a tree, dropped an item, cleared a bandit camp). - On load, we re-run worldgen, reload the player, then on first access of each chunk the generator runs fresh; deltas overlay on top. A save does not contain any chunk that the player hasn't personally modified. ### 3.5 Player entity & input `Actor` is a plain POCO in Core; `PlayerActor` adds persistence hooks. ```csharp public class Actor { public int Id; public Vector2 Position; // world-pixel space; reused in both views public float FacingAngleRad; public float SpeedWorldPxPerSec; // continuous-time travel speed on world map } ``` Two input modes, both driven by `PlayerController`: **World-map mode (camera.Mode == WorldMap):** - Click a destination on the map; compute an A\* path over world tiles using the existing road network as cost discount. Player walks the path at `SpeedWorldPxPerSec`, clock advances proportionally (see §3.6). - Shift-click or ESC cancels. - No tile-by-tile action. This matches **Decision #3** — continuous time-advanced travel. **Tactical mode (camera.Mode == Tactical):** - WASD / arrow keys step one tactical tile at a time. Each step is a "turn" (Phase 4 doesn't yet consume AP, but the hook is there for Phase 5 combat). - Diagonal movement allowed if both cardinal neighbors are walkable (standard tactical D\&D rule). - Step is blocked by impassable decorations, water without a bridge, settlement walls. The controller is in Game (needs keyboard input), but the resolution logic — "is this move legal, what's the resulting position" — lives in Core (`TacticalMovementRules` static class). Testable. ### 3.6 World clock & travel time We need a clock in Phase 4 because travel is time-advanced. Weather and seasons read it later in Phase 8. `WorldClock` is a single `long InGameSeconds` counter plus helpers (`Day`, `Hour`, `Season`). It is advanced by: - **Continuous travel:** time += distance / effectiveSpeed. Effective speed = base × terrainModifier × roadModifier. Road tiles give a 2× multiplier (matches the `EXISTING_ROAD_COST=0.1f` philosophy at the worldgen layer). - **Tactical steps:** each step advances some trivial amount (10 in-game seconds per tactical tile, walking pace). This only matters so tactical play doesn't freeze the world clock, which would break future weather logic. Travel-time formula (Phase 4 version, deliberately simple, can be tuned in Phase 8): ``` seconds_per_world_pixel = BASE_SEC_PER_WORLD_PIXEL (const) * biome_speed_mod[biome] // 1.0 grassland, 1.5 forest, 3.0 mountain, ... * (has_road ? ROAD_SPEED_BONUS : 1.0) ``` `BASE_SEC_PER_WORLD_PIXEL` chosen so a Tier-3 to Tier-3 hop over roads takes ~half a day. New constants in `Constants.cs`: ```csharp public const float BASE_SEC_PER_WORLD_PIXEL = 8f; // ~36h to cross the world on foot public const float ROAD_SPEED_MULT = 0.5f; // roads are 2× faster public const int TACTICAL_STEP_SECONDS = 10; ``` These are knobs. Playtest in Phase 5+. --- ## 4. Save / Load ### 4.1 Format **File = header JSON + body MessagePack**, concatenated with a 4-byte little-endian header length prefix. This lets a quick scan (slot picker) deserialize only the header. ``` [ 4 bytes: headerLen ][ headerLen bytes: JSON ][ remaining: MessagePack body ] ``` Header (JSON, `System.Text.Json`): ```jsonc { "version": 4, // bump on breaking schema change "worldSeed": "0xCAFEBABE", "stageHashes": { "ElevationGen": 0xAB..., "HydrologyGen": 0xCD..., ... }, "playerName": "Grev", "playerTier": 3, // highest settlement tier the player has reached, cheap to show in slot picker "inGameSeconds": 483812, "realPlayTime": "PT4H13M", "savedAt": "2026-04-20T18:22:31Z", "appVersion": "0.4.0" } ``` Body (MessagePack, binary, `MessagePack-CSharp` nuget): ```csharp [MessagePackObject] public sealed class SaveBody { [Key(0)] public PlayerActor Player; [Key(1)] public WorldFlags Flags; // global quest/story flags [Key(2)] public FactionDeltas Factions; // per-faction influence offsets from baseline [Key(3)] public QuestState Quests; // empty in Phase 4 [Key(4)] public ReputationState Reputation; // empty in Phase 4 [Key(5)] public List DiscoveredPoiIds; [Key(6)] public Dictionary ModifiedChunks; [Key(7)] public List ModifiedWorldTiles; [Key(8)] public WorldClockState Clock; } ``` - **Phase 4 writes** `Player`, `DiscoveredPoiIds`, `ModifiedChunks`, `ModifiedWorldTiles`, `Clock`. - **Phase 4 reserves** `Flags`, `Factions`, `Quests`, `Reputation` as empty containers. This means Phase 5/6 adds no migration — the schema already covers them. ### 4.2 Save procedure 1. Flush in-memory chunk deltas through `IChunkDeltaStore` so nothing is lost. 2. Populate `SaveBody` from live systems. 3. Take `world.StageHashes` as-is and embed in header. 4. Serialize header (JSON), compute its byte length, write prefix + header + body to a temp file. 5. Atomic rename over the target slot file. Crash-safe: if the rename fails or is interrupted, the previous save remains intact. Save location (per `SavePaths.cs`): - Windows: `%LOCALAPPDATA%\Theriapolis\Saves\slot_N.trps` - Linux: `$XDG_DATA_HOME/Theriapolis/saves/slot_N.trps` (fallback `~/.local/share/Theriapolis`) - macOS: `~/Library/Application Support/Theriapolis/Saves/slot_N.trps` Slot count: 10. Plus one `autosave.trps` slot managed by the game (writes on screen transitions that matter: exiting a settlement, completing a travel leg). ### 4.3 Load procedure 1. Read header. Validate `version` (see §4.4). Reject on unknown. 2. Re-run worldgen from `worldSeed`. This is the hot path — measured today at ~30 s at 512×512, faster at 256×256 (target: < 10 s). Show the existing `WorldGenProgressScreen`. 3. Compare each stage's recomputed hash against `header.stageHashes`. Any mismatch → enter migration flow (§4.4). 4. Read body. Restore `PlayerActor`, `WorldClock`, `DiscoveredPoiIds`. 5. Restore `ModifiedWorldTiles` by patching `world.Tiles` in place. (Player burned Millhaven's mill? Its tile's `Biome` goes to `Ashland` per the delta.) 6. Seed the chunk delta store from `ModifiedChunks`. No chunk is actually generated — they'll lazy-load around the player's spawn. 7. Transition to `PlayScreen`. ### 4.4 Migration policy There are three possible outcomes when `stageHashes` mismatch: | Mismatch location | Policy | Rationale | |---|---|---| | Any stage ≤ `BiomeAssign` (tile arrays) | **Hard block.** Prompt user: "This save was made with an older worldgen. Start new game from the same seed?" | The player's position is anchored in tile space. If the biome under them changed, we cannot safely keep their position. Their faction state could also have been earned in a world that no longer exists. | | `HydrologyGen` through `PolylineCleanup` (polylines) | **Soft warning + proceed.** Log every polyline whose id-to-position mapping shifted. Player keeps everything; world may have a differently-shaped river next to their house. | Polylines don't anchor player state. Deltas that *referenced* a polyline by id will still work because the id survives; the shape is just different. | | `Settlement*` through `ValidationPass` | **Soft warning + proceed,** but run a post-load sanity check: if the player was standing inside a settlement footprint that no longer exists, teleport them to the nearest Tier ≥ 3 settlement. Log. | Settlements can shift slightly when distance constants change — we already saw this with the 256×256 rescale. | All migrations register under `Persistence/SaveMigrations/`. A migration is `(fromVersion, toVersion, Action)`. Registry picks the chain and runs it. If no chain exists, header `version` determines behavior per the table above. ### 4.5 IPersistable pattern Modules that own save-worthy state implement: ```csharp public interface IPersistable { TState CaptureState(); void RestoreState(TState state); } ``` `ActorManager : IPersistable`, `WorldClock : IPersistable`, `ChunkStreamer : IPersistable>`, etc. `SaveCodec` wires them. Adding a new module in Phase 5 is just: implement the interface, add a MessagePack key, bump version, add a no-op migration from the previous version. --- ## 5. Changes to existing files ### 5.1 `Constants.cs` additions ```csharp // ── Phase 4: Tactical streaming ──────────────────────────────────────── public const ulong RNG_TACTICAL_GROUND = 0x7AC71C01UL; public const ulong RNG_TACTICAL_SCATTER = 0x7AC71C02UL; public const ulong RNG_TACTICAL_SPAWN = 0x7AC71C03UL; public const int CHUNK_CACHE_SOFT_MAX = 16; // allow a little slack past the 9-chunk window // ── Phase 4: Actor + clock ───────────────────────────────────────────── public const float BASE_SEC_PER_WORLD_PIXEL = 8f; public const float ROAD_SPEED_MULT = 0.5f; public const int TACTICAL_STEP_SECONDS = 10; public const ulong RNG_ACTOR_ID = 0xAC704DUL; // ── Phase 4: Save ────────────────────────────────────────────────────── public const int SAVE_SCHEMA_VERSION = 4; ``` The existing `RNG_TACTICAL = 0x7AC71CA1UL` stays as a parent; the three new sub-streams are specifically for the tactical gen passes. No collisions. ### 5.2 `WorldState.cs` No schema change. Already has `StageHashes` dict. Phase 4 just reads from it. ### 5.3 `WorldMapScreen.cs` → `PlayScreen.cs` `WorldMapScreen` is preserved as the debug/post-worldgen viewer; a new `PlayScreen` becomes the in-game screen. Differences: - Takes an additional `PlayerActor`, `ActorManager`, `ChunkStreamer`, `WorldClock`. - Swaps renderers based on `camera.Mode`. - `PlayerController` replaces the click-to-copy-clipboard debug handler (that moves back to `WorldMapScreen` behind a `--dev` flag). The title screen routes: new game → `WorldGenProgressScreen(seed)` → `PlayScreen`. Load game → `SaveLoadScreen` → `WorldGenProgressScreen(seedFromSave)` → `PlayScreen` (with save-file applied post-worldgen). ### 5.4 Architecture test Extend `CoreNoDependencyTests` to ensure new Core namespaces `Theriapolis.Core.Tactical`, `Theriapolis.Core.Entities`, `Theriapolis.Core.Persistence`, `Theriapolis.Core.Time` have no MonoGame refs. --- ## 6. Performance & memory budgets | Item | Target | Notes | |---|---|---| | Single chunk gen | < 5 ms | 64×64 = 4096 tiles; biome lookup + scatter + polyline burn-in. Will measure. | | 3×3 world-tile window load from cold | < 30 ms | First entry to tactical after worldgen. Player will briefly see a placeholder while we stream in. | | Pre-warm on world-tile crossing | < 5 ms on main thread | Background `Task` does the real work; main thread is just kicking it. | | Steady-state tactical memory | < 5 MB | 9 chunks × 600 KB + decoration sprite refs. | | Save write | < 100 ms | For a 10-hour playthrough save. Dominated by MessagePack serialization. | | Save load (excluding worldgen) | < 500 ms | Deserialize + apply deltas + seed chunk cache. | | Full load (worldgen + save apply) | < 10 s | Dominated by worldgen. 256×256 pipeline is already fast. | These are targets to hold, not guesses. Each gets a test (§7.5). --- ## 7. Testing strategy Reuses the conventions already established — `WorldCache` for fixture-shared worldgen output, FNV-1a hashes for determinism, xUnit `Theory` for multi-seed checks. ### 7.1 Chunk determinism ```csharp [Theory, MultipleSeeds] public void Chunk_GeneratesSameBytesAcrossRuns(ulong seed) { var w = _cache.Get(seed).World; var a = TacticalChunkGen.Generate(seed, new ChunkCoord(5, 5), w); var b = TacticalChunkGen.Generate(seed, new ChunkCoord(5, 5), w); Assert.Equal(Hash(a), Hash(b)); } ``` ### 7.2 Stream cycle invariance Generate a chunk → evict → regenerate → hash must match the first. Confirms no lingering state corrupts a fresh gen. ### 7.3 Delta round-trip Mutate a few tiles in a chunk → flush to delta store → evict → reload → assert post-delta state matches. This is the single most important save-correctness test. ### 7.4 Save/load round-trip Play a scripted 30-second session (move player, mutate one chunk, advance clock) → save → load → assert every `IPersistable.CaptureState()` produces the same state. ### 7.5 Performance guards ```csharp [Fact] public void ChunkGen_Under5ms() { var w = _cache.Get(0xCAFEBABEUL).World; var sw = Stopwatch.StartNew(); for (int i = 0; i < 100; i++) TacticalChunkGen.Generate(0xCAFEBABEUL, new(i, 0), w); Assert.True(sw.Elapsed.TotalMilliseconds / 100 < 5); } ``` Flakes are handled by taking the median of ten runs and asserting against a 1.5× of the target. Keeps us honest without false-failing on a busy CI box. ### 7.6 Migration tests For each supported migration chain, construct a minimal save of the old version, run it through migration, assert the new-version `SaveBody` has the expected field values. ### 7.7 Existing tests stay green All 83 current tests must continue to pass. The determinism suite, in particular, is the canary: if a Phase 4 change mutates a `Stage*` hash, something has accidentally perturbed worldgen. Every such change must be intentional and explained in the commit message. --- ## 8. Milestones Sized in "chunks of work" rather than calendar days. Each milestone ends with the test suite green and a demoable state. **M1 — Actor skeleton (no streaming yet).** - `Actor`, `PlayerActor`, `ActorManager` in Core. - `PlayerSprite` in Game. - Click on world map → player walks to that tile at constant speed; clock advances. - `WorldClock` ticking. - Save/load slot picker UI (stubs). - Ship point: can wander around Theriapolis on the world map like a slow cursor. **M2 — Tactical chunk gen, no rendering.** - `TacticalTile`, `TacticalChunk`, `TacticalChunkGen` in Core. - Deterministic generation tests. - `headless-tactical-dump` tool command that renders one chunk to PNG, for eyeballing biome coverage and polyline burn-in. - No Game-side integration yet. Purely Core work. - Ship point: tools + tests prove tactical gen is correct. **M3 — Tactical renderer + zoom crossover.** - `TacticalRenderer : IMapView`. - `ChunkStreamer` wired up in `PlayScreen`. - Zoom past `TacticalThreshold` switches to tactical render; player sprite stays put. - Chunks stream in as the player moves through tiles. - WASD-step movement on tactical. - Ship point: walk into Millhaven at world scale, scroll wheel in, start stepping through its streets. **M4 — Save/load end-to-end.** - Save file format, `SaveCodec`, slot picker wired. - Autosave on screen transitions. - Migration scaffold with one test migration (v3→v4, hypothetical). - Ship point: save, quit, restart, load, pick up where you left off. **M5 — Delta persistence for chunks & world tiles.** - `IChunkDeltaStore` implementation. - Smoke test: chop a tree, leave the chunk, come back, tree is still gone. - Ship point: the world remembers player actions. **M6 — Polish & performance.** - Pre-warm tuning, cache sizing, eviction correctness. - Cross-fade polish on the zoom swap (optional). - Performance guards green on CI. - Ship point: a playable, save-capable Phase 4. --- ## 9. Risks & mitigations | Risk | Likelihood | Impact | Mitigation | |---|---|---|---| | Tactical gen proves too slow on mid-range hardware | Med | High | Build M2 first; measure before integrating. If we miss the 5 ms budget, reduce scatter density or cache per-biome scatter templates. | | Chunk boundaries show visible seams | Med | Med | Deterministic gen means adjacent chunks see the same inputs at the seam. Real risk is when a chunk reads a "halo" of neighbors for scatter and we don't give it enough halo. Budget 2 tactical tiles of halo read. | | Save schema churn across Phase 5/6 | High | Med | The `IPersistable` pattern is designed for this. Reserved fields (`Flags`, `Factions`, `Quests`, `Reputation`) mean no migration needed for those in Phase 5/6. | | Worldgen determinism regressions from Phase 4 changes | Low | Critical | Determinism tests gate every commit. If a tactical change ever mutates a `Stage*` hash, something is wrong — investigate, don't silence. | | Player standing inside a no-longer-existent settlement on load | Med | Low | Covered by §4.4 teleport-to-nearest-Tier-3 rule. | | Continuous world travel through unwalkable terrain | Med | Med | Path A\* on worldgen uses the existing road-cost fabric; extend it to include an "unwalkable" cost for ocean/high mountain. Settle this in M1. | | "Empty expanses" returning at tactical scale | Low | Med | We've already halved settlement distances. Tactical scatter density is independent and tuned per biome. | --- ## 10. Open questions These are decisions worth making explicit before M1 starts. None is a blocker; flagging them for conversation. 1. **Travel cancellation ergonomics.** If the player clicks a second destination mid-travel, do we abort the first path immediately or queue? Proposed: abort, treat the new click as authoritative. 2. **Encounter triggering during travel.** Phase 4 rolls spawn lists but doesn't resolve encounters. When do we actually pause travel and drop into tactical? Probably Phase 6 concern, but worth thinking about so we don't bake in wrong assumptions. 3. **Tactical movement UI.** WASD per-step is fine for debug; is there a "click a destination tile and auto-path" mode for tactical too? Probably — defer to M3 so we can feel the step-at-a-time version first. 4. **Save slot naming.** Player-chosen names vs autogenerated (`Grev, Day 12, near Millhaven`)? Proposed: autogenerated with an override option. The header already has enough data for a nice auto-string. 5. **Dev-mode preserve.** Keep the click-to-copy-clipboard debug affordance somewhere? I'd argue yes — add a `--dev` command-line flag that keeps `WorldMapScreen` reachable from the title. --- ## 11. What Phase 4 does not finish, and why that's OK Phase 4's exit criteria are that **a player exists, moves around the world at two scales, and their progress is saved**. That is all. It deliberately leaves out: - **Combat.** The hook is the tactical-step turn counter; Phase 5 reads it. - **Dialogue.** The hook is settlement footprints being addressable by id; Phase 6 reads them. - **Content richness.** Biome scatter is flat placeholders; artists come later. - **Wang corner-based autotiling between dissimilar surfaces.** Phase 4 ships the Option B colour-blend edge overlay code in [TacticalRenderer.cs](Theriapolis.Game/Rendering/TacticalRenderer.cs) (`DrawChunkEdgeBlends`) but it's gated off via `EdgeBlendEnabled = false`. Initial tuning (55% tint alpha, 16-pixel falloff, 4 overlapping masks per tile) washed colours out badly when surrounding placeholder colours had high saturation (snow≈white, sand=cream). The code stays in tree as a starting point for a re-tune once we have real per-tile art and can judge subtler blend parameters. The full plan is **"Option C"**: replace the per-edge blend entirely with proper Wang transition tiles generated by Pixellab's `create_topdown_tileset`. Each terrain *pair* gets its own 16-tile transition strip; each cell samples its 4 corners' surfaces and picks the matching tile. Switch over once the per-tile art set is finalised — both sides of the renderer (per-tile sprites and edge handling) need cohesive art before the upgrade is worth the refactor. The payoff of holding the line on scope is that Phase 5 (combat + rules + character creation) has one thing to plug into, not three. Ship Phase 4 narrow, ship it solid, and the rest comes up cleanly on top.