Files

196 lines
8.3 KiB
C#
Raw Permalink Normal View History

using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Loot;
using Theriapolis.Core.World;
namespace Theriapolis.Core.Dungeons;
/// <summary>
/// Phase 7 M2 — populate a generated <see cref="Dungeon"/>'s encounter
/// and container slots. Pure deterministic given the same inputs:
/// <c>(worldSeed, poiId, dungeon, content, levelBand)</c>.
///
/// For each room:
/// - Walk the room's source <see cref="RoomTemplateDef.EncounterSlots"/>
/// and resolve each slot to a concrete <see cref="NpcTemplateDef"/> via
/// <see cref="NpcTemplateContent.SpawnKindToTemplateByDungeonType"/>
/// (with a fallback to <see cref="NpcTemplateContent.SpawnKindToTemplateByZone"/>
/// middle tier if the per-dungeon-type table doesn't list that kind).
/// - Walk the source <see cref="RoomTemplateDef.ContainerSlots"/> and
/// pre-roll each container's loot via
/// <see cref="LootGenerator.RollContainer"/>, mapping the container's
/// <c>loot_table_band</c> through the layout's
/// <c>loot_table_per_band</c> to a concrete table id.
///
/// Boss-room encounter slots use the dungeon-type's <c>"Boss"</c>
/// template if available, otherwise fall back to the <c>"PoiGuard"</c>
/// template — no boss → strong-guard graceful degradation.
///
/// Per-NPC-spawn RNG sub-seed:
/// <c>populateSeed = dungeonLayoutSeed ^ C.RNG_DUNGEON_POPULATE ^ roomId ^ slotIdx</c>
/// — 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.
/// </summary>
public static class DungeonPopulator
{
/// <summary>
/// Populate a freshly-generated dungeon. <paramref name="layoutId"/>
/// is the dungeon-layout id (from <see cref="DungeonLayoutDef.Id"/>);
/// the populator looks it up via <paramref name="content"/> to read
/// loot-band → table mappings. <paramref name="levelBand"/> is the
/// PoI's authored level band (0..3) and selects which loot tier
/// each container slot rolls.
/// </summary>
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<DungeonSpawn>();
var containers = new List<DungeonContainer>();
// 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<ItemInstance>()
: 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",
};
}
/// <summary>
/// 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.
/// </summary>
private static NpcTemplateDef? ResolveSpawnTemplate(
string slotKind,
RoomRole roomRole,
IReadOnlyDictionary<string, string>? 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;
}
}