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>
14 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Build & Run Commands
# 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 bindingtheriapolis-rpg-implementation-plan-phase2-3.md— current phase scope (hydrology, linear features, settlements, infrastructure)theriapolis-rpg-procgen.mdandtheriapolis-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 ofIWorldGenStagestages orchestrated byWorldGenerator. Shared mutable state flows throughWorldGenContext.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/—Polylineis the source of truth for linear features. Tile flags likeHasRiver/HasRoad/HasRailare rasterized views, not canonical state.Util/SeededRng.cs— SplitMix64-based RNG with named sub-streams. All RNG must go through this — neverSystem.Random().Constants.cs(classC) — 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
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 Nwidens the tile window (default 3).--dump-allprints every point of each matching polyline instead of ±2 around the closest segment.--stop-at-stage Nruns the pipeline through indexNonly (0-based;RunThroughis 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 byRoadNetworkGenStage/RailNetworkGenStageor by cleanup.--stop-at-stage 10— post-RiverMeanderGenStage, pre-cleanup for rivers. Rivers are run throughPolylineCleanupStage.SnapAndConnectexactly 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. UseIsAtSettlementCentre(matches a settlement's exactTileX/TileY) when you actually mean "at the destination point". - Junction anchors sit at tile centres, not on the existing polyline's body.
SplitByExistingFeatureinPolylineBuilderchops 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.BuildRoadPolylinethen converts them withTileToWorldPixel(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 2SnapToBodyis what pulls it onto the body — any guard that skips snapping needs to stay narrow enough to let that happen. PolylineCleanupStage.SnapAndConnectruns three times (roads, rails, rivers). Roads usedropSubsumed: true(enabling the subsumption drop and the redundant-parallel-stub pass); rails and rivers usefalse. 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 (theclusteredset) and their guards interact. - Rail tile paths are turn-capped. After A* +
SmoothStaircases,RailNetworkGenStagerunsPolylineBuilder.LimitTurnAngleto elide any vertex whose deflection exceedsC.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.
EmitRailSubPathinRailNetworkGenStagedoes the junction detection and pairs each junction end with ±t fromGetExistingRailTangent. 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.BuildRailPolylineconstructs each leg by extending the visible trackWYE_LEG_LENGTH_PX(2 tiles) past the junction tile along the chosen ±t direction, with aWYE_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.LimitTurnAnglealone 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.SnapToBodylater 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 inClusterEndpointsSETTLEMENT_CONNECT_DIST=64f— max distance from a settlement anchor for a cluster member to snap onto itPOLYLINE_SNAP_BODY_DIST=128f— Phase 2 body-snap radiusPOLYLINE_MERGE_DIST=80f— Phase 3 parallel-merge radiusEXISTING_ROAD_COST=0.1f— A* discount for reusing existing road tilesSETTLEMENT_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 inMergeOverlappingTANGENT_ALIGN_MIN=0.75f—|cos(angle)| ≥ 0.75(≤ ~41° off-axis) required for a parallel-merge snapBRANCH_PREFER_DIST=16finClusterEndpoints— 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:
tile-inspectconfirms the bug is gone at the reported(seed, tile).tile-inspectconfirms 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.dotnet testpasses (~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-reachWorld/Polylines/PolylineBuilder.cs— static helpers:CatmullRomSmooth,ApplyMeanderNoise,RasterizeToTileFlags,SplitByExistingFeature,SmoothStaircases,LimitTurnAngle,RDPSimplify,TileToWorldPixelWorld/Generation/Stages/RoadNetworkGenStage.cs/RailNetworkGenStage.cs— pre-cleanup A* generators; cost functions, halo logic,EnsureEndpointSegment,SplitByExistingFeatureinvocationWorld/Generation/Stages/HydrologyGenStage.cs/RiverMeanderGenStage.cs— pre-cleanup river tracing and meander/smoothing
Hard Rules
Theriapolis.Coremust never reference MonoGame orMicrosoft.Xna.*- All RNG via
SeededRngwith named sub-streams declared inConstants.cs— noSystem.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 byPolylineBuilder.LimitTurnAngleinRailNetworkGenStage.
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.dllhas 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.