Files
TheriapolisV3/Theriapolis.Core/World/Settlements/SettlementStamper.cs
T
Christopher Wiebe b451f83174 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>
2026-04-30 20:40:51 -07:00

466 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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];
}
}