Files
TheriapolisV3/theriapolis-rpg-implementation-plan-phase4.md
T
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

535 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.