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>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user