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:
@@ -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 — < 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user