# Theriapolis — Implementation Plan: Phase 2 + Phase 3 ### Handoff Document for Sonnet ### Version 1.0 — 2026-04-12 --- ## 0. Purpose This document is the agreed implementation plan for **Phase 2 (Hydrology + Linear Features)** and **Phase 3 (Settlements + Infrastructure)** of Theriapolis. It continues from the Phase 0+1 work that is now complete and accepted. Sonnet: **read this entire file before writing code.** Also re-read these files from the prior plan, as they remain authoritative: - `theriapolis-rpg-implementation-plan.md` — the original architectural spec (Sections 5, 6, 12 are still binding) - `theriapolis-rpg-procgen.md` — world generation design (Layers 2–4, 5–7) - `theriapolis-rpg-procgen-addendum-a.md` — border organics AND **linear feature exclusion rules** (Issue 2 is the critical new constraint) - `theriapolis-rpg-questline.md` — narrative anchor requirements - `theriapolis-rpg-clades.md` — clade sizes, affinities, demographics - `theriapolis-rpg-reputation.md` — faction definitions and influence **All hard rules from the original plan (Section 12) remain in force.** This document adds no new hard rules — it only extends scope. --- ## 1. What Phase 0+1 Built (Your Foundation) You are building on top of a working, tested codebase: - **9-stage worldgen pipeline** (`WorldGenerator.BuildPipeline()`): SeedInit, MacroTemplateLoad, ElevationGen, MoistureGen, TemperatureGen, CoastalFeatureGen, BorderDistortion, WaterBodyClamp, BiomeAssign - **`WorldState`** with `WorldTile[1024,1024]` — elevation, moisture, temperature, BiomeId, FeatureFlags, macro cell coordinates all populated - **`SeededRng`** with 13 named sub-streams (RNG_HYDRO, RNG_SETTLE, RNG_ROAD, RNG_RAIL, RNG_FACTION, RNG_POI are unused and waiting for you) - **`FeatureFlags`** bitmask with HasRiver, RiverAdjacent, HasRoad, HasRail, RailroadAdjacent, IsSettlement, IsPoi all defined but unset - **`Camera2D`**, `WorldMapRenderer`, `TileAtlas`, screen stack, input manager - **Macro grid** with per-cell BiomeType, Development, Covenant, CladeAffinities, elevation/moisture constraints - **Tests**: determinism, macro constraints, border organics, biome coverage, architecture (Core has no MonoGame dependency) - **Tools**: `worldgen-dump` exports PNG of biome map You do NOT need to modify any Phase 0+1 stage code. Build on top of it. --- ## 2. What This Phase Delivers When Phase 2+3 is complete, `dotnet run --project Theriapolis.Desktop` shows: 1. A world map with **rivers** (blue polylines, varying width by classification) flowing from mountains to sea, with natural meanders. 2. **Lakes** at drainage collection points, visible as water bodies. 3. **Settlement icons** at correct tiers, correctly constrained (narrative anchors at valid locations, general settlements by habitability score). 4. **Rail lines** (dark gray polylines) connecting the capital to Tier 2 cities. 5. **Roads** (brown polylines) connecting Tier 1–3 settlements, routed around rivers and rail per Addendum A §2 exclusion rules. 6. **Settlement labels** visible above a zoom threshold. 7. All of the above visible at any zoom level thanks to the shared polyline coordinate space (world-pixel space). The `worldgen-dump` tool produces a PNG showing all of this overlaid on the biome map. --- ## 3. New Constants Add these to `Constants.cs` alongside the existing constants. Do not modify existing constants. ```csharp // ── Phase 2: Hydrology ────────────────────────────────────────────────── public const int RIVER_MIN_FLOW_ACCUM = 80; // tiles of upstream catchment to become a stream public const int RIVER_MAJOR_THRESHOLD = 400; // flow accumulation for "major river" public const int RIVER_MODERATE_THRESHOLD = 180; // flow accumulation for "river" (vs stream) public const float RIVER_CARVE_DEPTH = 0.02f; // elevation reduction along river paths public const int LAKE_MIN_AREA = 12; // tiles; smaller basins stay dry public const float MEANDER_AMP_FLAT = 5f; // max world-pixel lateral offset on plains public const float MEANDER_AMP_MOUNTAIN = 1.5f; // max world-pixel lateral offset in mountains public const float MEANDER_FREQ = 0.08f; // noise frequency for meander offset public const int SPLINE_SUBDIVISIONS = 4; // Catmull-Rom subdivisions per control point public const float RDP_TOLERANCE = 2.0f; // Ramer-Douglas-Peucker LOD tolerance (world px) // ── Phase 3: Settlements ──────────────────────────────────────────────── public const int SETTLE_TIER1_COUNT = 1; // capitals public const int SETTLE_TIER2_MIN = 4; public const int SETTLE_TIER2_MAX = 6; public const int SETTLE_TIER3_MIN = 15; public const int SETTLE_TIER3_MAX = 25; public const int SETTLE_TIER4_MIN = 40; public const int SETTLE_TIER4_MAX = 80; public const int SETTLE_TIER5_MIN = 100; public const int SETTLE_TIER5_MAX = 200; public const int SETTLE_MIN_DIST_TIER1 = 120; // min tile distance between same/higher tier public const int SETTLE_MIN_DIST_TIER2 = 60; public const int SETTLE_MIN_DIST_TIER3 = 20; public const int SETTLE_MIN_DIST_TIER4 = 8; public const int SETTLE_MIN_DIST_TIER5 = 5; public const float ANCHOR_MIN_DIST = 80f; // min tile distance between narrative anchors // ── Phase 3: Infrastructure ───────────────────────────────────────────── public const float ROAD_SHORTCUT_FRACTION = 0.30f; // fraction of MST edges to add as shortcuts public const float BRIDGE_COST = 50f; // A* cost to cross a river public const float CROSSING_COST = 20f; // A* cost for road-rail crossing public const float SETBACK_COST_SCALE = 8f; // multiplier for river/rail proximity penalty public const int SETBACK_DISTANCE = 4; // ideal tile separation from parallel features public const float RAIL_BRIDGE_COST = 80f; // rail crossing a river is expensive // ── Phase 3: Factions ─────────────────────────────────────────────────── public const float FACTION_INFLUENCE_RADIUS = 60f; // tiles; falloff radius from seed points public const float FACTION_DECAY_RATE = 0.015f; // per-tile decay // ── Phase 3: PoIs ─────────────────────────────────────────────────────── public const int POI_MIN_DIST_FROM_SETTLE = 6; // tiles from any settlement public const int POI_MIN_DIST_FROM_POI = 4; // tiles from other PoIs // ── Phase 2+3: RNG sub-streams (new, non-colliding) ──────────────────── public const ulong RNG_LAKE = 0x1A4EUL; public const ulong RNG_MEANDER = 0xE41DE7UL; public const ulong RNG_HABITAT = 0x4AB17A7UL; public const ulong RNG_ANCHOR = 0xA1C407UL; public const ulong RNG_SETTLE_ATTR = 0x5A774UL; public const ulong RNG_TRADE = 0x74ADE5UL; public const ulong RNG_ENCOUNTER = 0xE1C0U17UL; ``` --- ## 4. New Data Structures ### 4.1 Polyline (the backbone of this phase) Create `Theriapolis.Core/World/Polylines/Polyline.cs`: ```csharp /// /// A polyline in world-pixel space (0..32768 on each axis). /// Source of truth for rivers, roads, and rail. Per-tile flags are derived. /// public sealed class Polyline { public PolylineType Type { get; init; } // River, Road, Rail public int Id { get; init; } // unique per type public List Points { get; } // control points in world-pixel space public List? SimplifiedPoints { get; set; } // RDP-reduced for LOD public float Width { get; set; } // world-pixel width for rendering // River-specific public RiverClass RiverClassification { get; set; } public int FlowAccumulation { get; set; } // Road-specific public RoadType RoadClassification { get; set; } // Source/destination settlement IDs (for roads, rail) public int FromSettlementId { get; set; } = -1; public int ToSettlementId { get; set; } = -1; } public enum PolylineType : byte { River, Road, Rail } public enum RiverClass : byte { Stream, River, MajorRiver } public enum RoadType : byte { Footpath, DirtRoad, PostRoad, Highway } ``` Use a plain `Vec2` struct (two floats) instead of pulling in `System.Numerics` — Core has no external dependencies. Create `Theriapolis.Core/Util/Vec2.cs`: ```csharp public readonly struct Vec2 { public readonly float X, Y; public Vec2(float x, float y) { X = x; Y = y; } public static Vec2 operator +(Vec2 a, Vec2 b) => new(a.X + b.X, a.Y + b.Y); public static Vec2 operator -(Vec2 a, Vec2 b) => new(a.X - b.X, a.Y - b.Y); public static Vec2 operator *(Vec2 a, float s) => new(a.X * s, a.Y * s); public float LengthSquared => X * X + Y * Y; public float Length => MathF.Sqrt(LengthSquared); public Vec2 Normalized => this * (1f / Length); public Vec2 Perp => new(-Y, X); // 90-degree CCW rotation public static float Dot(Vec2 a, Vec2 b) => a.X * b.X + a.Y * b.Y; public static float DistSq(Vec2 a, Vec2 b) => (a - b).LengthSquared; } ``` ### 4.2 Settlement Create `Theriapolis.Core/World/Settlement.cs`: ```csharp public sealed class Settlement { public int Id { get; init; } public string Name { get; set; } = ""; public int Tier { get; init; } // 1–5 public int TileX { get; init; } // world tile coords public int TileY { get; init; } public float WorldPixelX => TileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS / 2f; public float WorldPixelY => TileY * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS / 2f; // Narrative anchor tag (null for non-anchor settlements) public NarrativeAnchor? Anchor { get; init; } // Generated attributes (Phase 3, stage 14) public SettlementEconomy Economy { get; set; } public SettlementGovernance Governance { get; set; } public string[] CladeRatios { get; set; } = Array.Empty(); public float WealthLevel { get; set; } public int Population { get; set; } public float HybridPct { get; set; } public string ScentProfile { get; set; } = ""; // Derived after road/rail gen public bool HasRailStation { get; set; } public bool IsOnRiver { get; set; } } public enum NarrativeAnchor : byte { Millhaven, Thornfield, FortDustwall, TheTangles, SanctumFidelis, Heartstone } public enum SettlementEconomy : byte { Farming, Mining, Manufacturing, Trade, Military, Fishing } public enum SettlementGovernance : byte { Council, Mayor, MilitaryCommandant, ClanElder, Corporate, Anarchic } ``` ### 4.3 Faction Influence Create `Theriapolis.Core/World/FactionInfluence.cs`: ```csharp public sealed class FactionInfluenceMap { // Per-faction influence at each world tile: [factionIndex, x, y] // Faction indices: 0=Enforcers, 1=Inheritors, 2=ThornCouncil public float[,,] Influence { get; } = new float[3, C.WORLD_WIDTH_TILES, C.WORLD_HEIGHT_TILES]; public int DominantFaction(int x, int y) { float best = 0; int idx = -1; for (int f = 0; f < 3; f++) if (Influence[f, x, y] > best) { best = Influence[f, x, y]; idx = f; } return idx; } } public enum FactionId : byte { CovenantEnforcers = 0, Inheritors = 1, ThornCouncil = 2, } ``` ### 4.4 Extend WorldState Add these fields to `WorldState`: ```csharp // ── Polylines (source of truth for linear features) ───────────────── public List Rivers { get; } = new(); public List Roads { get; } = new(); public List Rails { get; } = new(); // ── Settlements ───────────────────────────────────────────────────── public List Settlements { get; } = new(); // ── Faction influence (3-layer float map) ─────────────────────────── public FactionInfluenceMap? FactionInfluence { get; set; } // ── Habitability score (reusable across stages) ───────────────────── public float[,]? Habitability { get; set; } // ── Encounter density (derived) ───────────────────────────────────── public float[,]? EncounterDensity { get; set; } // ── Hash helpers for new data ─────────────────────────────────────── public ulong HashSettlements() { /* FNV-1a over sorted settlement ids + tile coords */ } public ulong HashPolylines() { /* FNV-1a over all polyline point arrays */ } ``` --- ## 5. The Polyline System (critical — read twice) This is the architectural core of Phase 2+3, extending the seamless zoom model from the original plan (Section 5). **Read Section 5 of the original plan again before implementing this.** ### 5.1 Coordinate space All polyline points are in **world-pixel space**: `(0..32768, 0..32768)`. Convert from world tile coords: `worldPixelX = tileX * 32 + 16` (tile center). ### 5.2 Generation pipeline 1. **Cell-path stage** produces a sequence of world tile coordinates `(tileX, tileY)` — for rivers this comes from drainage simulation, for roads/rail from A* pathfinding. 2. **Convert to control points** in world-pixel space (tile centers). 3. **Catmull-Rom spline** interpolation through control points, producing `SPLINE_SUBDIVISIONS` intermediate points per segment. 4. **Perpendicular noise offset** at each subdivided point, sampled from FastNoiseLite with a per-polyline sub-seed. Amplitude varies by terrain (see constants). Rivers get more meander; roads get less. 5. **RDP simplification** produces a second point list for LOD rendering at low zoom (fewer vertices, same visual shape). 6. **Tile flag derivation**: rasterize the polyline onto the tile grid, setting `HasRiver`/`HasRoad`/`HasRail` + adjacency flags on each tile the polyline crosses and its neighbors. Create `Theriapolis.Core/World/Polylines/PolylineBuilder.cs` with static methods: `CatmullRomSmooth(...)`, `ApplyMeanderNoise(...)`, `RDPSimplify(...)`, `RasterizeToTileFlags(...)`. ### 5.3 Tile flag derivation After each feature type's polylines are finalized: ``` for each polyline P: for each segment (p0, p1) in P.Points: rasterize the segment (Bresenham in world-pixel space scaled to tile coords) for each tile (tx, ty) the segment passes through: set FeatureFlags.HasRiver/HasRoad/HasRail on that tile for each of the 8 neighbors: set FeatureFlags.RiverAdjacent/RailroadAdjacent as appropriate ``` The tile flags are a **derived cache**, not a source of truth. The polyline points are authoritative. --- ## 6. World Generation Stages 10–22 All stages implement `IWorldGenStage` and live in `Theriapolis.Core/World/Generation/Stages/`. Add them to `WorldGenerator.BuildPipeline()` after BiomeAssignStage. ### Stage 10: HydrologyGenStage **Input:** Elevation map, moisture map, biome map. **Output:** `WorldState.Rivers` populated with polylines; `HasRiver` and `RiverAdjacent` flags set; lakes carved into elevation map. Algorithm: 1. **Flow direction map**: For each land tile, compute gradient descent — the neighbor (of 8) with the lowest elevation. Store as a `byte[1024,1024]` direction map. Handle flat areas by finding the nearest downhill tile (BFS from flat regions to their lowest-elevation edge). 2. **Flow accumulation**: Traverse tiles in elevation order (highest first). Each tile sends its accumulated flow (starts at 1) downstream along the flow direction. After all tiles are processed, each tile's accumulation count indicates how many upstream tiles drain through it. 3. **Lake detection**: Tiles where flow direction points to a neighbor of equal or higher elevation are sink tiles. Flood-fill from each sink to find the basin. If basin area >= `LAKE_MIN_AREA`, carve the basin to a flat elevation (the basin's outlet elevation) and set biome to Ocean (inland lakes reuse the water biome). Mark basin tiles with HasRiver flag so they connect visually. Re-run flow direction for filled basins. 4. **River extraction**: Starting from each tile with `accumulation >= RIVER_MIN_FLOW_ACCUM`, trace downstream to the coast or a lake. Merge paths that converge (keep the higher-accumulation trunk). Classify each river by peak flow accumulation: Stream / River / MajorRiver per the threshold constants. 5. **Constraint check**: Verify at least one river with `accumulation >= RIVER_MODERATE_THRESHOLD` exists in each required macro region (Eastern Industrial Belt, Central Grasslands, Subtropical Lowlands). If a region lacks a river, seed an artificial spring at the highest point in that region and re-run accumulation for that drainage. 6. **Elevation carving**: Along each extracted river path, reduce tile elevation by `RIVER_CARVE_DEPTH` to create natural-looking valleys. Clamp to sea level minimum. 7. **Polyline conversion**: Convert each extracted cell-path to a polyline in world-pixel space. Apply Catmull-Rom smoothing and perpendicular meander noise. Set river width: Stream=1 world-px, River=2, MajorRiver=3. Compute RDP simplification. 8. **Tile flag derivation**: Rasterize all river polylines onto the tile grid, setting `HasRiver` on crossed tiles and `RiverAdjacent` on neighbors. 9. **Hash**: FNV-1a over all river polyline points (sorted by river ID). ### Stage 11: RiverMeanderGenStage **Input:** River polylines from stage 10. **Output:** Enhanced polylines with natural meander. This stage applies additional organic shaping that is too expensive for the extraction pass: 1. **Oxbow generation**: For rivers crossing flat terrain (average elevation < 0.45 along a 20-tile window), check if any meander loop brings two points of the polyline within 3 world-pixels of each other. If so, with 30% probability (from RNG_MEANDER), cut the loop into an oxbow lake — a disconnected water body adjacent to the river. 2. **Width variation**: Rivers passing through wetland biomes widen by 1.5x. Rivers in mountain canyons narrow by 0.7x. 3. **Re-derive tile flags** after meander modifications. ### Stage 12: HabitabilityScoreStage **Input:** Full terrain + rivers + lakes. **Output:** `WorldState.Habitability` — `float[1024,1024]` score map. Per-tile formula (from the procgen design doc): ``` habitability = (water_proximity * 3.0) + (flatness * 2.0) + (fertility * 2.0) + (trade_route_potential * 1.5) + (resource_proximity * 1.0) - (elevation_extreme * 2.0) - (hazard_proximity * 1.5) ``` Where: - `water_proximity`: `1.0 / (1 + distance_to_nearest_HasRiver_or_lake_tile)`, computed via BFS from all water tiles outward (fast O(n) wavefront). - `flatness`: `1.0 - local_elevation_variance` in a 5×5 neighborhood. - `fertility`: `moisture * temperature` (good growing conditions). - `trade_route_potential`: At this stage, approximate as centrality — average distance to the 8 nearest land tiles with `habitability_so_far > median`. (This is a chicken-and-egg problem; compute in two passes: first pass without trade_route_potential, second pass adds it.) - `resource_proximity`: `1.0 / (1 + min_distance_to_mountain_or_forest_or_coast)`. - `elevation_extreme`: `max(0, elevation - 0.7) * 4 + max(0, 0.38 - elevation) * 4`. - `hazard_proximity`: `1.0 / (1 + distance_to_nearest_deep_wilderness)` where deep wilderness = tile > 30 tiles from any high-habitability tile. Normalize the result to [0, 1] range. Parallelize with `Parallel.For` over rows. ### Stage 13: NarrativeAnchorPlaceStage **Input:** Habitability map, macro grid, rivers. **Output:** 6 Settlement records added to `WorldState.Settlements` with their `Anchor` field set. **This stage runs BEFORE general settlement placement.** (Hard rule #7.) For each narrative anchor, define constraints as code (not data — these are bespoke): | Anchor | Constraints | |--------|-------------| | **Millhaven** | Tier 3. MacroCell.BiomeType == "temperate_forest" or "temperate_deciduous". Tile has `RiverAdjacent` or adjacent tile has `HasRiver`. At least 40 tiles from any Tier 2. Top 30% habitability within qualifying region. | | **Thornfield** | Tier 2. MacroCell.BiomeType contains "industrial" OR Development == "industrial". Tile has `HasRiver` or `RiverAdjacent`. Top 20% habitability in qualifying region. | | **Fort Dustwall** | Tier 2. MacroCell.BiomeType == "temperate_grassland" or Development == "agricultural" AND near a clade-majority border (within 3 macro cells of a cell with different primary CladeAffinities). Not required to be on rail. Top 30% habitability in qualifying region. | | **The Tangles** | Not a single settlement — tag 2–4 macro cells in the subtropical lowlands region (MacroCell.BiomeType == "subtropical_forest" AND Covenant == "weak" or "nominal"). Place 2–4 Tier 4 settlements within those cells, one tagged as Thornback Hollow. | | **Sanctum Fidelis** | Tier 1. Central-eastern continent (macro X in [12,22], Y in [10,22]). Adjacent to a `HasRiver` tile with `FlowAccumulation >= RIVER_MAJOR_THRESHOLD`. Top 5% habitability globally. | | **Heartstone** | Tier 2. MacroCell.BiomeType contains "mountain". Elevation >= 0.65. Not adjacent to any rail line (enforced after rail gen — place it, then verify). Top 20% habitability in mountain region. | **Placement algorithm:** 1. For each anchor (in order: Sanctum Fidelis first — it's the most constrained, then Heartstone, Thornfield, Fort Dustwall, Millhaven, The Tangles last): a. Collect all tiles matching the anchor's constraints. b. Score them by habitability. c. Filter out any tile within `ANCHOR_MIN_DIST` of an already-placed anchor. d. Place at the highest-scoring remaining tile. e. If no tile qualifies, relax constraints one at a time (drop the river adjacency requirement first, then widen the macro cell search) and log a warning. 2. For The Tangles: identify qualifying macro cells, then run the Tier 4 placement sub-routine within those cells only. ### Stage 14: SettlementPlaceStage **Input:** Habitability map, narrative anchors already placed. **Output:** Remaining settlements added to `WorldState.Settlements`. Algorithm: 1. Create a sorted list of all land tiles by habitability (descending). 2. For each tier (Tier 1 through Tier 5 — Tier 1 is already placed as Sanctum Fidelis; this stage places Tier 2–5): a. Target count is drawn from RNG_SETTLE within `[TierN_MIN, TierN_MAX]`. b. Walk the sorted habitability list. For each candidate tile: - Skip if within `SETTLE_MIN_DIST_TIERn` of any already-placed settlement of the same or higher tier. - Skip if tile is ocean. - For Tier 2: require river or coast adjacency. - For Tier 3: require within 60 tiles of a Tier 2 settlement OR on a tile with `HasRiver`. - For Tier 5 (PoIs): **invert** — require LOW habitability (bottom 40%). Place in mountains, deep forest, swamps, tundra. - Place settlement, set IsSettlement flag on tile + 3×3 neighborhood. c. Stop when target count is reached or candidates exhausted. 3. Assign sequential IDs. Narrative anchors keep their IDs from stage 13. ### Stage 15: SettlementAttributesStage **Input:** Placed settlements, macro grid, biome map, rivers. **Output:** Settlement attribute fields populated. For each settlement: 1. **Name**: Generate from a syllable table keyed by macro region biome type and clade affinity. (Create a simple name generator in `Theriapolis.Core/Util/NameGenerator.cs` — 3-5 syllable tables, combine prefix + root + suffix. Narrative anchors keep their canonical names.) 2. **Population**: Random within tier range (from procgen doc), biased by habitability score. 3. **Economy**: Derived from surrounding terrain in a 10-tile radius: - Many mountain tiles → Mining - Many forest tiles → Manufacturing (timber) - Many grassland/farmable tiles → Farming - Coast adjacency → Fishing or Trade - High trade_route_potential → Trade - MacroCell.Development == "military" → Military 4. **Governance**: Weighted random from macro cell clade affinity: - Canid-heavy → Mayor or MilitaryCommandant - Cervid-heavy → Council or ClanElder - Bovid-heavy → Council - Mixed/Industrial → Mayor or Corporate - Frontier/low covenant → Anarchic 5. **CladeRatios**: Base from MacroCell.CladeAffinities with ±15% noise (from RNG_SETTLE_ATTR). 6. **HybridPct**: 0.5–5% normally, 10–30% in Tangles-tagged cells or if MacroCell.Covenant == "nominal" or "weak". 7. **WealthLevel**: Derived from habitability + trade_route_potential. 8. **ScentProfile**: Derived from economy type (see procgen doc). 9. **IsOnRiver**: Check if any tile within 2 tiles of settlement has HasRiver. ### Stage 16: RailNetworkGenStage **Input:** Settlements (Tier 1–2), elevation map, rivers. **Output:** `WorldState.Rails` populated with polylines; `HasRail` and `RailroadAdjacent` flags set. Algorithm: 1. **Identify rail nodes**: Sanctum Fidelis (hub) + all Tier 2 settlements except Heartstone (which is explicitly NOT rail-connected per the design). 2. **A\* pathfinding** from the capital to each Tier 2 node: - Cost function per the original plan + Addendum A §2: ``` cost(tile) = base_terrain_cost(elevation, biome) + (if HasRiver: INFINITY) + (if RiverAdjacent AND direction_parallel: INFINITY) + (if RiverAdjacent AND direction_perpendicular: RAIL_BRIDGE_COST) ``` - `base_terrain_cost`: proportional to elevation (mountains are expensive for rail) + biome modifier (wetland expensive, grassland cheap). - Direction tracking: A* nodes carry the entry direction (which of 8 neighbors they came from). Parallel check = angle between entry direction and river flow direction is ≤ 45°. 3. **Transcontinental line**: Find the easternmost coastal Tier 2/3 settlement and the westernmost Tier 2 settlement. A* between them using the same cost function but with a strong westward bias (add a small bonus for tiles further west to prefer direct routing). 4. **Polyline conversion**: Convert each A* path to a polyline. Apply Catmull-Rom smoothing with LOW meander amplitude (rail is engineered). Width = 1.5 world-pixels. 5. **Tile flag derivation**: Set `HasRail` and `RailroadAdjacent` on the tile grid. 6. **Set HasRailStation** on each rail-connected settlement. 7. **Heartstone check**: Verify Heartstone's tile is NOT within 3 tiles of any rail polyline. If it is, log a warning (anchor placement should have prevented this, but verify). ### Stage 17: RoadNetworkGenStage **Input:** Settlements (Tier 1–3), elevation map, rivers, rail. **Output:** `WorldState.Roads` populated with polylines; `HasRoad` flags set. This is the most complex stage. Follow Addendum A §2 exactly. Algorithm: 1. **Build settlement graph**: All Tier 1–3 settlements are nodes. Edge weight = A* shortest path cost through terrain. 2. **Minimum Spanning Tree**: Prim's or Kruskal's on the settlement graph. This ensures all Tier 1–3 settlements are connected. 3. **Add shortcuts**: Sort non-MST edges by weight. Add the cheapest `ROAD_SHORTCUT_FRACTION * |MST_edges|` edges (rounded up). These provide alternate routes. 4. **A\* routing** for each edge, using the full Addendum A §2 cost function: ``` cost(tile) = base_terrain_cost + (if HasRiver: INFINITY) + (if HasRail: INFINITY) + (if RiverAdjacent AND direction_parallel: INFINITY) + (if RailroadAdjacent AND direction_parallel: INFINITY) + (if RiverAdjacent AND direction_perpendicular: BRIDGE_COST) + (if RailroadAdjacent AND direction_perpendicular: CROSSING_COST) + SETBACK_COST_SCALE / max(1, distance_to_nearest_river_or_rail) ``` The setback cost gently pushes roads away from rivers/rail while keeping them in the same valley (see Addendum A, "The Setback Rule"). 5. **Road classification** by connected tier: - Tier 1↔Tier 1 or Tier 1↔Tier 2 = Highway - Tier 2↔Tier 2 or Tier 2↔Tier 3 = PostRoad - Tier 3↔Tier 3 = DirtRoad 6. **Tier 4 connections**: For each Tier 4 settlement, find the nearest Tier 3+ settlement and route a Footpath/DirtRoad to it. Use the same cost function but with relaxed exclusion (footpaths can be narrower, so reduce setback). 7. **Polyline conversion**: Same as rail but with slightly higher meander amplitude (roads are less rigid than rail). Width by type: Footpath=0.5, DirtRoad=1, PostRoad=1.5, Highway=2 world-pixels. 8. **Tile flag derivation**: Set `HasRoad` flags. ### Stage 18: TradeRouteGenStage **Input:** Settlements with economies, road/rail network. **Output:** Trade route metadata on settlements (WealthLevel adjustments). This is a light overlay stage, not a visual feature: 1. For each settlement, determine produced goods (from Economy enum) and demanded goods (everything it doesn't produce locally). 2. For each (producer, consumer) pair connected by road/rail, compute a trade score = `supply * demand / transport_cost`. 3. Settlements on high-trade-score routes get a WealthLevel boost. 4. Tag road polylines that carry trade (optional metadata for future use: merchant caravan spawns in Phase 5). ### Stage 19: FactionInfluenceGenStage **Input:** Settlements, macro grid, biome map. **Output:** `WorldState.FactionInfluence` — 3-layer influence map. Algorithm per faction: **Covenant Enforcers (faction 0):** - Seed points: Sanctum Fidelis (strength 1.0), Tier 2 cities (0.7), Tier 3 towns in macro cells with Covenant == "strong" (0.4). - Falloff: `strength * max(0, 1 - distance / FACTION_INFLUENCE_RADIUS)`. - Boost in macro cells with Covenant == "strong" or "moderate" (+0.2). **Inheritors (faction 1):** - Seed points: settlements in macro cells where CladeAffinities includes predator clades (canid, felid, ursid) AND Covenant == "weak" or "nominal" (strength 0.6). Frontier settlements (Development == "frontier") get 0.4. - Anti-correlated with Enforcer influence: where Enforcers are strong, Inheritors are suppressed. **Thorn Council (faction 2):** - Seed points: settlements in macro cells where CladeAffinities includes prey clades (cervid, bovid, leporid) (strength 0.5). Progressive urban centers (Development == "urban" or "industrial") get 0.4. - Moderate presence in Tangles region. For each faction, BFS outward from seed points with distance decay. Store result in the 3D influence array. Normalize so max influence per faction is 1.0. ### Stage 20: PoIPlacementStage **Input:** Habitability map, settlements, biome map, macro grid. **Output:** Tier 5 PoI settlements added to `WorldState.Settlements` with `IsPoi` flag set. PoIs are placed in LOW habitability areas (bottom 40%). For each PoI: 1. Select a tile from the low-habitability pool. 2. Enforce `POI_MIN_DIST_FROM_SETTLE` from any existing settlement. 3. Enforce `POI_MIN_DIST_FROM_POI` from any other PoI. 4. Tag with a PoI type based on surrounding terrain: - Mountain biome → AbandonedMine or NaturalCave - Forest biome → CultDen or OvergrownSettlement - Tundra/boreal → ImperiumRuin - Wetland/subtropical → NaturalCave or CultDen 5. Place `SETTLE_TIER5_MIN` to `SETTLE_TIER5_MAX` PoIs (count from RNG_POI). ### Stage 21: EncounterDensityGenStage **Input:** Settlements, terrain, faction influence, roads. **Output:** `WorldState.EncounterDensity` — `float[1024,1024]`. Per-tile density: ``` density = base_biome_density * distance_from_settlement_factor // closer = safer * (1.0 - road_proximity * 0.5) // on-road is safer * macro_hostility_factor // frontier = more hostile * (1.0 - enforcer_influence * 0.6) // enforcer presence = safer ``` Normalize to [0, 1]. This map is consumed by the encounter spawner in Phase 5. ### Stage 22: ValidationPassStage **Input:** Complete world state with all features. **Output:** Validation results logged; throws if critical violations found. Checks: 1. **Border organics (Addendum A §1)**: Re-run the straight-line detector from BorderDistortionGenStage. Expect 0 violations (stages 10+ should not have introduced new straight borders). 2. **Linear feature exclusion (Addendum A §2)**: For every tile with >1 linear feature, verify they are crossing (angle diff >= 60°) or within a settlement. Log and count violations. **Assert 0 violations.** 3. **River drainage**: Every river terminates at the ocean, a lake, or a wetland biome tile. No rivers that dead-end on dry land. 4. **Settlement reachability**: BFS from Sanctum Fidelis along tiles with `HasRoad`. All Tier 1–3 settlements must be reachable. Warn (don't fail) if Tier 4 settlements are unreachable. 5. **Narrative anchor constraints**: Re-verify each anchor's placement constraints are still satisfied after all generation is complete (e.g., Heartstone is not adjacent to rail after rail was generated). 6. **No overlapping settlements**: No two settlements share the same tile. --- ## 7. A* Pathfinder Create `Theriapolis.Core/Util/AStarPathfinder.cs`. This is used by RailNetworkGen, RoadNetworkGen, and later phases. It needs to be efficient on a 1024×1024 grid. ```csharp public static class AStarPathfinder { /// /// Find the lowest-cost path from (sx,sy) to (gx,gy) on the world grid. /// costFn(fromX, fromY, toX, toY) returns the cost of moving between /// adjacent tiles, or float.PositiveInfinity for impassable. /// Returns null if no path exists. /// public static List<(int X, int Y)>? FindPath( int sx, int sy, int gx, int gy, Func costFn, int width = C.WORLD_WIDTH_TILES, int height = C.WORLD_HEIGHT_TILES); } ``` Implementation notes: - Use a **binary heap** priority queue (implement in `Theriapolis.Core/Util/BinaryHeap.cs`). The standard `PriorityQueue` in .NET 6+ lacks decrease-key, so use a custom min-heap with handle-based decrease-key. - 8-directional movement (diagonals cost sqrt(2) * tile cost). - Heuristic: octile distance (max(|dx|,|dy|) + (sqrt(2)-1) * min(|dx|,|dy|)). - Allocate the open/closed arrays once and reuse via a generation counter to avoid clearing (the grid is 1M tiles; clearing per query is expensive if many paths are computed). --- ## 8. Rendering Updates ### 8.1 LineFeatureRenderer Create `Theriapolis.Game/Rendering/LineFeatureRenderer.cs`. Draws polylines in world-pixel space at the current camera zoom. This is the shared renderer for rivers, roads, and rail (original plan Section 7.4). Implementation: - Input: list of polylines, Camera2D, SpriteBatch. - For each polyline, use the `SimplifiedPoints` at low zoom (zoom < 0.15), `Points` at high zoom. - Render as **screen-space thick lines** (build a quad strip along the polyline with perpendicular width). Use a 1×1 white pixel texture and tint with the feature color. - Colors: River = blue (#4488CC), Road = brown (#8B6914), Rail = dark gray (#444444). - Width scales with zoom: `screenWidth = worldPixelWidth * zoom`. Clamp minimum to 1 screen pixel so lines are always visible. - Frustum cull: skip polyline segments whose bounding box doesn't overlap the camera's visible rect. ### 8.2 Settlement rendering Update `WorldMapRenderer` to draw settlement icons after terrain: - Tier 1: 8×8 gold diamond - Tier 2: 6×6 white square - Tier 3: 4×4 light gray circle (approximated with a small square) - Tier 4: 3×3 brown dot - Tier 5 (PoIs): 2×2 red dot (only visible above zoom threshold 0.3) Generate these as runtime textures in `TileAtlas` (same pattern as biome tiles — no external art files needed for placeholder). ### 8.3 Settlement labels Above zoom threshold 0.2, draw settlement names as `SpriteFont` text centered above the settlement icon. Use Myra's default font or MonoGame's built-in `SpriteFont` (already available in the project). Tier 1–2 labels always show; Tier 3 labels show above zoom 0.3; Tier 4+ labels show above zoom 0.5. ### 8.4 WorldMapScreen update In `WorldMapScreen.Draw()`, after drawing terrain: ``` 1. worldMapRenderer.DrawTerrain(...) // existing 2. lineFeatureRenderer.DrawRivers(...) // NEW 3. lineFeatureRenderer.DrawRails(...) // NEW 4. lineFeatureRenderer.DrawRoads(...) // NEW 5. worldMapRenderer.DrawSettlements(...) // NEW 6. worldMapRenderer.DrawLabels(...) // NEW ``` --- ## 9. Tools Update Update `worldgen-dump` to render the new features on the exported PNG: - Rivers: draw as blue lines (width proportional to classification). - Rail: draw as dark gray lines. - Roads: draw as brown lines. - Settlements: draw as colored dots (gold for Tier 1, white for Tier 2, gray for Tier 3, small brown for Tier 4). - PoIs: draw as small red dots. - Narrative anchors: draw with a named label. Add a new command `settlement-report --seed ` that runs the full pipeline and prints a table of all settlements with their tier, name, anchor tag, coordinates, economy, governance, and whether they have rail access. --- ## 10. Data Files ### 10.1 factions.json Create `Content/Data/factions.json`: ```json [ { "id": "covenant_enforcers", "display_name": "Covenant Enforcers", "color": "#2266AA", "seed_covenant_levels": ["strong", "moderate"], "seed_development_levels": ["urban", "industrial"], "base_strength": 0.7, "affinity_clades": [] }, { "id": "inheritors", "display_name": "The Inheritors", "color": "#AA2222", "seed_covenant_levels": ["weak", "nominal"], "seed_development_levels": ["frontier", "wilderness"], "base_strength": 0.6, "affinity_clades": ["canid", "felid", "ursid"] }, { "id": "thorn_council", "display_name": "Thorn Council", "color": "#22AA44", "seed_covenant_levels": ["moderate", "weak"], "seed_development_levels": ["urban", "agricultural"], "base_strength": 0.5, "affinity_clades": ["cervid", "bovid", "leporid"] } ] ``` Create a `FactionDef` record in `Theriapolis.Core/Data/FactionDef.cs` and load it in `ContentLoader`. ### 10.2 No changes to macro_template.json or biomes.json The existing data files are sufficient. Settlement names are procedurally generated, not loaded from JSON (except narrative anchor names which are hardcoded). --- ## 11. Tests All new tests go in `Theriapolis.Tests/`. Run with `dotnet test`. ### 11.1 Determinism tests (extend existing) - **Full pipeline determinism**: Seed `0xCAFEBABE` run twice. Hash settlements (sorted by ID: tier + tileX + tileY), hash all polyline points (sorted by type then ID), hash faction influence map. Assert identical. - **Different seeds diverge**: Seeds `0xCAFEBABE` and `0xDEADFACE` produce different settlement locations and river paths. ### 11.2 Hydrology tests - **Rivers drain to water**: For every river polyline, the final point is within 2 tiles of an ocean or lake tile. Test on 5 seeds. - **Required rivers exist**: Eastern Industrial Belt, Central Grasslands, and Subtropical Lowlands each contain at least one river with `FlowAccumulation >= RIVER_MODERATE_THRESHOLD`. Test on 10 seeds. - **No dry-land dead ends**: No river polyline's final point is on a tile with elevation > SeaLevel + 0.05 that isn't a lake. ### 11.3 Settlement tests - **Narrative anchor placement**: On 100 random seeds, all 6 narrative anchors are placed successfully (no relaxation warnings). Each satisfies its constraints. - **Tier counts**: For 10 seeds, settlement counts per tier fall within the `[MIN, MAX]` ranges. - **Minimum distance**: No two settlements of the same or higher tier are closer than `SETTLE_MIN_DIST_TIERn`. - **Sanctum Fidelis is on a major river**: Verify on 20 seeds. - **Heartstone is not on rail**: Verify on 20 seeds. - **Millhaven is near a river**: Verify within 2 tiles of a HasRiver tile on 20 seeds. - **All Tier 1–3 reachable by road**: BFS from Sanctum Fidelis along HasRoad tiles reaches all Tier 1–3 settlements. Test on 10 seeds. ### 11.4 Linear feature exclusion tests (Addendum A §2) - **Zero parallel violations**: Run ValidationPassStage on 10 seeds. Assert 0 tiles with parallel linear features outside settlements. - **Crossings are near-perpendicular**: For every tile with >1 linear feature (outside settlements), verify the angle between features is >= 60°. - **Rivers have exclusion zones**: For every tile with `HasRail` or `HasRoad`, verify it is not `RiverAdjacent` with a parallel direction (or is a crossing tile within a settlement). ### 11.5 Road network tests - **MST connectivity**: All Tier 1–3 settlements are in the same connected component of the road graph. - **Shortcut count**: Number of road edges beyond the MST is within `[0.2, 0.4] * |MST_edges|`. ### 11.6 Architecture test (existing — no change needed) The existing `CoreNoDependencyTests` already ensures Core has no MonoGame reference. No new tests needed for this. --- ## 12. Implementation Order Sonnet should implement in this order, building up iteratively. Each step should compile and the existing tests should continue to pass. ### Step 1: Foundation types - [ ] `Vec2` struct - [ ] `Polyline` class and enums - [ ] `Settlement` class and enums - [ ] `FactionInfluenceMap` class - [ ] Extend `WorldState` with Rivers, Roads, Rails, Settlements, FactionInfluence, Habitability, EncounterDensity lists/arrays - [ ] Add new constants to `Constants.cs` - [ ] `BinaryHeap` priority queue - [ ] `AStarPathfinder` - [ ] `PolylineBuilder` (CatmullRom, MeanderNoise, RDP, RasterizeToFlags) - [ ] `NameGenerator` (simple syllable combiner) - [ ] Hash methods on WorldState for new data ### Step 2: Hydrology (stages 10–11) - [ ] `HydrologyGenStage` — flow direction, accumulation, lakes, river extraction, polyline conversion, tile flags - [ ] `RiverMeanderGenStage` — oxbows, width variation - [ ] Add stages to `WorldGenerator.BuildPipeline()` - [ ] Hydrology tests ### Step 3: Settlements (stages 12–15) - [ ] `HabitabilityScoreStage` - [ ] `NarrativeAnchorPlaceStage` - [ ] `SettlementPlaceStage` - [ ] `SettlementAttributesStage` - [ ] `FactionDef` + `factions.json` + content loading - [ ] Settlement placement tests - [ ] Narrative anchor tests (100-seed sweep) ### Step 4: Infrastructure (stages 16–18) - [ ] `RailNetworkGenStage` - [ ] `RoadNetworkGenStage` - [ ] `TradeRouteGenStage` - [ ] Linear feature exclusion tests ### Step 5: Factions, PoIs, density, validation (stages 19–22) - [ ] `FactionInfluenceGenStage` - [ ] `PoIPlacementStage` - [ ] `EncounterDensityGenStage` - [ ] `ValidationPassStage` - [ ] Full determinism tests - [ ] Validation pass tests ### Step 6: Rendering - [ ] `LineFeatureRenderer` - [ ] Settlement icons in `TileAtlas` - [ ] Settlement rendering in `WorldMapRenderer` - [ ] Label rendering - [ ] `WorldMapScreen` draw order update ### Step 7: Tools - [ ] Update `worldgen-dump` to render rivers, roads, rail, settlements - [ ] Add `settlement-report` command --- ## 13. Acceptance Criteria for the Phase 2+3 Branch 1. `dotnet build` succeeds. 2. `dotnet test` passes all tests (both old Phase 0+1 tests AND all new tests). 3. Running `Theriapolis.Desktop`: - Click "New World", enter or accept a seed. - World map shows colored biome tiles (as before) PLUS: - Blue river lines flowing from mountains to coast/lakes. - Dark gray rail lines connecting the capital to major cities. - Brown road lines connecting all Tier 1–3 settlements. - Gold/white/gray/brown settlement dots at correct positions. - Settlement names as labels when zoomed in. - Pan and zoom work smoothly. Lines are visible at all zoom levels. - Rivers, roads, and rail are clearly separated (no visual overlap except at crossing points and within settlements). 4. `dotnet run --project Theriapolis.Tools -- worldgen-dump --seed 12345 --out world.png` produces a PNG with all features overlaid. 5. `dotnet run --project Theriapolis.Tools -- settlement-report --seed 12345` prints a settlement table showing all narrative anchors placed correctly. 6. On 10 random seeds tested manually via the tool, the world looks plausible: rivers flow downhill, settlements cluster near water and flat terrain, roads follow valleys, rail connects major cities, narrative anchors are in their correct macro regions. --- ## 14. Performance Notes - The A* pathfinder is likely the hot spot. Rail needs ~5 paths, roads need ~30–50. Each path on a 1024×1024 grid takes 50–200ms with a good heap. Total: 2–10 seconds. Acceptable within the 60-second budget. - Flow accumulation over 1M tiles is O(n log n) for the sort + O(n) for the sweep. ~1–2 seconds. Fine. - Habitability BFS is O(n). Sub-second. - If total generation exceeds 30 seconds, profile with BenchmarkDotNet before optimizing. The most likely optimization: parallelize independent A* paths (rail paths are independent of each other; road MST edges are independent once computed). - Do NOT speculatively parallelize A* itself. Single-threaded A* with a good heap is faster than thread-synchronization overhead for individual pathfinding queries. --- ## 15. What NOT to Build Do not implement any of the following in this phase: - Tactical map / chunk streaming (Phase 4) - Player entity or movement (Phase 4) - Combat (Phase 5) - Quest engine (Phase 6) - Dungeon room templates / interior generation (Phase 7) - Weather simulation (Phase 8) - Any UI panels (inventory, character sheet, dialog) — those are Phase 5+ - Save/load system (Phase 4+) - Touch input (Phase 9) Stick to world generation stages 10–22, the A* pathfinder, the polyline system, rendering the features on the world map, and testing. --- *Theriapolis Implementation Plan v2.0 — Phase 2+3 — 2026-04-12* *Authored by ENI for LO, for handoff to Claude Sonnet*