using Theriapolis.Core.Data; using Theriapolis.Core.Tactical; using Theriapolis.Core.Util; namespace Theriapolis.Core.World.Settlements; /// /// Phase 6 M0 — drives building stamping for tactical chunks. /// /// The original Phase 4 plan promised template-driven building burn-in /// (theriapolis-rpg-implementation-plan-phase4.md §3.4 step 3) but /// only a placeholder cobble plaza + outer wall ring shipped. This module /// catches that up. /// /// Two paths: /// 1. **Content-driven** — when a is /// available the stamper resolves a /// 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 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 /// worldSeed ^ RNG_BUILDING_LAYOUT ^ settlementId; identical across /// reloads. /// - Procedural Tier 2–5 layouts roll templates and offsets from this RNG; /// preset (Tier 1 / anchor) layouts are entirely data-driven. /// public static class SettlementStamper { /// /// 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. /// 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 ───────────────── /// /// Build the settlement's list from /// its matched layout. Idempotent — only runs once per settlement. /// 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 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 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? 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 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]; } }