Files
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
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>
2026-04-30 20:40:51 -07:00

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;
}
}