153 lines
14 KiB
Markdown
153 lines
14 KiB
Markdown
|
|
# 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 1–9 (terrain, climate, biomes); phase 2/3 delivered 10–23 (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.
|