b451f83174
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>
466 lines
19 KiB
C#
466 lines
19 KiB
C#
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 2–5 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];
|
||
}
|
||
}
|