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>
196 lines
8.3 KiB
C#
196 lines
8.3 KiB
C#
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;
|
|
}
|
|
}
|