Files
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

153 lines
14 KiB
Markdown
Raw Permalink 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.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build & Run Commands
```bash
# Build entire solution (.NET 8, C# 12)
dotnet build
# Run the desktop game
dotnet run --project Theriapolis.Desktop
# Run all tests
dotnet test
# Run a single test class or method
dotnet test --filter "FullyQualifiedName~WorldgenDeterminismTests"
dotnet test --filter "FullyQualifiedName~BiomeCoverageTests.NoSingleBiomeDominates"
# Headless worldgen → PNG (full 23-stage pipeline)
dotnet run --project Theriapolis.Tools -- worldgen-dump --seed 12345 --out world.png --data-dir ./Content/Data [--show-violations]
# Headless settlement tier/location report
dotnet run --project Theriapolis.Tools -- settlement-report --seed 12345 --data-dir ./Content/Data
```
## Design Authority
Before any non-trivial code work, read the relevant design docs (all at the repo root):
- **`theriapolis-rpg-implementation-plan.md`** — original architectural spec; Sections 5, 6, 12 remain binding
- **`theriapolis-rpg-implementation-plan-phase2-3.md`** — current phase scope (hydrology, linear features, settlements, infrastructure)
- **`theriapolis-rpg-procgen.md`** and **`theriapolis-rpg-procgen-addendum-a.md`** — worldgen algorithms; Addendum A §1 (border organics) and §2 (linear feature exclusion) are hard constraints
## Project Architecture
Five projects with strict dependency flow:
```
Core (no MonoGame) ← Game (MonoGame) ← Desktop (thin shell)
Core ← Tools (ImageSharp, headless)
Core ← Tests (xUnit)
```
**`Theriapolis.Core`** — All deterministic simulation. No MonoGame allowed (enforced by `Architecture/CoreNoDependencyTests.cs`). Key systems:
- `World/Generation/` — Pipeline of `IWorldGenStage` stages orchestrated by `WorldGenerator`. Shared mutable state flows through `WorldGenContext`.
- `World/WorldState.cs` — Runtime world: `WorldTile[512, 512]` + `MacroCell[32, 32]` + polyline lists (`Rivers`, `Roads`, `Rails`) + `Settlements` + per-stage hashes for save integrity.
- `World/Polylines/``Polyline` is the **source of truth** for linear features. Tile flags like `HasRiver` / `HasRoad` / `HasRail` are rasterized views, not canonical state.
- `Util/SeededRng.cs` — SplitMix64-based RNG with named sub-streams. **All RNG must go through this — never `System.Random()`.**
- `Constants.cs` (class `C`) — All magic numbers and every RNG sub-stream ID live here exclusively.
**`Theriapolis.Game`** — Rendering and UI via MonoGame 3.8.2 + Myra. Stack-based `ScreenManager` with deferred transitions (`TitleScreen`, `WorldGenProgressScreen`, `WorldMapScreen`). `Rendering/Camera2D.cs` operates in world-pixel space and drives the seamless zoom model.
**`Content/Data/`** — JSON loaded at runtime: `biomes.json`, `macro_template.json`, `factions.json`. Copied to output directory by each project that needs it (including the test project, which mirrors them under `Data/`).
## Seamless Zoom Model
One `Camera2D` with continuous zoom covers both world and tactical views. All linear features (rivers, roads, rail) are stored as polylines in **world-pixel space** so they render correctly at any zoom level.
- World tiles: **512×512** grid, 32 px each → 16,384×16,384 world pixels (see `C.WORLD_WIDTH_TILES` / `C.WORLD_TILE_PIXELS`)
- Macro grid: 32×32 cells, each covering 16×16 world tiles (authored in `macro_template.json`)
- Tactical view: 3×3 world-tile window around the player (`C.TACTICAL_WINDOW_WORLD_TILES`), streamed in 64-tile chunks
- Below zoom threshold → world map view; above → tactical view
## World Generation Pipeline
23 ordered stages, wired in `WorldGenerator.BuildPipeline()`. Phase 0/1 delivered stages 19 (terrain, climate, biomes); phase 2/3 delivered 1023 (hydrology, settlements, infrastructure, validation).
Stage order matters — later stages read state left by earlier ones (e.g. `HydrologyGenStage` needs elevation from stage 3; `RailNetworkGenStage` needs settlements from stage 14). Use `WorldGenerator.RunThrough(ctx, index)` when a test or tool only needs partial state.
**Determinism contract:** Same seed → byte-identical tile arrays, polylines, and settlement lists, always. Enforced by `WorldgenDeterminismTests` and `Phase23DeterminismTests` via per-stage FNV-1a hashes on `WorldState`.
## Debugging Linear-Feature Anomalies
When the user reports a polyline bug (river, road, rail) from the in-game debug overlay, they'll give `seed=<N> tile=(X,Y)`. Use this workflow.
### Primary tool: `tile-inspect`
```bash
dotnet run --project Theriapolis.Tools -- tile-inspect --seed <N> --tile X,Y \
[--radius 3] [--dump-all] [--stop-at-stage <k>] --data-dir ./Content/Data
```
- `--radius N` widens the tile window (default 3).
- `--dump-all` prints every point of each matching polyline instead of ±2 around the closest segment.
- `--stop-at-stage N` runs the pipeline through index `N` only (0-based; `RunThrough` is inclusive). The two checkpoints that matter:
- **`--stop-at-stage 16`** — post road-gen, pre-`PolylineCleanupStage`. Compare against the full pipeline output to isolate whether a bug was introduced by `RoadNetworkGenStage`/`RailNetworkGenStage` or by cleanup.
- **`--stop-at-stage 10`** — post-`RiverMeanderGenStage`, pre-cleanup for rivers. Rivers are run through `PolylineCleanupStage.SnapAndConnect` exactly like roads and rails.
The report lists every polyline passing within `radius` of `(X, Y)`: id, type, classification, endpoint settlement ids, closest segment index + distance in tiles, and a window of world-pixel coordinates. It also dumps nearby `HasRiver` tiles, settlements within the radius, and bridges.
### Mental model
- **Polylines are the source of truth.** Tile flags (`HasRoad`, `HasRiver`, `HasRail`) are rasterized views. Fix the polyline; flags re-derive. Never patch flags.
- **Tier-2+ settlements have a 3×3 footprint** where every tile carries `IsSettlement`, not just the centre tile. Guards of the form "is pt on a settlement tile?" (`IsOnSettlement`) over-fire on the whole footprint. Use `IsAtSettlementCentre` (matches a settlement's exact `TileX`/`TileY`) when you actually mean "at the destination point".
- **Junction anchors sit at tile centres, not on the existing polyline's body.** `SplitByExistingFeature` in `PolylineBuilder` chops a tile path by existing-feature tiles to produce "new construction" sub-paths; the sub-path's first and last tile coords are the junction anchors. `BuildRoadPolyline` then converts them with `TileToWorldPixel` (tile centre). So the first point of a split-off polyline can be up to ~half a tile from the actual point on the existing polyline's body. Phase 2 `SnapToBody` is what pulls it onto the body — any guard that skips snapping needs to stay narrow enough to let that happen.
- **`PolylineCleanupStage.SnapAndConnect` runs three times** (roads, rails, rivers). Roads use `dropSubsumed: true` (enabling the subsumption drop and the redundant-parallel-stub pass); rails and rivers use `false`. If a river or rail bug looks like a road bug, check whether the road-only passes would have caught it — you may need to generalise.
- **Phase order inside `SnapAndConnect`:** `ClusterEndpoints``SnapToBody``MergeOverlapping``RemoveSmallLoops``RemoveNearDuplicates``RDPSimplify`. Read the whole file before editing — the phases share state (the `clustered` set) and their guards interact.
- **Rail tile paths are turn-capped.** After A* + `SmoothStaircases`, `RailNetworkGenStage` runs `PolylineBuilder.LimitTurnAngle` to elide any vertex whose deflection exceeds `C.MAX_RAIL_TURN_DEGREES` (default 75°). This models heavy rolling stock that can't corner sharply. The 45° grid produces turns of 0°/45°/90°/135°, so 75° keeps 0°/45° and forces 90°/135° corners to be smoothed into two consecutive diagonals. Preserve-mask tiles (existing rail/road/river bridges, settlement footprint) are never elided. Roads and rivers intentionally skip this pass — they're allowed tight bends.
- **Rail T-junctions are emitted as a full railway wye.** Heavy rolling stock can't make a 90° T, so each sub-path endpoint that lands on an existing-rail tile (HasRail set, not a settlement) splits into *two* polylines — the two legs of the wye. `EmitRailSubPath` in `RailNetworkGenStage` does the junction detection and pairs each junction end with ±t from `GetExistingRailTangent`. A sub-path with one junction emits 2 polylines; both endpoints on junctions emits 4 (Cartesian product). Non-junction sub-paths emit 1 polyline with null wye dirs — identical to pre-wye behavior. `BuildRailPolyline` constructs each leg by extending the visible track `WYE_LEG_LENGTH_PX` (2 tiles) past the junction tile along the chosen ±t direction, with a `WYE_LEG_GHOST_PX` (2 more tiles) ghost control point biasing the Catmull-Rom tangent at the leg end toward the main line. After trimming the ghost→legEnd segment, the polyline starts/ends at legEnd — two tiles onto the main rail body in opposite directions — producing Y-shaped geometry rather than a perpendicular corner. `LimitTurnAngle` alone can't fix this because the preserve mask blocks elision at the junction tile itself, which is why the leg extension + ghost approach is needed. `PolylineCleanupStage.SnapToBody` later pulls the leg end onto the main rail's exact polyline body, so the final leg may be slightly shorter than two tiles but still clearly wye-shaped.
### Key tuning constants
In `Constants.cs`:
- `POLYLINE_SNAP_ENDPOINT_DIST=160f` — Union-Find clustering radius in `ClusterEndpoints`
- `SETTLEMENT_CONNECT_DIST=64f` — max distance from a settlement anchor for a cluster member to snap onto it
- `POLYLINE_SNAP_BODY_DIST=128f` — Phase 2 body-snap radius
- `POLYLINE_MERGE_DIST=80f` — Phase 3 parallel-merge radius
- `EXISTING_ROAD_COST=0.1f` — A* discount for reusing existing road tiles
- `SETTLEMENT_HALO_RADIUS=1` — tiles around a settlement where the existing-road discount is disabled (prevents fork-and-rejoin "teardrop" artefacts near settlements)
- `MAX_RAIL_TURN_DEGREES=75f` — max deflection at any rail tile-path vertex (rail-only, post-`SmoothStaircases`)
Algorithmic thresholds kept local to `PolylineCleanupStage.cs` because they're tied to specific guards:
- `ENDPOINT_GUARD_DIST=24f` / `SHARED_ENDPOINT_GUARD_DIST=96f` — reject merge snaps near a polyline's own endpoints in `MergeOverlapping`
- `TANGENT_ALIGN_MIN=0.75f``|cos(angle)| ≥ 0.75` (≤ ~41° off-axis) required for a parallel-merge snap
- `BRANCH_PREFER_DIST=16f` in `ClusterEndpoints` — if a sibling polyline's body is within this of a candidate endpoint, defer to Phase 2 body-snap instead of pulling it onto the settlement anchor
### Verification discipline
A fix is not done until all three hold:
1. `tile-inspect` confirms the bug is gone at the reported `(seed, tile)`.
2. `tile-inspect` confirms **every previously-fixed site for this seed** is still clean. Keep an explicit running list during the session — a Phase 2 relaxation can easily regress a Phase 1 or Phase 3 fix from earlier. In past sessions this list has grown to 5+ sites; re-check every one before declaring done.
3. `dotnet test` passes (~7 min). Per-stage determinism hashes and the road/rail/hydrology invariant tests catch cross-cutting regressions.
### Where fixes usually go
- **`World/Generation/Stages/PolylineCleanupStage.cs`** — the bulk of linear-feature visual bugs, since cleanup is where aggressive snapping/merging can over-reach
- **`World/Polylines/PolylineBuilder.cs`** — static helpers: `CatmullRomSmooth`, `ApplyMeanderNoise`, `RasterizeToTileFlags`, `SplitByExistingFeature`, `SmoothStaircases`, `LimitTurnAngle`, `RDPSimplify`, `TileToWorldPixel`
- **`World/Generation/Stages/RoadNetworkGenStage.cs`** / **`RailNetworkGenStage.cs`** — pre-cleanup A* generators; cost functions, halo logic, `EnsureEndpointSegment`, `SplitByExistingFeature` invocation
- **`World/Generation/Stages/HydrologyGenStage.cs`** / **`RiverMeanderGenStage.cs`** — pre-cleanup river tracing and meander/smoothing
## Hard Rules
- `Theriapolis.Core` must never reference MonoGame or `Microsoft.Xna.*`
- All RNG via `SeededRng` with named sub-streams declared in `Constants.cs` — no `System.Random()`, no ad-hoc seeds
- All magic numbers in `Constants.cs` (`C.*`) only
- World generation must be 100% deterministic — same seed, identical output (arrays, polylines, settlements)
- Polylines are authoritative for rivers/roads/rails; tile-level flags are derived rasterizations
- Respect Addendum A §2 linear-feature exclusion rules (roads/rail avoid rivers except at bridges, etc.)
- Rails must not deflect by more than `C.MAX_RAIL_TURN_DEGREES` (default 75°) at any tile-path vertex — heavy rolling stock can't corner sharply. Enforced by `PolylineBuilder.LimitTurnAngle` in `RailNetworkGenStage`.
## Testing Strategy
- **Determinism tests** (`Determinism/`) — Same seed run twice → identical hashes; different seeds → different hashes
- **Macro constraint tests** (`Worldgen/MacroConstraintTests.cs`) — Mountain cells maintain elevation ≥ floor; grassland ≤ ceiling, etc.
- **Border organics tests** (`Worldgen/BorderOrganicsTests.cs`) — Zero straight-line border violations (Addendum A §1)
- **Biome coverage tests** — No single biome >80%; all required biomes >1% coverage
- **Hydrology / linear feature / settlement / road connectivity tests** — domain invariants for phase 2/3 systems
- **Architecture test** — Reflection check that `Core.dll` has no MonoGame dependency
**Test performance:** a full pipeline run is ~30s. `Theriapolis.Tests/WorldCache.cs` is an xUnit `IClassFixture` that memoizes `WorldGenContext` per `(seed, stageThroughIndex, variant)`. New test classes that need worldgen output should take `WorldCache` as a constructor dependency and call `cache.Get(seed)` or `cache.GetThrough(seed, stageIndex)` rather than building a fresh context.