Files
TheriapolisV3/theriapolis-rpg-implementation-plan-phase2-3.md
T

1120 lines
46 KiB
Markdown
Raw Normal View 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.
```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
/// <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`:
```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; } // 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`:
```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<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.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 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.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 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.
```csharp
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`:
```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*