141 lines
5.9 KiB
C#
141 lines
5.9 KiB
C#
|
|
using Theriapolis.Core.Data;
|
||
|
|
using Theriapolis.Core.World;
|
||
|
|
|
||
|
|
namespace Theriapolis.Core.Dungeons;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Phase 7 M1 — top-level deterministic entry point for generating the
|
||
|
|
/// interior of a PoI on first visit.
|
||
|
|
///
|
||
|
|
/// Determinism contract: <c>(worldSeed, poiId)</c> → byte-identical
|
||
|
|
/// <see cref="Dungeon"/> across runs. Internally:
|
||
|
|
/// <c>dungeonLayoutSeed = worldSeed ^ C.RNG_DUNGEON_LAYOUT ^ poiId</c>
|
||
|
|
///
|
||
|
|
/// Anchor-locked PoIs (Old Howl mine, Imperium Ruin showcase, Phase 7 M5
|
||
|
|
/// content) bypass the procedural pipeline by routing to a pinned-rooms
|
||
|
|
/// layout JSON. The pinned layout names the exact templates to use, in
|
||
|
|
/// order; the assembler's branching policy still applies (typically
|
||
|
|
/// linear). M1 does NOT pin any specific anchor PoI to a layout — that
|
||
|
|
/// wiring lands in M5 alongside <c>side_act_i_old_howl.json</c> and the
|
||
|
|
/// showcase rebuild. M1 ships the routing infrastructure.
|
||
|
|
/// </summary>
|
||
|
|
public static class DungeonGenerator
|
||
|
|
{
|
||
|
|
/// <summary>
|
||
|
|
/// Pure deterministic generator. Caller supplies the PoI's id (used as
|
||
|
|
/// the per-dungeon sub-seed nonce), the dungeon type (drives layout
|
||
|
|
/// selection + tile family), and the resolved content.
|
||
|
|
///
|
||
|
|
/// <paramref name="anchorOverride"/> — optional anchor id; when set,
|
||
|
|
/// the generator looks up
|
||
|
|
/// <see cref="ContentResolver.DungeonLayoutsByAnchor"/> first. M1
|
||
|
|
/// callers leave it null; M5+ wires the Old Howl + Imperium showcase
|
||
|
|
/// anchor routing.
|
||
|
|
/// </summary>
|
||
|
|
public static Dungeon Generate(
|
||
|
|
ulong worldSeed,
|
||
|
|
int poiId,
|
||
|
|
PoiType type,
|
||
|
|
ContentResolver content,
|
||
|
|
string? anchorOverride = null)
|
||
|
|
{
|
||
|
|
if (content is null) throw new ArgumentNullException(nameof(content));
|
||
|
|
|
||
|
|
ulong layoutSeed = worldSeed ^ C.RNG_DUNGEON_LAYOUT ^ (ulong)poiId;
|
||
|
|
|
||
|
|
DungeonLayoutDef layout = ResolveLayout(type, content, anchorOverride);
|
||
|
|
|
||
|
|
// Build the room-graph plan.
|
||
|
|
RoomGraphAssembler.Plan plan;
|
||
|
|
if (layout.PinnedRooms.Length > 0)
|
||
|
|
{
|
||
|
|
plan = AssemblePinnedLayout(layout, content, layoutSeed);
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
// Procedural layout — pick from typed templates.
|
||
|
|
string typeKey = TypeKeyFor(type);
|
||
|
|
if (!content.RoomTemplatesByType.TryGetValue(typeKey, out var typeTemplates) || typeTemplates.Count == 0)
|
||
|
|
throw new InvalidOperationException(
|
||
|
|
$"No room templates for dungeon type '{typeKey}' (PoiType.{type}). " +
|
||
|
|
"Did you author Content/Data/room_templates/{type}/*.json?");
|
||
|
|
|
||
|
|
plan = DungeonLayoutBuilder.Build(layout, typeTemplates, layoutSeed);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Paint the tiles.
|
||
|
|
var tiles = RoomTilePainter.Paint(plan, content.RoomTemplates, type);
|
||
|
|
|
||
|
|
return new Dungeon(
|
||
|
|
poiId: poiId,
|
||
|
|
type: type,
|
||
|
|
tiles: tiles,
|
||
|
|
rooms: plan.Rooms,
|
||
|
|
connections: plan.Connections,
|
||
|
|
entranceTile: plan.EntranceTile);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Find a procedural layout for the given type. M1 picks the first
|
||
|
|
/// matching layout deterministically (room-count band tied to layout
|
||
|
|
/// id); M2+ may add LevelBand-driven small/medium/large selection.
|
||
|
|
/// </summary>
|
||
|
|
private static DungeonLayoutDef ResolveLayout(PoiType type, ContentResolver content, string? anchorOverride)
|
||
|
|
{
|
||
|
|
if (!string.IsNullOrEmpty(anchorOverride)
|
||
|
|
&& content.DungeonLayoutsByAnchor.TryGetValue(anchorOverride, out var pinned))
|
||
|
|
return pinned;
|
||
|
|
|
||
|
|
// Find the first non-anchor layout for this type. Stable order:
|
||
|
|
// dictionary iteration is unordered in C# but DungeonLayouts is built
|
||
|
|
// from a sorted file list (LoadDungeonLayouts orders by file path),
|
||
|
|
// so iteration follows that order on .NET 8.
|
||
|
|
foreach (var l in content.DungeonLayouts.Values)
|
||
|
|
{
|
||
|
|
if (!string.IsNullOrEmpty(l.Anchor)) continue;
|
||
|
|
if (string.Equals(l.DungeonType, type.ToString(), StringComparison.OrdinalIgnoreCase))
|
||
|
|
return l;
|
||
|
|
}
|
||
|
|
throw new InvalidOperationException(
|
||
|
|
$"No dungeon layout found for PoiType.{type}. " +
|
||
|
|
"Author Content/Data/dungeon_layouts/<type>_<size>.json.");
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string TypeKeyFor(PoiType type) => type switch
|
||
|
|
{
|
||
|
|
PoiType.ImperiumRuin => "imperium",
|
||
|
|
PoiType.AbandonedMine => "mine",
|
||
|
|
PoiType.CultDen => "cult",
|
||
|
|
PoiType.NaturalCave => "cave",
|
||
|
|
PoiType.OvergrownSettlement => "overgrown",
|
||
|
|
_ => "imperium",
|
||
|
|
};
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Build a plan from a pinned-rooms layout. Pinned layouts always use
|
||
|
|
/// linear branching (the canonical Old Howl + Imperium showcase shape)
|
||
|
|
/// — no retry, no fallback, no random pick.
|
||
|
|
/// </summary>
|
||
|
|
private static RoomGraphAssembler.Plan AssemblePinnedLayout(
|
||
|
|
DungeonLayoutDef layout, ContentResolver content, ulong layoutSeed)
|
||
|
|
{
|
||
|
|
var picks = new List<RoomTemplateDef>(layout.PinnedRooms.Length);
|
||
|
|
var roles = new List<RoomRole>(layout.PinnedRooms.Length);
|
||
|
|
foreach (var pin in layout.PinnedRooms)
|
||
|
|
{
|
||
|
|
if (!content.RoomTemplates.TryGetValue(pin.Template, out var def))
|
||
|
|
throw new InvalidOperationException(
|
||
|
|
$"Pinned layout '{layout.Id}' references unknown template '{pin.Template}'. " +
|
||
|
|
"ContentLoader should have caught this — re-run content-validate.");
|
||
|
|
picks.Add(def);
|
||
|
|
roles.Add(RoomRoleExtensions.Parse(pin.Role));
|
||
|
|
}
|
||
|
|
var rng = new Util.SeededRng(layoutSeed);
|
||
|
|
var plan = RoomGraphAssembler.TryAssemble(picks, roles, "linear", rng);
|
||
|
|
if (plan is null)
|
||
|
|
throw new InvalidOperationException(
|
||
|
|
$"Pinned layout '{layout.Id}' failed to assemble. Pinned layouts must be hand-validated.");
|
||
|
|
return plan;
|
||
|
|
}
|
||
|
|
}
|