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

227 lines
9.1 KiB
C#

using Theriapolis.Core.Data;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Dungeons;
/// <summary>
/// Phase 7 M1 — picks room templates per layout, then hands them to
/// <see cref="RoomGraphAssembler"/>. Pure deterministic given the seed
/// and layout.
///
/// Algorithm:
/// 1. Roll <c>roomCount</c> in [<c>RoomCountMin</c>, <c>RoomCountMax</c>].
/// 2. Pick the entry-room template (filtered to <c>role: entry</c>).
/// 3. Reserve slots for required roles (boss, narrative, etc.). Insert
/// them last so they don't get crowded out by transit picks.
/// 4. Fill remaining slots with transit / loot / dead-end picks.
/// 5. Hand the template list + role list to the assembler.
/// 6. On assembly failure, retry up to
/// <c>C.DUNGEON_LAYOUT_MAX_ATTEMPTS</c> times. If all retries fail,
/// fall back to a guaranteed-valid linear chain of N transit rooms.
///
/// Anchor-locked layouts (Old Howl mine, Imperium showcase) bypass this
/// pipeline — <see cref="DungeonGenerator"/> short-circuits to the pinned
/// template list directly.
/// </summary>
internal static class DungeonLayoutBuilder
{
/// <summary>
/// Build a fully-assembled <see cref="RoomGraphAssembler.Plan"/> for
/// the given layout + content. Returns the plan from the first
/// successful attempt; falls back to the linear-chain plan if every
/// attempt fails.
/// </summary>
public static RoomGraphAssembler.Plan Build(
DungeonLayoutDef layout,
IReadOnlyList<RoomTemplateDef> typeTemplates,
ulong layoutSeed)
{
if (layout is null) throw new ArgumentNullException(nameof(layout));
if (typeTemplates is null || typeTemplates.Count == 0)
throw new ArgumentException("no templates available for this dungeon type", nameof(typeTemplates));
var rng = new SeededRng(layoutSeed);
for (int attempt = 0; attempt < C.DUNGEON_LAYOUT_MAX_ATTEMPTS; attempt++)
{
int roomCount = rng.NextInt(layout.RoomCountMin, layout.RoomCountMax + 1);
var (picks, roles) = PickTemplates(layout, typeTemplates, roomCount, rng);
if (picks is null) continue;
var plan = RoomGraphAssembler.TryAssemble(picks, roles!, layout.Branching, rng);
if (plan is not null) return plan;
}
// Fallback: guaranteed-valid linear chain.
return BuildLinearFallback(layout, typeTemplates, rng);
}
private static (RoomTemplateDef[]? picks, RoomRole[]? roles) PickTemplates(
DungeonLayoutDef layout,
IReadOnlyList<RoomTemplateDef> typeTemplates,
int roomCount,
SeededRng rng)
{
// Group templates by eligible role for fast picking.
var byRole = new Dictionary<RoomRole, List<RoomTemplateDef>>();
foreach (var t in typeTemplates)
foreach (var roleTag in t.RolesEligible)
{
RoomRole role;
try { role = RoomRoleExtensions.Parse(roleTag); } catch { continue; }
if (!byRole.TryGetValue(role, out var list))
byRole[role] = list = new List<RoomTemplateDef>();
list.Add(t);
}
// Required roles must each be satisfiable.
var requiredRoles = new List<RoomRole>();
foreach (var raw in layout.RequiredRoles)
{
try
{
var r = RoomRoleExtensions.Parse(raw);
if (!byRole.ContainsKey(r) || byRole[r].Count == 0) return (null, null);
requiredRoles.Add(r);
}
catch { return (null, null); }
}
// 1. Entry: pick from "entry" pool (always required).
if (!byRole.TryGetValue(RoomRole.Entry, out var entryPool) || entryPool.Count == 0) return (null, null);
var entryPick = PickWeighted(entryPool, rng);
var picks = new List<RoomTemplateDef> { entryPick };
var roles = new List<RoomRole> { RoomRole.Entry };
// 2. Reserve required-role slots (excluding entry, which is implicit).
var deferred = new List<RoomRole>();
foreach (var r in requiredRoles)
{
if (r == RoomRole.Entry) continue; // already placed
deferred.Add(r);
}
// 3. Fill remaining slots with transit / loot / dead-end.
var optionalRoles = new List<RoomRole>();
foreach (var raw in layout.OptionalRoles)
{
try { optionalRoles.Add(RoomRoleExtensions.Parse(raw)); } catch { /* ignore */ }
}
if (optionalRoles.Count == 0) optionalRoles.Add(RoomRole.Transit);
int slotsLeft = roomCount - 1 - deferred.Count;
if (slotsLeft < 0) return (null, null); // required-role count exceeds room count
for (int i = 0; i < slotsLeft; i++)
{
var role = optionalRoles[rng.NextInt(0, optionalRoles.Count)];
if (!byRole.TryGetValue(role, out var pool) || pool.Count == 0)
role = RoomRole.Transit;
// Fall back to typeTemplates if the role pool is empty.
var fromPool = byRole.TryGetValue(role, out var p) && p.Count > 0
? PickWeighted(p, rng)
: PickWeighted(typeTemplates, rng);
picks.Add(fromPool);
roles.Add(role);
}
// 4. Append deferred required-role picks (boss last so it ends the layout).
deferred.Sort((a, b) => RolePriority(a).CompareTo(RolePriority(b)));
foreach (var r in deferred)
{
picks.Add(PickWeighted(byRole[r], rng));
roles.Add(r);
}
return (picks.ToArray(), roles.ToArray());
}
private static int RolePriority(RoomRole r) => r switch
{
RoomRole.Narrative => 0,
RoomRole.Loot => 1,
RoomRole.DeadEnd => 2,
RoomRole.Boss => 9, // boss room last
_ => 5,
};
private static RoomTemplateDef PickWeighted(IReadOnlyList<RoomTemplateDef> pool, SeededRng rng)
{
if (pool.Count == 1) return pool[0];
float total = 0f;
foreach (var t in pool) total += t.Weight > 0 ? t.Weight : 1f;
float roll = rng.NextFloat() * total;
float acc = 0f;
foreach (var t in pool)
{
acc += t.Weight > 0 ? t.Weight : 1f;
if (roll <= acc) return t;
}
return pool[pool.Count - 1];
}
private static RoomGraphAssembler.Plan BuildLinearFallback(
DungeonLayoutDef layout,
IReadOnlyList<RoomTemplateDef> typeTemplates,
SeededRng rng)
{
// Linear chain: entry → N transits → boss (if required). The pure
// last-resort path; any layout reaches it via a deterministic-but-
// logged rng state.
var entryPool = typeTemplates.Where(t => t.RolesEligible.Contains("entry", StringComparer.OrdinalIgnoreCase)).ToList();
if (entryPool.Count == 0) entryPool = typeTemplates.ToList();
var transitPool = typeTemplates.Where(t => t.RolesEligible.Contains("transit", StringComparer.OrdinalIgnoreCase)).ToList();
if (transitPool.Count == 0) transitPool = typeTemplates.ToList();
var bossPool = typeTemplates.Where(t => t.RolesEligible.Contains("boss", StringComparer.OrdinalIgnoreCase)).ToList();
bool wantsBoss = layout.RequiredRoles.Contains("boss", StringComparer.OrdinalIgnoreCase) && bossPool.Count > 0;
int roomCount = layout.RoomCountMin;
var picks = new List<RoomTemplateDef> { PickWeighted(entryPool, rng) };
var roles = new List<RoomRole> { RoomRole.Entry };
int transitCount = wantsBoss ? roomCount - 2 : roomCount - 1;
for (int i = 0; i < transitCount; i++)
{
picks.Add(PickWeighted(transitPool, rng));
roles.Add(RoomRole.Transit);
}
if (wantsBoss)
{
picks.Add(PickWeighted(bossPool, rng));
roles.Add(RoomRole.Boss);
}
// Force the linear branching policy here regardless of layout; the
// fallback exists exactly because branching/loop assembly failed.
var plan = RoomGraphAssembler.TryAssemble(picks, roles, "linear", rng);
if (plan is null)
{
// Truly degenerate — single-room dungeon (entry only).
var only = picks[0];
int pad = C.DUNGEON_AABB_PADDING;
var rooms = new[]
{
new Room
{
Id = 0, TemplateId = only.Id,
AabbX = pad, AabbY = pad,
AabbW = only.FootprintWTiles, AabbH = only.FootprintHTiles,
BuiltBy = only.BuiltBy, Role = RoomRole.Entry,
NarrativeText = only.NarrativeText,
},
};
(int X, int Y) entrance = only.Doors.Length > 0
? (pad + only.Doors[0].X, pad + only.Doors[0].Y)
: (pad + only.FootprintWTiles / 2, pad);
return new RoomGraphAssembler.Plan(
rooms, Array.Empty<RoomConnection>(),
only.FootprintWTiles + 2 * pad,
only.FootprintHTiles + 2 * pad,
entrance);
}
return plan;
}
}