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>
This commit is contained in:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -0,0 +1,77 @@
using Theriapolis.Core.Entities;
namespace Theriapolis.Core.World.Settlements;
/// <summary>
/// Phase 6 M1 — runtime map between symbolic ids used by quest scripts /
/// dialogue conditions and live world entities. Quest scripts never embed
/// world coordinates; they reference NPCs by role tag and locations by
/// anchor id (per master plan §8.4):
///
/// <code>
/// anchor:millhaven → the live Settlement
/// role:millhaven.innkeeper → the live NpcActor for that named role
/// </code>
///
/// Built lazily as chunks stream in: when a settlement's buildings resolve
/// (and any named NPC instantiates), the entry registers here. Phase 6 M2
/// persists the registry; M1 rebuilds it on every load from the live
/// settlement list and active NpcActors.
/// </summary>
public sealed class AnchorRegistry
{
private readonly Dictionary<string, int> _anchorToSettlementId = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, int> _roleToNpcId = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Register a settlement under its anchor id (e.g. "anchor:millhaven").</summary>
public void RegisterAnchor(NarrativeAnchor anchor, int settlementId)
{
string key = $"anchor:{anchor.ToString().ToLowerInvariant()}";
_anchorToSettlementId[key] = settlementId;
}
/// <summary>Register an NpcActor under its named role tag (e.g. "role:millhaven.innkeeper").</summary>
public void RegisterRole(string roleTag, int npcId)
{
if (string.IsNullOrEmpty(roleTag)) return;
if (!roleTag.Contains('.')) return; // generic role; only named (anchor.role) roles register
_roleToNpcId[$"role:{roleTag.ToLowerInvariant()}"] = npcId;
}
/// <summary>Forget the role mapping (called on chunk evict / NPC despawn).</summary>
public void UnregisterRole(string roleTag)
{
if (string.IsNullOrEmpty(roleTag)) return;
_roleToNpcId.Remove($"role:{roleTag.ToLowerInvariant()}");
}
/// <summary>Resolve "anchor:millhaven" → SettlementId (or null when not registered yet).</summary>
public int? ResolveAnchor(string id)
{
return _anchorToSettlementId.TryGetValue(id, out int sid) ? sid : null;
}
/// <summary>Resolve "role:millhaven.innkeeper" → NpcId (or null when not loaded / not yet streamed).</summary>
public int? ResolveRole(string id)
{
return _roleToNpcId.TryGetValue(id, out int nid) ? nid : null;
}
/// <summary>Bulk re-register every settlement's anchor (e.g. after world load).</summary>
public void RegisterAllAnchors(WorldState world)
{
foreach (var s in world.Settlements)
if (s.Anchor is { } a)
RegisterAnchor(a, s.Id);
}
/// <summary>For diagnostics: every (id → entityId) mapping currently held.</summary>
public IReadOnlyDictionary<string, int> AllAnchors => _anchorToSettlementId;
public IReadOnlyDictionary<string, int> AllRoles => _roleToNpcId;
public void Clear()
{
_anchorToSettlementId.Clear();
_roleToNpcId.Clear();
}
}
@@ -0,0 +1,64 @@
namespace Theriapolis.Core.World.Settlements;
/// <summary>
/// Phase 6 M0 — runtime record of a single stamped building inside a
/// settlement. Created by <see cref="SettlementStamper"/> at chunk-gen time
/// and attached to the parent <see cref="World.Settlement"/>.
///
/// Buildings can straddle chunk boundaries; the footprint is in
/// world-pixel (= tactical-tile) coordinates so cross-chunk lookups
/// (e.g. "is the player inside the Millhaven inn?") work without per-chunk
/// reconstruction.
/// </summary>
public sealed class BuildingFootprint
{
/// <summary>Unique id within the parent settlement (sequential, 0-based).</summary>
public int Id { get; init; }
/// <summary>Building template id (e.g. "inn_small").</summary>
public string TemplateId { get; init; } = "";
/// <summary>Inclusive minimum X in world-pixel space.</summary>
public int MinX { get; init; }
/// <summary>Inclusive minimum Y in world-pixel space.</summary>
public int MinY { get; init; }
/// <summary>Inclusive maximum X in world-pixel space.</summary>
public int MaxX { get; init; }
/// <summary>Inclusive maximum Y in world-pixel space.</summary>
public int MaxY { get; init; }
/// <summary>Door positions in world-pixel space (one entry per door).</summary>
public (int X, int Y)[] Doors { get; init; } = Array.Empty<(int, int)>();
/// <summary>Resident slots: role tag (possibly anchor-prefixed) → spawn position in world-pixel space.</summary>
public BuildingResidentSlot[] Residents { get; init; } = Array.Empty<BuildingResidentSlot>();
public bool ContainsTile(int worldPxX, int worldPxY)
=> worldPxX >= MinX && worldPxX <= MaxX
&& worldPxY >= MinY && worldPxY <= MaxY;
}
/// <summary>One resident slot inside a building.</summary>
public readonly struct BuildingResidentSlot
{
/// <summary>Role tag — either generic ("innkeeper") or anchor-qualified ("millhaven.innkeeper").</summary>
public readonly string RoleTag;
/// <summary>Spawn point in world-pixel (tactical-tile) coordinates.</summary>
public readonly int SpawnX;
public readonly int SpawnY;
/// <summary>Optional category match for procedural residents — passed through from BuildingTemplateDef.Category.</summary>
public readonly string Category;
public BuildingResidentSlot(string roleTag, int spawnX, int spawnY, string category)
{
RoleTag = roleTag;
SpawnX = spawnX;
SpawnY = spawnY;
Category = category;
}
}
@@ -0,0 +1,465 @@
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];
}
}