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];
}
}