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

14 KiB
Raw Permalink Blame History

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 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

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: ClusterEndpointsSnapToBodyMergeOverlappingRemoveSmallLoopsRemoveNearDuplicatesRDPSimplify. 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.