b451f83174
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>
1120 lines
46 KiB
Markdown
1120 lines
46 KiB
Markdown
# 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
|
||
/// <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; } // 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<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 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
|
||
{
|
||
/// <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 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 <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 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<T>` 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*
|