using Theriapolis.Core.Data; using Theriapolis.Core.Items; using Theriapolis.Core.Loot; using Theriapolis.Core.World; namespace Theriapolis.Core.Dungeons; /// /// Phase 7 M2 — populate a generated 's encounter /// and container slots. Pure deterministic given the same inputs: /// (worldSeed, poiId, dungeon, content, levelBand). /// /// For each room: /// - Walk the room's source /// and resolve each slot to a concrete via /// /// (with a fallback to /// middle tier if the per-dungeon-type table doesn't list that kind). /// - Walk the source and /// pre-roll each container's loot via /// , mapping the container's /// loot_table_band through the layout's /// loot_table_per_band to a concrete table id. /// /// Boss-room encounter slots use the dungeon-type's "Boss" /// template if available, otherwise fall back to the "PoiGuard" /// template — no boss → strong-guard graceful degradation. /// /// Per-NPC-spawn RNG sub-seed: /// populateSeed = dungeonLayoutSeed ^ C.RNG_DUNGEON_POPULATE ^ roomId ^ slotIdx /// — present so future variant rolls (e.g. random which-of-N-equivalent- /// templates) stay deterministic. M2 doesn't read it (per-kind template /// is fixed by the override map) but the helper is wired up so M3+ /// content can use it without a contract change. /// public static class DungeonPopulator { /// /// Populate a freshly-generated dungeon. /// is the dungeon-layout id (from ); /// the populator looks it up via to read /// loot-band → table mappings. is the /// PoI's authored level band (0..3) and selects which loot tier /// each container slot rolls. /// public static DungeonPopulation Populate( Dungeon dungeon, DungeonLayoutDef layout, ContentResolver content, int levelBand, ulong worldSeed) { if (dungeon is null) throw new System.ArgumentNullException(nameof(dungeon)); if (layout is null) throw new System.ArgumentNullException(nameof(layout)); if (content is null) throw new System.ArgumentNullException(nameof(content)); ulong dungeonLayoutSeed = worldSeed ^ C.RNG_DUNGEON_LAYOUT ^ (ulong)dungeon.PoiId; // Resolve loot-band → table-id mapping for this layout + level band. string lootBand = ResolveLootBand(layout, levelBand); layout.LootTablePerBand.TryGetValue(lootBand, out var lootTableForBand); var spawns = new List(); var containers = new List(); // Per-dungeon spawn-kind resolver lookup. string typeKey = dungeon.Type.ToString(); content.Npcs.SpawnKindToTemplateByDungeonType.TryGetValue(typeKey, out var kindMap); int globalContainerIdx = 0; int globalSpawnIdx = 0; foreach (var room in dungeon.Rooms) { if (!content.RoomTemplates.TryGetValue(room.TemplateId, out var def)) continue; // Encounter slots → concrete spawns. foreach (var slot in def.EncounterSlots) { var template = ResolveSpawnTemplate(slot.Kind, room.Role, kindMap, content.Npcs, dungeon.Type); if (template is null) { globalSpawnIdx++; continue; } int absX = room.AabbX + slot.X; int absY = room.AabbY + slot.Y; spawns.Add(new DungeonSpawn( RoomId: room.Id, X: absX, Y: absY, Template: template, Kind: slot.Kind)); globalSpawnIdx++; } // Container slots → pre-rolled loot. foreach (var slot in def.ContainerSlots) { int absX = room.AabbX + slot.X; int absY = room.AabbY + slot.Y; // Per-container band: room's container slot may declare its // own band (e.g. boss room slot says "t3"); otherwise we // use the layout's level-band → loot-band lookup. string slotBand = !string.IsNullOrEmpty(slot.LootTableBand) ? slot.LootTableBand : lootBand; layout.LootTablePerBand.TryGetValue(slotBand, out var slotTableId); slotTableId ??= lootTableForBand ?? ""; var drops = string.IsNullOrEmpty(slotTableId) ? System.Array.Empty() : LootGenerator.RollContainer( tableId: slotTableId, dungeonLayoutSeed: dungeonLayoutSeed, slotIdx: globalContainerIdx, tables: content.LootTables, items: content.Items); containers.Add(new DungeonContainer( RoomId: room.Id, X: absX, Y: absY, TableId: slotTableId ?? "", Drops: drops, Locked: slot.Locked, LockTier: slot.Lock)); globalContainerIdx++; } } return new DungeonPopulation(spawns.ToArray(), containers.ToArray()); } private static string ResolveLootBand(DungeonLayoutDef layout, int levelBand) { var key = levelBand.ToString(System.Globalization.CultureInfo.InvariantCulture); if (layout.LevelBandToLootBand.TryGetValue(key, out var band) && !string.IsNullOrEmpty(band)) return band; // Default per the Phase 7 plan §5.5 thresholds: // levelBand 0..1 → t1, 2 → t2, 3+ → t3. return levelBand switch { <= 1 => "t1", 2 => "t2", _ => "t3", }; } /// /// Resolve a slot's spawn-kind tag to a concrete NPC template. Boss /// slots get the dungeon-type's "Boss" template if listed; otherwise /// the slot's own kind, with a graceful fall-through to the /// existing per-zone table at the mid tier. /// private static NpcTemplateDef? ResolveSpawnTemplate( string slotKind, RoomRole roomRole, IReadOnlyDictionary? kindMap, NpcTemplateContent npcs, PoiType dungeonType) { // Boss-role rooms with a boss kind: prefer the per-dungeon-type // "Boss" entry. If neither the slot says "Boss" nor the room is // a boss room, this branch doesn't fire. string effectiveKind = slotKind; if (roomRole == RoomRole.Boss && string.Equals(slotKind, "Boss", System.StringComparison.OrdinalIgnoreCase)) { effectiveKind = "Boss"; } // 1. Per-dungeon-type override. if (kindMap is not null && kindMap.TryGetValue(effectiveKind, out var tplId)) { foreach (var t in npcs.Templates) if (string.Equals(t.Id, tplId, System.StringComparison.OrdinalIgnoreCase)) return t; } // 2. Boss kind unmapped → fall back to PoiGuard for the same dungeon type. if (effectiveKind == "Boss" && kindMap is not null && kindMap.TryGetValue("PoiGuard", out var guardId)) { foreach (var t in npcs.Templates) if (string.Equals(t.Id, guardId, System.StringComparison.OrdinalIgnoreCase)) return t; } // 3. Final fallback: the per-zone table at zone 2 (mid). if (npcs.SpawnKindToTemplateByZone.TryGetValue(slotKind, out var byZone) && byZone.Length > 0) { int z = System.Math.Min(2, byZone.Length - 1); string id = byZone[z]; foreach (var t in npcs.Templates) if (string.Equals(t.Id, id, System.StringComparison.OrdinalIgnoreCase)) return t; } return null; } }