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>
227 lines
9.1 KiB
C#
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;
|
|
}
|
|
}
|