Files

535 lines
30 KiB
Markdown
Raw Permalink Normal View History

# Theriapolis — Phase 4 + Save/Load — Design & Implementation Plan
Status: Proposed. Targets the codebase state as of 2026-04-20 (worldgen phases 03 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.