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,171 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Settlements;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Phase 6 M1 — turns a chunk's <see cref="SpawnKind.Resident"/> records
/// into live <see cref="NpcActor"/>s.
///
/// Each <see cref="TacticalSpawn"/> with kind Resident sits at a
/// world-pixel position that <see cref="SettlementStamper"/> emitted from a
/// <see cref="BuildingResidentSlot"/>. Resolution:
///
/// 1. Walk the world's settlements. Find the one whose
/// <see cref="Settlement.Buildings"/> contains a building footprint
/// that contains this spawn point. Within that building, find the
/// slot whose <c>SpawnX/SpawnY</c> match — that's the role tag.
/// 2. Look up the resident template. Named (anchor-prefixed) tags hit
/// <see cref="ContentResolver.ResidentsByRoleTag"/> directly. Generic
/// tags hit <see cref="ContentResolver.Residents"/> filtered by
/// <see cref="ResidentTemplateDef.RoleTag"/> equality, weighted by
/// <see cref="ResidentTemplateDef.Weight"/>.
/// 3. Build an <see cref="NpcActor"/> from the chosen template. Register
/// named-role NPCs in the <see cref="AnchorRegistry"/> so quest
/// scripts can resolve them by symbolic id.
///
/// The lookup is a linear walk over settlements (small N — &lt; 100) but is
/// deterministic for a given (worldSeed, chunk, spawnIndex).
/// </summary>
public static class ResidentInstantiator
{
/// <summary>
/// Resolve and spawn an NpcActor for a single Resident spawn record.
/// Returns null when the world has no resident template configured for
/// this slot's role tag (the spawn is silently dropped — the building
/// just stays empty, which is fine).
/// </summary>
public static NpcActor? Spawn(
ulong worldSeed,
TacticalChunk chunk,
int spawnIndex,
TacticalSpawn spawn,
WorldState world,
ContentResolver content,
ActorManager actors,
AnchorRegistry? registry = null)
{
if (spawn.Kind != SpawnKind.Resident) return null;
int worldPxX = chunk.OriginX + spawn.LocalX;
int worldPxY = chunk.OriginY + spawn.LocalY;
if (!TryFindSlot(world, worldPxX, worldPxY, out var settlement, out var building, out var slot))
return null;
var template = ResolveTemplate(slot.RoleTag, content, worldSeed, settlement!.Id, building!.Id, spawnIndex);
if (template is null) return null;
var npc = new NpcActor(template)
{
Id = -1, // ActorManager assigns
Position = new Vec2(worldPxX, worldPxY),
SourceChunk = chunk.Coord,
SourceSpawnIndex = spawnIndex,
// The named role tag wins over the generic one declared on the
// template — preserves "millhaven.innkeeper" identity even when
// the generic "innkeeper" template is what spawned.
RoleTag = string.IsNullOrEmpty(slot.RoleTag) ? template.RoleTag : slot.RoleTag,
// Phase 6 M5 — anchor the resident to its host settlement so
// RepPropagation can compute their local faction standing.
HomeSettlementId = settlement.Id,
};
var spawned = actors.SpawnNpc(npc);
if (registry is not null)
{
if (settlement.Anchor is not null)
registry.RegisterAnchor(settlement.Anchor.Value, settlement.Id);
registry.RegisterRole(spawned.RoleTag, spawned.Id);
}
return spawned;
}
/// <summary>
/// Pick the resident template for a given role tag. Named anchor-
/// prefixed tags ("millhaven.innkeeper") prefer named templates;
/// generic tags ("innkeeper") roll among matching generics by weight.
/// </summary>
public static ResidentTemplateDef? ResolveTemplate(
string roleTag,
ContentResolver content,
ulong worldSeed,
int settlementId,
int buildingId,
int spawnIndex)
{
// Named, anchor-prefixed: prefer the exact match.
if (content.ResidentsByRoleTag.TryGetValue(roleTag, out var named))
return named;
// Generic: collect all unnamed templates whose RoleTag equals the
// suffix-stripped tag (e.g. "millhaven.innkeeper" → "innkeeper").
string suffix = roleTag;
int dot = roleTag.LastIndexOf('.');
if (dot >= 0) suffix = roleTag[(dot + 1)..];
var pool = new List<ResidentTemplateDef>();
foreach (var r in content.Residents.Values)
if (!r.Named && string.Equals(r.RoleTag, suffix, System.StringComparison.OrdinalIgnoreCase))
pool.Add(r);
if (pool.Count == 0) return null;
if (pool.Count == 1) return pool[0];
// Weighted roll, deterministic per (worldSeed, settlementId, buildingId, spawnIndex).
var rng = SeededRng.ForSubsystem(worldSeed,
unchecked(C.RNG_NPC_SPAWN ^ (ulong)settlementId
^ ((ulong)buildingId << 16)
^ ((ulong)spawnIndex << 32)));
// Sort for stable iteration before the RNG roll.
pool.Sort(static (a, b) => string.Compare(a.Id, b.Id, System.StringComparison.Ordinal));
float total = 0f;
foreach (var t in pool) total += System.Math.Max(0f, t.Weight);
if (total <= 0f) return pool[0];
float roll = rng.NextFloat() * total;
float acc = 0f;
foreach (var t in pool)
{
acc += System.Math.Max(0f, t.Weight);
if (roll <= acc) return t;
}
return pool[^1];
}
/// <summary>
/// Walk the world's settlements to find the one whose building footprint
/// contains <paramref name="worldPxX"/>/<paramref name="worldPxY"/> AND
/// whose resident slot sits exactly on that point.
/// </summary>
public static bool TryFindSlot(
WorldState world, int worldPxX, int worldPxY,
out Settlement? settlement, out BuildingFootprint? building, out BuildingResidentSlot slot)
{
settlement = null;
building = null;
slot = default;
foreach (var s in world.Settlements)
{
if (!s.BuildingsResolved) continue;
foreach (var b in s.Buildings)
{
if (!b.ContainsTile(worldPxX, worldPxY)) continue;
foreach (var r in b.Residents)
{
if (r.SpawnX == worldPxX && r.SpawnY == worldPxY)
{
settlement = s;
building = b;
slot = r;
return true;
}
}
}
}
return false;
}
}