Files
TheriapolisV3/theriapolis-rpg-implementation-plan-phase2-3.md
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

46 KiB
Raw Permalink Blame History

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 24, 57)
  • 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 13 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.

// ── 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:

/// <summary>
/// A polyline in world-pixel space (0..32768 on each axis).
/// Source of truth for rivers, roads, and rail. Per-tile flags are derived.
/// </summary>
public sealed class Polyline
{
    public PolylineType Type { get; init; }         // River, Road, Rail
    public int          Id   { get; init; }         // unique per type
    public List<Vec2>   Points { get; }             // control points in world-pixel space
    public List<Vec2>?  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:

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:

public sealed class Settlement
{
    public int           Id          { get; init; }
    public string        Name        { get; set; } = "";
    public int           Tier        { get; init; }      // 15
    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<string>();
    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:

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:

// ── Polylines (source of truth for linear features) ─────────────────
public List<Polyline> Rivers { get; } = new();
public List<Polyline> Roads  { get; } = new();
public List<Polyline> Rails  { get; } = new();

// ── Settlements ─────────────────────────────────────────────────────
public List<Settlement> 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 1022

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.Habitabilityfloat[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 24 macro cells in the subtropical lowlands region (MacroCell.BiomeType == "subtropical_forest" AND Covenant == "weak" or "nominal"). Place 24 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 25): 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.55% normally, 1030% 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 12), 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 13), 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 13 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 13 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.EncounterDensityfloat[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 13 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.

public static class AStarPathfinder
{
    /// <summary>
    /// 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.
    /// </summary>
    public static List<(int X, int Y)>? FindPath(
        int sx, int sy, int gx, int gy,
        Func<int, int, int, int, float> 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<T,P> 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 12 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 <n> 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:

[
  {
    "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 13 reachable by road: BFS from Sanctum Fidelis along HasRoad tiles reaches all Tier 13 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 13 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<T> priority queue
  • AStarPathfinder
  • PolylineBuilder (CatmullRom, MeanderNoise, RDP, RasterizeToFlags)
  • NameGenerator (simple syllable combiner)
  • Hash methods on WorldState for new data

Step 2: Hydrology (stages 1011)

  • 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 1215)

  • HabitabilityScoreStage
  • NarrativeAnchorPlaceStage
  • SettlementPlaceStage
  • SettlementAttributesStage
  • FactionDef + factions.json + content loading
  • Settlement placement tests
  • Narrative anchor tests (100-seed sweep)

Step 4: Infrastructure (stages 1618)

  • RailNetworkGenStage
  • RoadNetworkGenStage
  • TradeRouteGenStage
  • Linear feature exclusion tests

Step 5: Factions, PoIs, density, validation (stages 1922)

  • 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 13 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 ~3050. Each path on a 1024×1024 grid takes 50200ms with a good heap. Total: 210 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. ~12 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 1022, 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