Initial commit: Theriapolis baseline at port/godot branch point

Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
using Theriapolis.Core.Util;
namespace Theriapolis.Core.World;
/// <summary>
/// A bridge where a road or rail polyline crosses a river.
/// Endpoints are in world-pixel space and follow the actual road polyline,
/// so the deck visually matches the road at the crossing regardless of meander.
/// </summary>
public readonly struct Bridge
{
public readonly Vec2 Start;
public readonly Vec2 End;
public readonly int RoadId;
public Bridge(Vec2 start, Vec2 end, int roadId)
{
Start = start;
End = end;
RoadId = roadId;
}
/// <summary>Center of the deck (midpoint of StartEnd).</summary>
public float WorldPixelX => (Start.X + End.X) * 0.5f;
public float WorldPixelY => (Start.Y + End.Y) * 0.5f;
/// <summary>Road direction angle (radians) from Start to End.</summary>
public float RoadAngle => MathF.Atan2(End.Y - Start.Y, End.X - Start.X);
}
+123
View File
@@ -0,0 +1,123 @@
using Theriapolis.Core.World.Polylines;
namespace Theriapolis.Core.World;
/// <summary>
/// Phase 5 M5: per-chunk threat-tier index. Drives which template each
/// <see cref="Tactical.SpawnKind"/> instantiates: zone 0 = safest (footpads,
/// pups), zone 4 = deepest wilds (captains, dire wolves, brown bears).
///
/// Computed once per chunk at instantiation time from biome + distance to
/// player-start + distance to nearest road + distance to nearest settlement.
/// Stored on <see cref="Tactical.TacticalChunk.DangerZone"/> and folded into
/// the chunk's hash so determinism tests catch any formula drift.
/// </summary>
public static class DangerZone
{
/// <summary>
/// Compute the danger zone for a given world-tile center. Pass the
/// world's player-start tile so distance-from-start is meaningful even
/// before the actual <see cref="Entities.PlayerActor"/> spawns.
/// </summary>
public static int Compute(int worldTileX, int worldTileY, WorldState world, int startTileX, int startTileY)
{
int zone = 0;
zone += DistanceFromStartZone(worldTileX, worldTileY, startTileX, startTileY);
zone += DistanceFromRoadZone(worldTileX, worldTileY, world);
zone += DistanceFromSettlementZone(worldTileX, worldTileY, world);
zone += BiomeDangerBonus(in world.TileAt(
System.Math.Clamp(worldTileX, 0, C.WORLD_WIDTH_TILES - 1),
System.Math.Clamp(worldTileY, 0, C.WORLD_HEIGHT_TILES - 1)));
return System.Math.Clamp(zone, C.DANGER_ZONE_MIN, C.DANGER_ZONE_MAX);
}
/// <summary>
/// Convenience: pick the player-start tile from the world (Tier-1
/// settlement if any, else map centre) and compute the zone.
/// </summary>
public static int Compute(int worldTileX, int worldTileY, WorldState world)
{
var (sx, sy) = ResolveStartTile(world);
return Compute(worldTileX, worldTileY, world, sx, sy);
}
private static int DistanceFromStartZone(int x, int y, int startX, int startY)
{
int dist = ChebyshevDistance(x, y, startX, startY);
return dist / C.DANGER_DIST_FROM_START_PER_ZONE;
}
private static int DistanceFromRoadZone(int worldTileX, int worldTileY, WorldState world)
{
if (world.Roads.Count == 0) return 1; // no roads at all → treat as remote
int worldPxX = worldTileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS / 2;
int worldPxY = worldTileY * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS / 2;
float minDistSq = float.MaxValue;
foreach (var road in world.Roads)
{
foreach (var pt in road.Points)
{
float dx = pt.X - worldPxX;
float dy = pt.Y - worldPxY;
float d2 = dx * dx + dy * dy;
if (d2 < minDistSq) minDistSq = d2;
}
}
float distTiles = (float)System.Math.Sqrt(minDistSq) / C.WORLD_TILE_PIXELS;
return distTiles > C.DANGER_DIST_FROM_ROAD_THRESHOLD ? 1 : 0;
}
private static int DistanceFromSettlementZone(int worldTileX, int worldTileY, WorldState world)
{
if (world.Settlements.Count == 0) return 1;
int minDistTiles = int.MaxValue;
foreach (var s in world.Settlements)
{
int d = ChebyshevDistance(worldTileX, worldTileY, s.TileX, s.TileY);
if (d < minDistTiles) minDistTiles = d;
}
return minDistTiles > C.DANGER_DIST_FROM_SETTLE_THRESHOLD ? 1 : 0;
}
private static int BiomeDangerBonus(in WorldTile tile) => tile.Biome switch
{
// Settled / safe biomes
BiomeId.TemperateGrassland => 0,
BiomeId.TemperateDeciduous => 0,
BiomeId.RiverValley => 0,
BiomeId.Beach => 0,
BiomeId.Coastal => 0,
BiomeId.Ocean => 0, // not walkable but won't generate spawns either
// Mid-danger biomes
BiomeId.Boreal => 1,
BiomeId.Wetland => 1,
BiomeId.Tundra => 1,
BiomeId.SubtropicalForest => 1,
BiomeId.Scrubland => 1,
BiomeId.MountainForested => 1,
BiomeId.ForestEdge => 1,
BiomeId.Foothills => 1,
BiomeId.MarshEdge => 1,
BiomeId.Mangrove => 1,
// High-danger biomes
BiomeId.MountainAlpine => 2,
BiomeId.DesertCold => 2,
BiomeId.TidalFlat => 1,
BiomeId.Cliff => 2,
_ => 1,
};
private static int ChebyshevDistance(int x1, int y1, int x2, int y2)
=> System.Math.Max(System.Math.Abs(x1 - x2), System.Math.Abs(y1 - y2));
/// <summary>Tier-1 settlement tile if available, else map centre.</summary>
public static (int x, int y) ResolveStartTile(WorldState world)
{
foreach (var s in world.Settlements)
{
if (s.Tier == 1 && !s.IsPoi)
return (s.TileX, s.TileY);
}
return (C.WORLD_WIDTH_TILES / 2, C.WORLD_HEIGHT_TILES / 2);
}
}
@@ -0,0 +1,50 @@
namespace Theriapolis.Core.World;
public enum FactionId : byte
{
CovenantEnforcers = 0,
Inheritors = 1,
ThornCouncil = 2,
}
/// <summary>
/// Per-tile influence map for the three primary factions.
/// Influence[factionIndex, x, y] ∈ [0, 1].
/// </summary>
public sealed class FactionInfluenceMap
{
public const int FactionCount = 3;
// Flat array: [faction * W * H + y * W + x]
private readonly float[] _data;
private const int W = C.WORLD_WIDTH_TILES;
private const int H = C.WORLD_HEIGHT_TILES;
public FactionInfluenceMap()
{
_data = new float[FactionCount * W * H];
}
public float Get(int faction, int x, int y) => _data[faction * W * H + y * W + x];
public void Set(int faction, int x, int y, float value) =>
_data[faction * W * H + y * W + x] = value;
public void Add(int faction, int x, int y, float delta)
{
int idx = faction * W * H + y * W + x;
_data[idx] = Math.Max(0f, Math.Min(1f, _data[idx] + delta));
}
/// <summary>Returns the index of the faction with highest influence at this tile, or -1 if all zero.</summary>
public int DominantFaction(int x, int y)
{
float best = 0f;
int idx = -1;
for (int f = 0; f < FactionCount; f++)
{
float v = Get(f, x, y);
if (v > best) { best = v; idx = f; }
}
return idx;
}
}
@@ -0,0 +1,12 @@
namespace Theriapolis.Core.World.Generation;
/// <summary>
/// A single stage in the deterministic world-generation pipeline.
/// Each stage is a pure function of (WorldGenContext) given its sub-seed.
/// Same seed + same stage order = byte-identical world every run.
/// </summary>
public interface IWorldGenStage
{
string Name { get; }
void Run(WorldGenContext ctx);
}
@@ -0,0 +1,81 @@
namespace Theriapolis.Core.World.Generation;
/// <summary>
/// Computes 8-connected land-component IDs and sizes via flood fill. Settlement
/// placement uses this to confine settlements to the main landmass — roads
/// can't cross ocean, so a settlement on a disconnected island would either be
/// unreachable or get a sea-crossing straight-line connector stub from
/// <c>EnsureSettlementConnectivity</c>. 8-connected matches A*'s movement
/// model so "reachable by road" and "same component" coincide.
/// </summary>
internal static class LandmassMap
{
private const int W = C.WORLD_WIDTH_TILES;
private const int H = C.WORLD_HEIGHT_TILES;
private static readonly (int dx, int dy)[] Neighbors =
{
( 0,-1), ( 1,-1), ( 1, 0), ( 1, 1),
( 0, 1), (-1, 1), (-1, 0), (-1,-1),
};
/// <summary>
/// Returns a pair of arrays: <c>componentId[x,y]</c> gives the 8-connected
/// land component ID at (x,y), or -1 if (x,y) is ocean; <c>componentSize[id]</c>
/// gives the tile count of component <paramref name="id"/>.
/// </summary>
public static (int[,] componentId, int[] componentSize) Compute(WorldState world)
{
var compId = new int[W, H];
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
compId[x, y] = -1;
var sizes = new List<int>();
var queue = new Queue<(int x, int y)>();
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
{
if (compId[x, y] != -1) continue;
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
int id = sizes.Count;
int size = 0;
compId[x, y] = id;
queue.Enqueue((x, y));
while (queue.Count > 0)
{
var (cx, cy) = queue.Dequeue();
size++;
foreach (var (dx, dy) in Neighbors)
{
int nx = cx + dx, ny = cy + dy;
if ((uint)nx >= W || (uint)ny >= H) continue;
if (compId[nx, ny] != -1) continue;
if (world.Tiles[nx, ny].Biome == BiomeId.Ocean) continue;
compId[nx, ny] = id;
queue.Enqueue((nx, ny));
}
}
sizes.Add(size);
}
return (compId, sizes.ToArray());
}
/// <summary>
/// Returns the component ID of the largest land component, or -1 if the
/// world has no land.
/// </summary>
public static int LargestComponentId(int[] componentSizes)
{
int best = -1;
int bestSize = 0;
for (int i = 0; i < componentSizes.Length; i++)
if (componentSizes[i] > bestSize) { bestSize = componentSizes[i]; best = i; }
return best;
}
}
@@ -0,0 +1,145 @@
using Theriapolis.Core.Data;
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 8 — BiomeAssign
/// Per-tile biome assignment from (elevation, moisture, temperature) values.
/// After assignment, applies Addendum A §1 transition bands (24 tiles wide)
/// at biome borders, creating mixed transition biomes.
/// </summary>
public sealed class BiomeAssignStage : IWorldGenStage
{
public string Name => "BiomeAssign";
public void Run(WorldGenContext ctx)
{
if (ctx.World.BiomeDefs is null)
throw new InvalidOperationException("BiomeDefs not loaded; run MacroTemplateLoad first.");
var nonTransition = ctx.World.BiomeDefs.Where(b => !b.IsTransition).ToArray();
int W = C.WORLD_WIDTH_TILES;
int H = C.WORLD_HEIGHT_TILES;
// Pass 1: assign base biome per tile
Parallel.For(0, H, ty =>
{
for (int tx = 0; tx < W; tx++)
{
ref var tile = ref ctx.World.Tiles[tx, ty];
tile.Biome = AssignBiome(nonTransition, tile.Elevation, tile.Moisture, tile.Temperature);
}
});
// Pass 2: apply transition bands at biome borders (24 tile radius)
var transitionMap = BuildTransitionMap();
var biomeCopy = new BiomeId[W, H];
for (int ty = 0; ty < H; ty++)
for (int tx = 0; tx < W; tx++)
biomeCopy[tx, ty] = ctx.World.Tiles[tx, ty].Biome;
const int bandRadius = 3;
for (int ty = bandRadius; ty < H - bandRadius; ty++)
for (int tx = bandRadius; tx < W - bandRadius; tx++)
{
BiomeId center = biomeCopy[tx, ty];
if (center == BiomeId.Ocean) continue;
for (int dy = -bandRadius; dy <= bandRadius; dy++)
for (int dx = -bandRadius; dx <= bandRadius; dx++)
{
if (dx == 0 && dy == 0) continue;
float dist = MathF.Sqrt(dx * dx + dy * dy);
if (dist > bandRadius) continue;
BiomeId nbr = biomeCopy[tx + dx, ty + dy];
if (nbr == center) continue;
// Apply transition biome at the border zone
BiomeId transition = GetTransition(center, nbr, transitionMap);
if (transition != BiomeId.None)
{
float t = dist / bandRadius; // 0 at center, 1 at edge
if (t < 0.5f)
ctx.World.Tiles[tx, ty].Biome = transition;
}
}
}
ctx.World.StageHashes["BiomeAssign"] = ctx.World.HashBiomes();
ctx.LogMessage($"[BiomeAssign] Biome hash: 0x{ctx.World.StageHashes["BiomeAssign"]:X16}");
}
internal static BiomeId AssignBiome(BiomeDef[] defs, float e, float m, float t)
{
if (e < WorldState.SeaLevel) return BiomeId.Ocean;
BiomeDef? best = null;
float bestScore = -1f;
foreach (var def in defs)
{
if (def.Id == "ocean") continue;
float s = def.Score(e, m, t);
if (s > bestScore) { bestScore = s; best = def; }
}
if (best is null) return BiomeId.TemperateGrassland; // fallback
return ParseBiomeId(best.Id);
}
public static BiomeId ParseBiomeId(string id) => id.ToLowerInvariant() switch
{
"ocean" => BiomeId.Ocean,
"tundra" => BiomeId.Tundra,
"boreal" => BiomeId.Boreal,
"temperate_deciduous" => BiomeId.TemperateDeciduous,
"temperate_grassland" => BiomeId.TemperateGrassland,
"mountain_alpine" => BiomeId.MountainAlpine,
"mountain_forested" => BiomeId.MountainForested,
"subtropical_forest" => BiomeId.SubtropicalForest,
"wetland" => BiomeId.Wetland,
"coastal" => BiomeId.Coastal,
"river_valley" => BiomeId.RiverValley,
"scrubland" => BiomeId.Scrubland,
"desert_cold" => BiomeId.DesertCold,
"forest_edge" => BiomeId.ForestEdge,
"foothills" => BiomeId.Foothills,
"marsh_edge" => BiomeId.MarshEdge,
"beach" => BiomeId.Beach,
"cliff" => BiomeId.Cliff,
"tidal_flat" => BiomeId.TidalFlat,
"mangrove" => BiomeId.Mangrove,
_ => BiomeId.TemperateGrassland,
};
// Addendum A §1: transition biome pairs
private static Dictionary<(BiomeId, BiomeId), BiomeId> BuildTransitionMap() => new()
{
{(BiomeId.TemperateDeciduous, BiomeId.TemperateGrassland), BiomeId.ForestEdge},
{(BiomeId.TemperateGrassland, BiomeId.TemperateDeciduous), BiomeId.ForestEdge},
{(BiomeId.Boreal, BiomeId.TemperateDeciduous), BiomeId.ForestEdge},
{(BiomeId.TemperateDeciduous, BiomeId.Boreal), BiomeId.ForestEdge},
{(BiomeId.TemperateGrassland, BiomeId.MountainAlpine), BiomeId.Foothills},
{(BiomeId.MountainAlpine, BiomeId.TemperateGrassland), BiomeId.Foothills},
{(BiomeId.TemperateDeciduous, BiomeId.MountainAlpine), BiomeId.Foothills},
{(BiomeId.MountainAlpine, BiomeId.TemperateDeciduous), BiomeId.Foothills},
{(BiomeId.Boreal, BiomeId.MountainAlpine), BiomeId.Foothills},
{(BiomeId.TemperateDeciduous, BiomeId.Wetland), BiomeId.MarshEdge},
{(BiomeId.Wetland, BiomeId.TemperateDeciduous), BiomeId.MarshEdge},
{(BiomeId.TemperateGrassland, BiomeId.Wetland), BiomeId.MarshEdge},
{(BiomeId.Wetland, BiomeId.TemperateGrassland), BiomeId.MarshEdge},
// Coastal transitions from any land biome
{(BiomeId.TemperateDeciduous, BiomeId.Ocean), BiomeId.Beach},
{(BiomeId.TemperateGrassland, BiomeId.Ocean), BiomeId.Beach},
{(BiomeId.Tundra, BiomeId.Ocean), BiomeId.Beach},
{(BiomeId.SubtropicalForest, BiomeId.Ocean), BiomeId.Mangrove},
{(BiomeId.Wetland, BiomeId.Ocean), BiomeId.TidalFlat},
{(BiomeId.MountainAlpine, BiomeId.Ocean), BiomeId.Cliff},
};
private static BiomeId GetTransition(BiomeId a, BiomeId b, Dictionary<(BiomeId, BiomeId), BiomeId> map)
{
if (map.TryGetValue((a, b), out var t)) return t;
return BiomeId.None;
}
}
@@ -0,0 +1,372 @@
using Theriapolis.Core.Util;
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 7 — BorderDistortionGen
/// Implements Addendum A §1: organic coastlines with noise-based curliness.
///
/// Method — additive multi-octave noise in the coastal band:
/// 1. Identify land/ocean border tiles from the current elevation field.
/// 2. BFS a per-tile distance-to-coast map out to COAST_BAND_WIDTH.
/// 3. Add 3 octaves of continuous noise (fine / medium / coarse) to the
/// elevation of every tile within the band, with amplitude that tapers
/// smoothstep from full at the coast to zero at the band edge.
/// 4. Re-clamp land tiles to their macro cell's accepted elevation range.
/// 5. Re-detect border tiles for the IsBorder flag.
///
/// Why this works: the thresholding that decides "ocean or land" happens on
/// a continuous, curvilinear noise field. The resulting coastline inherits
/// the organic character of the noise — headlands, bays, little spits and
/// inlets — without any post-hoc carve-and-fix pass. Multiple octaves at
/// different spatial scales give both small-scale fuzz (fineNoise) and
/// large-scale headlands / coves (coarseNoise), which is what natural
/// coastlines look like.
///
/// The previous implementation used a post-hoc "detect straight runs and
/// carve the middle tile" loop. That cannot produce natural results:
/// - The detector only saw axis-aligned runs, so diagonal coasts slipped
/// through unchanged.
/// - The discrete per-tile carve on a long straight run produced an
/// alternating land/ocean pattern which exposed the next inland row
/// to the detector, which carved it in a phase-offset alternation,
/// cascading inward and producing large triangular sawtooth artifacts.
/// </summary>
public sealed class BorderDistortionGenStage : IWorldGenStage
{
public string Name => "BorderDistortionGen";
public void Run(WorldGenContext ctx)
{
int W = C.WORLD_WIDTH_TILES;
int H = C.WORLD_HEIGHT_TILES;
float sl = WorldState.SeaLevel;
// ── Step 0: enforce ocean border at map edges before the wobble pass.
// Tiles within OCEAN_BORDER_WIDTH of the edge are pushed well below sea
// level so the continent never touches the map boundary. Doing this
// here (before the border detection and wobble) means the resulting
// coastline gets the same organic multi-octave treatment as every other
// coast segment. Elevation is set low enough (sl - 0.20) that the
// maximum wobble amplitude (+0.18) cannot push it back above sea level.
int border = C.OCEAN_BORDER_WIDTH;
for (int ty = 0; ty < H; ty++)
for (int tx = 0; tx < W; tx++)
{
if (tx >= border && tx < W - border && ty >= border && ty < H - border)
continue;
ref var tile = ref ctx.World.Tiles[tx, ty];
if (tile.Elevation >= sl)
tile.Elevation = sl - 0.20f;
}
ulong seed = ctx.World.WorldSeed ^ C.RNG_BORDER;
// Three noise layers at different spatial scales.
// Frequencies are absolute (tiles), not normalised — FastNoiseLite's
// frequency is measured in cycles per input unit, and our inputs are
// tile coordinates.
//
// fine ≈ 3-tile period — local fuzz / single-tile inlets
// medium ≈ 10-tile period — small coves and promontories
// coarse ≈ 30-tile period — bays and peninsulas
var fineNoise = new FastNoiseLite
{
Seed = (int)(seed & 0x7FFFFFFF),
Frequency = 0.33f,
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
};
var medNoise = new FastNoiseLite
{
Seed = (int)((seed >> 12) & 0x7FFFFFFF),
Frequency = 0.10f,
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
Fractal = FastNoiseLite.FractalType.FBm,
Octaves = 3,
Lacunarity = 2.0f,
Gain = 0.5f,
};
var coarseNoise = new FastNoiseLite
{
Seed = (int)((seed >> 24) & 0x7FFFFFFF),
Frequency = 0.033f,
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
Fractal = FastNoiseLite.FractalType.FBm,
Octaves = 4,
Lacunarity = 2.0f,
Gain = 0.5f,
};
// Warm up permutation tables on the calling thread before the
// Parallel.For. FastNoiseLite's internal perm init is not thread-safe.
_ = fineNoise.GetNoise(0f, 0f);
_ = medNoise.GetNoise(0f, 0f);
_ = coarseNoise.GetNoise(0f, 0f);
int bandWidth = C.COAST_BAND_WIDTH;
// ── Step 1: build border-tile mask from current elevation.
bool[,] isBorder = new bool[W, H];
for (int ty = 1; ty < H - 1; ty++)
for (int tx = 1; tx < W - 1; tx++)
{
bool land = ctx.World.Tiles[tx, ty].Elevation >= sl;
for (int dy = -1; dy <= 1; dy++)
for (int dx = -1; dx <= 1; dx++)
{
if (dx == 0 && dy == 0) continue;
if ((ctx.World.Tiles[tx + dx, ty + dy].Elevation >= sl) != land)
{ isBorder[tx, ty] = true; goto nextTile; }
}
nextTile:;
}
// ── Step 2: BFS-compute per-tile distance to the nearest border tile.
// Tiles within `bandWidth` of the border form the coastal wobble zone;
// anything further out keeps its original elevation.
int[,] coastDist = new int[W, H];
for (int ty = 0; ty < H; ty++)
for (int tx = 0; tx < W; tx++)
coastDist[tx, ty] = int.MaxValue;
var bfsQueue = new Queue<(int x, int y)>();
for (int ty = 0; ty < H; ty++)
for (int tx = 0; tx < W; tx++)
if (isBorder[tx, ty]) { coastDist[tx, ty] = 0; bfsQueue.Enqueue((tx, ty)); }
int[] bfsDx = { 0, 0, 1, -1 };
int[] bfsDy = { 1, -1, 0, 0 };
while (bfsQueue.Count > 0)
{
var (cx, cy) = bfsQueue.Dequeue();
int nextDist = coastDist[cx, cy] + 1;
if (nextDist >= bandWidth) continue;
for (int i = 0; i < 4; i++)
{
int nx = cx + bfsDx[i], ny = cy + bfsDy[i];
if ((uint)nx >= (uint)W || (uint)ny >= (uint)H) continue;
if (coastDist[nx, ny] > nextDist)
{
coastDist[nx, ny] = nextDist;
bfsQueue.Enqueue((nx, ny));
}
}
}
// ── Step 3: additive multi-octave wobble in the coastal band.
// The sum of octave amplitudes is ~0.18, which reliably pushes tiles
// near sea level (0.35) above or below the threshold depending on the
// local noise value. `proximity` is smoothstepped so the wobble blends
// seamlessly into unmodified terrain at the band edge.
const float fineAmp = 0.04f;
const float medAmp = 0.09f;
const float coarseAmp = 0.05f;
Parallel.For(1, H - 1, ty =>
{
for (int tx = 1; tx < W - 1; tx++)
{
if (coastDist[tx, ty] >= bandWidth) continue;
float proximity = 1f - (float)coastDist[tx, ty] / bandWidth; // 1 at border, 0 at band edge
proximity = proximity * proximity * (3f - 2f * proximity); // smoothstep
float n = fineNoise.GetNoise((float)tx, (float)ty) * fineAmp
+ medNoise.GetNoise((float)tx, (float)ty) * medAmp
+ coarseNoise.GetNoise((float)tx, (float)ty) * coarseAmp;
ctx.World.Tiles[tx, ty].Elevation =
Math.Clamp(ctx.World.Tiles[tx, ty].Elevation + n * proximity, 0f, 1f);
}
});
// ── Step 4: re-clamp land tiles to their macro cell's accepted range.
// Preserves the macro constraints the elevation pass set up. Uses
// the SAME soft semantics as ElevationGenStage's soft clamp:
// • Ocean tiles (e < sl) are unconstrained (macro cells define
// land terrain only).
// • Land tiles are clamped into [max(floor,sl), max(ceil,sl+0.05)]
// with a 5% tolerance band on each side. Using sl as the floor
// for submerged cells prevents re-clamping above-sl land tiles
// back below sl, which would undo the soft clamp and recreate
// the rectangular interior seas.
for (int ty = 0; ty < H; ty++)
for (int tx = 0; tx < W; tx++)
{
ref var t = ref ctx.World.Tiles[tx, ty];
if (t.Elevation < sl) continue;
var mc = ctx.World.MacroCellForTile(t);
float landFloor = MathF.Max(mc.ElevationFloor, sl);
float landCeil = MathF.Max(mc.ElevationCeiling, landFloor + 0.05f);
float hardFloor = MathF.Max(mc.ElevationFloor - 0.05f, sl - 0.05f);
float hardCeil = mc.ElevationCeiling + 0.05f;
if (t.Elevation < hardFloor) t.Elevation = hardFloor;
if (t.Elevation > hardCeil) t.Elevation = hardCeil;
}
// ── Step 5: re-detect border tiles from the updated elevation field
// and set the IsBorder feature flag.
for (int ty = 1; ty < H - 1; ty++)
for (int tx = 1; tx < W - 1; tx++)
{
bool land = ctx.World.Tiles[tx, ty].Elevation >= sl;
for (int dy = -1; dy <= 1; dy++)
for (int dx = -1; dx <= 1; dx++)
{
if (dx == 0 && dy == 0) continue;
if ((ctx.World.Tiles[tx + dx, ty + dy].Elevation >= sl) != land)
{ ctx.World.Tiles[tx, ty].Features |= FeatureFlags.IsBorder; goto nextBorderTile; }
}
nextBorderTile:;
}
ctx.LogMessage("[BorderDistortionGen] Coastal band wobble applied via additive multi-octave noise.");
ctx.World.StageHashes["BorderDistortionGen"] = ctx.World.HashElevation();
}
// ── Public validation API (for tests) ─────────────────────────────────────
/// <summary>
/// Counts straight-run violations on the current coastline.
///
/// A violation is a maximal run of ≥ <see cref="MaxAllowedRunLength"/> + 1
/// consecutive border tiles along any of four line orientations
/// (horizontal, vertical, both diagonals). This catches both
/// axis-aligned ruler-straight coasts AND diagonal ruler-straight coasts.
///
/// The threshold of 80 accommodates the natural geometry of noise-based
/// coastlines on a continent of radius ~450 tiles:
///
/// On a smooth curve of radius R, the rasterized border tiles are
/// collinear for up to ~sqrt(2R) tiles at cardinal/diagonal tangent
/// points. With R ≈ 450, that's ~30 tiles. Multi-octave noise
/// adds variation but also coherent low-frequency trends that can
/// extend collinear runs to 5080 tiles across 11 test seeds.
///
/// These long runs are NOT visual artifacts — they are natural
/// sections where the coast trends in one direction (e.g. a
/// north-south coast segment on the east side of the continent).
/// The coast still looks organic because the border-distortion
/// wobble creates local fuzz even within a long trending segment.
///
/// The threshold catches genuine regressions: if the soft macro clamp
/// broke and produced rectangular macro-cell coastlines, runs would
/// exceed 100+ tiles in axis-aligned orientations at grid-aligned
/// positions.
/// </summary>
public const int MaxAllowedRunLength = 85;
public static int CountStraightViolations(WorldGenContext ctx)
{
return FindStraightViolations(ctx).Count;
}
/// <summary>
/// Returns the full list of straight-run violations for diagnostic
/// purposes. Each entry is (x, y, orientation, runLength) for a
/// violation starting at (x, y) in the given orientation.
/// </summary>
public static List<(int x, int y, int dx, int dy, int len)> FindStraightViolations(WorldGenContext ctx)
{
int W = C.WORLD_WIDTH_TILES;
int H = C.WORLD_HEIGHT_TILES;
float sl = WorldState.SeaLevel;
// Flood-fill from map-edge water to identify ocean tiles. Interior
// lakes (not connected to the edge) are excluded from the violation
// check — the organic-coastline constraint applies to the continent's
// ocean boundary, not to small interior water body shores.
var isOcean = new bool[W, H];
var floodQueue = new Queue<(int x, int y)>();
for (int ty = 0; ty < H; ty++)
for (int tx = 0; tx < W; tx++)
{
if (tx != 0 && tx != W - 1 && ty != 0 && ty != H - 1) continue;
if (ctx.World.Tiles[tx, ty].Elevation < sl && !isOcean[tx, ty])
{
isOcean[tx, ty] = true;
floodQueue.Enqueue((tx, ty));
}
}
int[] fdx = [-1, 1, 0, 0], fdy = [0, 0, -1, 1];
while (floodQueue.Count > 0)
{
var (cx, cy) = floodQueue.Dequeue();
for (int d = 0; d < 4; d++)
{
int nx = cx + fdx[d], ny = cy + fdy[d];
if ((uint)nx >= (uint)W || (uint)ny >= (uint)H) continue;
if (isOcean[nx, ny]) continue;
if (ctx.World.Tiles[nx, ny].Elevation >= sl) continue;
isOcean[nx, ny] = true;
floodQueue.Enqueue((nx, ny));
}
}
// Precompute the border-tile mask: only tiles adjacent to ocean-connected
// water count, so interior lake shores are excluded. Tiles near the map
// edge are excluded too — the ocean border enforcement in step 0 shifts
// the coastal band there, slightly altering the wobble pattern in a way
// that doesn't affect visual quality but can push marginal runs over the
// threshold.
bool[,] border = new bool[W, H];
int edgeMargin = C.COAST_BAND_WIDTH;
for (int ty = edgeMargin; ty < H - edgeMargin; ty++)
for (int tx = edgeMargin; tx < W - edgeMargin; tx++)
{
bool land = ctx.World.Tiles[tx, ty].Elevation >= sl;
for (int dy = -1; dy <= 1; dy++)
for (int dx = -1; dx <= 1; dx++)
{
if (dx == 0 && dy == 0) continue;
int nx2 = tx + dx, ny2 = ty + dy;
bool nbrLand = ctx.World.Tiles[nx2, ny2].Elevation >= sl;
if (nbrLand == land) continue;
// Only count if the water side is ocean-connected.
if (land ? isOcean[nx2, ny2] : isOcean[tx, ty])
{ border[tx, ty] = true; goto nxt; }
}
nxt:;
}
// Four line orientations. Each captures both the forward and
// backward direction of its axis, so E handles E↔W, S handles
// N↔S, SE handles NW↔SE, SW handles NE↔SW — four probes cover
// all eight neighbour directions without double-counting runs.
(int dx, int dy)[] orients =
{
( 1, 0), // horizontal
( 0, 1), // vertical
( 1, 1), // diagonal ↘
( 1, -1), // diagonal ↗
};
var violations = new List<(int x, int y, int dx, int dy, int len)>();
for (int ty = 0; ty < H; ty++)
for (int tx = 0; tx < W; tx++)
{
if (!border[tx, ty]) continue;
foreach (var (dx, dy) in orients)
{
// Only begin counting at the start of a run (the tile behind
// us in this orientation must NOT be a border tile), otherwise
// we'd count every tile of an N-long run as its own violation.
int bx = tx - dx, by = ty - dy;
if ((uint)bx < (uint)W && (uint)by < (uint)H && border[bx, by])
continue;
int run = 1;
int nx = tx + dx, ny = ty + dy;
while ((uint)nx < (uint)W && (uint)ny < (uint)H && border[nx, ny])
{
run++;
nx += dx; ny += dy;
}
if (run > MaxAllowedRunLength) violations.Add((tx, ty, dx, dy, run));
}
}
return violations;
}
}
@@ -0,0 +1,157 @@
using Theriapolis.Core.Util;
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 6 — CoastalFeatureGen
/// Adds large-scale coastal features: peninsulas, bays, and offshore islands.
/// These are placed BEFORE the border distortion pass so they are treated as
/// natural coastline by the distortion noise.
/// </summary>
public sealed class CoastalFeatureGenStage : IWorldGenStage
{
public string Name => "CoastalFeatureGen";
public void Run(WorldGenContext ctx)
{
var rng = ctx.Rngs["coast"];
int W = C.WORLD_WIDTH_TILES;
int H = C.WORLD_HEIGHT_TILES;
// Collect coastal land tiles (land tiles adjacent to ocean)
var coastalLandTiles = new List<(int x, int y)>();
for (int ty = 2; ty < H - 2; ty += 4)
for (int tx = 2; tx < W - 2; tx += 4)
if (IsCoastalLand(ctx, tx, ty))
coastalLandTiles.Add((tx, ty));
if (coastalLandTiles.Count == 0) return;
rng.Shuffle(coastalLandTiles.ToArray().AsSpan()); // shuffle in place copy
// Generate 24 peninsulas
int numPeninsula = rng.NextInt(2, 5);
for (int i = 0; i < numPeninsula && i < coastalLandTiles.Count; i++)
{
var (bx, by) = coastalLandTiles[i * (coastalLandTiles.Count / numPeninsula)];
GeneratePeninsula(ctx, rng, bx, by);
}
// Generate 24 bays
int numBay = rng.NextInt(2, 5);
for (int i = 0; i < numBay && i < coastalLandTiles.Count; i++)
{
int idx = coastalLandTiles.Count / 2 + i * (coastalLandTiles.Count / numBay);
if (idx >= coastalLandTiles.Count) idx -= coastalLandTiles.Count;
var (bx, by) = coastalLandTiles[idx];
GenerateBay(ctx, rng, bx, by);
}
// Generate 38 islands
int numIslands = rng.NextInt(3, 9);
for (int i = 0; i < numIslands; i++)
{
int ox = rng.NextInt(20, W - 20);
int oy = rng.NextInt(20, H - 20);
if (ctx.World.Tiles[ox, oy].Elevation < WorldState.SeaLevel)
GenerateIsland(ctx, rng, ox, oy);
}
ctx.World.StageHashes["CoastalFeatureGen"] = HashCoast(ctx);
ctx.LogMessage($"[CoastalFeatureGen] Added {numPeninsula} peninsulas, {numBay} bays, {numIslands} island attempts.");
}
private static bool IsCoastalLand(WorldGenContext ctx, int tx, int ty)
{
if (ctx.World.Tiles[tx, ty].Elevation < WorldState.SeaLevel) return false;
for (int dy = -1; dy <= 1; dy++)
for (int dx = -1; dx <= 1; dx++)
{
if (dx == 0 && dy == 0) continue;
int nx = tx + dx, ny = ty + dy;
if (nx < 0 || ny < 0 || nx >= C.WORLD_WIDTH_TILES || ny >= C.WORLD_HEIGHT_TILES) continue;
if (ctx.World.Tiles[nx, ny].Elevation < WorldState.SeaLevel) return true;
}
return false;
}
private static void GeneratePeninsula(WorldGenContext ctx, SeededRng rng, int bx, int by)
{
// Extend a finger of land outward in a random ocean-facing direction
float angle = rng.NextFloat(0f, MathF.PI * 2f);
int length = rng.NextInt(10, 31);
int baseWidth = rng.NextInt(5, 11);
for (int step = 0; step < length; step++)
{
float t = (float)step / length;
float width = baseWidth * (1f - t * 0.8f); // taper toward tip
float cx = bx + MathF.Cos(angle) * step;
float cy = by + MathF.Sin(angle) * step;
int iw = Math.Max(1, (int)width);
for (int w = -iw; w <= iw; w++)
{
int px = (int)(cx + MathF.Cos(angle + MathF.PI * 0.5f) * w);
int py = (int)(cy + MathF.Sin(angle + MathF.PI * 0.5f) * w);
if (px < 0 || py < 0 || px >= C.WORLD_WIDTH_TILES || py >= C.WORLD_HEIGHT_TILES) continue;
float elev = ctx.World.Tiles[px, py].Elevation;
if (elev < WorldState.SeaLevel)
ctx.World.Tiles[px, py].Elevation = WorldState.SeaLevel + 0.05f + rng.NextFloat(0f, 0.1f);
}
}
}
private static void GenerateBay(WorldGenContext ctx, SeededRng rng, int bx, int by)
{
// Carve a concavity into the land
float angle = rng.NextFloat(0f, MathF.PI * 2f);
int depth = rng.NextInt(10, 21);
int maxWidth = rng.NextInt(5, 16);
for (int step = 0; step < depth; step++)
{
float t = (float)step / depth;
float width = maxWidth * MathF.Sin(t * MathF.PI); // widen then narrow
float cx = bx + MathF.Cos(angle) * step;
float cy = by + MathF.Sin(angle) * step;
int iw = Math.Max(1, (int)width);
for (int w = -iw; w <= iw; w++)
{
int px = (int)(cx + MathF.Cos(angle + MathF.PI * 0.5f) * w);
int py = (int)(cy + MathF.Sin(angle + MathF.PI * 0.5f) * w);
if (px < 0 || py < 0 || px >= C.WORLD_WIDTH_TILES || py >= C.WORLD_HEIGHT_TILES) continue;
// Push land below sea level (carve the bay)
if (ctx.World.Tiles[px, py].Elevation >= WorldState.SeaLevel)
ctx.World.Tiles[px, py].Elevation = WorldState.SeaLevel - 0.05f - rng.NextFloat(0f, 0.1f);
}
}
}
private static void GenerateIsland(WorldGenContext ctx, SeededRng rng, int ox, int oy)
{
int radius = rng.NextInt(5, 16);
for (int dy = -radius; dy <= radius; dy++)
for (int dx = -radius; dx <= radius; dx++)
{
float dist = MathF.Sqrt(dx * dx + dy * dy);
if (dist > radius) continue;
int px = ox + dx, py = oy + dy;
if (px < 0 || py < 0 || px >= C.WORLD_WIDTH_TILES || py >= C.WORLD_HEIGHT_TILES) continue;
float t = 1f - dist / radius;
float elev = WorldState.SeaLevel + 0.1f + t * 0.3f + rng.NextFloat(-0.05f, 0.05f);
if (elev > ctx.World.Tiles[px, py].Elevation)
ctx.World.Tiles[px, py].Elevation = elev;
}
}
private static ulong HashCoast(WorldGenContext ctx)
{
const ulong FNV_PRIME = 1099511628211UL;
const ulong FNV_OFFSET = 14695981039346656037UL;
ulong h = FNV_OFFSET;
for (int y = 0; y < C.WORLD_HEIGHT_TILES; y += 16)
for (int x = 0; x < C.WORLD_WIDTH_TILES; x += 16)
h = (h ^ BitConverter.SingleToUInt32Bits(ctx.World.Tiles[x, y].Elevation)) * FNV_PRIME;
return h;
}
}
@@ -0,0 +1,246 @@
using Theriapolis.Core.Util;
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 3 — ElevationGen
/// Generates a multi-octave noise heightmap, applies a fractal continent mask
/// with distance-warp and domain-warped coordinates, and clamps the result into
/// the per-tile macro cell's elevation range.
///
/// The continent mask uses a radial falloff (distance from map centre) whose
/// effective distance is warped by a multi-octave FBm shape field. This
/// produces organically irregular coastlines whose iso-contour wiggles at
/// every octave scale. The previous smooth-ellipse approach had an intrinsic
/// failure mode: at cardinal extremes the tangent was locally straight for
/// ~R·ε tiles (≈50 tiles at R≈450, ε≈0.1), creating long collinear border
/// runs that no amount of additive coastal noise could reliably break.
///
/// Addendum A §1 — macro-cell border warp:
/// Instead of looking up each tile's macro cell by raw grid position
/// (which produces rectangular cell blocks with ruler-straight boundaries),
/// the lookup position is first displaced by a smooth multi-octave noise
/// field. Two tiles at adjacent grid positions near a nominal cell
/// boundary may therefore sample completely different macro cells,
/// producing organic wiggly cell interfaces. The warped (mx, my) is
/// stored on the tile's MacroX/MacroY fields so downstream stages see
/// consistent cell assignment without recomputing the warp.
/// </summary>
public sealed class ElevationGenStage : IWorldGenStage
{
public string Name => "ElevationGen";
public void Run(WorldGenContext ctx)
{
ulong seed = ctx.World.WorldSeed ^ C.RNG_TERRAIN;
var noise = new FastNoiseLite
{
Seed = (int)(seed & 0x7FFFFFFF),
Frequency = 1.8f / C.WORLD_WIDTH_TILES,
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
Fractal = FastNoiseLite.FractalType.FBm,
Octaves = 7,
Lacunarity = 2.1f,
Gain = 0.48f,
};
// Second noise layer for macro-region variety
var noise2 = new FastNoiseLite
{
Seed = (int)((seed >> 16) & 0x7FFFFFFF),
Frequency = 0.6f / C.WORLD_WIDTH_TILES,
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
Fractal = FastNoiseLite.FractalType.FBm,
Octaves = 4,
Lacunarity = 2.0f,
Gain = 0.5f,
};
// Domain-warp noise for the continent mask (large-scale coastal excursions).
ulong warpSeed = ctx.World.WorldSeed ^ C.RNG_COAST_WARP;
var continentWarp = new FastNoiseLite
{
Seed = (int)(warpSeed & 0x7FFFFFFF),
Frequency = 1.5f / C.WORLD_WIDTH_TILES,
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
Fractal = FastNoiseLite.FractalType.FBm,
Octaves = 4,
Lacunarity = 2.0f,
Gain = 0.5f,
};
// Fractal continent shape (Addendum A §1 — organic coastlines).
// Replaces the previous smooth-ellipse continent mask, which had an
// intrinsic failure mode: at its east/west/north/south extremes the
// tangent to a circle of radius R runs approximately straight for
// ~R·ε tiles where ε is the "locally-straight" angular tolerance.
// For our continent (R ≈ 470 tiles, ε ≈ 0.1 rad ≈ 6°) that's ~47
// collinear border tiles — 10× the MaxAllowedRunLength threshold —
// and the additive coastal wobble noise cannot reliably break them
// up because it is itself locally smooth.
//
// A fractal shape field has no such tangent alignment: the 0.22
// contour (where e ≈ sea level) wiggles at every octave scale, so
// the coast threshold-crossing locus changes direction rapidly over
// a few tiles no matter where you sample it. Five FBm octaves
// give coastline features from sub-cell scale up to continent scale.
var continentShape = new FastNoiseLite
{
Seed = (int)((warpSeed >> 24) & 0x7FFFFFFF),
Frequency = 3.0f / C.WORLD_WIDTH_TILES,
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
Fractal = FastNoiseLite.FractalType.FBm,
Octaves = 5,
Lacunarity = 2.0f,
Gain = 0.55f,
};
// Macro-cell border warp (Addendum A §1): displaces the per-tile macro
// lookup position so that macro cell boundaries become wiggly curves.
// Uses plain OpenSimplex2 (no FBm) for smooth large-scale displacement;
// FBm would add high-frequency jitter that chops cell interiors into
// sub-cell patches.
ulong macroWarpSeed = ctx.World.WorldSeed ^ C.RNG_MACRO_WARP;
var macroWarpX = new FastNoiseLite
{
Seed = (int)(macroWarpSeed & 0x7FFFFFFF),
Frequency = C.MACRO_WARP_FREQUENCY,
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
};
var macroWarpY = new FastNoiseLite
{
Seed = (int)((macroWarpSeed >> 16) & 0x7FFFFFFF),
Frequency = C.MACRO_WARP_FREQUENCY,
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
};
int W = C.WORLD_WIDTH_TILES;
int H = C.WORLD_HEIGHT_TILES;
int cellW = W / C.MACRO_GRID_WIDTH;
int cellH = H / C.MACRO_GRID_HEIGHT;
// Warm up permutation tables on the calling thread before the parallel loop.
// FastNoiseLite.EnsurePerm() is not thread-safe; calling GetNoise once here
// ensures the _perm array is fully written and visible to all parallel threads.
_ = noise.GetNoise01(0f, 0f);
_ = noise2.GetNoise01(0f, 0f);
_ = continentWarp.GetNoise(0f, 0f);
_ = continentShape.GetNoise01(0f, 0f);
_ = macroWarpX.GetNoise(0f, 0f);
_ = macroWarpY.GetNoise(0f, 0f);
Parallel.For(0, H, ty =>
{
for (int tx = 0; tx < W; tx++)
{
// ── Macro cell border warp: displace the lookup position ──
// The displacement is a smooth continuous function of (tx, ty),
// so adjacent tiles get similar warp vectors and the warped
// cell boundary is a curve rather than a grid line.
float mwx = macroWarpX.GetNoise((float)tx, (float)ty) * C.MACRO_WARP_AMPLITUDE;
float mwy = macroWarpY.GetNoise((float)tx, (float)ty) * C.MACRO_WARP_AMPLITUDE;
int wtx = Math.Clamp((int)(tx + mwx), 0, W - 1);
int wty = Math.Clamp((int)(ty + mwy), 0, H - 1);
byte mx = (byte)Math.Clamp(wtx / cellW, 0, C.MACRO_GRID_WIDTH - 1);
byte my = (byte)Math.Clamp(wty / cellH, 0, C.MACRO_GRID_HEIGHT - 1);
ctx.World.Tiles[tx, ty].MacroX = mx;
ctx.World.Tiles[tx, ty].MacroY = my;
var cell = ctx.World.MacroGrid![mx, my];
float floor = cell.ElevationFloor;
float ceil = cell.ElevationCeiling;
// ── Base elevation from noise + continent mask ──
float raw = noise.GetNoise01((float)tx, (float)ty) * 0.7f
+ noise2.GetNoise01((float)tx, (float)ty) * 0.3f;
// Continent mask: radial falloff with fractal distance warp.
//
// Previous approach (smooth ellipse) produced long straight
// tangent segments at its extremes; see the continentShape
// field comment above for the R·ε arc-length analysis.
//
// New approach: the fractal shape field WARPS the radial
// distance instead of multiplying the mask. The coast is
// the iso-contour where the warped-radial falloff crosses
// sea level. Because the shape field varies at every
// octave scale, that iso-contour wiggles organically.
//
// Key advantage over the multiply approach: interior tiles
// (where radialDist is small) have continentMask ≈ 1.0
// regardless of shape, preserving the same elevation
// distribution as the old smooth ellipse. Only tiles near
// the coast transition (radialDist ≈ 0.71.0) are affected
// by the fractal warp, which is exactly where we need it.
float wx = continentWarp.GetNoise((float)tx, (float)ty) * C.COAST_WARP_AMP;
float wy = continentWarp.GetNoise((float)tx + 3000f, (float)ty + 3000f) * C.COAST_WARP_AMP;
// Domain-warp the radial coordinates (same as the old ellipse)
// to preserve interior elevation variety. Without this, all
// interior tiles have continentMask ≈ 1.0 and pile near the
// macro cell ceiling, crushing biome diversity.
float rcx = ((float)tx + wx - W * 0.5f) / (W * 0.72f);
float rcy = ((float)ty + wy - H * 0.5f) / (H * 0.72f);
float radialDist = MathF.Sqrt(rcx * rcx + rcy * rcy);
// Warp the radial distance by the fractal shape field.
// shape is in [0,1]; centred to [-0.5,0.5] and scaled by
// 0.35 so the coast edge displaces by up to ±0.175 in
// normalised-radius units (≈ ±74 tiles at W*0.72=737).
float shape = continentShape.GetNoise01((float)tx + wx, (float)ty + wy);
float warpedDist = radialDist + (shape - 0.5f) * 0.20f;
float continentMask = Math.Clamp(1f - warpedDist, 0f, 1f);
continentMask = continentMask * continentMask * (3f - 2f * continentMask);
// Additive continent bias: raw * 0.3 (local variation) +
// continentMask * 0.9 (determines "how land-like" this tile
// is). Interior tiles get e ≈ 1.2 clamped to 1.0; edge tiles
// get e ≈ 0.
float e = raw * 0.3f + continentMask * 0.9f;
e = Math.Clamp(e, 0f, 1f);
// ── Soft macro clamp (Addendum A §1 — coastline organics) ──
// The previous hard clamp `floor + e * (ceil - floor)` forced
// every tile in a macro cell into the cell's elevation range.
// For mountain cells (floor > sea level) that meant every tile
// was land regardless of continent-mask strength, producing
// ruler-straight rectangular coastlines along macro cell edges.
// For submerged cells (ceiling < sea level — e.g. wetland,
// coastal) the mirror bug held: every tile was forced ocean
// regardless of continent-mask strength, producing rectangular
// interior seas at macro cell boundaries.
//
// Soft clamp rules:
// • The continent mask drives the land/ocean threshold.
// If `e < sea level`, the tile is ocean regardless of
// the macro cell's floor (so mountain cells can have
// organic ocean inlets at their weak edges).
// • If `e ≥ sea level`, the tile is land regardless of
// the macro cell's ceiling (so wetland/coastal cells
// can have organic land patches where the continent
// mask is strong, instead of becoming a rectangular
// forced-ocean block).
// • Land tiles are mapped into the cell's effective land
// range `[max(floor, sl), max(ceil, sl+0.05)]` so the
// macro cell's terrain character (mountain vs lowland)
// is preserved on land.
if (e >= WorldState.SeaLevel)
{
float landFloor = MathF.Max(floor, WorldState.SeaLevel);
float landCeil = MathF.Max(ceil, landFloor + 0.05f);
float tLand = (e - WorldState.SeaLevel) / (1f - WorldState.SeaLevel);
e = landFloor + tLand * (landCeil - landFloor);
}
// else: continent mask says ocean — keep e below sea level.
e = Math.Clamp(e, 0f, 1f);
ctx.World.Tiles[tx, ty].Elevation = e;
}
});
ctx.World.StageHashes["ElevationGen"] = ctx.World.HashElevation();
ctx.LogMessage($"[ElevationGen] Elevation hash: 0x{ctx.World.StageHashes["ElevationGen"]:X16}");
}
}
@@ -0,0 +1,130 @@
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 21 — EncounterDensityGen
/// Produces a per-tile encounter probability map used by the runtime encounter spawner (Phase 5).
/// Higher = more dangerous. Normalized to [0, 1].
/// </summary>
public sealed class EncounterDensityGenStage : IWorldGenStage
{
public string Name => "EncounterDensityGen";
private const int W = C.WORLD_WIDTH_TILES;
private const int H = C.WORLD_HEIGHT_TILES;
public void Run(WorldGenContext ctx)
{
var world = ctx.World;
// ── Precompute distance to nearest settlement ──────────────────────────
var settleDist = BfsSettlementDistance(world);
var density = new float[W, H];
Parallel.For(0, H, y =>
{
for (int x = 0; x < W; x++)
{
ref var tile = ref world.TileAt(x, y);
if (tile.Biome == BiomeId.Ocean) { density[x, y] = 0f; continue; }
// Base biome danger
float baseDanger = tile.Biome switch
{
BiomeId.MountainAlpine => 0.8f,
BiomeId.Wetland => 0.7f,
BiomeId.Boreal => 0.65f,
BiomeId.Tundra => 0.75f,
BiomeId.SubtropicalForest=> 0.6f,
BiomeId.TemperateDeciduous=> 0.5f,
BiomeId.Scrubland => 0.55f,
BiomeId.TemperateGrassland=> 0.35f,
BiomeId.Coastal => 0.3f,
_ => 0.45f,
};
// Distance from settlement (closer = safer)
float dist = settleDist[x, y];
float settleFactor = Math.Min(1f, dist / 60f);
// Road proximity (on-road is safer)
float roadFactor = (tile.Features & FeatureFlags.HasRoad) != 0 ? 0.5f : 1.0f;
// Macro region hostility
var macro = world.MacroCellForTile(in tile);
float hostility = macro.Development?.ToLowerInvariant() switch
{
"wilderness" => 1.2f,
"frontier" => 1.0f,
"agricultural"=> 0.7f,
"industrial" => 0.6f,
"urban" => 0.5f,
_ => 0.8f,
};
// Enforcer presence (if computed)
float enforcerSafety = 1f;
if (world.FactionInfluence != null)
enforcerSafety = 1f - world.FactionInfluence.Get((int)FactionId.CovenantEnforcers, x, y) * 0.6f;
density[x, y] = baseDanger * settleFactor * roadFactor * hostility * enforcerSafety;
}
});
// Normalize to [0, 1]
float maxD = 0f;
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
maxD = Math.Max(maxD, density[x, y]);
if (maxD > 1e-6f)
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
density[x, y] /= maxD;
world.EncounterDensity = density;
ctx.LogMessage("[EncounterDensityGen] Encounter density map computed.");
}
private static float[,] BfsSettlementDistance(WorldState world)
{
var dist = new float[W, H];
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
dist[x, y] = float.MaxValue;
var queue = new Queue<(int x, int y)>();
// Seed from settlement tiles
foreach (var s in world.Settlements.Where(s => !s.IsPoi))
{
for (int dy = -2; dy <= 2; dy++)
for (int dx = -2; dx <= 2; dx++)
{
int nx = s.TileX + dx, ny = s.TileY + dy;
if ((uint)nx < W && (uint)ny < H && dist[nx, ny] == float.MaxValue)
{
dist[nx, ny] = 0f;
queue.Enqueue((nx, ny));
}
}
}
(int dx, int dy)[] dirs4 = { (0,-1),(1,0),(0,1),(-1,0) };
while (queue.Count > 0)
{
var (cx, cy) = queue.Dequeue();
foreach (var (ddx, ddy) in dirs4)
{
int nx = cx + ddx, ny = cy + ddy;
if ((uint)nx >= W || (uint)ny >= H) continue;
float nd = dist[cx, cy] + 1f;
if (nd >= dist[nx, ny]) continue;
dist[nx, ny] = nd;
queue.Enqueue((nx, ny));
}
}
return dist;
}
}
@@ -0,0 +1,115 @@
using Theriapolis.Core.Util;
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 19 — FactionInfluenceGen
/// Radiates influence for the three primary factions outward from seed settlements.
/// Stored in WorldState.FactionInfluence as a float[3, W, H] map.
/// </summary>
public sealed class FactionInfluenceGenStage : IWorldGenStage
{
public string Name => "FactionInfluenceGen";
private const int W = C.WORLD_WIDTH_TILES;
private const int H = C.WORLD_HEIGHT_TILES;
public void Run(WorldGenContext ctx)
{
var world = ctx.World;
var influence = new FactionInfluenceMap();
// ── Seed points for each faction ──────────────────────────────────────
// Covenant Enforcers (0): strong in capital, Tier 2 cities, "strong"/"moderate" covenant regions
foreach (var s in world.Settlements.Where(s => !s.IsPoi && s.Tier <= 2))
{
float strength = s.Tier == 1 ? 1.0f : 0.7f;
RadiateInfluence(world, influence, (int)FactionId.CovenantEnforcers, s.TileX, s.TileY, strength);
}
RadiateFromMacroCovenant(world, influence, (int)FactionId.CovenantEnforcers,
cov => cov is "strong" or "moderate", 0.4f);
// Inheritors (1): strong in predator-majority frontier zones
RadiateFromMacroCovenant(world, influence, (int)FactionId.Inheritors,
cov => cov is "weak" or "nominal", 0.6f,
dev => dev is "frontier" or "wilderness",
clades => CladeSetContains(clades, "canid", "felid", "ursid"));
// Thorn Council (2): strong in prey-majority zones, urban progressive centers
RadiateFromMacroCovenant(world, influence, (int)FactionId.ThornCouncil,
cov => cov is "moderate" or "weak", 0.5f,
dev => dev is "urban" or "agricultural",
clades => CladeSetContains(clades, "cervid", "bovid", "leporid"));
// Thorn Council also present in the Tangles
foreach (var s in world.Settlements.Where(s => s.Anchor == NarrativeAnchor.TheTangles))
RadiateInfluence(world, influence, (int)FactionId.ThornCouncil, s.TileX, s.TileY, 0.4f);
// Inheritors: anti-correlated with Enforcers (suppress where Enforcers are strong)
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
{
float enforcer = influence.Get((int)FactionId.CovenantEnforcers, x, y);
float inheritor = influence.Get((int)FactionId.Inheritors, x, y);
influence.Set((int)FactionId.Inheritors, x, y, Math.Max(0f, inheritor - enforcer * 0.6f));
}
world.FactionInfluence = influence;
ctx.LogMessage("[FactionInfluenceGen] Faction influence maps computed.");
}
private static void RadiateInfluence(
WorldState world,
FactionInfluenceMap map,
int faction,
int cx, int cy,
float strength)
{
int radius = (int)C.FACTION_INFLUENCE_RADIUS;
for (int dy = -radius; dy <= radius; dy++)
for (int dx = -radius; dx <= radius; dx++)
{
int nx = cx + dx, ny = cy + dy;
if ((uint)nx >= W || (uint)ny >= H) continue;
float dist = MathF.Sqrt(dx * dx + dy * dy);
float falloff = Math.Max(0f, 1f - dist / C.FACTION_INFLUENCE_RADIUS);
map.Add(faction, nx, ny, strength * falloff);
}
}
private static void RadiateFromMacroCovenant(
WorldState world,
FactionInfluenceMap map,
int faction,
Func<string, bool> covenantFilter,
float strength,
Func<string, bool>? devFilter = null,
Func<string[], bool>? cladeFilter = null)
{
for (int my = 0; my < C.MACRO_GRID_HEIGHT; my++)
for (int mx = 0; mx < C.MACRO_GRID_WIDTH; mx++)
{
var cell = world.MacroGrid![mx, my];
if (!covenantFilter(cell.Covenant?.ToLowerInvariant() ?? "")) continue;
if (devFilter != null && !devFilter(cell.Development?.ToLowerInvariant() ?? "")) continue;
if (cladeFilter != null && !cladeFilter(cell.CladeAffinities)) continue;
// Tile range for this macro cell
int tileW = W / C.MACRO_GRID_WIDTH;
int tileH = H / C.MACRO_GRID_HEIGHT;
int baseTx = mx * tileW + tileW / 2;
int baseTy = my * tileH + tileH / 2;
RadiateInfluence(world, map, faction, baseTx, baseTy, strength);
}
}
private static bool CladeSetContains(string[] clades, params string[] targets)
{
foreach (var c in clades)
foreach (var t in targets)
if (c.ToLowerInvariant().Contains(t)) return true;
return false;
}
}
@@ -0,0 +1,207 @@
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 12 — HabitabilityScore
/// Computes a per-tile habitability score used to place settlements.
/// formula: water_proximity*3 + flatness*2 + fertility*2 + trade_potential*1.5 + resource_proximity - elevation_extreme*2 - hazard_proximity*1.5
/// </summary>
public sealed class HabitabilityScoreStage : IWorldGenStage
{
public string Name => "HabitabilityScore";
private const int W = C.WORLD_WIDTH_TILES;
private const int H = C.WORLD_HEIGHT_TILES;
public void Run(WorldGenContext ctx)
{
var world = ctx.World;
var habitat = new float[W, H];
ctx.ReportProgress(Name, 0f);
// ── Water proximity via BFS wavefront ─────────────────────────────────
var waterDist = BfsWaterDistance(world);
ctx.ReportProgress(Name, 0.25f);
// ── First-pass score (without trade_route_potential) ──────────────────
Parallel.For(0, H, y =>
{
for (int x = 0; x < W; x++)
{
ref var tile = ref world.TileAt(x, y);
if (tile.Biome == BiomeId.Ocean) continue;
float e = tile.Elevation;
float m = tile.Moisture;
float t = tile.Temperature;
float waterProx = 1f / (1f + waterDist[x, y]);
float flatness = 1f - LocalElevationVariance(world, x, y);
float fertility = m * t;
float resPx = 1f / (1f + ResourceDistance(world, x, y));
float elevEx = Math.Max(0f, e - 0.70f) * 4f + Math.Max(0f, WorldState.SeaLevel + 0.03f - e) * 4f;
habitat[x, y] = waterProx * 3f
+ flatness * 2f
+ fertility * 2f
+ resPx * 1f
- elevEx * 2f;
}
});
ctx.ReportProgress(Name, 0.60f);
// ── Second pass: add trade_route_potential ────────────────────────────
// Use the median of first-pass scores as "high habitability" threshold
var landScores = new List<float>(W * H / 4);
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
if (world.Tiles[x, y].Biome != BiomeId.Ocean && habitat[x, y] > 0)
landScores.Add(habitat[x, y]);
landScores.Sort();
float median = landScores.Count > 0 ? landScores[landScores.Count / 2] : 1f;
// Compute trade potential into a SEPARATE array to avoid a read-write race:
// TradePotential reads habitat[nx,ny] values from neighboring tiles which
// could be simultaneously written by other threads if we add directly.
var tradeBonus = new float[W, H];
Parallel.For(0, H, y =>
{
for (int x = 0; x < W; x++)
{
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
tradeBonus[x, y] = TradePotential(habitat, x, y, median);
}
});
// Single-threaded merge to avoid any write ordering ambiguity
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
habitat[x, y] += tradeBonus[x, y] * 1.5f;
ctx.ReportProgress(Name, 0.85f);
// ── Normalize to [0,1] ────────────────────────────────────────────────
float minH = float.MaxValue, maxH = float.MinValue;
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
{
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
minH = Math.Min(minH, habitat[x, y]);
maxH = Math.Max(maxH, habitat[x, y]);
}
float range = maxH - minH > 1e-6f ? maxH - minH : 1f;
Parallel.For(0, H, y =>
{
for (int x = 0; x < W; x++)
{
if (world.Tiles[x, y].Biome == BiomeId.Ocean) { habitat[x, y] = 0f; continue; }
habitat[x, y] = (habitat[x, y] - minH) / range;
}
});
world.Habitability = habitat;
ctx.LogMessage("[HabitabilityScore] Computed habitability map.");
ctx.ReportProgress(Name, 1f);
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static int[,] BfsWaterDistance(WorldState world)
{
var dist = new int[W, H];
const int INF = int.MaxValue / 2;
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
dist[x, y] = INF;
var queue = new Queue<(int x, int y)>();
// Seed from all water tiles (ocean + HasRiver)
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
{
ref var t = ref world.TileAt(x, y);
if (t.Biome == BiomeId.Ocean || (t.Features & FeatureFlags.HasRiver) != 0)
{
dist[x, y] = 0;
queue.Enqueue((x, y));
}
}
(int dx, int dy)[] dirs4 = { (0,-1),(1,0),(0,1),(-1,0) };
while (queue.Count > 0)
{
var (cx, cy) = queue.Dequeue();
foreach (var (ddx, ddy) in dirs4)
{
int nx = cx + ddx, ny = cy + ddy;
if ((uint)nx >= W || (uint)ny >= H) continue;
if (dist[nx, ny] != INF) continue;
dist[nx, ny] = dist[cx, cy] + 1;
queue.Enqueue((nx, ny));
}
}
return dist;
}
private static float LocalElevationVariance(WorldState world, int x, int y, int radius = 2)
{
float sum = 0, sumSq = 0, count = 0;
for (int dy = -radius; dy <= radius; dy++)
for (int dx = -radius; dx <= radius; dx++)
{
int nx = x + dx, ny = y + dy;
if ((uint)nx >= W || (uint)ny >= H) continue;
float e = world.Tiles[nx, ny].Elevation;
sum += e; sumSq += e * e; count++;
}
if (count < 2) return 0f;
float mean = sum / count;
float variance = sumSq / count - mean * mean;
return Math.Min(1f, MathF.Sqrt(Math.Max(0f, variance)) * 10f);
}
private static int ResourceDistance(WorldState world, int x, int y)
{
// Returns approximate distance to nearest mountain, forest, or coast tile
int best = 30;
for (int r = 1; r <= 15; r++)
{
for (int dy = -r; dy <= r; dy++)
for (int dx = -r; dx <= r; dx++)
{
if (Math.Abs(dx) != r && Math.Abs(dy) != r) continue;
int nx = x + dx, ny = y + dy;
if ((uint)nx >= W || (uint)ny >= H) continue;
var b = world.Tiles[nx, ny].Biome;
if (b is BiomeId.MountainAlpine or BiomeId.MountainForested
or BiomeId.TemperateDeciduous or BiomeId.Boreal or BiomeId.Coastal
or BiomeId.Coastal or BiomeId.SubtropicalForest)
{
best = Math.Min(best, r);
goto nextTile;
}
}
nextTile:;
if (best < r) break;
}
return best;
}
private static float TradePotential(float[,] habitat, int x, int y, float median)
{
// Count high-habitability tiles reachable within a radius (centrality proxy)
int count = 0;
const int radius = 12;
for (int dy = -radius; dy <= radius; dy++)
for (int dx = -radius; dx <= radius; dx++)
{
int nx = x + dx, ny = y + dy;
if ((uint)nx >= W || (uint)ny >= H) continue;
if (habitat[nx, ny] > median) count++;
}
float diameter = 2 * radius + 1;
return count / (diameter * diameter);
}
}
@@ -0,0 +1,505 @@
using Theriapolis.Core.Util;
using Theriapolis.Core.World.Polylines;
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 10 — HydrologyGen
/// Drainage simulation → rivers as polylines in world-pixel space + lakes.
/// Per-tile HasRiver and RiverAdjacent flags derived from polylines.
/// </summary>
public sealed class HydrologyGenStage : IWorldGenStage
{
public string Name => "HydrologyGen";
private const int W = C.WORLD_WIDTH_TILES;
private const int H = C.WORLD_HEIGHT_TILES;
// 8 neighbor deltas: N, NE, E, SE, S, SW, W, NW
private static readonly (int dx, int dy)[] Dirs8 =
{
( 0,-1), ( 1,-1), ( 1, 0), ( 1, 1),
( 0, 1), (-1, 1), (-1, 0), (-1,-1),
};
public void Run(WorldGenContext ctx)
{
var world = ctx.World;
var rng = ctx.Rngs["hydro"];
ctx.ReportProgress(Name, 0f);
// ── Step 1: Flow direction ────────────────────────────────────────────
var flowDir = ComputeFlowDirections(world);
ctx.ReportProgress(Name, 0.20f);
// ── Step 2: Flow accumulation ─────────────────────────────────────────
var accumulation = ComputeFlowAccumulation(world, flowDir);
ctx.ReportProgress(Name, 0.40f);
// ── Step 3: Lake detection ────────────────────────────────────────────
DetectAndFillLakes(world, flowDir, accumulation, rng);
ctx.ReportProgress(Name, 0.55f);
// ── Step 4: Extract river paths ───────────────────────────────────────
var riverPaths = ExtractRiverPaths(world, accumulation, flowDir);
ctx.ReportProgress(Name, 0.65f);
// ── Step 5: Guarantee rivers in required regions ──────────────────────
EnsureRequiredRivers(world, riverPaths, accumulation, rng);
ctx.ReportProgress(Name, 0.70f);
// ── Step 6: Carve elevation along rivers ──────────────────────────────
foreach (var path in riverPaths)
CarveRiverPath(world, path.tiles);
ctx.ReportProgress(Name, 0.80f);
// ── Step 7: Convert to polylines ──────────────────────────────────────
var meanderRng = SeededRng.ForSubsystem(world.WorldSeed, C.RNG_MEANDER);
int polyId = 0;
foreach (var path in riverPaths)
{
var poly = BuildRiverPolyline(world, path, polyId++, meanderRng);
world.Rivers.Add(poly);
}
ctx.ReportProgress(Name, 0.92f);
// ── Step 8: Rasterize tile flags ──────────────────────────────────────
foreach (var poly in world.Rivers)
PolylineBuilder.RasterizeToTileFlags(world, poly, setAdjacency: true);
world.StageHashes["HydrologyGen"] = world.HashPolylines();
ctx.LogMessage($"[HydrologyGen] Generated {world.Rivers.Count} rivers.");
ctx.ReportProgress(Name, 1f);
}
// ── Flow direction map ───────────────────────────────────────────────────
private byte[,] ComputeFlowDirections(WorldState world)
{
var flow = new byte[W, H];
// Initialize to Dir.None
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
flow[x, y] = Dir.None;
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
{
ref var tile = ref world.TileAt(x, y);
if (tile.Biome == BiomeId.Ocean) continue;
float lowestElev = tile.Elevation;
int bestDx = 0, bestDy = 0;
foreach (var (ddx, ddy) in Dirs8)
{
int nx = x + ddx;
int ny = y + ddy;
if ((uint)nx >= W || (uint)ny >= H) continue;
float ne = world.Tiles[nx, ny].Elevation;
if (ne < lowestElev)
{
lowestElev = ne;
bestDx = ddx; bestDy = ddy;
}
}
if (bestDx != 0 || bestDy != 0)
flow[x, y] = Dir.FromDelta(bestDx, bestDy);
}
// Handle flat areas: BFS from edges outward
ResolveFlatAreas(world, flow);
return flow;
}
private void ResolveFlatAreas(WorldState world, byte[,] flow)
{
// BFS-compute ocean distance for every cell.
// Flow always moves towards lower ocean distance → guaranteed cycle-free.
var oceanDist = new int[W, H];
const int INF = int.MaxValue / 2;
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
oceanDist[x, y] = world.Tiles[x, y].Biome == BiomeId.Ocean ? 0 : INF;
var bfsQ = new Queue<(int x, int y)>();
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
if (oceanDist[x, y] == 0) bfsQ.Enqueue((x, y));
while (bfsQ.Count > 0)
{
var (cx, cy) = bfsQ.Dequeue();
foreach (var (ddx, ddy) in Dirs8)
{
int nx = cx + ddx, ny = cy + ddy;
if ((uint)nx >= W || (uint)ny >= H) continue;
if (oceanDist[nx, ny] != INF) continue;
oceanDist[nx, ny] = oceanDist[cx, cy] + 1;
bfsQ.Enqueue((nx, ny));
}
}
// Assign direction to every Dir.None land tile:
// - Prefer the neighbor with strictly lower ocean distance.
// - Tiebreak (same distance): prefer lower elevation, then earliest in Dirs8.
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
{
if (flow[x, y] != Dir.None) continue;
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
int myDist = oceanDist[x, y];
int bestDist = myDist;
float bestElev = float.MaxValue;
int bdx = 0, bdy = 0;
for (int di = 0; di < Dirs8.Length; di++)
{
var (ddx, ddy) = Dirs8[di];
int nx = x + ddx, ny = y + ddy;
if ((uint)nx >= W || (uint)ny >= H) continue;
int nd = oceanDist[nx, ny];
float ne = world.Tiles[nx, ny].Elevation;
if (nd < bestDist || (nd == bestDist && ne < bestElev))
{
bestDist = nd; bestElev = ne;
bdx = ddx; bdy = ddy;
}
}
if (bdx != 0 || bdy != 0)
flow[x, y] = Dir.FromDelta(bdx, bdy);
}
}
// ── Flow accumulation ────────────────────────────────────────────────────
/// <summary>
/// Topological-sort based accumulation. Processes each tile exactly once in
/// upstream-to-downstream order, so flat areas with cycles are handled correctly.
/// Cycle tiles (in-degree never reaches 0) receive a self-contribution of 1.
/// </summary>
private int[,] ComputeFlowAccumulation(WorldState world, byte[,] flowDir)
{
var accum = new int[W, H];
var inDeg = new int[W, H];
var processed = new bool[W, H];
// Count in-degrees: how many non-ocean land tiles flow into each tile
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
{
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
byte d = flowDir[x, y];
if (d == Dir.None) continue;
var (ddx, ddy) = Dir.ToDelta(d);
int nx = x + ddx, ny = y + ddy;
if ((uint)nx >= W || (uint)ny >= H) continue;
if (world.Tiles[nx, ny].Biome == BiomeId.Ocean) continue;
inDeg[nx, ny]++;
}
// Seed: all source tiles (no upstream contributors)
var queue = new Queue<(int x, int y)>();
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
{
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
if (inDeg[x, y] == 0) queue.Enqueue((x, y));
}
while (queue.Count > 0)
{
var (cx, cy) = queue.Dequeue();
if (processed[cx, cy]) continue;
processed[cx, cy] = true;
accum[cx, cy]++; // self-contribution
byte d = flowDir[cx, cy];
if (d == Dir.None) continue;
var (ddx, ddy) = Dir.ToDelta(d);
int nx = cx + ddx, ny = cy + ddy;
if ((uint)nx >= W || (uint)ny >= H) continue;
if (world.Tiles[nx, ny].Biome == BiomeId.Ocean) continue;
accum[nx, ny] += accum[cx, cy];
if (--inDeg[nx, ny] == 0)
queue.Enqueue((nx, ny));
}
// Tiles still unprocessed are in cycles — assign self-contribution
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
{
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
if (!processed[x, y]) accum[x, y] = Math.Max(1, accum[x, y]);
}
return accum;
}
// ── Lake detection and filling ───────────────────────────────────────────
private void DetectAndFillLakes(WorldState world, byte[,] flowDir, int[,] accum, SeededRng rng)
{
var visited = new bool[W, H];
for (int y = 1; y < H - 1; y++)
for (int x = 1; x < W - 1; x++)
{
if (visited[x, y]) continue;
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
if (flowDir[x, y] != Dir.None) continue; // has a direction — not a sink
// BFS flood fill from this sink point
var basin = new List<(int, int)>();
var q = new Queue<(int, int)>();
q.Enqueue((x, y));
visited[x, y] = true;
float outletElev = world.Tiles[x, y].Elevation;
while (q.Count > 0)
{
var (cx, cy) = q.Dequeue();
basin.Add((cx, cy));
foreach (var (ddx, ddy) in Dirs8)
{
int nx2 = cx + ddx;
int ny2 = cy + ddy;
if ((uint)nx2 >= W || (uint)ny2 >= H) continue;
if (visited[nx2, ny2]) continue;
if (world.Tiles[nx2, ny2].Biome == BiomeId.Ocean) continue;
if (world.Tiles[nx2, ny2].Elevation > outletElev + 0.05f) continue;
visited[nx2, ny2] = true;
q.Enqueue((nx2, ny2));
}
}
if (basin.Count < C.LAKE_MIN_AREA) continue;
// Fill the basin as a lake (set elevation to outlet and biome to Ocean for rendering)
foreach (var (lx, ly) in basin)
{
ref var tile = ref world.TileAt(lx, ly);
tile.Elevation = outletElev;
tile.Biome = BiomeId.Ocean; // reuse ocean for inland lakes (renders as water)
tile.Features |= FeatureFlags.HasRiver; // mark as water body
flowDir[lx, ly] = Dir.None;
}
}
}
// ── River path extraction ────────────────────────────────────────────────
private record RiverPath(List<(int x, int y)> tiles, int maxAccum);
private List<RiverPath> ExtractRiverPaths(WorldState world, int[,] accum, byte[,] flowDir)
{
var paths = new List<RiverPath>();
var onRiver = new bool[W, H];
// Find all tiles with flow >= threshold, sorted by accumulation (descending)
var highAccum = new List<(int accum, int x, int y)>();
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
{
if (accum[x, y] >= C.RIVER_MIN_FLOW_ACCUM && world.Tiles[x, y].Biome != BiomeId.Ocean)
highAccum.Add((accum[x, y], x, y));
}
// Sort ASCENDING so we start at headwaters (low accum = most upstream).
// Each headwater traces one long path to coast; downstream tiles are marked onRiver
// and skipped by subsequent iterations — producing one river per tributary.
highAccum.Sort((a, b) => a.accum.CompareTo(b.accum));
foreach (var (_, sx, sy) in highAccum)
{
if (paths.Count >= C.RIVER_MAX_COUNT) break; // hard cap
if (onRiver[sx, sy]) continue;
var tilePath = new List<(int x, int y)>();
int curX = sx, curY = sy;
int maxAccumVal = 0;
int safety = W + H;
while (safety-- > 0)
{
if (onRiver[curX, curY]) break;
if (world.Tiles[curX, curY].Biome == BiomeId.Ocean) break;
tilePath.Add((curX, curY));
maxAccumVal = Math.Max(maxAccumVal, accum[curX, curY]);
// Navigate downstream via precomputed flow direction
byte dir = flowDir[curX, curY];
if (dir == Dir.None) break; // true sink
var (ddx, ddy) = Dir.ToDelta(dir);
int nextX = curX + ddx, nextY = curY + ddy;
if ((uint)nextX >= W || (uint)nextY >= H) break;
curX = nextX; curY = nextY;
}
// Only mark tiles as onRiver AFTER confirming path length.
// Premature marking (before the check) would block subsequent upstream traces.
if (tilePath.Count >= 3)
{
foreach (var (tx, ty) in tilePath)
onRiver[tx, ty] = true;
paths.Add(new RiverPath(tilePath, maxAccumVal));
}
}
return paths;
}
// ── Ensure required macro-region rivers ──────────────────────────────────
private void EnsureRequiredRivers(WorldState world, List<RiverPath> paths, int[,] accum, SeededRng rng)
{
// Check which required regions have rivers
var required = new[]
{
"temperate_deciduous", "temperate_forest",
"temperate_grassland",
"subtropical_forest",
};
var covered = new HashSet<string>();
foreach (var path in paths)
{
if (path.maxAccum < C.RIVER_MODERATE_THRESHOLD) continue;
foreach (var (tx, ty) in path.tiles)
{
var macro = world.MacroCellForTile(world.TileAt(tx, ty));
covered.Add(macro.BiomeType.ToLowerInvariant());
}
}
// For each uncovered required region, find its highest tile and add a short river
foreach (var req in required)
{
if (covered.Contains(req)) continue;
// Find the highest-elevation tile in this macro region
int bestX = -1, bestY = -1;
float bestElev = 0f;
for (int y = 0; y < H; y += 4)
for (int x = 0; x < W; x += 4)
{
ref var tile = ref world.TileAt(x, y);
if (tile.Biome == BiomeId.Ocean) continue;
var macro = world.MacroCellForTile(in tile);
if (!macro.BiomeType.ToLowerInvariant().Contains(req.Split('_')[0])) continue;
if (tile.Elevation > bestElev)
{
bestElev = tile.Elevation; bestX = x; bestY = y;
}
}
if (bestX < 0) continue;
// Trace a path from that tile downhill
var tilePath = new List<(int, int)>();
int cx = bestX, cy = bestY;
int safety = 200;
while (safety-- > 0 && world.Tiles[cx, cy].Biome != BiomeId.Ocean)
{
tilePath.Add((cx, cy));
int bx = cx, by = cy;
float be = world.Tiles[cx, cy].Elevation;
foreach (var (ddx, ddy) in Dirs8)
{
int nx = cx + ddx, ny = cy + ddy;
if ((uint)nx >= W || (uint)ny >= H) continue;
if (world.Tiles[nx, ny].Elevation < be)
{
be = world.Tiles[nx, ny].Elevation; bx = nx; by = ny;
}
}
if (bx == cx && by == cy) break;
cx = bx; cy = by;
}
if (tilePath.Count >= 5)
{
// Override accumulation to moderate threshold so it qualifies
foreach (var (tx, ty) in tilePath)
accum[tx, ty] = Math.Max(accum[tx, ty], C.RIVER_MODERATE_THRESHOLD);
paths.Add(new RiverPath(tilePath, C.RIVER_MODERATE_THRESHOLD));
}
}
}
// ── Elevation carving ────────────────────────────────────────────────────
private static void CarveRiverPath(WorldState world, List<(int x, int y)> tiles)
{
foreach (var (x, y) in tiles)
{
ref var tile = ref world.TileAt(x, y);
if (tile.Biome == BiomeId.Ocean) continue;
// Don't carve mountain tiles — river carving would push them below the macro
// ElevationFloor constraint that BiomeAssignStage enforced.
if (tile.Biome is BiomeId.MountainAlpine or BiomeId.MountainForested) continue;
tile.Elevation = Math.Max(WorldState.SeaLevel, tile.Elevation - C.RIVER_CARVE_DEPTH);
}
}
// ── Polyline conversion ──────────────────────────────────────────────────
private static Polyline BuildRiverPolyline(WorldState world, RiverPath path, int id, SeededRng rng)
{
var poly = new Polyline { Type = PolylineType.River, Id = id };
poly.FlowAccumulation = path.maxAccum;
// Classify
if (path.maxAccum >= C.RIVER_MAJOR_THRESHOLD)
{
poly.RiverClassification = RiverClass.MajorRiver;
poly.Width = 3f;
}
else if (path.maxAccum >= C.RIVER_MODERATE_THRESHOLD)
{
poly.RiverClassification = RiverClass.River;
poly.Width = 2f;
}
else
{
poly.RiverClassification = RiverClass.Stream;
poly.Width = 1f;
}
// Control points from tile centers
var controlPts = path.tiles
.Select(t => PolylineBuilder.TileToWorldPixel(t.x, t.y))
.ToList();
// Smooth
var smoothed = PolylineBuilder.CatmullRomSmooth(controlPts);
// Meander amplitude based on average elevation
float avgElev = path.tiles.Average(t => world.Tiles[t.x, t.y].Elevation);
float amp = avgElev > 0.55f ? C.MEANDER_AMP_MOUNTAIN : C.MEANDER_AMP_FLAT;
ulong noiseSeed = world.WorldSeed ^ C.RNG_HYDRO ^ (ulong)id;
PolylineBuilder.ApplyMeanderNoise(smoothed, amp, C.MEANDER_FREQ, noiseSeed);
poly.Points.AddRange(smoothed);
poly.SimplifiedPoints = PolylineBuilder.RDPSimplify(poly.Points);
return poly;
}
}
@@ -0,0 +1,49 @@
using Theriapolis.Core.Data;
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 2 — MacroTemplateLoad
/// Reads macro_template.json and Content/Data/biomes.json.
/// Populates ctx.World.MacroGrid and ctx.World.BiomeDefs.
/// Also stamps macro cell coordinates onto every tile.
/// </summary>
public sealed class MacroTemplateLoadStage : IWorldGenStage
{
public string Name => "MacroTemplateLoad";
public void Run(WorldGenContext ctx)
{
var loader = new ContentLoader(ctx.DataDirectory);
var template = loader.LoadMacroTemplate();
ctx.World.MacroGrid = template.Build();
ctx.World.BiomeDefs = loader.LoadBiomes();
ctx.World.FactionDefs = loader.LoadFactions();
// Stamp macro cell coordinates onto all tiles (used later for constraints)
int cellW = C.WORLD_WIDTH_TILES / C.MACRO_GRID_WIDTH;
int cellH = C.WORLD_HEIGHT_TILES / C.MACRO_GRID_HEIGHT;
for (int ty = 0; ty < C.WORLD_HEIGHT_TILES; ty++)
{
int my = ty / cellH;
for (int tx = 0; tx < C.WORLD_WIDTH_TILES; tx++)
{
int mx = tx / cellW;
ctx.World.Tiles[tx, ty].MacroX = (byte)Math.Min(mx, C.MACRO_GRID_WIDTH - 1);
ctx.World.Tiles[tx, ty].MacroY = (byte)Math.Min(my, C.MACRO_GRID_HEIGHT - 1);
}
}
// Hash: XOR of all biome-type string hashes from the macro grid
ulong hash = 0;
for (int y = 0; y < C.MACRO_GRID_HEIGHT; y++)
for (int x = 0; x < C.MACRO_GRID_WIDTH; x++)
hash ^= (ulong)ctx.World.MacroGrid[x, y].BiomeType.GetHashCode();
ctx.World.StageHashes["MacroTemplateLoad"] = hash;
ctx.LogMessage($"[MacroTemplateLoad] Loaded {C.MACRO_GRID_WIDTH}×{C.MACRO_GRID_HEIGHT} macro cells, {ctx.World.BiomeDefs.Length} biome defs.");
}
}
@@ -0,0 +1,60 @@
using Theriapolis.Core.Util;
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 4 — MoistureGen
/// Independent noise layer, combined with elevation to derive biome detail.
/// Respects macro-cell moisture floors/ceilings.
/// </summary>
public sealed class MoistureGenStage : IWorldGenStage
{
public string Name => "MoistureGen";
public void Run(WorldGenContext ctx)
{
ulong seed = ctx.World.WorldSeed ^ C.RNG_MOISTURE;
var noise = new FastNoiseLite
{
Seed = (int)(seed & 0x7FFFFFFF),
Frequency = 2.3f / C.WORLD_WIDTH_TILES,
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
Fractal = FastNoiseLite.FractalType.FBm,
Octaves = 5,
Lacunarity = 2.0f,
Gain = 0.5f,
};
int W = C.WORLD_WIDTH_TILES;
int H = C.WORLD_HEIGHT_TILES;
Parallel.For(0, H, ty =>
{
for (int tx = 0; tx < W; tx++)
{
float elev = ctx.World.Tiles[tx, ty].Elevation;
float raw = noise.GetNoise01((float)tx, (float)ty);
// Elevation modifier: high elevation slightly reduces moisture
raw -= (elev - 0.5f) * 0.25f;
raw = Math.Clamp(raw, 0f, 1f);
// Apply macro cell constraints for all tiles including ocean.
// Skipping ocean tiles here was a shortcut that breaks macro constraints
// when later stages (CoastalFeatureGen, BorderDistortion) raise ocean
// tiles to land: those tiles would inherit the wrong moisture value.
// Use the tile's stored warped macro coords so moisture follows
// the same organic cell boundaries as elevation.
var cell = ctx.World.MacroCellForTile(ctx.World.Tiles[tx, ty]);
float m = cell.MoistureFloor + raw * (cell.MoistureCeiling - cell.MoistureFloor);
m = Math.Clamp(m, 0f, 1f);
ctx.World.Tiles[tx, ty].Moisture = m;
}
});
ctx.World.StageHashes["MoistureGen"] = ctx.World.HashMoisture();
ctx.LogMessage($"[MoistureGen] Moisture hash: 0x{ctx.World.StageHashes["MoistureGen"]:X16}");
}
}
@@ -0,0 +1,353 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 13 — NarrativeAnchorPlace
/// Places the 6 narrative anchor settlements (Sanctum Fidelis first, then others).
/// Must run BEFORE general settlement placement.
/// </summary>
public sealed class NarrativeAnchorPlaceStage : IWorldGenStage
{
public string Name => "NarrativeAnchorPlace";
private const int W = C.WORLD_WIDTH_TILES;
private const int H = C.WORLD_HEIGHT_TILES;
private int _nextId = 1;
private int[,] _componentIds = null!;
private int _mainLandmassId;
public void Run(WorldGenContext ctx)
{
var world = ctx.World;
var rng = SeededRng.ForSubsystem(world.WorldSeed, C.RNG_ANCHOR);
// Confine anchors to the main (largest) landmass so the road network
// doesn't need to cross ocean to reach them.
var (componentIds, componentSizes) = LandmassMap.Compute(world);
_componentIds = componentIds;
_mainLandmassId = LandmassMap.LargestComponentId(componentSizes);
var placed = new List<Settlement>();
// Placement order: most-constrained first
PlaceSanctumFidelis(world, placed, rng);
PlaceHeartstone (world, placed, rng);
PlaceThornfield (world, placed, rng);
PlaceFortDustwall (world, placed, rng);
PlaceMillhaven (world, placed, rng);
PlaceTheTangles (world, placed, rng);
foreach (var s in placed)
{
world.Settlements.Add(s);
MarkSettlementTiles(world, s);
}
ctx.LogMessage($"[NarrativeAnchorPlace] Placed {placed.Count} anchor settlements.");
}
// ── Anchor placements ─────────────────────────────────────────────────────
private void PlaceSanctumFidelis(WorldState world, List<Settlement> placed, SeededRng rng)
{
// Preferred: center region, near a river
var candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
{
if (tile.Biome == BiomeId.Ocean) return false;
if (tile.MacroX < 12 || tile.MacroX > 22) return false;
if (tile.MacroY < 10 || tile.MacroY > 22) return false;
return (tile.Features & (FeatureFlags.HasRiver | FeatureFlags.RiverAdjacent)) != 0;
});
if (candidates.Count == 0)
{
// Relaxed: center region, any non-ocean land tile
candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
tile.Biome != BiomeId.Ocean &&
tile.MacroX >= 10 && tile.MacroX <= 24 &&
tile.MacroY >= 8 && tile.MacroY <= 24);
}
if (candidates.Count == 0)
{
// Last resort: any suitable non-ocean tile
candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
tile.Biome != BiomeId.Ocean && tile.Elevation < 0.75f);
}
var best = PickBest(candidates, world, placed, rng, (int)C.ANCHOR_MIN_DIST);
if (best.HasValue)
{
var s = MakeSettlement(world, best.Value.x, best.Value.y, 1, NarrativeAnchor.SanctumFidelis);
s.Name = "Sanctum Fidelis";
placed.Add(s);
}
}
private void PlaceHeartstone(WorldState world, List<Settlement> placed, SeededRng rng)
{
// Preferred: high mountain in western half
var candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
{
if (tile.Biome == BiomeId.Ocean) return false;
if (tile.Biome is not (BiomeId.MountainAlpine or BiomeId.MountainForested)) return false;
if (tile.Elevation < 0.60f) return false;
return tile.MacroX <= 14; // western half
});
if (candidates.Count == 0)
{
// Relaxed: any mountain tile anywhere, no elevation minimum
candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
tile.Biome != BiomeId.Ocean &&
tile.Biome is BiomeId.MountainAlpine or BiomeId.MountainForested);
}
if (candidates.Count == 0)
{
// Last resort: any highland tile (elevation ≥ 0.50) that isn't ocean
candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
tile.Biome != BiomeId.Ocean && tile.Elevation >= 0.50f);
}
var best = PickBest(candidates, world, placed, rng, (int)(C.ANCHOR_MIN_DIST * 0.5f));
if (best.HasValue)
{
var s = MakeSettlement(world, best.Value.x, best.Value.y, 2, NarrativeAnchor.Heartstone);
s.Name = "Heartstone";
placed.Add(s);
}
}
private void PlaceThornfield(WorldState world, List<Settlement> placed, SeededRng rng)
{
var candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
{
if (tile.Biome == BiomeId.Ocean) return false;
if (tile.MacroX < 16) return false;
var dev = macro.Development?.ToLowerInvariant() ?? "";
if (!dev.Contains("industrial") && !dev.Contains("urban")) return false;
return (tile.Features & (FeatureFlags.HasRiver | FeatureFlags.RiverAdjacent)) != 0;
});
if (candidates.Count == 0)
{
// Relaxed: any eastern developed region with river
candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
tile.Biome != BiomeId.Ocean && tile.MacroX >= 16 &&
(tile.Features & (FeatureFlags.HasRiver | FeatureFlags.RiverAdjacent)) != 0);
}
if (candidates.Count == 0)
{
// Last resort: any non-ocean tile with a river (drop east-half
// requirement). On small maps the eastern half may not happen to
// contain any river tiles for a given seed; Thornfield still
// needs to be placed somewhere reasonable.
candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
tile.Biome != BiomeId.Ocean &&
(tile.Features & (FeatureFlags.HasRiver | FeatureFlags.RiverAdjacent)) != 0);
}
if (candidates.Count == 0)
{
// Absolute fallback: any non-ocean land tile. Matches the
// last-resort tier that SanctumFidelis/Heartstone already use.
candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
tile.Biome != BiomeId.Ocean);
}
var best = PickBest(candidates, world, placed, rng, (int)C.ANCHOR_MIN_DIST);
if (best.HasValue)
{
var s = MakeSettlement(world, best.Value.x, best.Value.y, 2, NarrativeAnchor.Thornfield);
s.Name = "Thornfield";
placed.Add(s);
}
}
private void PlaceFortDustwall(WorldState world, List<Settlement> placed, SeededRng rng)
{
var candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
{
if (tile.Biome == BiomeId.Ocean) return false;
if (tile.Biome is not (BiomeId.TemperateGrassland or BiomeId.Scrubland
or BiomeId.TemperateDeciduous)) return false;
return tile.MacroX >= 10 && tile.MacroX <= 22 &&
tile.MacroY >= 12 && tile.MacroY <= 26;
});
var best = PickBest(candidates, world, placed, rng, (int)C.ANCHOR_MIN_DIST);
if (best.HasValue)
{
int tier = rng.NextBool(0.6) ? 2 : 3;
var s = MakeSettlement(world, best.Value.x, best.Value.y, tier, NarrativeAnchor.FortDustwall);
s.Name = "Fort Dustwall";
placed.Add(s);
}
}
private void PlaceMillhaven(WorldState world, List<Settlement> placed, SeededRng rng)
{
// Preferred: eastern forest/grassland near a river
var candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
{
if (tile.Biome == BiomeId.Ocean) return false;
if (tile.Biome is not (BiomeId.TemperateDeciduous or BiomeId.ForestEdge
or BiomeId.TemperateGrassland)) return false;
if (tile.MacroX < 16) return false;
if ((tile.Features & (FeatureFlags.HasRiver | FeatureFlags.RiverAdjacent)) == 0) return false;
// At least 40 tiles from any Tier 2+ anchor already placed
foreach (var p in placed)
{
if (p.Tier <= 2)
{
float d = MathF.Sqrt((x - p.TileX) * (float)(x - p.TileX)
+ (y - p.TileY) * (float)(y - p.TileY));
if (d < 40) return false;
}
}
return true;
});
if (candidates.Count == 0)
{
// Relaxed: drop river requirement, keep biome and location
candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
tile.Biome != BiomeId.Ocean &&
tile.Biome is BiomeId.TemperateDeciduous or BiomeId.ForestEdge
or BiomeId.TemperateGrassland &&
tile.MacroX >= 12);
}
if (candidates.Count == 0)
{
// Last resort: any non-ocean land tile in the eastern two-thirds
candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
tile.Biome != BiomeId.Ocean && tile.MacroX >= 10);
}
var best = PickBest(candidates, world, placed, rng, (int)C.ANCHOR_MIN_DIST / 2);
if (best.HasValue)
{
var s = MakeSettlement(world, best.Value.x, best.Value.y, 3, NarrativeAnchor.Millhaven);
s.Name = "Millhaven";
placed.Add(s);
}
}
private void PlaceTheTangles(WorldState world, List<Settlement> placed, SeededRng rng)
{
var candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
{
if (tile.Biome == BiomeId.Ocean) return false;
if (tile.Biome is not (BiomeId.SubtropicalForest or BiomeId.Wetland
or BiomeId.Mangrove or BiomeId.MarshEdge)) return false;
var cov = macro.Covenant?.ToLowerInvariant() ?? "";
return cov is "weak" or "nominal";
});
int count = rng.NextInt(2, 5);
bool firstTangle = true;
for (int i = 0; i < count && candidates.Count > 0; i++)
{
var best = PickBest(candidates, world, placed, rng, C.SETTLE_MIN_DIST_TIER4);
if (!best.HasValue) break;
var s = MakeSettlement(world, best.Value.x, best.Value.y, 4, NarrativeAnchor.TheTangles);
s.Name = firstTangle ? "Thornback Hollow" : NameGenerator.Generate(rng, "subtropical");
firstTangle = false;
placed.Add(s);
MarkSettlementTiles(world, s);
candidates = candidates
.Where(c => !IsCloseTo(c.x, c.y, best.Value.x, best.Value.y, C.SETTLE_MIN_DIST_TIER4))
.ToList();
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private List<(int x, int y)> CollectCandidates(
WorldState world,
List<Settlement> placed,
Func<int, int, WorldTile, MacroCell, bool> filter,
int step = 2)
{
var result = new List<(int x, int y)>();
for (int y = 0; y < H; y += step)
for (int x = 0; x < W; x += step)
{
if (_componentIds[x, y] != _mainLandmassId) continue;
ref var tile = ref world.TileAt(x, y);
var macro = world.MacroCellForTile(in tile);
if (!filter(x, y, tile, macro)) continue;
if (IsTooCloseToAnySettlement(x, y, placed, C.SETTLE_MIN_DIST_TIER2)) continue;
result.Add((x, y));
}
return result;
}
private static (int x, int y)? PickBest(
List<(int x, int y)> candidates,
WorldState world,
List<Settlement> placed,
SeededRng rng,
int anchorMinDist)
{
if (candidates.Count == 0) return null;
var ranked = candidates
.Where(c => !IsTooCloseToAnySettlement(c.x, c.y, placed, anchorMinDist))
.OrderByDescending(c => world.Habitability?[c.x, c.y] ?? 0f)
.Take(10)
.ToList();
if (ranked.Count == 0)
ranked = candidates.OrderByDescending(c => world.Habitability?[c.x, c.y] ?? 0f)
.Take(5).ToList();
if (ranked.Count == 0) return null;
return ranked[rng.NextInt(Math.Min(3, ranked.Count))];
}
private Settlement MakeSettlement(WorldState world, int x, int y, int tier, NarrativeAnchor anchor)
{
var (nx, ny) = SettlementPlaceStage.NudgeOffRiver(world, x, y);
return new Settlement
{
Id = _nextId++,
Tier = tier,
TileX = nx,
TileY = ny,
Anchor = anchor,
};
}
private static void MarkSettlementTiles(WorldState world, Settlement s)
{
for (int dy = -2; dy <= 2; dy++)
for (int dx = -2; dx <= 2; dx++)
{
int nx = s.TileX + dx, ny = s.TileY + dy;
if ((uint)nx >= W || (uint)ny >= H) continue;
ref var tile = ref world.TileAt(nx, ny);
tile.Features |= FeatureFlags.IsSettlement;
tile.SettlementId = (ushort)Math.Min(s.Id, ushort.MaxValue);
}
}
private static bool IsCloseTo(int x1, int y1, int x2, int y2, int minDist)
=> (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2) < minDist * minDist;
private static bool IsTooCloseToAnySettlement(int x, int y, List<Settlement> placed, int minDist)
{
foreach (var s in placed)
if (IsCloseTo(x, y, s.TileX, s.TileY, minDist)) return true;
return false;
}
}
@@ -0,0 +1,106 @@
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 20 — PoIPlacement
/// Additional Tier 5 PoIs beyond those placed by SettlementPlaceStage.
/// This stage is a lightweight pass to ensure minimum PoI count is met.
/// (SettlementPlaceStage already places Tier 5 — this stage tops up if needed.)
/// </summary>
public sealed class PoIPlacementStage : IWorldGenStage
{
public string Name => "PoIPlacement";
private const int W = C.WORLD_WIDTH_TILES;
private const int H = C.WORLD_HEIGHT_TILES;
public void Run(WorldGenContext ctx)
{
var world = ctx.World;
var rng = ctx.Rngs["poi"];
int existing = world.Settlements.Count(s => s.IsPoi);
int nextId = world.Settlements.Count + 1;
// SettlementPlaceStage may have already placed Tier 5 PoIs
// This stage ensures the minimum is met and tags the world tiles
int needed = Math.Max(0, C.SETTLE_TIER5_MIN - existing);
if (needed == 0)
{
ctx.LogMessage($"[PoIPlacement] {existing} PoIs already placed — no top-up needed.");
return;
}
// Find low-habitability land tiles not near settlements
var candidates = new List<(int x, int y)>();
for (int y = 0; y < H; y += 4)
for (int x = 0; x < W; x += 4)
{
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
float hab = world.Habitability?[x, y] ?? 0f;
if (hab > 0.35f) continue;
if ((world.Tiles[x, y].Features & FeatureFlags.IsSettlement) != 0) continue;
if (IsTooCloseToSettlement(world, x, y)) continue;
candidates.Add((x, y));
}
rng.Shuffle(candidates.ToArray().AsSpan());
int placed = 0;
foreach (var (x, y) in candidates)
{
if (placed >= needed) break;
if ((world.Tiles[x, y].Features & FeatureFlags.IsPoi) != 0) continue;
var poiType = PickPoiType(world.Tiles[x, y].Biome, rng);
var s = new Settlement
{
Id = nextId++,
Tier = 5,
TileX = x,
TileY = y,
IsPoi = true,
PoiType = poiType,
Name = DescribePoi(poiType),
};
world.Settlements.Add(s);
world.TileAt(x, y).Features |= FeatureFlags.IsPoi;
placed++;
}
ctx.LogMessage($"[PoIPlacement] Placed {placed} additional PoIs (total: {world.Settlements.Count(s => s.IsPoi)}).");
}
private static bool IsTooCloseToSettlement(WorldState world, int x, int y)
{
foreach (var s in world.Settlements)
{
if (s.IsPoi) continue;
int dx = x - s.TileX, dy = y - s.TileY;
if (dx * dx + dy * dy < C.POI_MIN_DIST_FROM_SETTLE * C.POI_MIN_DIST_FROM_SETTLE)
return true;
}
return false;
}
private static PoiType PickPoiType(BiomeId biome, Util.SeededRng rng)
{
return biome switch
{
BiomeId.MountainAlpine or BiomeId.MountainForested => rng.NextBool() ? PoiType.AbandonedMine : PoiType.NaturalCave,
BiomeId.Tundra or BiomeId.Boreal => PoiType.ImperiumRuin,
BiomeId.Wetland or BiomeId.SubtropicalForest => rng.NextBool() ? PoiType.NaturalCave : PoiType.CultDen,
_ => (PoiType)(rng.NextInt(1, 6)),
};
}
private static string DescribePoi(PoiType t) => t switch
{
PoiType.ImperiumRuin => "Imperium Ruin",
PoiType.AbandonedMine => "Abandoned Mine",
PoiType.CultDen => "Hidden Den",
PoiType.NaturalCave => "Natural Cave",
PoiType.OvergrownSettlement=> "Overgrown Ruins",
_ => "Unknown Site",
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,357 @@
using Theriapolis.Core.Util;
using Theriapolis.Core.World.Polylines;
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 16 — RailNetworkGen
/// A* from the capital (Sanctum Fidelis) to each Tier 2 city (except Heartstone).
/// Plus one transcontinental line: easternmost coastal Tier 2/3 to westernmost Tier 2.
/// Addendum A §2 exclusion enforced: rivers are impassable, river-adjacent parallel is INFINITY.
/// T-junctions are emitted as railway wyes: each sub-path endpoint sitting on
/// existing rail forks into two polylines whose visible track extends onto the
/// main line in opposite tangent directions, producing the familiar Y geometry
/// rather than a raw perpendicular corner.
/// </summary>
public sealed class RailNetworkGenStage : IWorldGenStage
{
public string Name => "RailNetworkGen";
private const int W = C.WORLD_WIDTH_TILES;
private const int H = C.WORLD_HEIGHT_TILES;
private const FeatureFlags PathPreserveMask =
FeatureFlags.HasRoad | FeatureFlags.HasRiver | FeatureFlags.HasRail | FeatureFlags.IsSettlement;
// Railway-wye leg dimensions. When a sub-path endpoint sits on pre-existing
// rail (not a settlement), EmitRailSubPath emits two polylines whose visible
// track extends WYE_LEG_LENGTH_PX past the junction tile along ±t of the
// main line. A ghost control point WYE_LEG_GHOST_PX further out biases the
// Catmull-Rom tangent at the leg end toward the main line's direction,
// producing a smooth ~45° merge. Both are 2 tiles — long enough to read as
// a wye, short enough to stay within the junction's local area.
private const float WYE_LEG_LENGTH_PX = 2f * C.WORLD_TILE_PIXELS;
private const float WYE_LEG_GHOST_PX = 2f * C.WORLD_TILE_PIXELS;
public void Run(WorldGenContext ctx)
{
if (!C.ENABLE_RAIL)
{
ctx.LogMessage("[RailNetworkGen] Rail disabled via C.ENABLE_RAIL — skipping.");
return;
}
var world = ctx.World;
var pathfinder = new AStarPathfinder();
// Find capital
var capital = world.Settlements.FirstOrDefault(s => s.Anchor == NarrativeAnchor.SanctumFidelis);
if (capital == null)
{
ctx.LogMessage("[RailNetworkGen] No capital found — skipping rail.");
return;
}
int polyId = 0;
// ── Capital → each Tier 2 city (except Heartstone) ──────────────────
var tier2 = world.Settlements
.Where(s => s.Tier <= 2 && s != capital && s.Anchor != NarrativeAnchor.Heartstone)
.ToList();
foreach (var dest in tier2)
{
var costFn = RailCostFn(world);
var path = pathfinder.FindPath(capital.TileX, capital.TileY, dest.TileX, dest.TileY, costFn);
if (path == null) { ctx.LogMessage($"[RailNetworkGen] No path to {dest.Name}"); continue; }
PolylineBuilder.SmoothStaircases(world, path, PathPreserveMask, costFn);
PolylineBuilder.LimitTurnAngle(world, path, C.MAX_RAIL_TURN_DEGREES, PathPreserveMask, costFn);
var segments = PolylineBuilder.SplitByExistingFeature(world, path, FeatureFlags.HasRail);
foreach (var seg in segments)
EmitRailSubPath(world, seg, ref polyId, capital.Id, dest.Id);
dest.HasRailStation = true;
}
capital.HasRailStation = true;
// ── Transcontinental line ─────────────────────────────────────────────
var eastPort = world.Settlements
.Where(s => s.Tier <= 3 && !s.IsPoi &&
(world.Tiles[Math.Clamp(s.TileX, 0, W-1), Math.Clamp(s.TileY, 0, H-1)].Features
& (FeatureFlags.IsCoast | FeatureFlags.HasRiver)) != 0)
.OrderByDescending(s => s.TileX)
.FirstOrDefault();
var westTier2 = world.Settlements
.Where(s => s.Tier == 2 && s.Anchor != NarrativeAnchor.Heartstone)
.OrderBy(s => s.TileX)
.FirstOrDefault();
if (eastPort != null && westTier2 != null && eastPort != westTier2)
{
var costFn = RailCostFn(world);
var path = pathfinder.FindPath(eastPort.TileX, eastPort.TileY, westTier2.TileX, westTier2.TileY, costFn);
if (path != null)
{
PolylineBuilder.SmoothStaircases(world, path, PathPreserveMask, costFn);
PolylineBuilder.LimitTurnAngle(world, path, C.MAX_RAIL_TURN_DEGREES, PathPreserveMask, costFn);
var segments = PolylineBuilder.SplitByExistingFeature(world, path, FeatureFlags.HasRail);
foreach (var seg in segments)
EmitRailSubPath(world, seg, ref polyId, eastPort.Id, westTier2.Id);
}
}
// Verify Heartstone is not near rail
var heartstone = world.Settlements.FirstOrDefault(s => s.Anchor == NarrativeAnchor.Heartstone);
if (heartstone != null)
{
ref var ht = ref world.TileAt(heartstone.TileX, heartstone.TileY);
if ((ht.Features & FeatureFlags.HasRail) != 0 ||
(ht.Features & FeatureFlags.RailroadAdjacent) != 0)
ctx.LogMessage("[RailNetworkGen] WARNING: Heartstone has rail adjacent — anchor placement issue.");
}
world.StageHashes["RailNetworkGen"] = world.HashPolylines();
ctx.LogMessage($"[RailNetworkGen] Generated {world.Rails.Count} rail lines.");
}
// ── Cost function ─────────────────────────────────────────────────────────
private static Func<int, int, int, int, byte, float> RailCostFn(WorldState world)
{
return (fx, fy, tx, ty, entryDir) =>
{
if ((uint)tx >= W || (uint)ty >= H) return float.PositiveInfinity;
ref var tile = ref world.TileAt(tx, ty);
// Ocean tiles are impassable — no rail over water
if (tile.Biome == BiomeId.Ocean) return float.PositiveInfinity;
// Rails are engineered infrastructure that share tracks — always prefer
// joining existing rail (no settlement halo, unlike roads).
if ((tile.Features & FeatureFlags.HasRail) != 0) return C.EXISTING_RAIL_COST;
// River tiles: perpendicular crossing allowed at bridge cost; parallel travel blocked
if ((tile.Features & FeatureFlags.HasRiver) != 0)
{
if (tile.RiverFlowDir != Dir.None && Dir.IsParallel(entryDir, tile.RiverFlowDir))
return float.PositiveInfinity;
return C.RAIL_BRIDGE_COST + 1f + tile.Elevation * 4f;
}
// River-adjacent: parallel = INFINITY, perpendicular = RAIL_BRIDGE_COST
if ((tile.Features & FeatureFlags.RiverAdjacent) != 0 && tile.RiverFlowDir != Dir.None)
{
if (Dir.IsParallel(entryDir, tile.RiverFlowDir))
return float.PositiveInfinity;
if (Dir.IsPerpendicular(entryDir, tile.RiverFlowDir))
return C.RAIL_BRIDGE_COST;
}
// Base terrain cost (mountains expensive for rail)
float elev = tile.Elevation;
float terrainCost = 1f + elev * 4f;
// Biome modifiers
terrainCost += tile.Biome switch
{
BiomeId.Wetland or BiomeId.MarshEdge => 5f,
BiomeId.MountainAlpine => 8f,
BiomeId.MountainForested => 4f,
_ => 0f,
};
return terrainCost;
};
}
// ── Polyline construction ─────────────────────────────────────────────────
/// <summary>
/// Emits one, two, or four polylines for a single sub-path depending on how
/// many endpoints sit on pre-existing rail. A junction endpoint produces a
/// wye: two polylines whose visible track extends in opposite tangent
/// directions onto the main line, giving the familiar railway "Y" instead
/// of a raw T. Non-junction endpoints contribute a single null tangent
/// (one polyline) to the Cartesian product.
/// </summary>
private static void EmitRailSubPath(
WorldState world,
List<(int X, int Y)> seg,
ref int polyId,
int fromId, int toId)
{
if (seg.Count < 2) return;
bool startIsJunction =
(world.Tiles[seg[0].X, seg[0].Y].Features & FeatureFlags.HasRail) != 0 &&
(world.Tiles[seg[0].X, seg[0].Y].Features & FeatureFlags.IsSettlement) == 0;
bool endIsJunction =
(world.Tiles[seg[^1].X, seg[^1].Y].Features & FeatureFlags.HasRail) != 0 &&
(world.Tiles[seg[^1].X, seg[^1].Y].Features & FeatureFlags.IsSettlement) == 0;
Vec2? startT = null, endT = null;
if (startIsJunction)
{
var px = PolylineBuilder.TileToWorldPixel(seg[0].X, seg[0].Y);
var t = GetExistingRailTangent(world, px, fromId, toId);
if (t.LengthSquared > 0.1f) startT = t;
}
if (endIsJunction)
{
var px = PolylineBuilder.TileToWorldPixel(seg[^1].X, seg[^1].Y);
var t = GetExistingRailTangent(world, px, fromId, toId);
if (t.LengthSquared > 0.1f) endT = t;
}
Vec2?[] startDirs = startT.HasValue
? new Vec2?[] { startT.Value, new Vec2(-startT.Value.X, -startT.Value.Y) }
: new Vec2?[] { null };
Vec2?[] endDirs = endT.HasValue
? new Vec2?[] { endT.Value, new Vec2(-endT.Value.X, -endT.Value.Y) }
: new Vec2?[] { null };
foreach (var sd in startDirs)
foreach (var ed in endDirs)
{
var poly = BuildRailPolyline(world, seg, polyId++, fromId, toId, sd, ed);
world.Rails.Add(poly);
PolylineBuilder.RasterizeToTileFlags(world, poly, setAdjacency: true);
}
}
private static Polyline BuildRailPolyline(
WorldState world,
List<(int X, int Y)> path,
int id, int fromId, int toId,
Vec2? startWyeDir,
Vec2? endWyeDir)
{
var poly = new Polyline
{
Type = PolylineType.Rail,
Id = id,
Width = 1.5f,
FromSettlementId = fromId,
ToSettlementId = toId,
};
var controlPts = path.Select(p => PolylineBuilder.TileToWorldPixel(p.X, p.Y)).ToList();
var origStart = controlPts[0];
var origEnd = controlPts[^1];
// Build the branch-body curve from the raw control points with no wye
// ghosts inserted. This is the geometry shared between the two legs of
// a wye. If the ghost control point were inserted here, its ±t
// direction would propagate backward through Catmull-Rom's tangent
// calculation into the segment beyond origStart (since legEnd would
// become p0 for that segment), giving the +t and -t legs different
// paths along the shared body — the "gore" artifact.
var smoothed = PolylineBuilder.CatmullRomSmooth(controlPts);
// Wye-leg splicing. Each leg-end replaces the main curve's endpoint
// with a short Catmull-Rom curve (legEnd → origStart) computed
// separately. The main curve is preserved verbatim from origStart
// onward, guaranteeing identical branch-body geometry across both
// legs of a wye.
Vec2 effectiveStart = origStart;
Vec2 effectiveEnd = origEnd;
if (startWyeDir.HasValue && controlPts.Count >= 2)
{
var d = startWyeDir.Value;
var legEnd = origStart + d * WYE_LEG_LENGTH_PX;
var ghost = legEnd + d * WYE_LEG_GHOST_PX;
// Segment 1 of [ghost, legEnd, origStart, controlPts[1]] is the
// legEnd→origStart leg curve; its indices are
// [SPLINE_SUBDIVISIONS .. 2*SPLINE_SUBDIVISIONS] inclusive
// (legEnd, interior×(SUB1), origStart).
var legSmooth = PolylineBuilder.CatmullRomSmooth(
new List<Vec2> { ghost, legEnd, origStart, controlPts[1] });
var legSegment = legSmooth.GetRange(C.SPLINE_SUBDIVISIONS, C.SPLINE_SUBDIVISIONS + 1);
smoothed.RemoveAt(0); // drop duplicate origStart
smoothed.InsertRange(0, legSegment);
effectiveStart = legEnd;
}
if (endWyeDir.HasValue && controlPts.Count >= 2)
{
var d = endWyeDir.Value;
var legEnd = origEnd + d * WYE_LEG_LENGTH_PX;
var ghost = legEnd + d * WYE_LEG_GHOST_PX;
// Mirror: segment 1 of [controlPts[^2], origEnd, legEnd, ghost]
// is the origEnd→legEnd leg curve.
var legSmooth = PolylineBuilder.CatmullRomSmooth(
new List<Vec2> { controlPts[^2], origEnd, legEnd, ghost });
var legSegment = legSmooth.GetRange(C.SPLINE_SUBDIVISIONS, C.SPLINE_SUBDIVISIONS + 1);
smoothed.RemoveAt(smoothed.Count - 1); // drop duplicate origEnd
smoothed.AddRange(legSegment);
effectiveEnd = legEnd;
}
// Rail has low meander (engineered). Applied after splicing so leg-end
// pixels get noise perpendicular to the leg tangent rather than branch.
ulong seed = world.WorldSeed ^ C.RNG_RAIL ^ (ulong)id;
PolylineBuilder.ApplyMeanderNoise(smoothed, 1.5f, 0.04f, seed);
// Pin endpoints exactly so meander drift doesn't misalign with the
// main rail (wye case) or the destination settlement (non-wye case).
smoothed[0] = effectiveStart;
smoothed[^1] = effectiveEnd;
poly.Points.AddRange(smoothed);
poly.SimplifiedPoints = PolylineBuilder.RDPSimplify(poly.Points);
return poly;
}
/// <summary>
/// Returns the unit tangent of the nearest pre-existing rail polyline at a
/// junction world-pixel position. Rails sharing both (fromId, toId) with the
/// sub-path being built are skipped so sibling sub-paths of the same trip
/// (which also terminate at this junction) don't poison the lookup with
/// their own perpendicular approach direction. Returns a zero vector when
/// no existing rail is within 2 tiles — caller should then skip ghost
/// insertion and fall back to raw endpoint smoothing.
/// </summary>
private static Vec2 GetExistingRailTangent(
WorldState world,
Vec2 junction,
int skipFromId,
int skipToId)
{
Polyline? foundRail = null;
int bestIdx = 0;
float bestDistSq = float.MaxValue;
float maxRadiusSq = 4f * C.WORLD_TILE_PIXELS * C.WORLD_TILE_PIXELS; // 2 tiles
foreach (var rail in world.Rails)
{
if (rail.FromSettlementId == skipFromId && rail.ToSettlementId == skipToId) continue;
var pts = rail.Points;
for (int i = 0; i < pts.Count; i++)
{
float d = Vec2.DistSq(pts[i], junction);
if (d < bestDistSq) { bestDistSq = d; foundRail = rail; bestIdx = i; }
}
}
if (foundRail == null || foundRail.Points.Count < 2) return new Vec2(0, 0);
if (bestDistSq > maxRadiusSq) return new Vec2(0, 0);
var fp = foundRail.Points;
Vec2 tangent;
if (bestIdx == 0)
tangent = fp[1] - fp[0];
else if (bestIdx == fp.Count - 1)
tangent = fp[^1] - fp[^2];
else
tangent = fp[bestIdx + 1] - fp[bestIdx - 1];
return tangent.Normalized;
}
}
@@ -0,0 +1,102 @@
using Theriapolis.Core.Util;
using Theriapolis.Core.World.Polylines;
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 11 — RiverMeanderGen
/// Adds oxbow lakes and width variation to rivers generated by HydrologyGenStage.
/// </summary>
public sealed class RiverMeanderGenStage : IWorldGenStage
{
public string Name => "RiverMeanderGen";
private const int W = C.WORLD_WIDTH_TILES;
private const int H = C.WORLD_HEIGHT_TILES;
public void Run(WorldGenContext ctx)
{
var world = ctx.World;
var rng = SeededRng.ForSubsystem(world.WorldSeed, C.RNG_MEANDER);
int oxbows = 0;
foreach (var poly in world.Rivers)
{
// Width variation: wider in wetlands, narrower in mountains
AdjustWidth(world, poly);
// Oxbow generation for major rivers crossing flat terrain
if (poly.RiverClassification >= RiverClass.River)
oxbows += TryGenerateOxbows(world, poly, rng);
}
// Re-derive tile flags (widths may have changed)
// No re-rasterize needed here — oxbows are just visual edits
ctx.LogMessage($"[RiverMeanderGen] Generated {oxbows} oxbow lakes.");
ctx.World.StageHashes["RiverMeanderGen"] = world.HashPolylines();
}
private static void AdjustWidth(WorldState world, Polyline poly)
{
if (poly.Points.Count < 2) return;
// Sample biome at midpoint of polyline
var mid = poly.Points[poly.Points.Count / 2];
int tx = (int)(mid.X / C.WORLD_TILE_PIXELS);
int ty = (int)(mid.Y / C.WORLD_TILE_PIXELS);
tx = Math.Clamp(tx, 0, W - 1);
ty = Math.Clamp(ty, 0, H - 1);
var biome = world.Tiles[tx, ty].Biome;
if (biome is BiomeId.Wetland or BiomeId.MarshEdge)
poly.Width *= 1.5f;
else if (biome is BiomeId.MountainAlpine or BiomeId.MountainForested)
poly.Width *= 0.7f;
poly.Width = Math.Clamp(poly.Width, 0.5f, 5f);
}
private static int TryGenerateOxbows(WorldState world, Polyline poly, SeededRng rng)
{
if (poly.Points.Count < 20) return 0;
int created = 0;
float oxbowProb = 0.04f;
// Scan for meander loops: points that come close to each other.
// Start inner j at i+20 (≈5 tiles) to skip zigzag artifacts from grid-aligned flow.
for (int i = 5; i < poly.Points.Count - 10; i += 10)
{
for (int j = i + 20; j < Math.Min(i + 50, poly.Points.Count); j++)
{
float dsq = Vec2.DistSq(poly.Points[i], poly.Points[j]);
if (dsq > 9f * C.WORLD_TILE_PIXELS * C.WORLD_TILE_PIXELS) continue;
// Check average elevation in the loop area — must be flat
int midTx = (int)(poly.Points[(i + j) / 2].X / C.WORLD_TILE_PIXELS);
int midTy = (int)(poly.Points[(i + j) / 2].Y / C.WORLD_TILE_PIXELS);
midTx = Math.Clamp(midTx, 0, W - 1);
midTy = Math.Clamp(midTy, 0, H - 1);
if (world.Tiles[midTx, midTy].Elevation > 0.45f) break;
if (!rng.NextBool(oxbowProb)) break;
// Mark a few tiles in the loop area as water (oxbow lake)
for (int k = i; k <= j; k++)
{
int ox = (int)(poly.Points[k].X / C.WORLD_TILE_PIXELS);
int oy = (int)(poly.Points[k].Y / C.WORLD_TILE_PIXELS);
if ((uint)ox < W && (uint)oy < H && world.Tiles[ox, oy].Biome != BiomeId.Ocean)
{
world.TileAt(ox, oy).Features |= FeatureFlags.HasRiver;
}
}
created++;
break; // only one oxbow per pass
}
}
return created;
}
}
@@ -0,0 +1,733 @@
using Theriapolis.Core.Util;
using Theriapolis.Core.World.Polylines;
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 17 — RoadNetworkGen
/// Minimum Spanning Tree of Tier 13 settlements + 30% shortcuts, routed via A* with
/// full Addendum A §2 exclusion (rivers and rail both excluded).
/// Tier 4 settlements get a footpath/dirt road to the nearest Tier 3+ neighbor.
/// </summary>
public sealed class RoadNetworkGenStage : IWorldGenStage
{
public string Name => "RoadNetworkGen";
private const int W = C.WORLD_WIDTH_TILES;
private const int H = C.WORLD_HEIGHT_TILES;
// Tiles SmoothStaircases must not reroute through or off of: existing
// infrastructure, river crossings (bridge tiles), and settlement footprints.
private const FeatureFlags PathPreserveMask =
FeatureFlags.HasRoad | FeatureFlags.HasRiver | FeatureFlags.HasRail | FeatureFlags.IsSettlement;
public void Run(WorldGenContext ctx)
{
var world = ctx.World;
var rng = ctx.Rngs["road"];
var nodes = world.Settlements
.Where(s => s.Tier <= 3 && !s.IsPoi)
.ToList();
if (nodes.Count < 2)
{
ctx.LogMessage("[RoadNetworkGen] Not enough settlements for road network.");
return;
}
// ── Build MST edges via Kruskal (Euclidean edge weights) ─────────────
var mstEdges = KruskalMST(nodes);
// ── Add shortcut edges ────────────────────────────────────────────────
int shortcutCount = (int)Math.Ceiling(mstEdges.Count * C.ROAD_SHORTCUT_FRACTION);
var allEdges = AllEdges(nodes);
allEdges = allEdges
.Where(e => !mstEdges.Any(m => (m.a == e.a && m.b == e.b) || (m.a == e.b && m.b == e.a)))
.OrderBy(e => e.weight)
.Take(shortcutCount)
.ToList();
var routeEdges = mstEdges.Concat(allEdges).ToList();
// ── Route each edge with A* ───────────────────────────────────────────
var pathfinder = new AStarPathfinder();
int polyId = 0;
foreach (var edge in routeEdges)
{
var a = nodes[edge.a];
var b = nodes[edge.b];
var costFn = RoadCostFn(world, a.TileX, a.TileY, b.TileX, b.TileY);
var path = pathfinder.FindPath(a.TileX, a.TileY, b.TileX, b.TileY, costFn);
if (path == null || path.Count < 2) continue;
PolylineBuilder.SmoothStaircases(world, path, PathPreserveMask, costFn);
var segments = PolylineBuilder.SplitByExistingFeature(world, path, FeatureFlags.HasRoad);
// Ensure the first segment reaches the 'from' settlement and
// the last segment reaches the 'to' settlement.
EnsureEndpointSegment(world, segments, path, 0, a.TileX, a.TileY);
EnsureEndpointSegment(world, segments, path, path.Count - 1, b.TileX, b.TileY);
foreach (var seg in segments)
{
var poly = BuildRoadPolyline(world, seg, polyId++, a, b);
world.Roads.Add(poly);
PolylineBuilder.RasterizeToTileFlags(world, poly, setAdjacency: false);
}
}
// ── Tier 4 connections ────────────────────────────────────────────────
// Footpaths split by existing road so they don't parallel Tier 1-3 roads.
// The village end gets a stub; the network end just joins at the junction.
var tier4 = world.Settlements.Where(s => s.Tier == 4 && !s.IsPoi).ToList();
foreach (var s4 in tier4)
{
var nearest = nodes
.OrderBy(n => Dist(s4.TileX, s4.TileY, n.TileX, n.TileY))
.FirstOrDefault();
if (nearest == null) continue;
var costFn = RoadCostFnRelaxed(world, s4.TileX, s4.TileY, nearest.TileX, nearest.TileY);
var path = pathfinder.FindPath(s4.TileX, s4.TileY, nearest.TileX, nearest.TileY, costFn);
if (path == null || path.Count < 2) continue;
PolylineBuilder.SmoothStaircases(world, path, PathPreserveMask, costFn);
var segments = PolylineBuilder.SplitByExistingFeature(world, path, FeatureFlags.HasRoad);
EnsureEndpointSegment(world, segments, path, 0, s4.TileX, s4.TileY);
foreach (var seg in segments)
{
var poly = BuildRoadPolyline(world, seg, polyId++, s4, nearest, forceTier: 4);
world.Roads.Add(poly);
PolylineBuilder.RasterizeToTileFlags(world, poly, setAdjacency: false);
}
}
// Bridge detection is deferred to PolylineCleanupStage so that bridges
// align with roads after endpoint snapping and interior merging.
world.StageHashes["RoadNetworkGen"] = world.HashPolylines();
ctx.LogMessage($"[RoadNetworkGen] Generated {world.Roads.Count} roads.");
}
// ── Cost functions ────────────────────────────────────────────────────────
private static Func<int, int, int, int, byte, float> RoadCostFn(WorldState world, int sx, int sy, int ex, int ey)
{
return (fx, fy, tx, ty, entryDir) =>
{
if ((uint)tx >= W || (uint)ty >= H) return float.PositiveInfinity;
ref var tile = ref world.TileAt(tx, ty);
// Ocean tiles are impassable — no roads over water
if (tile.Biome == BiomeId.Ocean) return float.PositiveInfinity;
// Strongly prefer joining an existing road over building a parallel one,
// but NOT within the settlement halo — each route must find its own path
// near endpoints to prevent multiple roads fanning through shared tiles.
if ((tile.Features & FeatureFlags.HasRoad) != 0)
{
int dStart = Math.Max(Math.Abs(tx - sx), Math.Abs(ty - sy));
int dEnd = Math.Max(Math.Abs(tx - ex), Math.Abs(ty - ey));
if (dStart > C.SETTLEMENT_HALO_RADIUS && dEnd > C.SETTLEMENT_HALO_RADIUS)
return C.EXISTING_ROAD_COST;
// Within halo: fall through to normal terrain cost
}
// Settlement tiles are always reachable (they are road endpoints).
// Extend Addendum A §2 to the settlement footprint: disallow parallel
// travel on a rail tile EXCEPT at the actual start/end tile. Without
// this, road and rail both exit a tier-1+ settlement through the
// same rail-bearing footprint tiles for several steps, producing
// visually overlapping smoothed polylines.
if ((tile.Features & FeatureFlags.IsSettlement) != 0)
{
float settleCost = 1f + tile.Elevation * 2f;
bool isEndpointTile = (tx == sx && ty == sy) || (tx == ex && ty == ey);
if (!isEndpointTile
&& (tile.Features & FeatureFlags.HasRail) != 0
&& tile.RailDir != Dir.None)
{
if (Dir.IsParallel(entryDir, tile.RailDir))
return float.PositiveInfinity;
if (Dir.IsPerpendicular(entryDir, tile.RailDir))
settleCost += C.CROSSING_COST;
}
return settleCost;
}
// River tiles: perpendicular crossing allowed at bridge cost; parallel travel blocked
if ((tile.Features & FeatureFlags.HasRiver) != 0)
{
if (tile.RiverFlowDir != Dir.None && Dir.IsParallel(entryDir, tile.RiverFlowDir))
return float.PositiveInfinity;
return C.BRIDGE_COST + 1f + tile.Elevation * 2f;
}
// Never on a rail tile
if ((tile.Features & FeatureFlags.HasRail) != 0) return float.PositiveInfinity;
float cost = 0f;
// River-adjacent exclusion
if ((tile.Features & FeatureFlags.RiverAdjacent) != 0 && tile.RiverFlowDir != Dir.None)
{
if (Dir.IsParallel(entryDir, tile.RiverFlowDir)) return float.PositiveInfinity;
if (Dir.IsPerpendicular(entryDir, tile.RiverFlowDir)) cost += C.BRIDGE_COST;
}
// Rail-adjacent exclusion
if ((tile.Features & FeatureFlags.RailroadAdjacent) != 0 && tile.RailDir != Dir.None)
{
if (Dir.IsParallel(entryDir, tile.RailDir)) return float.PositiveInfinity;
if (Dir.IsPerpendicular(entryDir, tile.RailDir)) cost += C.CROSSING_COST;
}
// Setback cost — pushes road away from rivers/rail without prohibiting valleys
int distToFeature = NearestFeatureDist(world, tx, ty);
if (distToFeature > 0 && distToFeature <= C.SETBACK_DISTANCE)
cost += C.SETBACK_COST_SCALE / distToFeature;
// Base terrain cost
float elev = tile.Elevation;
cost += 1f + elev * 2f;
cost += tile.Biome switch
{
BiomeId.Wetland or BiomeId.MarshEdge => 3f,
BiomeId.MountainAlpine => 6f,
BiomeId.MountainForested => 3f,
_ => 0f,
};
return cost;
};
}
internal static Func<int, int, int, int, byte, float> RoadCostFnRelaxed(WorldState world, int sx, int sy, int ex, int ey)
{
// Tier 4 footpaths — relaxed version (allow river crossing with bridge cost, still block rail)
return (fx, fy, tx, ty, entryDir) =>
{
if ((uint)tx >= W || (uint)ty >= H) return float.PositiveInfinity;
ref var tile = ref world.TileAt(tx, ty);
if (tile.Biome == BiomeId.Ocean) return float.PositiveInfinity;
// Strongly prefer joining an existing road, but not within settlement halo
if ((tile.Features & FeatureFlags.HasRoad) != 0)
{
int dStart = Math.Max(Math.Abs(tx - sx), Math.Abs(ty - sy));
int dEnd = Math.Max(Math.Abs(tx - ex), Math.Abs(ty - ey));
if (dStart > C.SETTLEMENT_HALO_RADIUS && dEnd > C.SETTLEMENT_HALO_RADIUS)
return C.EXISTING_ROAD_COST;
}
// Settlement tiles are always reachable (they are road endpoints),
// even when a rail passes through them. Extend Addendum A §2 to the
// settlement footprint: parallel travel on a rail tile is blocked
// except at the actual start/end tile.
if ((tile.Features & FeatureFlags.IsSettlement) != 0)
{
float settleCost = 1f + tile.Elevation * 2f;
bool isEndpointTile = (tx == sx && ty == sy) || (tx == ex && ty == ey);
if (!isEndpointTile
&& (tile.Features & FeatureFlags.HasRail) != 0
&& tile.RailDir != Dir.None)
{
if (Dir.IsParallel(entryDir, tile.RailDir))
return float.PositiveInfinity;
if (Dir.IsPerpendicular(entryDir, tile.RailDir))
settleCost += C.CROSSING_COST;
}
return settleCost;
}
if ((tile.Features & FeatureFlags.HasRiver) != 0)
return C.BRIDGE_COST * 0.5f + 1f + tile.Elevation * 2f;
if ((tile.Features & FeatureFlags.HasRail) != 0) return float.PositiveInfinity;
float cost = 1f + tile.Elevation * 2f;
if ((tile.Features & FeatureFlags.RiverAdjacent) != 0) cost += C.BRIDGE_COST * 0.5f;
return cost;
};
}
private static int NearestFeatureDist(WorldState world, int x, int y)
{
for (int r = 1; r <= C.SETBACK_DISTANCE; r++)
{
for (int dy = -r; dy <= r; dy++)
for (int dx = -r; dx <= r; dx++)
{
if (Math.Abs(dx) != r && Math.Abs(dy) != r) continue;
int nx = x + dx, ny = y + dy;
if ((uint)nx >= W || (uint)ny >= H) continue;
var f = world.Tiles[nx, ny].Features;
if ((f & (FeatureFlags.HasRiver | FeatureFlags.HasRail)) != 0) return r;
}
}
return 0;
}
// ── MST via Kruskal ───────────────────────────────────────────────────────
private record Edge(int a, int b, float weight);
private static List<Edge> KruskalMST(List<Settlement> nodes)
{
var edges = AllEdges(nodes);
edges.Sort((a, b) => a.weight.CompareTo(b.weight));
int n = nodes.Count;
var parent = Enumerable.Range(0, n).ToArray();
int Find(int x) => parent[x] == x ? x : (parent[x] = Find(parent[x]));
bool Union(int a, int b) { a = Find(a); b = Find(b); if (a == b) return false; parent[a] = b; return true; }
var mst = new List<Edge>();
foreach (var e in edges)
{
if (Union(e.a, e.b))
mst.Add(e);
if (mst.Count == n - 1) break;
}
return mst;
}
private static List<Edge> AllEdges(List<Settlement> nodes)
{
var edges = new List<Edge>();
for (int i = 0; i < nodes.Count; i++)
for (int j = i + 1; j < nodes.Count; j++)
{
float w = Dist(nodes[i].TileX, nodes[i].TileY, nodes[j].TileX, nodes[j].TileY);
edges.Add(new Edge(i, j, w));
}
return edges;
}
/// <summary>
/// If no segment starts/ends at the settlement tile, add a short stub segment
/// from the settlement to the nearest existing road tile so the road visually
/// reaches the settlement icon.
/// </summary>
private static void EnsureEndpointSegment(
WorldState world,
List<List<(int X, int Y)>> segments,
List<(int X, int Y)> fullPath,
int pathIndex,
int settleX, int settleY)
{
var target = fullPath[pathIndex];
if (target.X != settleX || target.Y != settleY) return;
// Check if any segment already includes this tile
foreach (var seg in segments)
{
if (seg[0].X == settleX && seg[0].Y == settleY) return;
if (seg[^1].X == settleX && seg[^1].Y == settleY) return;
}
// If the entire path runs along existing roads, a stub would parallel them.
if (segments.Count == 0) return;
// If the adjacent segment's junction-anchor tile already has a road, the
// new path attaches to the existing network at that junction and
// transitively reaches the settlement through it — adding a stub here
// would just duplicate the existing road's first/last tiles.
var junction = pathIndex == 0 ? segments[0][0] : segments[^1][^1];
if ((world.Tiles[junction.X, junction.Y].Features & FeatureFlags.HasRoad) != 0)
return;
// Build a short stub from the settlement to the next few tiles
var stub = new List<(int X, int Y)>();
if (pathIndex == 0)
{
// Grab the first few tiles of the path as a stub
int end = Math.Min(fullPath.Count, 4);
for (int i = 0; i < end; i++) stub.Add(fullPath[i]);
}
else
{
// Grab the last few tiles of the path as a stub
int start = Math.Max(0, fullPath.Count - 4);
for (int i = start; i < fullPath.Count; i++) stub.Add(fullPath[i]);
}
if (stub.Count >= 2) segments.Add(stub);
}
private static float Dist(int x1, int y1, int x2, int y2)
=> MathF.Sqrt((x1 - x2) * (float)(x1 - x2) + (y1 - y2) * (float)(y1 - y2));
// ── Bridge detection ─────────────────────────────────────────────────────
/// <summary>
/// Detect bridges by finding actual geometric intersections between road and
/// river polylines in world-pixel space. Uses a tile-based spatial index on
/// river segments for efficiency. Bridges are deduplicated per tile across
/// all roads so that shared crossings produce a single bridge.
/// </summary>
internal static void DetectBridges(WorldState world)
{
int px = C.WORLD_TILE_PIXELS;
// Build spatial index: tile key → river segment references
var index = new Dictionary<long, List<(int ri, int si)>>();
for (int ri = 0; ri < world.Rivers.Count; ri++)
{
var rpts = world.Rivers[ri].Points;
for (int si = 0; si < rpts.Count - 1; si++)
{
int x0 = (int)(rpts[si].X / px), y0 = (int)(rpts[si].Y / px);
int x1 = (int)(rpts[si + 1].X / px), y1 = (int)(rpts[si + 1].Y / px);
AddToIndex(index, x0, y0, ri, si);
if (x1 != x0 || y1 != y0) AddToIndex(index, x1, y1, ri, si);
}
}
// Global dedup: one bridge per tile across all roads, so roads sharing a
// crossing don't stack duplicate bridge sprites.
var bridgeTiles = new HashSet<long>();
foreach (var road in world.Roads)
{
var pts = road.Points;
// A polyline needs at least one segment to intersect a river.
// Short connector stubs created by EnsureSettlementConnectivity
// can still cross rivers and legitimately need bridges.
if (pts.Count < 2) continue;
// Consider every segment. Global tile dedup (bridgeTiles) already
// prevents duplicate bridges where two roads share a junction
// crossing. Excluding segments near endpoints would miss legitimate
// crossings on roads that start from a settlement and cross a river
// within their first few points.
//
// Scan every tile in the segment's bounding box (plus a 1-tile
// margin), not just the 3×3 neighborhood of pts[i]. A road segment
// spanning several tiles — e.g. after PolylineCleanup merges/trims
// points — can cross a river polyline in a tile that neither
// endpoint's 3×3 neighborhood covers.
for (int i = 0; i < pts.Count - 1; i++)
{
int tx0 = (int)(pts[i].X / px), ty0 = (int)(pts[i].Y / px);
int tx1 = (int)(pts[i + 1].X / px), ty1 = (int)(pts[i + 1].Y / px);
int minTx = Math.Min(tx0, tx1) - 1;
int maxTx = Math.Max(tx0, tx1) + 1;
int minTy = Math.Min(ty0, ty1) - 1;
int maxTy = Math.Max(ty0, ty1) + 1;
for (int ty = minTy; ty <= maxTy; ty++)
for (int tx = minTx; tx <= maxTx; tx++)
{
long key = TileKey(tx, ty);
if (!index.TryGetValue(key, out var segs)) continue;
foreach (var (ri, si) in segs)
{
var rpts = world.Rivers[ri].Points;
if (!SegmentsIntersect(pts[i], pts[i + 1], rpts[si], rpts[si + 1], out var crossPt, out var tOnRoad))
continue;
// Deduplicate: one bridge per tile
int btx = (int)(crossPt.X / px), bty = (int)(crossPt.Y / px);
if (!bridgeTiles.Add(TileKey(btx, bty))) continue;
// Walk the actual road polyline in each direction from the
// crossing so the deck endpoints sit ON the road instead of
// overshooting where meander has bent the path.
Vec2 start = WalkAlongPolyline(pts, i, tOnRoad, C.BRIDGE_DECK_HALF_LENGTH, forward: false);
Vec2 end = WalkAlongPolyline(pts, i, tOnRoad, C.BRIDGE_DECK_HALF_LENGTH, forward: true);
world.Bridges.Add(new Bridge(start, end, road.Id));
}
}
}
}
}
private static long TileKey(int tx, int ty) => ((long)ty << 32) | (uint)tx;
private static void AddToIndex(Dictionary<long, List<(int ri, int si)>> index, int tx, int ty, int ri, int si)
{
long key = TileKey(tx, ty);
if (!index.TryGetValue(key, out var list))
index[key] = list = new List<(int, int)>();
list.Add((ri, si));
}
private static bool SegmentsIntersect(Vec2 p1, Vec2 p2, Vec2 p3, Vec2 p4, out Vec2 point, out float tOnFirst)
{
Vec2 d1 = p2 - p1;
Vec2 d2 = p4 - p3;
float cross = d1.X * d2.Y - d1.Y * d2.X;
if (MathF.Abs(cross) < 1e-6f) { point = default; tOnFirst = 0f; return false; }
Vec2 d3 = p3 - p1;
float t = (d3.X * d2.Y - d3.Y * d2.X) / cross;
float u = (d3.X * d1.Y - d3.Y * d1.X) / cross;
if (t >= 0f && t <= 1f && u >= 0f && u <= 1f)
{
point = p1 + d1 * t;
tOnFirst = t;
return true;
}
point = default;
tOnFirst = 0f;
return false;
}
/// <summary>
/// Walk along a polyline starting from a point inside segment <paramref name="segIdx"/>
/// at parameter <paramref name="segT"/>, travelling <paramref name="distance"/> world
/// pixels along the actual polyline geometry. Returns the reached point, clamped to
/// the polyline's end if distance exceeds the remaining length.
/// </summary>
private static Vec2 WalkAlongPolyline(List<Vec2> pts, int segIdx, float segT, float distance, bool forward)
{
Vec2 current = pts[segIdx] + (pts[segIdx + 1] - pts[segIdx]) * segT;
float remaining = distance;
if (forward)
{
int i = segIdx + 1;
while (remaining > 0f && i < pts.Count)
{
Vec2 next = pts[i];
float segLen = Vec2.Dist(current, next);
if (segLen >= remaining)
return current + (next - current).Normalized * remaining;
remaining -= segLen;
current = next;
i++;
}
return current;
}
else
{
int i = segIdx;
while (remaining > 0f && i >= 0)
{
Vec2 prev = pts[i];
float segLen = Vec2.Dist(current, prev);
if (segLen >= remaining)
return current + (prev - current).Normalized * remaining;
remaining -= segLen;
current = prev;
i--;
}
return current;
}
}
// ── Polyline construction ─────────────────────────────────────────────────
private static Polyline BuildRoadPolyline(
WorldState world,
List<(int X, int Y)> path,
int id,
Settlement from, Settlement to,
int forceTier = 0)
{
int effectiveTier = forceTier > 0 ? forceTier : Math.Max(from.Tier, to.Tier);
return BuildRoadPolyline(world, path, id, from.Id, to.Id, effectiveTier);
}
internal static Polyline BuildRoadPolyline(
WorldState world,
List<(int X, int Y)> path,
int id,
int fromSettlementId,
int toSettlementId,
int tier)
{
RoadType roadType = tier switch
{
1 => RoadType.Highway,
2 => RoadType.PostRoad,
3 => RoadType.DirtRoad,
4 => RoadType.Footpath,
_ => RoadType.DirtRoad,
};
float width = roadType switch
{
RoadType.Highway => 2f,
RoadType.PostRoad => 1.5f,
RoadType.DirtRoad => 1f,
RoadType.Footpath => 0.5f,
_ => 1f,
};
var poly = new Polyline
{
Type = PolylineType.Road,
Id = id,
Width = width,
RoadClassification = roadType,
FromSettlementId = fromSettlementId,
ToSettlementId = toSettlementId,
};
var controlPts = path.Select(p => PolylineBuilder.TileToWorldPixel(p.X, p.Y)).ToList();
var startPt = controlPts[0];
var endPt = controlPts[^1];
var smoothed = PolylineBuilder.CatmullRomSmooth(controlPts);
// Roads get moderate meander
ulong seed = world.WorldSeed ^ C.RNG_ROAD ^ (ulong)id;
PolylineBuilder.ApplyMeanderNoise(smoothed, 2.5f, 0.06f, seed);
// Smoothing + meander can drift road points onto river tiles.
// Push them off unless they are a genuine perpendicular crossing (bridge).
ConstrainAwayFromRivers(world, smoothed);
// Pin endpoints so roads always reach their targets
smoothed[0] = startPt;
smoothed[^1] = endPt;
poly.Points.AddRange(smoothed);
poly.SimplifiedPoints = PolylineBuilder.RDPSimplify(poly.Points);
return poly;
}
/// <summary>
/// Push road polyline points off HasRiver tiles when the road is running
/// parallel to the river. Genuine river crossings — runs whose surrounding
/// road segments geometrically intersect a river polyline — are left alone
/// so the bridge-detection pass can place a bridge there.
///
/// Runs of river-tile points that DON'T cross any river polyline indicate
/// the road is travelling alongside a river (not across it). We handle
/// those as a unit: pick one perpendicular side clear of river tiles, then
/// shift every point in the run together. Per-point independent pushing
/// (an earlier approach) produced zigzags when adjacent points chose
/// opposite sides.
/// </summary>
private static void ConstrainAwayFromRivers(WorldState world, List<Vec2> points)
{
int px = C.WORLD_TILE_PIXELS;
int n = points.Count;
var onRiver = new bool[n];
for (int i = 0; i < n; i++)
{
int tx = (int)(points[i].X / px);
int ty = (int)(points[i].Y / px);
if ((uint)tx >= W || (uint)ty >= H) continue;
onRiver[i] = (world.TileAt(tx, ty).Features & FeatureFlags.HasRiver) != 0;
}
int k = 1;
while (k < n - 1)
{
if (!onRiver[k]) { k++; continue; }
int runStart = k;
int runEnd = k;
while (runEnd + 1 < n - 1 && onRiver[runEnd + 1]) runEnd++;
// Geometric crossing check: if any road segment bordering or inside
// this run intersects a river polyline, it's a genuine crossing.
// Leave it alone so bridge detection can place a bridge.
if (RunCrossesRiver(world, points, runStart - 1, runEnd + 1))
{
k = runEnd + 1;
continue;
}
Vec2 anchorPrev = points[runStart - 1];
Vec2 anchorNext = points[Math.Min(runEnd + 1, n - 1)];
Vec2 tangent = anchorNext - anchorPrev;
if (tangent.LengthSquared < 1e-6f) { k = runEnd + 1; continue; }
tangent = tangent.Normalized;
Vec2 perp = tangent.Perp;
Vec2 chosen = default;
bool resolved = false;
for (float dist = px * 0.5f; dist <= px * 3f; dist += px * 0.5f)
{
if (AllOffRiver(world, points, runStart, runEnd, perp, dist))
{ chosen = perp * dist; resolved = true; break; }
if (AllOffRiver(world, points, runStart, runEnd, perp, -dist))
{ chosen = perp * -dist; resolved = true; break; }
}
if (resolved)
for (int i = runStart; i <= runEnd; i++) points[i] = points[i] + chosen;
k = runEnd + 1;
}
}
private static bool AllOffRiver(WorldState world, List<Vec2> points, int start, int end, Vec2 perp, float dist)
{
int px = C.WORLD_TILE_PIXELS;
for (int i = start; i <= end; i++)
{
Vec2 p = points[i] + perp * dist;
int tx = (int)(p.X / px), ty = (int)(p.Y / px);
if ((uint)tx >= W || (uint)ty >= H) return false;
if ((world.TileAt(tx, ty).Features & FeatureFlags.HasRiver) != 0) return false;
}
return true;
}
/// <summary>
/// True if any road polyline segment in <c>[segStart, segEnd-1]</c> intersects
/// any river polyline segment. Uses a bounding-box prefilter on the road-run
/// region so this stays fast even with long rivers.
/// </summary>
private static bool RunCrossesRiver(WorldState world, List<Vec2> points, int segStart, int segEnd)
{
segStart = Math.Max(0, segStart);
segEnd = Math.Min(points.Count - 1, segEnd);
if (segEnd <= segStart) return false;
float minX = float.MaxValue, minY = float.MaxValue;
float maxX = float.MinValue, maxY = float.MinValue;
for (int i = segStart; i <= segEnd; i++)
{
if (points[i].X < minX) minX = points[i].X;
if (points[i].Y < minY) minY = points[i].Y;
if (points[i].X > maxX) maxX = points[i].X;
if (points[i].Y > maxY) maxY = points[i].Y;
}
float margin = C.WORLD_TILE_PIXELS;
minX -= margin; minY -= margin; maxX += margin; maxY += margin;
foreach (var river in world.Rivers)
{
var rpts = river.Points;
for (int ri = 0; ri < rpts.Count - 1; ri++)
{
Vec2 a = rpts[ri], b = rpts[ri + 1];
float sxMin = MathF.Min(a.X, b.X), sxMax = MathF.Max(a.X, b.X);
float syMin = MathF.Min(a.Y, b.Y), syMax = MathF.Max(a.Y, b.Y);
if (sxMax < minX || sxMin > maxX || syMax < minY || syMin > maxY) continue;
for (int i = segStart; i < segEnd; i++)
if (SegmentsCross(points[i], points[i + 1], a, b)) return true;
}
}
return false;
}
private static bool SegmentsCross(Vec2 p1, Vec2 p2, Vec2 p3, Vec2 p4)
{
Vec2 d1 = p2 - p1;
Vec2 d2 = p4 - p3;
float cross = d1.X * d2.Y - d1.Y * d2.X;
if (MathF.Abs(cross) < 1e-6f) return false;
Vec2 d3 = p3 - p1;
float t = (d3.X * d2.Y - d3.Y * d2.X) / cross;
float u = (d3.X * d1.Y - d3.Y * d1.X) / cross;
return t >= 0f && t <= 1f && u >= 0f && u <= 1f;
}
}
@@ -0,0 +1,42 @@
using Theriapolis.Core.Util;
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 1 — SeedInit
/// Builds the named RNG sub-stream table from the world seed.
/// All downstream stages consume their sub-stream from ctx.Rngs["name"].
/// </summary>
public sealed class SeedInitStage : IWorldGenStage
{
public string Name => "SeedInit";
public void Run(WorldGenContext ctx)
{
ulong seed = ctx.World.WorldSeed;
ctx.Rngs["terrain"] = SeededRng.ForSubsystem(seed, C.RNG_TERRAIN);
ctx.Rngs["moisture"] = SeededRng.ForSubsystem(seed, C.RNG_MOISTURE);
ctx.Rngs["temp"] = SeededRng.ForSubsystem(seed, C.RNG_TEMP);
ctx.Rngs["border"] = SeededRng.ForSubsystem(seed, C.RNG_BORDER);
ctx.Rngs["coast"] = SeededRng.ForSubsystem(seed, C.RNG_COAST);
ctx.Rngs["hydro"] = SeededRng.ForSubsystem(seed, C.RNG_HYDRO);
ctx.Rngs["settle"] = SeededRng.ForSubsystem(seed, C.RNG_SETTLE);
ctx.Rngs["road"] = SeededRng.ForSubsystem(seed, C.RNG_ROAD);
ctx.Rngs["rail"] = SeededRng.ForSubsystem(seed, C.RNG_RAIL);
ctx.Rngs["faction"] = SeededRng.ForSubsystem(seed, C.RNG_FACTION);
ctx.Rngs["poi"] = SeededRng.ForSubsystem(seed, C.RNG_POI);
ctx.Rngs["weather"] = SeededRng.ForSubsystem(seed, C.RNG_WEATHER);
ctx.Rngs["tactical"] = SeededRng.ForSubsystem(seed, C.RNG_TACTICAL);
ctx.Rngs["lake"] = SeededRng.ForSubsystem(seed, C.RNG_LAKE);
ctx.Rngs["meander"] = SeededRng.ForSubsystem(seed, C.RNG_MEANDER);
ctx.Rngs["habitat"] = SeededRng.ForSubsystem(seed, C.RNG_HABITAT);
ctx.Rngs["anchor"] = SeededRng.ForSubsystem(seed, C.RNG_ANCHOR);
ctx.Rngs["settle_attr"] = SeededRng.ForSubsystem(seed, C.RNG_SETTLE_ATTR);
ctx.Rngs["trade"] = SeededRng.ForSubsystem(seed, C.RNG_TRADE);
ctx.Rngs["encounter"] = SeededRng.ForSubsystem(seed, C.RNG_ENCOUNTER);
ctx.World.StageHashes["SeedInit"] = seed;
ctx.LogMessage($"[SeedInit] RNG streams initialised. World seed: 0x{seed:X16}");
}
}
@@ -0,0 +1,159 @@
using Theriapolis.Core.Util;
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 15 — SettlementAttributes
/// Assigns procedurally-generated attributes to all placed settlements.
/// Narrative anchors keep their canonical names; others get generated names.
/// </summary>
public sealed class SettlementAttributesStage : IWorldGenStage
{
public string Name => "SettlementAttributes";
private const int W = C.WORLD_WIDTH_TILES;
private const int H = C.WORLD_HEIGHT_TILES;
private static readonly int[] TierMinPop = { 0, 1_500_000, 30_000, 2_000, 50, 0 };
private static readonly int[] TierMaxPop = { 0, 2_500_000, 200_000, 30_000, 2_000, 0 };
public void Run(WorldGenContext ctx)
{
var world = ctx.World;
var rng = SeededRng.ForSubsystem(world.WorldSeed, C.RNG_SETTLE_ATTR);
foreach (var s in world.Settlements)
{
if (s.IsPoi) { AssignPoiAttributes(world, s, rng); continue; }
var macro = world.MacroCellForTile(world.TileAt(s.TileX, s.TileY));
// Name (anchors already named)
if (string.IsNullOrEmpty(s.Name))
s.Name = NameGenerator.Generate(rng, macro.BiomeType);
// Economy
s.Economy = DeriveEconomy(world, s, macro, rng);
// Governance
s.Governance = DeriveGovernance(macro, rng);
// Population
if (s.Tier >= 1 && s.Tier <= 4)
{
float habBonus = world.Habitability?[s.TileX, s.TileY] ?? 0.5f;
int min = TierMinPop[s.Tier];
int max = TierMaxPop[s.Tier];
s.Population = (int)(min + (max - min) * (0.3f + 0.7f * habBonus));
}
// Wealth
s.WealthLevel = Math.Clamp(
(world.Habitability?[s.TileX, s.TileY] ?? 0.5f) + rng.NextFloat(-0.1f, 0.1f),
0f, 1f);
// Clade ratios (simplified as strings referencing affinities)
s.CladeRatios = macro.CladeAffinities.Length > 0
? macro.CladeAffinities
: new[] { "mixed" };
// Hybrid percentage
var cov = macro.Covenant?.ToLowerInvariant() ?? "";
s.HybridPct = (cov is "weak" or "nominal")
? rng.NextFloat(0.10f, 0.30f)
: rng.NextFloat(0.005f, 0.05f);
// Scent profile from economy
s.ScentProfile = s.Economy switch
{
SettlementEconomy.Mining => "coal, metal, dust",
SettlementEconomy.Manufacturing => "smoke, timber, oil",
SettlementEconomy.Fishing => "salt, fish, tar",
SettlementEconomy.Trade => "spice, leather, woodsmoke",
SettlementEconomy.Military => "iron, leather, sweat",
_ => "grain, livestock, earth",
};
// River adjacency
s.IsOnRiver = IsNearRiver(world, s.TileX, s.TileY);
}
ctx.LogMessage("[SettlementAttributes] Assigned attributes to all settlements.");
}
private static SettlementEconomy DeriveEconomy(WorldState world, Settlement s, Data.MacroCell macro, SeededRng rng)
{
// Count surrounding biomes in 10-tile radius
int mountain = 0, forest = 0, grass = 0, coastal = 0;
const int radius = 10;
for (int dy = -radius; dy <= radius; dy++)
for (int dx = -radius; dx <= radius; dx++)
{
int nx = s.TileX + dx, ny = s.TileY + dy;
if ((uint)nx >= W || (uint)ny >= H) continue;
var b = world.Tiles[nx, ny].Biome;
if (b is BiomeId.MountainAlpine or BiomeId.MountainForested) mountain++;
if (b is BiomeId.TemperateDeciduous or BiomeId.Boreal or BiomeId.SubtropicalForest) forest++;
if (b is BiomeId.TemperateGrassland or BiomeId.Scrubland) grass++;
if (b is BiomeId.Coastal or BiomeId.Beach or BiomeId.TidalFlat) coastal++;
}
if (macro.Development?.ToLowerInvariant().Contains("military") == true)
return SettlementEconomy.Military;
if (coastal > 15)
return rng.NextBool() ? SettlementEconomy.Fishing : SettlementEconomy.Trade;
if (mountain > 20)
return SettlementEconomy.Mining;
if (forest > 30)
return SettlementEconomy.Manufacturing;
if (grass > 20)
return SettlementEconomy.Farming;
if ((world.Habitability?[s.TileX, s.TileY] ?? 0f) > 0.7f)
return SettlementEconomy.Trade;
return SettlementEconomy.Farming;
}
private static SettlementGovernance DeriveGovernance(Data.MacroCell macro, SeededRng rng)
{
var dev = macro.Development?.ToLowerInvariant() ?? "";
if (dev.Contains("military")) return SettlementGovernance.MilitaryCommandant;
if (dev.Contains("industrial") || dev.Contains("urban")) return rng.NextBool() ? SettlementGovernance.Mayor : SettlementGovernance.Corporate;
var clades = string.Join(",", macro.CladeAffinities ?? Array.Empty<string>()).ToLowerInvariant();
if (clades.Contains("canid")) return rng.NextBool() ? SettlementGovernance.Mayor : SettlementGovernance.MilitaryCommandant;
if (clades.Contains("cervid") || clades.Contains("bovid")) return SettlementGovernance.Council;
var cov = macro.Covenant?.ToLowerInvariant() ?? "";
if (cov is "nominal" or "weak") return SettlementGovernance.Anarchic;
return (SettlementGovernance)rng.NextInt(0, 6);
}
private static bool IsNearRiver(WorldState world, int tx, int ty)
{
for (int dy = -2; dy <= 2; dy++)
for (int dx = -2; dx <= 2; dx++)
{
int nx = tx + dx, ny = ty + dy;
if ((uint)nx >= W || (uint)ny >= H) continue;
if ((world.Tiles[nx, ny].Features & FeatureFlags.HasRiver) != 0) return true;
}
return false;
}
private static void AssignPoiAttributes(WorldState world, Settlement s, SeededRng rng)
{
if (string.IsNullOrEmpty(s.Name))
{
s.Name = s.PoiType switch
{
PoiType.ImperiumRuin => "Imperium Ruin",
PoiType.AbandonedMine => "Abandoned Mine",
PoiType.CultDen => "Hidden Den",
PoiType.NaturalCave => "Natural Cave",
PoiType.OvergrownSettlement => "Ruins",
_ => "Unknown Site",
};
}
}
}
@@ -0,0 +1,213 @@
using Theriapolis.Core.Util;
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 14 — SettlementPlace
/// Places Tier 25 settlements (narrative anchors were placed in stage 13).
/// Narrative anchors are pre-placed; this stage fills in the rest.
/// </summary>
public sealed class SettlementPlaceStage : IWorldGenStage
{
public string Name => "SettlementPlace";
private const int W = C.WORLD_WIDTH_TILES;
private const int H = C.WORLD_HEIGHT_TILES;
public void Run(WorldGenContext ctx)
{
var world = ctx.World;
var rng = ctx.Rngs["settle"];
// Confine settlements to the main (largest) landmass. Smaller islands
// can't be road-connected across ocean, so a settlement there would
// either be unreachable or force a sea-crossing straight-line stub.
var (componentIds, componentSizes) = LandmassMap.Compute(world);
int mainLandmassId = LandmassMap.LargestComponentId(componentSizes);
// Sort all land tiles by habitability descending
var landTiles = new List<(float hab, int x, int y)>(W * H / 3);
for (int y = 0; y < H; y += 2)
for (int x = 0; x < W; x += 2)
{
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
if (componentIds[x, y] != mainLandmassId) continue;
float h = world.Habitability?[x, y] ?? 0f;
if (h > 0f) landTiles.Add((h, x, y));
}
landTiles.Sort((a, b) => b.hab.CompareTo(a.hab));
int nextId = world.Settlements.Count + 1;
// Subtract narrative anchor counts so the TOTAL for each tier hits the target range.
int existingTier2 = world.Settlements.Count(s => s.Tier == 2 && !s.IsPoi);
int existingTier3 = world.Settlements.Count(s => s.Tier == 3 && !s.IsPoi);
int existingTier4 = world.Settlements.Count(s => s.Tier == 4 && !s.IsPoi);
// ── Tier 2 ────────────────────────────────────────────────────────────
int tier2Target = rng.NextInt(C.SETTLE_TIER2_MIN, C.SETTLE_TIER2_MAX + 1);
int tier2Count = Math.Max(0, tier2Target - existingTier2);
PlaceTier(world, landTiles, ref nextId, 2, tier2Count, C.SETTLE_MIN_DIST_TIER2, rng,
(x, y, tile) =>
(tile.Features & (FeatureFlags.HasRiver | FeatureFlags.RiverAdjacent)) != 0 ||
tile.Biome == BiomeId.Coastal);
// ── Tier 3 ────────────────────────────────────────────────────────────
int tier3Target = rng.NextInt(C.SETTLE_TIER3_MIN, C.SETTLE_TIER3_MAX + 1);
int tier3Count = Math.Max(0, tier3Target - existingTier3);
PlaceTier(world, landTiles, ref nextId, 3, tier3Count, C.SETTLE_MIN_DIST_TIER3, rng,
(x, y, tile) => IsNearHigherTier(x, y, world.Settlements, 2, 60) ||
(tile.Features & (FeatureFlags.HasRiver | FeatureFlags.RiverAdjacent)) != 0);
// ── Tier 4 ────────────────────────────────────────────────────────────
int tier4Target = rng.NextInt(C.SETTLE_TIER4_MIN, C.SETTLE_TIER4_MAX + 1);
int tier4Count = Math.Max(0, tier4Target - existingTier4);
PlaceTier(world, landTiles, ref nextId, 4, tier4Count, C.SETTLE_MIN_DIST_TIER4, rng,
(x, y, tile) => true);
// ── Tier 5 (PoIs) ─────────────────────────────────────────────────────
int tier5Count = rng.NextInt(C.SETTLE_TIER5_MIN, C.SETTLE_TIER5_MAX + 1);
PlacePoIs(world, landTiles, ref nextId, tier5Count, rng);
world.StageHashes["SettlementPlace"] = world.HashSettlements();
ctx.LogMessage($"[SettlementPlace] Total settlements: {world.Settlements.Count}");
}
private static void PlaceTier(
WorldState world,
List<(float hab, int x, int y)> sortedTiles,
ref int nextId,
int tier,
int target,
int minDist,
SeededRng rng,
Func<int, int, WorldTile, bool> extraFilter)
{
int placed = 0;
foreach (var (_, x, y) in sortedTiles)
{
if (placed >= target) break;
ref var tile = ref world.TileAt(x, y);
if (tile.Biome == BiomeId.Ocean) continue;
if ((tile.Features & FeatureFlags.IsSettlement) != 0) continue;
if (!extraFilter(x, y, tile)) continue;
if (IsTooClose(x, y, world.Settlements, tier, minDist)) continue;
var (nx, ny) = NudgeOffRiver(world, x, y);
var s = new Settlement { Id = nextId++, Tier = tier, TileX = nx, TileY = ny };
world.Settlements.Add(s);
MarkTiles(world, s);
placed++;
}
}
private static void PlacePoIs(
WorldState world,
List<(float hab, int x, int y)> sortedTiles,
ref int nextId,
int target,
SeededRng rng)
{
int placed = 0;
// PoIs go in LOW habitability cells — reverse the list
for (int i = sortedTiles.Count - 1; i >= 0 && placed < target; i--)
{
var (hab, x, y) = sortedTiles[i];
if (hab > 0.5f) break; // stop when habitability gets too high
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
if ((world.Tiles[x, y].Features & FeatureFlags.IsSettlement) != 0) continue;
if (IsTooClose(x, y, world.Settlements, 5, C.SETTLE_MIN_DIST_TIER5)) continue;
if (IsNearHigherTier(x, y, world.Settlements, 4, C.POI_MIN_DIST_FROM_SETTLE)) continue;
var s = new Settlement
{
Id = nextId++,
Tier = 5,
TileX = x,
TileY = y,
IsPoi = true,
PoiType = PickPoiType(world, x, y, rng),
};
world.Settlements.Add(s);
world.TileAt(x, y).Features |= FeatureFlags.IsPoi;
placed++;
}
}
private static PoiType PickPoiType(WorldState world, int x, int y, SeededRng rng)
{
var biome = world.Tiles[x, y].Biome;
return biome switch
{
BiomeId.MountainAlpine or BiomeId.MountainForested => rng.NextBool() ? PoiType.AbandonedMine : PoiType.NaturalCave,
BiomeId.TemperateDeciduous or BiomeId.Boreal or BiomeId.ForestEdge => rng.NextBool() ? PoiType.CultDen : PoiType.OvergrownSettlement,
BiomeId.Tundra or BiomeId.Boreal => PoiType.ImperiumRuin,
BiomeId.Wetland or BiomeId.SubtropicalForest or BiomeId.Mangrove => rng.NextBool() ? PoiType.NaturalCave : PoiType.CultDen,
_ => (PoiType)(rng.NextInt(1, 6)),
};
}
private static bool IsTooClose(int x, int y, List<Settlement> settlements, int tier, int minDist)
{
foreach (var s in settlements)
{
if (s.Tier > tier) continue; // only compare same or higher tiers
int dx = x - s.TileX, dy = y - s.TileY;
if (dx * dx + dy * dy < minDist * minDist) return true;
}
return false;
}
private static bool IsNearHigherTier(int x, int y, List<Settlement> settlements, int maxTier, int maxDist)
{
foreach (var s in settlements)
{
if (s.Tier > maxTier) continue;
int dx = x - s.TileX, dy = y - s.TileY;
if (dx * dx + dy * dy < maxDist * maxDist) return true;
}
return false;
}
/// <summary>
/// If the candidate tile sits on a river, nudge to the nearest adjacent tile
/// that is river-adjacent but not itself a river tile. Keeps settlements
/// beside rivers rather than on top of them.
/// </summary>
internal static (int x, int y) NudgeOffRiver(WorldState world, int x, int y)
{
ref var tile = ref world.TileAt(x, y);
if ((tile.Features & FeatureFlags.HasRiver) == 0) return (x, y);
// Scan outward in fixed order (deterministic) at radius 1, then 2
for (int r = 1; r <= 2; r++)
for (int dy = -r; dy <= r; dy++)
for (int dx = -r; dx <= r; dx++)
{
if (dx == 0 && dy == 0) continue;
if (Math.Abs(dx) != r && Math.Abs(dy) != r) continue; // shell only
int nx = x + dx, ny = y + dy;
if ((uint)nx >= W || (uint)ny >= H) continue;
ref var neighbor = ref world.TileAt(nx, ny);
if (neighbor.Biome == BiomeId.Ocean) continue;
if ((neighbor.Features & FeatureFlags.HasRiver) != 0) continue;
if ((neighbor.Features & FeatureFlags.IsSettlement) != 0) continue;
return (nx, ny);
}
return (x, y); // no valid neighbor found — keep original
}
private static void MarkTiles(WorldState world, Settlement s)
{
for (int dy = -1; dy <= 1; dy++)
for (int dx = -1; dx <= 1; dx++)
{
int nx = s.TileX + dx, ny = s.TileY + dy;
if ((uint)nx >= W || (uint)ny >= H) continue;
ref var tile = ref world.TileAt(nx, ny);
tile.Features |= FeatureFlags.IsSettlement;
tile.SettlementId = (ushort)Math.Min(s.Id, ushort.MaxValue);
}
}
}
@@ -0,0 +1,64 @@
using Theriapolis.Core.Util;
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 5 — TemperatureGen
/// Temperature is primarily latitude-derived (north = cold, south = warm),
/// modified by elevation (higher = colder) and minor noise for local variation.
/// Respects macro-cell temperature modifiers.
/// </summary>
public sealed class TemperatureGenStage : IWorldGenStage
{
public string Name => "TemperatureGen";
public void Run(WorldGenContext ctx)
{
ulong seed = ctx.World.WorldSeed ^ C.RNG_TEMP;
var noise = new FastNoiseLite
{
Seed = (int)(seed & 0x7FFFFFFF),
Frequency = 1.5f / C.WORLD_WIDTH_TILES,
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
Fractal = FastNoiseLite.FractalType.FBm,
Octaves = 3,
Lacunarity = 2.0f,
Gain = 0.5f,
};
int W = C.WORLD_WIDTH_TILES;
int H = C.WORLD_HEIGHT_TILES;
Parallel.For(0, H, ty =>
{
// Latitude: 0 at north (cold), 1 at south (warm)
float latitude = (float)ty / (H - 1);
for (int tx = 0; tx < W; tx++)
{
float elev = ctx.World.Tiles[tx, ty].Elevation;
// Base temperature from latitude (smooth curve — coldest at top)
float baseTemp = latitude * latitude * 0.8f + latitude * 0.2f;
// Elevation cooling: above sea level, temperature drops ~0.3 per unit elevation
float elevCool = MathF.Max(0f, elev - WorldState.SeaLevel) * 0.45f;
// Minor local noise
float localNoise = noise.GetNoise((float)tx, (float)ty) * 0.07f;
float t = baseTemp - elevCool + localNoise;
// Macro cell modifier — use the warped cell stored on the
// tile so temperature inherits the same organic cell shape.
var cell = ctx.World.MacroCellForTile(ctx.World.Tiles[tx, ty]);
t += cell.TempModifier;
ctx.World.Tiles[tx, ty].Temperature = Math.Clamp(t, 0f, 1f);
}
});
ctx.World.StageHashes["TemperatureGen"] = ctx.World.HashTemperature();
ctx.LogMessage($"[TemperatureGen] Temperature hash: 0x{ctx.World.StageHashes["TemperatureGen"]:X16}");
}
}
@@ -0,0 +1,99 @@
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 18 — TradeRouteGen
/// Supply/demand matching overlay. Adjusts WealthLevel on settlements that sit on
/// high-traffic trade paths. No new polylines — uses existing road/rail network.
/// </summary>
public sealed class TradeRouteGenStage : IWorldGenStage
{
public string Name => "TradeRouteGen";
public void Run(WorldGenContext ctx)
{
var world = ctx.World;
// Build adjacency list from road polylines (settlement endpoints)
var adj = new Dictionary<int, List<(int toId, float cost)>>();
foreach (var s in world.Settlements.Where(s => !s.IsPoi))
adj[s.Id] = new List<(int, float)>();
foreach (var road in world.Roads)
{
if (road.FromSettlementId < 0 || road.ToSettlementId < 0) continue;
float routeLen = road.Points.Count > 0 ? road.Points.Count * 0.1f : 1f;
if (adj.TryGetValue(road.FromSettlementId, out var la)) la.Add((road.ToSettlementId, routeLen));
if (adj.TryGetValue(road.ToSettlementId, out var lb)) lb.Add((road.FromSettlementId, routeLen));
}
foreach (var rail in world.Rails)
{
if (rail.FromSettlementId < 0 || rail.ToSettlementId < 0) continue;
float routeLen = rail.Points.Count > 0 ? rail.Points.Count * 0.05f : 1f; // rail cheaper
if (adj.TryGetValue(rail.FromSettlementId, out var la)) la.Add((rail.ToSettlementId, routeLen));
if (adj.TryGetValue(rail.ToSettlementId, out var lb)) lb.Add((rail.FromSettlementId, routeLen));
}
// Simple trade flow: for each pair of settlements with complementary economies,
// compute trade score and distribute wealth bonus along the path
var settleById = world.Settlements.Where(s => !s.IsPoi).ToDictionary(s => s.Id);
var tradeScore = new Dictionary<int, float>();
foreach (var s in settleById.Values) tradeScore[s.Id] = 0f;
foreach (var producer in settleById.Values)
foreach (var consumer in settleById.Values)
{
if (producer.Id == consumer.Id) continue;
if (producer.Economy == consumer.Economy) continue; // same economy = no trade
float supply = ProducerStrength(producer);
float demand = ConsumerNeed(consumer, producer.Economy);
if (supply <= 0 || demand <= 0) continue;
// Approximate transport cost as Euclidean distance (no full Dijkstra for all pairs)
float dist = MathF.Sqrt(
(producer.TileX - consumer.TileX) * (float)(producer.TileX - consumer.TileX) +
(producer.TileY - consumer.TileY) * (float)(producer.TileY - consumer.TileY));
if (dist > 200) continue; // too far to trade efficiently
float score = supply * demand / (1f + dist * 0.02f);
tradeScore[producer.Id] += score;
tradeScore[consumer.Id] += score;
}
// Normalize and apply wealth bonus
float maxScore = tradeScore.Values.DefaultIfEmpty(1f).Max();
if (maxScore < 1e-6f) return;
foreach (var s in settleById.Values)
{
float bonus = tradeScore[s.Id] / maxScore * 0.3f;
s.WealthLevel = Math.Clamp(s.WealthLevel + bonus, 0f, 1f);
}
ctx.LogMessage("[TradeRouteGen] Trade routes computed and wealth adjusted.");
}
private static float ProducerStrength(Settlement s) => s.Economy switch
{
SettlementEconomy.Farming => 0.8f,
SettlementEconomy.Mining => 0.9f,
SettlementEconomy.Fishing => 0.7f,
SettlementEconomy.Manufacturing=> 1.0f,
SettlementEconomy.Trade => 0.5f,
SettlementEconomy.Military => 0.3f,
_ => 0.5f,
};
private static float ConsumerNeed(Settlement consumer, SettlementEconomy producerEcon)
{
// Settlements need what they don't produce
if (consumer.Economy == producerEcon) return 0f;
return consumer.Economy switch
{
SettlementEconomy.Manufacturing => producerEcon is SettlementEconomy.Mining or SettlementEconomy.Farming ? 1.0f : 0.3f,
SettlementEconomy.Military => producerEcon is SettlementEconomy.Farming or SettlementEconomy.Manufacturing ? 0.8f : 0.2f,
SettlementEconomy.Trade => 0.6f,
_ => 0.4f,
};
}
}
@@ -0,0 +1,204 @@
using Theriapolis.Core.Util;
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 22 — ValidationPass
/// Checks all generated data for correctness:
/// 1. Linear feature exclusion (Addendum A §2) — 0 violations required
/// 2. River drainage to water
/// 3. Settlement reachability via roads
/// 4. Narrative anchor constraint re-verification
/// 5. No overlapping settlements
/// Throws on critical failures; logs warnings for soft failures.
/// </summary>
public sealed class ValidationPassStage : IWorldGenStage
{
public string Name => "ValidationPass";
private const int W = C.WORLD_WIDTH_TILES;
private const int H = C.WORLD_HEIGHT_TILES;
public void Run(WorldGenContext ctx)
{
var world = ctx.World;
int violations = 0;
int warnings = 0;
// ── 1. Linear feature exclusion ───────────────────────────────────────
violations += CheckLinearExclusion(world, ctx);
// ── 2. River drainage ────────────────────────────────────────────────
warnings += CheckRiverDrainage(world, ctx);
// ── 3. Settlement reachability ────────────────────────────────────────
warnings += CheckSettlementReachability(world, ctx);
// ── 4. Narrative anchor constraints ──────────────────────────────────
warnings += CheckNarrativeAnchors(world, ctx);
// ── 5. No overlapping settlements ─────────────────────────────────────
violations += CheckOverlappingSettlements(world, ctx);
ctx.World.StageHashes["ValidationPass"] = (ulong)(violations * 1000 + warnings);
ctx.LogMessage($"[ValidationPass] Done. Violations: {violations}, Warnings: {warnings}");
if (violations > 0)
ctx.LogMessage($"[ValidationPass] WARNING: {violations} violations detected. See log above.");
}
private static int CheckLinearExclusion(WorldState world, WorldGenContext ctx)
{
int count = 0;
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
{
ref var tile = ref world.TileAt(x, y);
// Skip settlement tiles (exempt from exclusion)
if ((tile.Features & FeatureFlags.IsSettlement) != 0) continue;
bool hasRiver = (tile.Features & FeatureFlags.HasRiver) != 0;
bool hasRail = (tile.Features & FeatureFlags.HasRail) != 0;
bool hasRoad = (tile.Features & FeatureFlags.HasRoad) != 0;
// More than one linear feature on the same tile
int featureCount = (hasRiver ? 1 : 0) + (hasRail ? 1 : 0) + (hasRoad ? 1 : 0);
if (featureCount < 2) continue;
// For crossings to be valid, they must be near-perpendicular
if (hasRiver && hasRail && tile.RiverFlowDir != Dir.None && tile.RailDir != Dir.None)
{
if (Dir.IsParallel(tile.RiverFlowDir, tile.RailDir))
{
ctx.LogMessage($"[ValidationPass] VIOLATION: River+Rail parallel at ({x},{y})");
count++;
}
}
// Road over river = bridge crossing — NOT a violation (perpendicular crossing is allowed)
if (hasRail && hasRoad && tile.RailDir != Dir.None)
{
ctx.LogMessage($"[ValidationPass] VIOLATION: Rail+Road on same tile at ({x},{y})");
count++;
}
}
if (count > 0)
ctx.LogMessage($"[ValidationPass] {count} linear exclusion violations.");
return count;
}
private static int CheckRiverDrainage(WorldState world, WorldGenContext ctx)
{
int warnings = 0;
foreach (var river in world.Rivers)
{
if (river.Points.Count < 2) continue;
var lastPt = river.Points[^1];
int lx = (int)(lastPt.X / C.WORLD_TILE_PIXELS);
int ly = (int)(lastPt.Y / C.WORLD_TILE_PIXELS);
lx = Math.Clamp(lx, 0, W - 1);
ly = Math.Clamp(ly, 0, H - 1);
var lastBiome = world.Tiles[lx, ly].Biome;
if (lastBiome != BiomeId.Ocean && lastBiome != BiomeId.Wetland &&
(world.Tiles[lx, ly].Features & FeatureFlags.HasRiver) == 0)
{
ctx.LogMessage($"[ValidationPass] WARNING: River {river.Id} endpoint not at water ({lx},{ly} biome={lastBiome})");
warnings++;
}
}
return warnings;
}
private static int CheckSettlementReachability(WorldState world, WorldGenContext ctx)
{
var capital = world.Settlements.FirstOrDefault(s => s.Anchor == NarrativeAnchor.SanctumFidelis);
if (capital == null) return 0;
// BFS from capital via HasRoad tiles
var reachable = new HashSet<int>(); // settlement IDs
var visited = new bool[W, H];
var queue = new Queue<(int x, int y)>();
queue.Enqueue((capital.TileX, capital.TileY));
visited[capital.TileX, capital.TileY] = true;
reachable.Add(capital.Id);
(int dx, int dy)[] dirs4 = { (0,-1),(1,0),(0,1),(-1,0) };
while (queue.Count > 0)
{
var (cx, cy) = queue.Dequeue();
ref var tile = ref world.TileAt(cx, cy);
if (tile.SettlementId > 0)
{
int sid = tile.SettlementId; // copy before lambda capture
var s = world.Settlements.FirstOrDefault(s => s.Id == sid);
if (s != null) reachable.Add(s.Id);
}
if ((tile.Features & FeatureFlags.HasRoad) == 0 &&
(tile.Features & FeatureFlags.IsSettlement) == 0) continue;
foreach (var (ddx, ddy) in dirs4)
{
int nx = cx + ddx, ny = cy + ddy;
if ((uint)nx >= W || (uint)ny >= H || visited[nx, ny]) continue;
var nf = world.Tiles[nx, ny].Features;
if ((nf & (FeatureFlags.HasRoad | FeatureFlags.IsSettlement)) == 0) continue;
visited[nx, ny] = true;
queue.Enqueue((nx, ny));
}
}
int warnings = 0;
foreach (var s in world.Settlements.Where(s => s.Tier <= 3 && !s.IsPoi))
{
if (!reachable.Contains(s.Id))
{
ctx.LogMessage($"[ValidationPass] WARNING: {s.Name} (Tier {s.Tier}) not reachable via roads.");
warnings++;
}
}
return warnings;
}
private static int CheckNarrativeAnchors(WorldState world, WorldGenContext ctx)
{
int warnings = 0;
var millhaven = world.Settlements.FirstOrDefault(s => s.Anchor == NarrativeAnchor.Millhaven);
if (millhaven != null && !millhaven.IsOnRiver)
{
ctx.LogMessage("[ValidationPass] WARNING: Millhaven is not near a river.");
warnings++;
}
var heartstone = world.Settlements.FirstOrDefault(s => s.Anchor == NarrativeAnchor.Heartstone);
if (heartstone != null)
{
var hs = world.TileAt(heartstone.TileX, heartstone.TileY);
if ((hs.Features & (FeatureFlags.HasRail | FeatureFlags.RailroadAdjacent)) != 0)
{
ctx.LogMessage("[ValidationPass] WARNING: Heartstone has rail adjacent.");
warnings++;
}
}
return warnings;
}
private static int CheckOverlappingSettlements(WorldState world, WorldGenContext ctx)
{
int violations = 0;
var positions = new HashSet<(int, int)>();
foreach (var s in world.Settlements)
{
if (!positions.Add((s.TileX, s.TileY)))
{
ctx.LogMessage($"[ValidationPass] VIOLATION: Two settlements at ({s.TileX},{s.TileY})");
violations++;
}
}
return violations;
}
}
@@ -0,0 +1,68 @@
namespace Theriapolis.Core.World.Generation.Stages;
/// <summary>
/// Stage 8 — WaterBodyClamp
/// Eliminates all interior water bodies (tiles below sea level that are not
/// connected to the map-edge ocean) by raising them just above sea level.
/// Interior lakes are a later-phase feature; in Phase 1 only ocean water exists.
///
/// Ocean border enforcement is handled earlier in BorderDistortionGenStage
/// (before the wobble pass) so the resulting coastline gets organic treatment.
/// </summary>
public sealed class WaterBodyClampStage : IWorldGenStage
{
public string Name => "WaterBodyClamp";
private static readonly (int dx, int dy)[] Dirs = [(-1, 0), (1, 0), (0, -1), (0, 1)];
public void Run(WorldGenContext ctx)
{
int W = C.WORLD_WIDTH_TILES;
int H = C.WORLD_HEIGHT_TILES;
var visited = new bool[W, H];
var queue = new Queue<(int x, int y)>();
// Flood-fill from map-edge water tiles to mark the ocean body.
for (int ty = 0; ty < H; ty++)
for (int tx = 0; tx < W; tx++)
{
if (tx != 0 && tx != W - 1 && ty != 0 && ty != H - 1) continue;
if (ctx.World.Tiles[tx, ty].Elevation >= WorldState.SeaLevel) continue;
if (visited[tx, ty]) continue;
visited[tx, ty] = true;
queue.Enqueue((tx, ty));
}
while (queue.Count > 0)
{
var (cx, cy) = queue.Dequeue();
foreach (var (dx, dy) in Dirs)
{
int nx = cx + dx, ny = cy + dy;
if ((uint)nx >= (uint)W || (uint)ny >= (uint)H) continue;
if (visited[nx, ny]) continue;
if (ctx.World.Tiles[nx, ny].Elevation >= WorldState.SeaLevel) continue;
visited[nx, ny] = true;
queue.Enqueue((nx, ny));
}
}
// Raise all unvisited water tiles above sea level, respecting the
// macro cell's land elevation range so we don't violate constraints.
int tilesRaised = 0;
for (int ty = 0; ty < H; ty++)
for (int tx = 0; tx < W; tx++)
{
if (visited[tx, ty]) continue;
ref var tile = ref ctx.World.Tiles[tx, ty];
if (tile.Elevation >= WorldState.SeaLevel) continue;
var cell = ctx.World.MacroGrid![tile.MacroX, tile.MacroY];
float landFloor = MathF.Max(cell.ElevationFloor, WorldState.SeaLevel);
tile.Elevation = landFloor + 0.02f;
tilesRaised++;
}
ctx.World.StageHashes["WaterBodyClamp"] = ctx.World.HashElevation();
ctx.LogMessage($"[WaterBodyClamp] Filled {tilesRaised} interior water tiles");
}
}
@@ -0,0 +1,37 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.World.Generation;
/// <summary>
/// Accumulating context passed through the world-generation pipeline.
/// Holds the WorldState under construction, per-subsystem RNG streams, and a logger.
/// </summary>
public sealed class WorldGenContext
{
public WorldState World { get; }
/// <summary>Named RNG sub-streams, keyed by the subsystem constant name.</summary>
public Dictionary<string, SeededRng> Rngs { get; } = new();
/// <summary>Directory that holds macro_template.json, biomes.json, etc.</summary>
public string DataDirectory { get; init; } = "";
/// <summary>Called after each stage completes: (stageName, progressFraction 01).</summary>
public Action<string, float>? ProgressCallback { get; set; }
/// <summary>Simple diagnostic log sink.</summary>
public Action<string>? Log { get; set; }
public WorldGenContext(ulong seed, string dataDirectory)
{
World = new WorldState { WorldSeed = seed };
DataDirectory = dataDirectory;
}
public void ReportProgress(string stageName, float fraction)
=> ProgressCallback?.Invoke(stageName, Math.Clamp(fraction, 0f, 1f));
public void LogMessage(string msg)
=> Log?.Invoke(msg);
}
@@ -0,0 +1,68 @@
using Theriapolis.Core.World.Generation.Stages;
namespace Theriapolis.Core.World.Generation;
/// <summary>
/// Runs the ordered world-generation pipeline.
/// Stages 18 are implemented for Phase 1. The remaining stubs are placeholders
/// that will be filled in Phase 2 onward.
/// </summary>
public static class WorldGenerator
{
/// <summary>Ordered pipeline. Index matches the spec's stage numbering (0-based list).</summary>
public static IWorldGenStage[] BuildPipeline() => new IWorldGenStage[]
{
new SeedInitStage(), // 1
new MacroTemplateLoadStage(), // 2
new ElevationGenStage(), // 3
new MoistureGenStage(), // 4
new TemperatureGenStage(), // 5
new CoastalFeatureGenStage(), // 6
new BorderDistortionGenStage(), // 7
new WaterBodyClampStage(), // 8
new BiomeAssignStage(), // 9
new HydrologyGenStage(), // 10
new RiverMeanderGenStage(), // 11
new HabitabilityScoreStage(), // 12
new NarrativeAnchorPlaceStage(), // 13
new SettlementPlaceStage(), // 14
new SettlementAttributesStage(), // 15
new RailNetworkGenStage(), // 16
new RoadNetworkGenStage(), // 17
new PolylineCleanupStage(), // 18
new TradeRouteGenStage(), // 19
new FactionInfluenceGenStage(), // 20
new PoIPlacementStage(), // 21
new EncounterDensityGenStage(), // 22
new ValidationPassStage(), // 23
};
/// <summary>
/// Run all pipeline stages, reporting progress via ctx.ProgressCallback.
/// </summary>
public static void RunAll(WorldGenContext ctx)
{
var stages = BuildPipeline();
for (int i = 0; i < stages.Length; i++)
{
ctx.LogMessage($"[WorldGen] Stage {i + 1}/{stages.Length}: {stages[i].Name}");
stages[i].Run(ctx);
ctx.ReportProgress(stages[i].Name, (i + 1f) / stages.Length);
}
ctx.LogMessage("[WorldGen] Pipeline complete.");
}
/// <summary>
/// Run only stages 1N (0-based index), used by tools and tests.
/// </summary>
public static void RunThrough(WorldGenContext ctx, int lastStageIndex)
{
var stages = BuildPipeline();
int limit = Math.Min(lastStageIndex + 1, stages.Length);
for (int i = 0; i < limit; i++)
{
stages[i].Run(ctx);
ctx.ReportProgress(stages[i].Name, (i + 1f) / limit);
}
}
}
@@ -0,0 +1,33 @@
using Theriapolis.Core.Util;
namespace Theriapolis.Core.World.Polylines;
public enum PolylineType : byte { River, Road, Rail }
public enum RiverClass : byte { Stream, River, MajorRiver }
public enum RoadType : byte { Footpath, DirtRoad, PostRoad, Highway }
/// <summary>
/// A polyline in world-pixel space (0..32768 on each axis).
/// Source of truth for rivers, roads, and rail. Per-tile flags on WorldTile are derived caches.
/// </summary>
public sealed class Polyline
{
public PolylineType Type { get; init; }
public int Id { get; init; }
public List<Vec2> Points { get; } = new();
public List<Vec2>? SimplifiedPoints { get; set; }
public float Width { get; set; }
// River-specific
public RiverClass RiverClassification { get; set; }
public int FlowAccumulation { get; set; }
// Road-specific
public RoadType RoadClassification { get; set; }
// Infrastructure-specific (source/destination settlement IDs)
public int FromSettlementId { get; set; } = -1;
public int ToSettlementId { get; set; } = -1;
public bool IsEmpty => Points.Count < 2;
}
@@ -0,0 +1,442 @@
using Theriapolis.Core.Util;
using Theriapolis.Core.World;
namespace Theriapolis.Core.World.Polylines;
/// <summary>
/// Static helpers for building, smoothing, and rasterizing polylines in world-pixel space.
/// </summary>
public static class PolylineBuilder
{
// ── Catmull-Rom spline smoothing ─────────────────────────────────────────
/// <summary>
/// Apply Catmull-Rom spline smoothing to a list of control points.
/// Produces <see cref="C.SPLINE_SUBDIVISIONS"/> intermediate points per segment.
/// </summary>
public static List<Vec2> CatmullRomSmooth(IReadOnlyList<Vec2> points, int subdivisions = C.SPLINE_SUBDIVISIONS)
{
var result = new List<Vec2>(points.Count * subdivisions);
if (points.Count < 2) { result.AddRange(points); return result; }
for (int i = 0; i < points.Count - 1; i++)
{
var p0 = points[Math.Max(0, i - 1)];
var p1 = points[i];
var p2 = points[i + 1];
var p3 = points[Math.Min(points.Count - 1, i + 2)];
for (int s = 0; s < subdivisions; s++)
{
float t = s / (float)subdivisions;
result.Add(CatmullRomPoint(p0, p1, p2, p3, t));
}
}
result.Add(points[^1]);
return result;
}
private static Vec2 CatmullRomPoint(Vec2 p0, Vec2 p1, Vec2 p2, Vec2 p3, float t)
{
float t2 = t * t;
float t3 = t2 * t;
float x = 0.5f * ((2f * p1.X)
+ (-p0.X + p2.X) * t
+ (2f * p0.X - 5f * p1.X + 4f * p2.X - p3.X) * t2
+ (-p0.X + 3f * p1.X - 3f * p2.X + p3.X) * t3);
float y = 0.5f * ((2f * p1.Y)
+ (-p0.Y + p2.Y) * t
+ (2f * p0.Y - 5f * p1.Y + 4f * p2.Y - p3.Y) * t2
+ (-p0.Y + 3f * p1.Y - 3f * p2.Y + p3.Y) * t3);
return new Vec2(x, y);
}
// ── Perpendicular meander noise ──────────────────────────────────────────
/// <summary>
/// Apply a perpendicular noise offset to each point along a smoothed polyline.
/// Uses FastNoiseLite for deterministic, seeded noise.
/// </summary>
public static void ApplyMeanderNoise(
List<Vec2> points,
float amplitude,
float frequency,
ulong noiseSeed)
{
if (points.Count < 2) return;
// Build a FastNoiseLite instance seeded for this polyline
var noise = new FastNoiseLite
{
Seed = (int)(noiseSeed & 0x7FFFFFFFu),
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
Frequency = frequency,
};
for (int i = 0; i < points.Count; i++)
{
// Perpendicular direction at this point
Vec2 tangent;
if (i == 0)
tangent = (points[1] - points[0]).Normalized;
else if (i == points.Count - 1)
tangent = (points[^1] - points[^2]).Normalized;
else
tangent = (points[i + 1] - points[i - 1]).Normalized;
Vec2 perp = tangent.Perp;
// Sample noise at world-pixel position
float offset = noise.GetNoise(points[i].X * 0.01f, points[i].Y * 0.01f) * amplitude;
points[i] = points[i] + perp * offset;
}
}
// ── Ramer-Douglas-Peucker simplification ─────────────────────────────────
/// <summary>
/// Ramer-Douglas-Peucker polyline simplification for LOD rendering.
/// Returns a simplified point list with perpendicular distance tolerance in world pixels.
/// </summary>
public static List<Vec2> RDPSimplify(IReadOnlyList<Vec2> points, float tolerance = C.RDP_TOLERANCE)
{
if (points.Count <= 2) return new List<Vec2>(points);
var result = new List<Vec2>();
var stack = new Stack<(int start, int end)>();
var keep = new bool[points.Count];
keep[0] = keep[^1] = true;
stack.Push((0, points.Count - 1));
while (stack.Count > 0)
{
var (start, end) = stack.Pop();
float maxDist = 0f;
int maxIndex = start;
for (int i = start + 1; i < end; i++)
{
float d = PerpendicularDistance(points[i], points[start], points[end]);
if (d > maxDist) { maxDist = d; maxIndex = i; }
}
if (maxDist > tolerance)
{
keep[maxIndex] = true;
stack.Push((start, maxIndex));
stack.Push((maxIndex, end));
}
}
for (int i = 0; i < points.Count; i++)
if (keep[i]) result.Add(points[i]);
return result;
}
private static float PerpendicularDistance(Vec2 pt, Vec2 lineStart, Vec2 lineEnd)
{
Vec2 d = lineEnd - lineStart;
float len = d.Length;
if (len < 1e-6f) return Vec2.Dist(pt, lineStart);
return MathF.Abs(Vec2.Dot(d.Perp, pt - lineStart)) / len;
}
// ── Tile flag rasterization ──────────────────────────────────────────────
/// <summary>
/// Rasterizes a polyline (in world-pixel space) onto the world tile grid,
/// setting the appropriate FeatureFlags and direction on each touched tile and its neighbors.
/// </summary>
public static void RasterizeToTileFlags(
WorldState world,
Polyline polyline,
bool setAdjacency = true)
{
var pts = polyline.Points;
if (pts.Count < 2) return;
int px = C.WORLD_TILE_PIXELS;
for (int seg = 0; seg < pts.Count - 1; seg++)
{
int x0 = (int)(pts[seg].X / px);
int y0 = (int)(pts[seg].Y / px);
int x1 = (int)(pts[seg + 1].X / px);
int y1 = (int)(pts[seg + 1].Y / px);
// Direction of this segment
int ddx = Math.Sign(x1 - x0);
int ddy = Math.Sign(y1 - y0);
byte dir = Dir.FromDelta(ddx == 0 && ddy == 0 ? 0 : ddx, ddy);
// Bresenham line in tile space
BresenhamLine(x0, y0, x1, y1, (tx, ty) =>
{
if ((uint)tx >= C.WORLD_WIDTH_TILES || (uint)ty >= C.WORLD_HEIGHT_TILES) return;
if (world.TileAt(tx, ty).Biome == BiomeId.Ocean) return;
ref var tile = ref world.TileAt(tx, ty);
switch (polyline.Type)
{
case PolylineType.River:
tile.Features |= FeatureFlags.HasRiver;
if (tile.RiverFlowDir == Dir.None) tile.RiverFlowDir = dir;
break;
case PolylineType.Road:
// Allow HasRoad on river tiles (bridge crossings) so that
// SplitByExistingFeature treats shared crossings as existing road
// and doesn't create redundant short segments.
// Still skip rail tiles (Addendum A §2).
if ((tile.Features & FeatureFlags.HasRail) == 0 ||
(tile.Features & FeatureFlags.IsSettlement) != 0)
tile.Features |= FeatureFlags.HasRoad;
break;
case PolylineType.Rail:
// Bridges: don't mark a river tile as also having rail.
// The crossing is implied by the route; co-location would be a violation.
if ((tile.Features & FeatureFlags.HasRiver) == 0 ||
(tile.Features & FeatureFlags.IsSettlement) != 0)
{
tile.Features |= FeatureFlags.HasRail;
if (tile.RailDir == Dir.None) tile.RailDir = dir;
}
break;
}
if (!setAdjacency) return;
// Mark 8 neighbors as adjacent
for (int ny = ty - 1; ny <= ty + 1; ny++)
for (int nx = tx - 1; nx <= tx + 1; nx++)
{
if (nx == tx && ny == ty) continue;
if ((uint)nx >= C.WORLD_WIDTH_TILES || (uint)ny >= C.WORLD_HEIGHT_TILES) continue;
if (world.TileAt(nx, ny).Biome == BiomeId.Ocean) continue;
ref var neighbor = ref world.TileAt(nx, ny);
switch (polyline.Type)
{
case PolylineType.River:
neighbor.Features |= FeatureFlags.RiverAdjacent;
if (neighbor.RiverFlowDir == Dir.None) neighbor.RiverFlowDir = dir;
break;
case PolylineType.Rail:
neighbor.Features |= FeatureFlags.RailroadAdjacent;
if (neighbor.RailDir == Dir.None) neighbor.RailDir = dir;
break;
}
}
});
}
}
private static void BresenhamLine(int x0, int y0, int x1, int y1, Action<int, int> visit)
{
int dx = Math.Abs(x1 - x0);
int dy = Math.Abs(y1 - y0);
int sx = x0 < x1 ? 1 : -1;
int sy = y0 < y1 ? 1 : -1;
int err = dx - dy;
while (true)
{
visit(x0, y0);
if (x0 == x1 && y0 == y1) break;
int e2 = 2 * err;
if (e2 > -dy) { err -= dy; x0 += sx; }
if (e2 < dx) { err += dx; y0 += sy; }
}
}
// ── Staircase smoothing ───────────────────────────────────────────────────
/// <summary>
/// Post-processes an A* tile path to reduce staircase artefacts in the
/// final smoothed polyline. A* tiebreaking can produce sequences like
/// <c>SE, E, SE, SE, SE</c> where a cardinal move lands in the middle of
/// runs of diagonal moves — Catmull-Rom smoothing then develops a visible
/// kink.
///
/// This walks the path looking for (cardinal, diagonal) move pairs and
/// swaps them to (diagonal, cardinal) when the alternative intermediate
/// tile is free of preserve-flag features and remains passable under
/// <paramref name="costFn"/>. Leaves total movement cost unchanged (the
/// two legs just get reordered) so A*'s optimality choice is respected.
/// Bubbling is forward-only, so any remaining cardinals end up at the
/// end of diagonal runs.
/// </summary>
public static void SmoothStaircases(
WorldState world,
List<(int X, int Y)> path,
FeatureFlags preserveMask,
Func<int, int, int, int, byte, float> costFn)
{
if (path.Count < 3) return;
bool changed = true;
int guard = path.Count; // bound passes to prevent oscillation
while (changed && guard-- > 0)
{
changed = false;
for (int i = 0; i < path.Count - 2; i++)
{
var a = path[i];
var b = path[i + 1];
var c = path[i + 2];
// Preserve tiles with load-bearing features (existing road/rail/
// river bridges, settlement footprint). Those are deliberate A*
// choices and junction/endpoint anchors downstream rely on them.
var bFeatures = world.Tiles[b.X, b.Y].Features;
if ((bFeatures & preserveMask) != 0) continue;
int dx1 = b.X - a.X, dy1 = b.Y - a.Y;
int dx2 = c.X - b.X, dy2 = c.Y - b.Y;
bool isCard1 = (dx1 == 0) ^ (dy1 == 0);
bool isDiag2 = (dx2 != 0) && (dy2 != 0);
if (!isCard1 || !isDiag2) continue;
// Alternative intermediate: do the diagonal first, then cardinal.
int nbx = a.X + dx2, nby = a.Y + dy2;
if ((uint)nbx >= C.WORLD_WIDTH_TILES || (uint)nby >= C.WORLD_HEIGHT_TILES) continue;
// Don't swap into a tile whose features A* would have weighed
// differently — stays equivalent in character to the old b.
if ((world.Tiles[nbx, nby].Features & preserveMask) != 0) continue;
byte dirToNb = Dir.FromDelta(dx2, dy2);
byte dirToC = Dir.FromDelta(dx1, dy1);
if (float.IsPositiveInfinity(costFn(a.X, a.Y, nbx, nby, dirToNb))) continue;
if (float.IsPositiveInfinity(costFn(nbx, nby, c.X, c.Y, dirToC))) continue;
path[i + 1] = (nbx, nby);
changed = true;
}
}
}
// ── Turn-angle limiter ────────────────────────────────────────────────────
/// <summary>
/// Caps the deflection angle at every internal vertex of an A* tile path.
/// A vertex whose turn (angle between incoming and outgoing edges) exceeds
/// <paramref name="maxTurnDegrees"/> is elided when the shortcut from its
/// predecessor to successor is a single passable A* step under
/// <paramref name="costFn"/>. Vertices carrying any flag in
/// <paramref name="preserveMask"/> are never removed — those are load-bearing
/// (existing rail/road junctions, river-bridge crossings, settlement footprint),
/// matching the contract used by <see cref="SmoothStaircases"/>.
///
/// Used by rail generation to avoid 90°/135° bends that look unrealistic
/// for heavy rail traffic. The grid produces turns in 45° steps, so a cap
/// of 75° permits 0°/45° turns and forces 90°/135° corners to be smoothed
/// into two consecutive diagonals.
/// </summary>
public static void LimitTurnAngle(
WorldState world,
List<(int X, int Y)> path,
float maxTurnDegrees,
FeatureFlags preserveMask,
Func<int, int, int, int, byte, float> costFn)
{
if (path.Count < 3) return;
float minCosAllowed = MathF.Cos(maxTurnDegrees * MathF.PI / 180f);
bool changed = true;
int guard = path.Count * 2;
while (changed && guard-- > 0)
{
changed = false;
for (int i = 1; i < path.Count - 1; i++)
{
var a = path[i - 1];
var b = path[i];
var c = path[i + 1];
float vx1 = b.X - a.X, vy1 = b.Y - a.Y;
float vx2 = c.X - b.X, vy2 = c.Y - b.Y;
float l1 = MathF.Sqrt(vx1 * vx1 + vy1 * vy1);
float l2 = MathF.Sqrt(vx2 * vx2 + vy2 * vy2);
if (l1 < 1e-6f || l2 < 1e-6f) continue;
float cosTurn = (vx1 * vx2 + vy1 * vy2) / (l1 * l2);
if (cosTurn >= minCosAllowed) continue; // already gentle enough
// Keep load-bearing vertices — junctions, bridges, endpoints.
if ((world.Tiles[b.X, b.Y].Features & preserveMask) != 0) continue;
// Shortcut a→c must be a single-step move (Chebyshev 1) and
// remain passable. Larger gaps would span multi-tile stretches
// the pathfinder never evaluated.
int dx = c.X - a.X, dy = c.Y - a.Y;
if (dx == 0 && dy == 0) continue;
if (Math.Max(Math.Abs(dx), Math.Abs(dy)) != 1) continue;
byte dir = Dir.FromDelta(Math.Sign(dx), Math.Sign(dy));
if (float.IsPositiveInfinity(costFn(a.X, a.Y, c.X, c.Y, dir))) continue;
path.RemoveAt(i);
changed = true;
break; // restart — indices shifted
}
}
}
// ── Path splitting for shared infrastructure ──────────────────────────────
/// <summary>
/// Splits a tile path into sub-paths covering only "new construction" — runs of tiles
/// that do NOT already have the given feature flag. Each sub-path includes one overlap
/// tile at each junction so the smoothed polyline visually connects to the existing feature.
/// Returns an empty list if the entire path is already covered.
///
/// </summary>
public static List<List<(int X, int Y)>> SplitByExistingFeature(
WorldState world,
List<(int X, int Y)> path,
FeatureFlags feature)
{
var segments = new List<List<(int X, int Y)>>();
List<(int X, int Y)>? current = null;
for (int i = 0; i < path.Count; i++)
{
var (x, y) = path[i];
var tileFeatures = world.Tiles[x, y].Features;
bool exists = (tileFeatures & feature) != 0;
if (!exists)
{
if (current == null)
{
current = new List<(int X, int Y)>();
// Include the last "existing" tile as a junction anchor
if (i > 0) current.Add(path[i - 1]);
}
current.Add(path[i]);
}
else if (current != null)
{
// Ending a new-construction run — include this existing tile as endpoint anchor
current.Add(path[i]);
if (current.Count >= 2) segments.Add(current);
current = null;
}
}
// Trailing new-construction segment (path ends on new tiles)
if (current != null && current.Count >= 2)
segments.Add(current);
return segments;
}
// ── Control point from tile coords ───────────────────────────────────────
/// <summary>Convert world tile coordinate to world-pixel center.</summary>
public static Vec2 TileToWorldPixel(int tileX, int tileY)
{
float px = C.WORLD_TILE_PIXELS;
return new Vec2(tileX * px + px * 0.5f, tileY * px + px * 0.5f);
}
}
+72
View File
@@ -0,0 +1,72 @@
namespace Theriapolis.Core.World;
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
}
public enum PoiType : byte
{
None, ImperiumRuin, AbandonedMine, CultDen, NaturalCave, OvergrownSettlement
}
/// <summary>
/// A placed settlement or point of interest on the world map.
/// Tier 14 are inhabited settlements; Tier 5 are PoIs (IsPoi == true).
/// </summary>
public sealed class Settlement
{
public int Id { get; init; }
public string Name { get; set; } = "";
public int Tier { get; init; }
public int TileX { get; init; }
public int TileY { get; init; }
/// <summary>World-pixel X (tile center).</summary>
public float WorldPixelX => TileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f;
/// <summary>World-pixel Y (tile center).</summary>
public float WorldPixelY => TileY * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f;
/// <summary>Narrative anchor this settlement fulfills, or null for non-anchors.</summary>
public NarrativeAnchor? Anchor { get; init; }
// ── Generated attributes (set by SettlementAttributesStage) ─────────────
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 infrastructure gen ────────────────────────────────────
public bool HasRailStation { get; set; }
public bool IsOnRiver { get; set; }
// ── PoI-specific ─────────────────────────────────────────────────────────
public bool IsPoi { get; init; }
public PoiType PoiType { get; set; }
// ── Phase 6 M0 — building footprints ────────────────────────────────────
/// <summary>
/// Buildings stamped inside this settlement, derived deterministically
/// from the matched <see cref="Data.SettlementLayoutDef"/>. Populated
/// lazily by <see cref="Settlements.SettlementStamper"/> on first chunk
/// generation that touches this settlement; identical across reloads.
/// </summary>
public List<Settlements.BuildingFootprint> Buildings { get; } = new();
/// <summary>True once <see cref="Buildings"/> has been resolved.</summary>
public bool BuildingsResolved { get; set; }
}
@@ -0,0 +1,77 @@
using Theriapolis.Core.Entities;
namespace Theriapolis.Core.World.Settlements;
/// <summary>
/// Phase 6 M1 — runtime map between symbolic ids used by quest scripts /
/// dialogue conditions and live world entities. Quest scripts never embed
/// world coordinates; they reference NPCs by role tag and locations by
/// anchor id (per master plan §8.4):
///
/// <code>
/// anchor:millhaven → the live Settlement
/// role:millhaven.innkeeper → the live NpcActor for that named role
/// </code>
///
/// Built lazily as chunks stream in: when a settlement's buildings resolve
/// (and any named NPC instantiates), the entry registers here. Phase 6 M2
/// persists the registry; M1 rebuilds it on every load from the live
/// settlement list and active NpcActors.
/// </summary>
public sealed class AnchorRegistry
{
private readonly Dictionary<string, int> _anchorToSettlementId = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, int> _roleToNpcId = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Register a settlement under its anchor id (e.g. "anchor:millhaven").</summary>
public void RegisterAnchor(NarrativeAnchor anchor, int settlementId)
{
string key = $"anchor:{anchor.ToString().ToLowerInvariant()}";
_anchorToSettlementId[key] = settlementId;
}
/// <summary>Register an NpcActor under its named role tag (e.g. "role:millhaven.innkeeper").</summary>
public void RegisterRole(string roleTag, int npcId)
{
if (string.IsNullOrEmpty(roleTag)) return;
if (!roleTag.Contains('.')) return; // generic role; only named (anchor.role) roles register
_roleToNpcId[$"role:{roleTag.ToLowerInvariant()}"] = npcId;
}
/// <summary>Forget the role mapping (called on chunk evict / NPC despawn).</summary>
public void UnregisterRole(string roleTag)
{
if (string.IsNullOrEmpty(roleTag)) return;
_roleToNpcId.Remove($"role:{roleTag.ToLowerInvariant()}");
}
/// <summary>Resolve "anchor:millhaven" → SettlementId (or null when not registered yet).</summary>
public int? ResolveAnchor(string id)
{
return _anchorToSettlementId.TryGetValue(id, out int sid) ? sid : null;
}
/// <summary>Resolve "role:millhaven.innkeeper" → NpcId (or null when not loaded / not yet streamed).</summary>
public int? ResolveRole(string id)
{
return _roleToNpcId.TryGetValue(id, out int nid) ? nid : null;
}
/// <summary>Bulk re-register every settlement's anchor (e.g. after world load).</summary>
public void RegisterAllAnchors(WorldState world)
{
foreach (var s in world.Settlements)
if (s.Anchor is { } a)
RegisterAnchor(a, s.Id);
}
/// <summary>For diagnostics: every (id → entityId) mapping currently held.</summary>
public IReadOnlyDictionary<string, int> AllAnchors => _anchorToSettlementId;
public IReadOnlyDictionary<string, int> AllRoles => _roleToNpcId;
public void Clear()
{
_anchorToSettlementId.Clear();
_roleToNpcId.Clear();
}
}
@@ -0,0 +1,64 @@
namespace Theriapolis.Core.World.Settlements;
/// <summary>
/// Phase 6 M0 — runtime record of a single stamped building inside a
/// settlement. Created by <see cref="SettlementStamper"/> at chunk-gen time
/// and attached to the parent <see cref="World.Settlement"/>.
///
/// Buildings can straddle chunk boundaries; the footprint is in
/// world-pixel (= tactical-tile) coordinates so cross-chunk lookups
/// (e.g. "is the player inside the Millhaven inn?") work without per-chunk
/// reconstruction.
/// </summary>
public sealed class BuildingFootprint
{
/// <summary>Unique id within the parent settlement (sequential, 0-based).</summary>
public int Id { get; init; }
/// <summary>Building template id (e.g. "inn_small").</summary>
public string TemplateId { get; init; } = "";
/// <summary>Inclusive minimum X in world-pixel space.</summary>
public int MinX { get; init; }
/// <summary>Inclusive minimum Y in world-pixel space.</summary>
public int MinY { get; init; }
/// <summary>Inclusive maximum X in world-pixel space.</summary>
public int MaxX { get; init; }
/// <summary>Inclusive maximum Y in world-pixel space.</summary>
public int MaxY { get; init; }
/// <summary>Door positions in world-pixel space (one entry per door).</summary>
public (int X, int Y)[] Doors { get; init; } = Array.Empty<(int, int)>();
/// <summary>Resident slots: role tag (possibly anchor-prefixed) → spawn position in world-pixel space.</summary>
public BuildingResidentSlot[] Residents { get; init; } = Array.Empty<BuildingResidentSlot>();
public bool ContainsTile(int worldPxX, int worldPxY)
=> worldPxX >= MinX && worldPxX <= MaxX
&& worldPxY >= MinY && worldPxY <= MaxY;
}
/// <summary>One resident slot inside a building.</summary>
public readonly struct BuildingResidentSlot
{
/// <summary>Role tag — either generic ("innkeeper") or anchor-qualified ("millhaven.innkeeper").</summary>
public readonly string RoleTag;
/// <summary>Spawn point in world-pixel (tactical-tile) coordinates.</summary>
public readonly int SpawnX;
public readonly int SpawnY;
/// <summary>Optional category match for procedural residents — passed through from BuildingTemplateDef.Category.</summary>
public readonly string Category;
public BuildingResidentSlot(string roleTag, int spawnX, int spawnY, string category)
{
RoleTag = roleTag;
SpawnX = spawnX;
SpawnY = spawnY;
Category = category;
}
}
@@ -0,0 +1,465 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.World.Settlements;
/// <summary>
/// Phase 6 M0 — drives building stamping for tactical chunks.
///
/// The original Phase 4 plan promised template-driven building burn-in
/// (<c>theriapolis-rpg-implementation-plan-phase4.md §3.4 step 3</c>) but
/// only a placeholder cobble plaza + outer wall ring shipped. This module
/// catches that up.
///
/// Two paths:
/// 1. **Content-driven** — when a <see cref="SettlementContent"/> is
/// available the stamper resolves a <see cref="SettlementLayoutDef"/>
/// for each settlement (preset by anchor, else procedural by tier),
/// materialises buildings on the settlement (lazily, once), and stamps
/// walls/floors/doors/decos for any building intersecting the chunk.
/// Per-resident <see cref="TacticalSpawn"/>s are emitted for Phase 6 M1
/// instantiation.
/// 2. **Fallback** — when content is unavailable (e.g. headless tools,
/// early-stage tests) the stamper writes the original cobble plaza +
/// outer wall ring so the existing Phase 5 visual baseline holds.
///
/// Determinism:
/// - Building list per settlement is resolved with a SeededRng keyed by
/// <c>worldSeed ^ RNG_BUILDING_LAYOUT ^ settlementId</c>; identical across
/// reloads.
/// - Procedural Tier 25 layouts roll templates and offsets from this RNG;
/// preset (Tier 1 / anchor) layouts are entirely data-driven.
/// </summary>
public static class SettlementStamper
{
/// <summary>
/// Half-width (in tactical tiles) of the gateway carved through a
/// settlement's wall ring wherever a road passes. Sized to the widest
/// road the world can produce plus 1-tile slop on each side. Mirrors
/// the Phase-4 placeholder constant.
/// </summary>
private const int GatewayHaloTiles = 3;
public static void Stamp(
ulong worldSeed,
TacticalChunk chunk,
WorldState world,
SettlementContent? content)
{
int x0 = chunk.OriginX;
int y0 = chunk.OriginY;
int x1 = x0 + C.TACTICAL_CHUNK_SIZE;
int y1 = y0 + C.TACTICAL_CHUNK_SIZE;
foreach (var s in world.Settlements)
{
// Cheap chunk-vs-settlement overlap reject — covers both the
// plaza ring (Phase 5 placeholder behaviour) and any building
// footprints we may have stamped on the settlement.
if (!OverlapsChunk(s, content, x0, y0, x1, y1)) continue;
// Always lay the plaza + ring first; buildings stamp on top.
StampPlazaAndWallRing(chunk, s, x0, y0, x1, y1);
if (content is not null)
StampBuildings(worldSeed, chunk, s, content, x0, y0, x1, y1);
}
}
// ── Lazy resolution of per-settlement building list ─────────────────
/// <summary>
/// Build the settlement's <see cref="Settlement.Buildings"/> list from
/// its matched layout. Idempotent — only runs once per settlement.
/// </summary>
public static void EnsureBuildingsResolved(ulong worldSeed, Settlement s, SettlementContent content)
{
if (s.BuildingsResolved) return;
var layout = content.ResolveFor(s);
if (layout is null)
{
s.BuildingsResolved = true; // nothing to stamp — bail with empty list
return;
}
var rng = SeededRng.ForSubsystem(worldSeed, C.RNG_BUILDING_LAYOUT ^ (ulong)s.Id);
var buildings = layout.Kind == "preset"
? ResolvePreset(s, layout, content)
: ResolveProcedural(s, layout, content, rng);
s.Buildings.Clear();
s.Buildings.AddRange(buildings);
s.BuildingsResolved = true;
}
private static IEnumerable<BuildingFootprint> ResolvePreset(
Settlement s,
SettlementLayoutDef layout,
SettlementContent content)
{
int cxPx = (int)s.WorldPixelX;
int cyPx = (int)s.WorldPixelY;
int next = 0;
foreach (var p in layout.Buildings)
{
if (!content.Buildings.TryGetValue(p.Template, out var def)) continue;
int ox = p.Offset.Length >= 1 ? p.Offset[0] : 0;
int oy = p.Offset.Length >= 2 ? p.Offset[1] : 0;
int minX = cxPx + ox - def.FootprintWTiles / 2;
int minY = cyPx + oy - def.FootprintHTiles / 2;
int maxX = minX + def.FootprintWTiles - 1;
int maxY = minY + def.FootprintHTiles - 1;
yield return BuildFootprint(next++, def, minX, minY, maxX, maxY, p.RoleOverrides);
}
}
private static IEnumerable<BuildingFootprint> ResolveProcedural(
Settlement s,
SettlementLayoutDef layout,
SettlementContent content,
SeededRng rng)
{
// Pull eligible templates. Tier numbering is inverted: Tier 1 is the
// capital (largest), Tier 5 is the hamlet (smallest). "Min tier
// eligible" is the *smallest settlement* where this template fits;
// a magistrate with min_tier_eligible=2 belongs in Tier 1 capitals
// and Tier 2 cities only — so the predicate is s.Tier <= MinTier.
var eligible = content.Buildings.Values
.Where(b => s.Tier <= b.MinTierEligible)
.Where(b => layout.CategoryWeights.ContainsKey(b.Category))
.OrderBy(b => b.Id, System.StringComparer.Ordinal) // stable order before RNG roll
.ToArray();
if (eligible.Length == 0) yield break;
int cxPx = (int)s.WorldPixelX;
int cyPx = (int)s.WorldPixelY;
int plazaR = layout.PlazaRadiusTiles > 0
? layout.PlazaRadiusTiles
: DefaultPlazaRadiusTiles(s.Tier);
// Slot grid: try concentric rings inside the plaza for placement.
var placed = new List<(int minX, int minY, int maxX, int maxY)>();
int placedCount = 0;
int attempts = 0;
int next = 0;
while (placedCount < layout.TargetBuildingCount && attempts < layout.TargetBuildingCount * 8)
{
attempts++;
// Weighted roll by category, then template.
string category = WeightedPick(layout.CategoryWeights, rng);
var inCategory = eligible.Where(b => string.Equals(b.Category, category, System.StringComparison.OrdinalIgnoreCase)).ToArray();
if (inCategory.Length == 0) continue;
var def = WeightedPickByTemplateWeight(inCategory, rng);
// Pick a candidate offset anywhere inside the plaza, biased toward outer rings to leave a centre.
int rx = rng.NextInt(-plazaR + def.FootprintWTiles / 2 + 2,
plazaR - def.FootprintWTiles / 2 - 1);
int ry = rng.NextInt(-plazaR + def.FootprintHTiles / 2 + 2,
plazaR - def.FootprintHTiles / 2 - 1);
int minX = cxPx + rx - def.FootprintWTiles / 2;
int minY = cyPx + ry - def.FootprintHTiles / 2;
int maxX = minX + def.FootprintWTiles - 1;
int maxY = minY + def.FootprintHTiles - 1;
// Reject if it overlaps another placed building (with the gap padding).
bool collide = false;
foreach (var p in placed)
{
if (RectsOverlap(
minX - C.SETTLEMENT_BUILDING_GAP_MIN, minY - C.SETTLEMENT_BUILDING_GAP_MIN,
maxX + C.SETTLEMENT_BUILDING_GAP_MIN, maxY + C.SETTLEMENT_BUILDING_GAP_MIN,
p.minX, p.minY, p.maxX, p.maxY))
{
collide = true;
break;
}
}
if (collide) continue;
placed.Add((minX, minY, maxX, maxY));
placedCount++;
yield return BuildFootprint(next++, def, minX, minY, maxX, maxY, roleOverrides: null);
}
}
private static BuildingFootprint BuildFootprint(
int id, BuildingTemplateDef def,
int minX, int minY, int maxX, int maxY,
Dictionary<string, string>? roleOverrides)
{
var doors = new (int X, int Y)[def.Doors.Length];
for (int i = 0; i < def.Doors.Length; i++)
doors[i] = (minX + def.Doors[i].X, minY + def.Doors[i].Y);
var residents = new BuildingResidentSlot[def.Roles.Length];
for (int i = 0; i < def.Roles.Length; i++)
{
var r = def.Roles[i];
string role = r.Tag;
if (roleOverrides is not null && roleOverrides.TryGetValue(r.Tag, out var named))
role = named;
residents[i] = new BuildingResidentSlot(
role,
minX + r.SpawnAt[0],
minY + r.SpawnAt[1],
def.Category);
}
return new BuildingFootprint
{
Id = id,
TemplateId = def.Id,
MinX = minX,
MinY = minY,
MaxX = maxX,
MaxY = maxY,
Doors = doors,
Residents = residents,
};
}
// ── Tile stamping ────────────────────────────────────────────────────
private static void StampPlazaAndWallRing(
TacticalChunk chunk, Settlement s,
int x0, int y0, int x1, int y1)
{
// Replicates the original Phase 5 placeholder behaviour. Buildings
// (if any) stamp on top.
int tilesRadius = s.Tier switch { 1 => 2, <= 4 => 1, _ => 0 };
int cxw = s.TileX, cyw = s.TileY;
int minWX = Math.Max(0, cxw - tilesRadius);
int minWY = Math.Max(0, cyw - tilesRadius);
int maxWX = Math.Min(C.WORLD_WIDTH_TILES - 1, cxw + tilesRadius);
int maxWY = Math.Min(C.WORLD_HEIGHT_TILES - 1, cyw + tilesRadius);
int fx0 = minWX * C.TACTICAL_PER_WORLD_TILE;
int fy0 = minWY * C.TACTICAL_PER_WORLD_TILE;
int fx1 = (maxWX + 1) * C.TACTICAL_PER_WORLD_TILE;
int fy1 = (maxWY + 1) * C.TACTICAL_PER_WORLD_TILE;
int sx = Math.Max(x0, fx0);
int sy = Math.Max(y0, fy0);
int ex = Math.Min(x1, fx1);
int ey = Math.Min(y1, fy1);
if (sx >= ex || sy >= ey) return;
int cxPx = (int)s.WorldPixelX;
int cyPx = (int)s.WorldPixelY;
int plazaR = s.Tier switch { 1 => 24, 2 => 18, 3 => 14, 4 => 10, _ => 6 };
int wallR = plazaR + 2;
for (int ty = sy; ty < ey; ty++)
for (int tx = sx; tx < ex; tx++)
{
int dx = tx - cxPx;
int dy = ty - cyPx;
int dist = Math.Max(Math.Abs(dx), Math.Abs(dy));
int lx = tx - chunk.OriginX;
int ly = ty - chunk.OriginY;
ref var dst = ref chunk.Tiles[lx, ly];
if (dist <= plazaR)
{
if ((dst.Flags & (byte)TacticalFlags.River) == 0)
dst.Surface = TacticalSurface.Cobble;
dst.Flags |= (byte)TacticalFlags.Settlement;
dst.Deco = TacticalDeco.None;
}
else if (dist == wallR && !s.IsPoi)
{
if ((dst.Flags & (byte)TacticalFlags.River) == 0 &&
!HasRoadInHalo(chunk, lx, ly, GatewayHaloTiles))
{
dst.Surface = TacticalSurface.Wall;
dst.Flags |= (byte)TacticalFlags.Settlement | (byte)TacticalFlags.Impassable;
dst.Deco = TacticalDeco.None;
}
}
}
}
private static void StampBuildings(
ulong worldSeed,
TacticalChunk chunk,
Settlement s,
SettlementContent content,
int x0, int y0, int x1, int y1)
{
EnsureBuildingsResolved(worldSeed, s, content);
foreach (var b in s.Buildings)
{
// Quick AABB intersect with this chunk's tactical window.
if (b.MaxX < x0 || b.MinX >= x1 || b.MaxY < y0 || b.MinY >= y1) continue;
int sx = Math.Max(x0, b.MinX);
int sy = Math.Max(y0, b.MinY);
int ex = Math.Min(x1, b.MaxX + 1);
int ey = Math.Min(y1, b.MaxY + 1);
if (sx >= ex || sy >= ey) continue;
for (int ty = sy; ty < ey; ty++)
for (int tx = sx; tx < ex; tx++)
{
int lx = tx - chunk.OriginX;
int ly = ty - chunk.OriginY;
ref var dst = ref chunk.Tiles[lx, ly];
bool perimeter = (tx == b.MinX || tx == b.MaxX || ty == b.MinY || ty == b.MaxY);
if (perimeter)
{
dst.Surface = TacticalSurface.Wall;
// Walls block but doorways don't — we patch doors next.
dst.Flags |= (byte)(TacticalFlags.Settlement | TacticalFlags.Building | TacticalFlags.Impassable);
dst.Deco = TacticalDeco.None;
}
else
{
dst.Surface = TacticalSurface.Floor;
dst.Flags |= (byte)(TacticalFlags.Settlement | TacticalFlags.Building);
// Clear Impassable for floors (a wall may have been stamped previously).
dst.Flags &= unchecked((byte)~(byte)TacticalFlags.Impassable);
dst.Deco = TacticalDeco.None;
}
}
// Doors override perimeter walls so the building is enterable.
foreach (var (dx, dy) in b.Doors)
{
if (dx < x0 || dx >= x1 || dy < y0 || dy >= y1) continue;
int lx = dx - chunk.OriginX;
int ly = dy - chunk.OriginY;
ref var dst = ref chunk.Tiles[lx, ly];
dst.Surface = TacticalSurface.Floor;
dst.Deco = TacticalDeco.Door;
dst.Flags |= (byte)(TacticalFlags.Settlement | TacticalFlags.Building | TacticalFlags.Doorway);
dst.Flags &= unchecked((byte)~(byte)TacticalFlags.Impassable);
}
// Interior decos.
if (content.Buildings.TryGetValue(b.TemplateId, out var def))
{
foreach (var deco in def.Decos)
{
int dx = b.MinX + deco.X;
int dy = b.MinY + deco.Y;
if (dx < x0 || dx >= x1 || dy < y0 || dy >= y1) continue;
int lx = dx - chunk.OriginX;
int ly = dy - chunk.OriginY;
ref var dst = ref chunk.Tiles[lx, ly];
dst.Deco = ParseDeco(deco.Deco);
}
}
// Resident spawn records.
foreach (var r in b.Residents)
{
if (r.SpawnX < x0 || r.SpawnX >= x1 || r.SpawnY < y0 || r.SpawnY >= y1) continue;
int lx = r.SpawnX - chunk.OriginX;
int ly = r.SpawnY - chunk.OriginY;
chunk.Spawns.Add(new TacticalSpawn(SpawnKind.Resident, lx, ly));
}
}
}
// ── Helpers ──────────────────────────────────────────────────────────
private static bool OverlapsChunk(Settlement s, SettlementContent? content, int x0, int y0, int x1, int y1)
{
// If buildings have already been resolved on this settlement, use
// their union AABB; otherwise fall back to the plaza+wall radius
// we'd stamp anyway.
int cxPx = (int)s.WorldPixelX;
int cyPx = (int)s.WorldPixelY;
int plazaR = s.Tier switch { 1 => 28, 2 => 22, 3 => 18, 4 => 14, _ => 10 };
int extentMinX = cxPx - plazaR, extentMinY = cyPx - plazaR;
int extentMaxX = cxPx + plazaR, extentMaxY = cyPx + plazaR;
if (s.BuildingsResolved && s.Buildings.Count > 0)
{
foreach (var b in s.Buildings)
{
extentMinX = Math.Min(extentMinX, b.MinX);
extentMinY = Math.Min(extentMinY, b.MinY);
extentMaxX = Math.Max(extentMaxX, b.MaxX);
extentMaxY = Math.Max(extentMaxY, b.MaxY);
}
}
return !(extentMaxX < x0 || extentMinX >= x1 || extentMaxY < y0 || extentMinY >= y1);
}
private static bool HasRoadInHalo(TacticalChunk chunk, int lx, int ly, int halo)
{
const byte ROAD = (byte)TacticalFlags.Road;
int sx = Math.Max(0, lx - halo);
int sy = Math.Max(0, ly - halo);
int ex = Math.Min(C.TACTICAL_CHUNK_SIZE - 1, lx + halo);
int ey = Math.Min(C.TACTICAL_CHUNK_SIZE - 1, ly + halo);
for (int qy = sy; qy <= ey; qy++)
for (int qx = sx; qx <= ex; qx++)
if ((chunk.Tiles[qx, qy].Flags & ROAD) != 0) return true;
return false;
}
private static bool RectsOverlap(int aMinX, int aMinY, int aMaxX, int aMaxY,
int bMinX, int bMinY, int bMaxX, int bMaxY)
=> !(aMaxX < bMinX || bMaxX < aMinX || aMaxY < bMinY || bMaxY < aMinY);
private static int DefaultPlazaRadiusTiles(int tier) => tier switch
{
1 => 24,
2 => 18,
3 => 14,
4 => 10,
_ => 6,
};
private static TacticalDeco ParseDeco(string raw) => raw.ToLowerInvariant() switch
{
"counter" => TacticalDeco.Counter,
"bed" => TacticalDeco.Bed,
"hearth" => TacticalDeco.Hearth,
"sign" => TacticalDeco.Sign,
_ => TacticalDeco.None,
};
private static string WeightedPick(IReadOnlyDictionary<string, float> weights, SeededRng rng)
{
// Stable order — keys sorted ascending — so identical seeds always
// pick identically regardless of dictionary insertion order.
var keys = weights.Keys.OrderBy(k => k, System.StringComparer.Ordinal).ToArray();
float total = 0f;
foreach (var k in keys) total += System.Math.Max(0f, weights[k]);
if (total <= 0f) return keys[0];
float roll = rng.NextFloat() * total;
float acc = 0f;
foreach (var k in keys)
{
acc += System.Math.Max(0f, weights[k]);
if (roll <= acc) return k;
}
return keys[^1];
}
private static BuildingTemplateDef WeightedPickByTemplateWeight(BuildingTemplateDef[] templates, SeededRng rng)
{
float total = 0f;
foreach (var t in templates) total += System.Math.Max(0f, t.Weight);
if (total <= 0f) return templates[0];
float roll = rng.NextFloat() * total;
float acc = 0f;
foreach (var t in templates)
{
acc += System.Math.Max(0f, t.Weight);
if (roll <= acc) return t;
}
return templates[^1];
}
}
+164
View File
@@ -0,0 +1,164 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.World.Polylines;
namespace Theriapolis.Core.World;
/// <summary>
/// The runtime world model. Holds all canonical simulation data for the generated continent.
/// Arrays are indexed [x, y] with (0,0) at the top-left (north-west).
/// </summary>
public sealed class WorldState
{
public ulong WorldSeed { get; init; }
// ── Canonical arrays ─────────────────────────────────────────────────────
public WorldTile[,] Tiles { get; } = new WorldTile[C.WORLD_WIDTH_TILES, C.WORLD_HEIGHT_TILES];
// Convenience accessors into the tile array (avoid struct copies in hot paths)
public ref WorldTile TileAt(int x, int y) => ref Tiles[x, y];
// ── Macro grid ────────────────────────────────────────────────────────────
public MacroCell[,]? MacroGrid { get; set; }
// ── Content defs (loaded from JSON, not generated) ────────────────────────
public BiomeDef[]? BiomeDefs { get; set; }
public FactionDef[]? FactionDefs { get; set; }
// ── Phase 2+3: 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();
// ── Phase 2+3: Settlements ───────────────────────────────────────────────
public List<Settlement> Settlements { get; } = new();
// ── Phase 2+3: Bridges (road/rail crossings over rivers) ────────────────
public List<Bridge> Bridges { get; } = new();
// ── Phase 2+3: Computed maps ─────────────────────────────────────────────
public float[,]? Habitability { get; set; }
public float[,]? EncounterDensity { get; set; }
public FactionInfluenceMap? FactionInfluence { get; set; }
// ── Stage hashes for save integrity ───────────────────────────────────────
// Each stage appends its hash here after completing.
public Dictionary<string, ulong> StageHashes { get; } = new();
// ── Helper: macro cell for a given world tile coordinate ─────────────────
/// <summary>
/// Looks up a macro cell by unwarped grid position. Use this only for
/// pre-ElevationGen stages or places where you explicitly want the raw
/// grid lookup. Most callers should use <see cref="MacroCellForTile"/>.
/// </summary>
public MacroCell MacroCellAt(int tileX, int tileY)
{
if (MacroGrid is null) throw new InvalidOperationException("MacroGrid not loaded yet.");
int mx = Math.Clamp(tileX / (C.WORLD_WIDTH_TILES / C.MACRO_GRID_WIDTH), 0, C.MACRO_GRID_WIDTH - 1);
int my = Math.Clamp(tileY / (C.WORLD_HEIGHT_TILES / C.MACRO_GRID_HEIGHT), 0, C.MACRO_GRID_HEIGHT - 1);
return MacroGrid[mx, my];
}
/// <summary>
/// Returns the macro cell stored on the given tile. <see cref="ElevationGenStage"/>
/// overwrites each tile's <see cref="WorldTile.MacroX"/> and <see cref="WorldTile.MacroY"/>
/// with border-warped coordinates so that macro cell boundaries follow
/// organic wiggly curves instead of grid-aligned lines (Addendum A §1).
/// All post-ElevationGen stages and tests should use this method.
/// </summary>
public MacroCell MacroCellForTile(in WorldTile tile)
{
if (MacroGrid is null) throw new InvalidOperationException("MacroGrid not loaded yet.");
return MacroGrid[tile.MacroX, tile.MacroY];
}
// ── Sea level constant ─────────────────────────────────────────────────────
// Tiles with elevation < SeaLevel are ocean.
public const float SeaLevel = 0.35f;
// ── Fast hash for determinism tests ──────────────────────────────────────
/// <summary>FNV-1a hash over all elevation values (for determinism tests).</summary>
public ulong HashElevation()
{
const ulong FNV_PRIME = 1099511628211UL;
const ulong FNV_OFFSET = 14695981039346656037UL;
ulong hash = FNV_OFFSET;
for (int y = 0; y < C.WORLD_HEIGHT_TILES; y++)
for (int x = 0; x < C.WORLD_WIDTH_TILES; x++)
{
uint bits = BitConverter.SingleToUInt32Bits(Tiles[x, y].Elevation);
hash = (hash ^ bits) * FNV_PRIME;
}
return hash;
}
public ulong HashMoisture()
{
const ulong FNV_PRIME = 1099511628211UL;
const ulong FNV_OFFSET = 14695981039346656037UL;
ulong hash = FNV_OFFSET;
for (int y = 0; y < C.WORLD_HEIGHT_TILES; y++)
for (int x = 0; x < C.WORLD_WIDTH_TILES; x++)
{
uint bits = BitConverter.SingleToUInt32Bits(Tiles[x, y].Moisture);
hash = (hash ^ bits) * FNV_PRIME;
}
return hash;
}
public ulong HashTemperature()
{
const ulong FNV_PRIME = 1099511628211UL;
const ulong FNV_OFFSET = 14695981039346656037UL;
ulong hash = FNV_OFFSET;
for (int y = 0; y < C.WORLD_HEIGHT_TILES; y++)
for (int x = 0; x < C.WORLD_WIDTH_TILES; x++)
{
uint bits = BitConverter.SingleToUInt32Bits(Tiles[x, y].Temperature);
hash = (hash ^ bits) * FNV_PRIME;
}
return hash;
}
public ulong HashBiomes()
{
const ulong FNV_PRIME = 1099511628211UL;
const ulong FNV_OFFSET = 14695981039346656037UL;
ulong hash = FNV_OFFSET;
for (int y = 0; y < C.WORLD_HEIGHT_TILES; y++)
for (int x = 0; x < C.WORLD_WIDTH_TILES; x++)
hash = (hash ^ (byte)Tiles[x, y].Biome) * FNV_PRIME;
return hash;
}
/// <summary>FNV-1a hash over all settlements (sorted by ID).</summary>
public ulong HashSettlements()
{
const ulong FNV_PRIME = 1099511628211UL;
const ulong FNV_OFFSET = 14695981039346656037UL;
ulong hash = FNV_OFFSET;
foreach (var s in Settlements.OrderBy(s => s.Id))
{
hash = (hash ^ (ulong)s.Id) * FNV_PRIME;
hash = (hash ^ (ulong)s.Tier) * FNV_PRIME;
hash = (hash ^ (ulong)s.TileX) * FNV_PRIME;
hash = (hash ^ (ulong)s.TileY) * FNV_PRIME;
}
return hash;
}
/// <summary>FNV-1a hash over all polyline points (rivers, then roads, then rails).</summary>
public ulong HashPolylines()
{
const ulong FNV_PRIME = 1099511628211UL;
const ulong FNV_OFFSET = 14695981039346656037UL;
ulong hash = FNV_OFFSET;
foreach (var polylineList in new[] { Rivers, Roads, Rails })
foreach (var p in polylineList.OrderBy(p => p.Id))
foreach (var pt in p.Points)
{
hash = (hash ^ BitConverter.SingleToUInt32Bits(pt.X)) * FNV_PRIME;
hash = (hash ^ BitConverter.SingleToUInt32Bits(pt.Y)) * FNV_PRIME;
}
return hash;
}
}
+86
View File
@@ -0,0 +1,86 @@
namespace Theriapolis.Core.World;
/// <summary>
/// Per-tile data for a single world tile (1/1024 of the continent's width).
/// All float fields are normalized to [0, 1] unless noted.
/// </summary>
public struct WorldTile
{
public float Elevation; // 0 = sea floor, 1 = mountain peak
public float Moisture; // 0 = arid, 1 = saturated
public float Temperature; // 0 = polar, 1 = equatorial
public BiomeId Biome;
public FeatureFlags Features;
/// <summary>Macro-grid cell index (x + y*32) for this tile.</summary>
public byte MacroX;
public byte MacroY;
// ── Phase 2+3 additions ──────────────────────────────────────────────────
/// <summary>
/// Direction (Dir.N..Dir.NW, or Dir.None) that a river polyline flows through this tile.
/// Set by HydrologyGenStage when rasterizing river polylines.
/// </summary>
public byte RiverFlowDir;
/// <summary>
/// Direction (Dir.N..Dir.NW, or Dir.None) that a rail polyline passes through this tile.
/// Set by RailNetworkGenStage when rasterizing rail polylines.
/// </summary>
public byte RailDir;
/// <summary>
/// Settlement ID (1-based) of the settlement whose footprint covers this tile.
/// 0 = no settlement.
/// </summary>
public ushort SettlementId;
}
/// <summary>
/// Biome identifiers. Enums, not magic ints.
/// </summary>
public enum BiomeId : byte
{
None = 0,
Ocean,
Tundra,
Boreal,
TemperateDeciduous,
TemperateGrassland,
MountainAlpine,
MountainForested,
SubtropicalForest,
Wetland,
Coastal,
RiverValley,
Scrubland,
DesertCold,
// Transition biomes (assigned by BorderDistortionGen)
ForestEdge,
Foothills,
MarshEdge,
Beach,
Cliff,
TidalFlat,
Mangrove,
}
/// <summary>
/// Bitmask of linear and special features on a tile.
/// Per-tile flags are derived caches — polylines are the source of truth for rivers/roads/rail.
/// </summary>
[Flags]
public enum FeatureFlags : ushort
{
None = 0,
HasRiver = 1 << 0,
HasRoad = 1 << 1,
HasRail = 1 << 2,
IsSettlement = 1 << 3,
IsPoi = 1 << 4,
IsCoast = 1 << 5,
IsBorder = 1 << 6, // biome or land/ocean border (set during distortion pass)
RiverAdjacent = 1 << 7,
RailroadAdjacent = 1 << 8,
}