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; /// /// Phase 6 M1 — turns a chunk's records /// into live s. /// /// Each with kind Resident sits at a /// world-pixel position that emitted from a /// . Resolution: /// /// 1. Walk the world's settlements. Find the one whose /// contains a building footprint /// that contains this spawn point. Within that building, find the /// slot whose SpawnX/SpawnY match — that's the role tag. /// 2. Look up the resident template. Named (anchor-prefixed) tags hit /// directly. Generic /// tags hit filtered by /// equality, weighted by /// . /// 3. Build an from the chosen template. Register /// named-role NPCs in the 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). /// public static class ResidentInstantiator { /// /// 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). /// 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; } /// /// 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. /// 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(); 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]; } /// /// Walk the world's settlements to find the one whose building footprint /// contains / AND /// whose resident slot sits exactly on that point. /// 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; } }