using Theriapolis.Core.Util; using Theriapolis.Core.World; using Theriapolis.Core.World.Polylines; namespace Theriapolis.Core.Tactical; /// /// Deterministic per-chunk generator. Pure function of (worldSeed, ChunkCoord, WorldState). /// /// Pipeline: /// 1. Ground layer — biome → surface variant per tactical tile /// 2. Polyline burn-in — rivers and roads stamp water/road tiles /// 3. Settlement burn-in — settlement footprints stamp cobble + walls /// 4. Scatter — biome decorations seeded from a per-chunk sub-seed /// 5. Spawn list — encounter density + sub-seed /// /// Every step takes its randomness from a sub-seed derived from the chunk /// coord, so adjacent chunks see independent (but globally deterministic) /// scatters. /// public static class TacticalChunkGen { public static TacticalChunk Generate(ulong worldSeed, ChunkCoord cc, WorldState world) => Generate(worldSeed, cc, world, settlementContent: null); /// /// Phase 6 M0 — overload accepting building/layout content. When /// is non-null, Pass 3 stamps /// templated buildings; when null, falls back to the Phase-4 /// cobble-plaza + outer-wall-ring placeholder so headless tools and /// older tests keep working unchanged. /// public static TacticalChunk Generate( ulong worldSeed, ChunkCoord cc, WorldState world, Theriapolis.Core.Data.SettlementContent? settlementContent) { var chunk = new TacticalChunk(cc); ulong chunkHash = Hash(cc); Pass1_Ground(worldSeed, chunk, world, chunkHash); Pass2_Polylines(chunk, world); Theriapolis.Core.World.Settlements.SettlementStamper.Stamp(worldSeed, chunk, world, settlementContent); Pass4_Scatter(worldSeed, chunk, world, chunkHash); Pass5_Spawns(worldSeed, chunk, world, chunkHash); return chunk; } // ── Pass 1 — ground layer ───────────────────────────────────────────── private static void Pass1_Ground(ulong seed, TacticalChunk chunk, WorldState world, ulong chunkHash) { var rng = SeededRng.ForSubsystem(seed, C.RNG_TACTICAL_GROUND ^ chunkHash); int origX = chunk.OriginX; int origY = chunk.OriginY; for (int ly = 0; ly < C.TACTICAL_CHUNK_SIZE; ly++) for (int lx = 0; lx < C.TACTICAL_CHUNK_SIZE; lx++) { int tx = origX + lx; // tactical-tile coord (= world pixel) int ty = origY + ly; int wx = tx / C.TACTICAL_PER_WORLD_TILE; int wy = ty / C.TACTICAL_PER_WORLD_TILE; wx = Math.Clamp(wx, 0, C.WORLD_WIDTH_TILES - 1); wy = Math.Clamp(wy, 0, C.WORLD_HEIGHT_TILES - 1); ref var src = ref world.TileAt(wx, wy); ref var dst = ref chunk.Tiles[lx, ly]; (dst.Surface, dst.Variant) = PickGround(src, rng); } } private static (TacticalSurface, byte) PickGround(in WorldTile w, SeededRng rng) { byte v = (byte)rng.NextInt(0, 4); return w.Biome switch { BiomeId.Ocean => (TacticalSurface.DeepWater, 0), BiomeId.Wetland => (TacticalSurface.Marsh, v), BiomeId.MarshEdge => (rng.NextBool(0.4) ? TacticalSurface.Mud : TacticalSurface.Grass, v), BiomeId.Mangrove => (rng.NextBool(0.5) ? TacticalSurface.ShallowWater : TacticalSurface.Mud, v), BiomeId.TidalFlat => (TacticalSurface.Mud, v), BiomeId.Beach => (TacticalSurface.Sand, v), BiomeId.Coastal => (rng.NextBool(0.3) ? TacticalSurface.Sand : TacticalSurface.Grass, v), BiomeId.Tundra => (rng.NextBool(0.6) ? TacticalSurface.Snow : TacticalSurface.Dirt, v), BiomeId.MountainAlpine => (rng.NextBool(0.5) ? TacticalSurface.Rock : TacticalSurface.Snow, v), BiomeId.MountainForested => (rng.NextBool(0.4) ? TacticalSurface.Rock : TacticalSurface.Dirt, v), BiomeId.Cliff => (TacticalSurface.Rock, v), BiomeId.Foothills => (rng.NextBool(0.3) ? TacticalSurface.Rock : TacticalSurface.Grass, v), BiomeId.DesertCold => (rng.NextBool(0.4) ? TacticalSurface.Sand : TacticalSurface.Dirt, v), BiomeId.Scrubland => (rng.NextBool(0.5) ? TacticalSurface.Dirt : TacticalSurface.Grass, v), BiomeId.Boreal => (rng.NextBool(0.4) ? TacticalSurface.Dirt : TacticalSurface.Grass, v), BiomeId.SubtropicalForest => (rng.NextBool(0.3) ? TacticalSurface.TallGrass : TacticalSurface.Grass, v), BiomeId.TemperateDeciduous => (rng.NextBool(0.2) ? TacticalSurface.Dirt : TacticalSurface.Grass, v), BiomeId.TemperateGrassland => (rng.NextBool(0.4) ? TacticalSurface.TallGrass : TacticalSurface.Grass, v), BiomeId.RiverValley => (TacticalSurface.Grass, v), BiomeId.ForestEdge => (TacticalSurface.Grass, v), _ => (TacticalSurface.Grass, v), }; } // ── Pass 2 — polyline burn-in ───────────────────────────────────────── private static void Pass2_Polylines(TacticalChunk chunk, WorldState world) { // World-pixel AABB for the chunk (since 1 tactical tile == 1 world pixel, // local (0,0) = world pixel (origX, origY)). int x0 = chunk.OriginX; int y0 = chunk.OriginY; int x1 = x0 + C.TACTICAL_CHUNK_SIZE; int y1 = y0 + C.TACTICAL_CHUNK_SIZE; // Roads first, then rivers — rivers stamp deeper water and override // road surface where they cross (bridges are stamped later from // World.Bridges). foreach (var road in world.Roads) { var (surf, hw) = RoadStyle(road); BurnPolyline(chunk, road, x0, y0, x1, y1, hw, surf, TacticalFlags.Road); } foreach (var river in world.Rivers) BurnPolyline(chunk, river, x0, y0, x1, y1, RiverHalfWidth(river), TacticalSurface.ShallowWater, TacticalFlags.River); // Bridges: short segments that re-stamp Cobble+Bridge across the river. foreach (var b in world.Bridges) { BurnSegment(chunk, x0, y0, x1, y1, new Vec2(b.Start.X, b.Start.Y), new Vec2(b.End.X, b.End.Y), halfWidth: 2.5f, surface: TacticalSurface.Cobble, flag: TacticalFlags.Road | TacticalFlags.Bridge); } } /// /// Maps road class to a (surface, half-width) pair so each road tier /// reads as a distinct material in tactical, not just a different width /// of the same cobble. Footpaths use a narrower stamp than dirt roads /// because they're walking trails, not cart routes. /// private static (TacticalSurface surface, float halfWidth) RoadStyle(Polyline p) => p.RoadClassification switch { RoadType.Highway => (TacticalSurface.Cobble, 2.5f), RoadType.PostRoad => (TacticalSurface.Cobble, 1.5f), RoadType.DirtRoad => (TacticalSurface.TroddenDirt, 1.0f), _ => (TacticalSurface.Gravel, 0.7f), // Footpath }; private static float RiverHalfWidth(Polyline p) => p.RiverClassification switch { RiverClass.MajorRiver => 4.0f, RiverClass.River => 2.5f, _ => 1.0f, }; private static void BurnPolyline( TacticalChunk chunk, Polyline p, int x0, int y0, int x1, int y1, float halfWidth, TacticalSurface surface, TacticalFlags flag) { var pts = p.Points; if (pts.Count < 2) return; // Cheap AABB rejection per polyline. bool any = false; for (int i = 0; i < pts.Count && !any; i++) if (pts[i].X >= x0 - 8 && pts[i].X <= x1 + 8 && pts[i].Y >= y0 - 8 && pts[i].Y <= y1 + 8) any = true; if (!any) return; for (int i = 0; i < pts.Count - 1; i++) BurnSegment(chunk, x0, y0, x1, y1, pts[i], pts[i + 1], halfWidth, surface, flag); } private static void BurnSegment( TacticalChunk chunk, int x0, int y0, int x1, int y1, Vec2 a, Vec2 b, float halfWidth, TacticalSurface surface, TacticalFlags flag) { // Walk the segment's AABB intersected with the chunk and test each pixel // for distance ≤ halfWidth. CHUNK_SIZE is small (64) so this is cheap. float minX = MathF.Min(a.X, b.X) - halfWidth; float maxX = MathF.Max(a.X, b.X) + halfWidth; float minY = MathF.Min(a.Y, b.Y) - halfWidth; float maxY = MathF.Max(a.Y, b.Y) + halfWidth; int sx = Math.Max(x0, (int)MathF.Floor(minX)); int sy = Math.Max(y0, (int)MathF.Floor(minY)); int ex = Math.Min(x1 - 1, (int)MathF.Ceiling(maxX)); int ey = Math.Min(y1 - 1, (int)MathF.Ceiling(maxY)); if (sx > ex || sy > ey) return; Vec2 d = b - a; float L2 = d.LengthSquared; float hw2 = halfWidth * halfWidth; for (int ty = sy; ty <= ey; ty++) for (int tx = sx; tx <= ex; tx++) { // Tile centre in world pixels. Each tactical tile is 1 world pixel // wide so the centre is at (tx + 0.5, ty + 0.5). Vec2 q = new(tx + 0.5f, ty + 0.5f); float t = L2 < 1e-6f ? 0f : Math.Clamp(Vec2.Dot(q - a, d) / L2, 0f, 1f); Vec2 closest = a + d * t; if (Vec2.DistSq(q, closest) > hw2) continue; int lx = tx - chunk.OriginX; int ly = ty - chunk.OriginY; ref var dst = ref chunk.Tiles[lx, ly]; // For a river-on-water cell we want DeepWater near the centre line // and ShallowWater near the bank. For a road, just stamp surface. if (flag == TacticalFlags.River) { float dist = MathF.Sqrt(Vec2.DistSq(q, closest)); bool deep = dist < halfWidth * 0.5f; if ((dst.Flags & (byte)TacticalFlags.Bridge) == 0) dst.Surface = deep ? TacticalSurface.DeepWater : TacticalSurface.ShallowWater; dst.Flags |= (byte)flag; } else { // Roads override river surface only when bridge flag is set // (BurnSegment is called with Bridge for actual bridge spans). if ((flag & TacticalFlags.Bridge) != 0 || (dst.Flags & (byte)TacticalFlags.River) == 0) { dst.Surface = surface; } dst.Flags |= (byte)flag; // Bridge lookup forgets the underlying river flag for walkability. if ((flag & TacticalFlags.Bridge) != 0) dst.Flags &= unchecked((byte)~(byte)TacticalFlags.River); } // Polyline stamps clear scatter. dst.Deco = TacticalDeco.None; } } // ── Pass 3 — settlement stamping handed off to SettlementStamper ───── // Phase 6 M0 moved the settlement burn-in into // World/Settlements/SettlementStamper.cs, which falls back to the // legacy plaza+wall-ring stamp when no content is supplied. // ── Pass 4 — scatter ────────────────────────────────────────────────── private static void Pass4_Scatter(ulong seed, TacticalChunk chunk, WorldState world, ulong chunkHash) { var rng = SeededRng.ForSubsystem(seed, C.RNG_TACTICAL_SCATTER ^ chunkHash); for (int ly = 0; ly < C.TACTICAL_CHUNK_SIZE; ly++) for (int lx = 0; lx < C.TACTICAL_CHUNK_SIZE; lx++) { ref var dst = ref chunk.Tiles[lx, ly]; // Don't scatter on impassable, water, road, settlement, or river tiles. if (dst.Surface == TacticalSurface.DeepWater) continue; if (dst.Surface == TacticalSurface.Wall) continue; if ((dst.Flags & ((byte)TacticalFlags.Road | (byte)TacticalFlags.Settlement | (byte)TacticalFlags.River)) != 0) continue; int wx = (chunk.OriginX + lx) / C.TACTICAL_PER_WORLD_TILE; int wy = (chunk.OriginY + ly) / C.TACTICAL_PER_WORLD_TILE; wx = Math.Clamp(wx, 0, C.WORLD_WIDTH_TILES - 1); wy = Math.Clamp(wy, 0, C.WORLD_HEIGHT_TILES - 1); ref var w = ref world.TileAt(wx, wy); float treeP = TreeDensity(w.Biome); float bushP = BushDensity(w.Biome); float rockP = RockDensity(w.Biome); double r = rng.NextDouble(); TacticalDeco candidate = TacticalDeco.None; if (r < treeP) candidate = TacticalDeco.Tree; else if (r < treeP + bushP) candidate = TacticalDeco.Bush; else if (r < treeP + bushP + rockP) candidate = rng.NextBool(0.15) ? TacticalDeco.Boulder : TacticalDeco.Rock; else if (rng.NextBool(0.02)) candidate = TacticalDeco.Flower; // Rule: no blocking decorations within 1 tactical tile of a road. // Trees/boulders adjacent to a road would visually clutter the // road edge and (since both are impassable) effectively narrow // the road by half a tile on each side. Bushes, rocks, and // flowers are walkable so they're allowed adjacent. bool blocking = candidate == TacticalDeco.Tree || candidate == TacticalDeco.Boulder; if (blocking && HasRoadNeighbour(chunk, lx, ly)) continue; dst.Deco = candidate; } } /// /// Returns true if any of the 4 cardinal neighbours of (lx, ly) within /// this chunk has the Road flag set. Cross-chunk neighbours aren't /// inspected (would require streamer access during generation, breaking /// determinism + adding latency); the worst-case artefact is one tile of /// blocking deco at a chunk boundary, which is rare and barely visible. /// private static bool HasRoadNeighbour(TacticalChunk chunk, int lx, int ly) { const byte ROAD = (byte)TacticalFlags.Road; if (lx > 0 && (chunk.Tiles[lx - 1, ly].Flags & ROAD) != 0) return true; if (lx + 1 < C.TACTICAL_CHUNK_SIZE && (chunk.Tiles[lx + 1, ly].Flags & ROAD) != 0) return true; if (ly > 0 && (chunk.Tiles[lx, ly - 1].Flags & ROAD) != 0) return true; if (ly + 1 < C.TACTICAL_CHUNK_SIZE && (chunk.Tiles[lx, ly + 1].Flags & ROAD) != 0) return true; return false; } private static float TreeDensity(BiomeId b) => b switch { BiomeId.Boreal => 0.25f, BiomeId.SubtropicalForest => 0.30f, BiomeId.TemperateDeciduous => 0.22f, BiomeId.MountainForested => 0.18f, BiomeId.ForestEdge => 0.10f, BiomeId.RiverValley => 0.05f, BiomeId.Mangrove => 0.20f, BiomeId.Wetland => 0.04f, _ => 0.02f, }; private static float BushDensity(BiomeId b) => b switch { BiomeId.TemperateGrassland => 0.04f, BiomeId.Scrubland => 0.10f, BiomeId.Foothills => 0.05f, BiomeId.ForestEdge => 0.06f, _ => 0.02f, }; private static float RockDensity(BiomeId b) => b switch { BiomeId.MountainAlpine => 0.20f, BiomeId.MountainForested => 0.12f, BiomeId.Cliff => 0.30f, BiomeId.Foothills => 0.06f, BiomeId.Tundra => 0.04f, BiomeId.DesertCold => 0.05f, _ => 0.01f, }; // ── Pass 5 — spawn list ─────────────────────────────────────────────── private static void Pass5_Spawns(ulong seed, TacticalChunk chunk, WorldState world, ulong chunkHash) { var rng = SeededRng.ForSubsystem(seed, C.RNG_TACTICAL_SPAWN ^ chunkHash); // Sample the encounter density at the chunk centre. Dense areas roll // a few candidates, sparse ones roll one or none. int cxw = (chunk.OriginX + C.TACTICAL_CHUNK_SIZE / 2) / C.TACTICAL_PER_WORLD_TILE; int cyw = (chunk.OriginY + C.TACTICAL_CHUNK_SIZE / 2) / C.TACTICAL_PER_WORLD_TILE; cxw = Math.Clamp(cxw, 0, C.WORLD_WIDTH_TILES - 1); cyw = Math.Clamp(cyw, 0, C.WORLD_HEIGHT_TILES - 1); // Phase 5 M5: stamp the chunk's danger zone. Pass5 is the natural place // because chunks needing a zone are the same chunks needing spawns. chunk.DangerZone = (byte)World.DangerZone.Compute(cxw, cyw, world); float density = world.EncounterDensity?[cxw, cyw] ?? 0f; int candidates = density switch { < 0.1f => 0, < 0.3f => 1, < 0.6f => 2, _ => 3, }; for (int i = 0; i < candidates; i++) { int lx = rng.NextInt(0, C.TACTICAL_CHUNK_SIZE); int ly = rng.NextInt(0, C.TACTICAL_CHUNK_SIZE); ref var t = ref chunk.Tiles[lx, ly]; if (!t.IsWalkable) continue; if ((t.Flags & (byte)TacticalFlags.Settlement) != 0) continue; SpawnKind kind = rng.NextDouble() switch { < 0.55 => SpawnKind.WildAnimal, < 0.80 => SpawnKind.Brigand, < 0.92 => SpawnKind.Patrol, < 0.98 => SpawnKind.Merchant, _ => SpawnKind.PoiGuard, }; chunk.Spawns.Add(new TacticalSpawn(kind, lx, ly)); } } // ── Helper: mix the chunk coord into a sub-seed ─────────────────────── private static ulong Hash(ChunkCoord cc) { // SplitMix-style avalanche of (X, Y) into 64 bits. ulong h = (ulong)(uint)cc.X | ((ulong)(uint)cc.Y << 32); h += 0x9e3779b97f4a7c15UL; h = (h ^ (h >> 30)) * 0xbf58476d1ce4e5b9UL; h = (h ^ (h >> 27)) * 0x94d049bb133111ebUL; return h ^ (h >> 31); } }