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>
172 lines
6.8 KiB
C#
172 lines
6.8 KiB
C#
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;
|
|
}
|
|
}
|