# 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<int> DiscoveredPoiIds;
    [Key(6)]  public Dictionary<ChunkCoord, ChunkDelta> ModifiedChunks;
    [Key(7)]  public List<WorldTileDelta> 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<SaveFile>)`. 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>
{
    TState CaptureState();
    void   RestoreState(TState state);
}
```

`ActorManager : IPersistable<PlayerActor>`, `WorldClock : IPersistable<WorldClockState>`, `ChunkStreamer : IPersistable<Dictionary<ChunkCoord, ChunkDelta>>`, 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.
