using Theriapolis.Core.Data; using Theriapolis.Core.Util; namespace Theriapolis.Core.Dungeons; /// /// Phase 7 M1 — picks room templates per layout, then hands them to /// . Pure deterministic given the seed /// and layout. /// /// Algorithm: /// 1. Roll roomCount in [RoomCountMin, RoomCountMax]. /// 2. Pick the entry-room template (filtered to role: entry). /// 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.DUNGEON_LAYOUT_MAX_ATTEMPTS 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 — short-circuits to the pinned /// template list directly. /// internal static class DungeonLayoutBuilder { /// /// Build a fully-assembled for /// the given layout + content. Returns the plan from the first /// successful attempt; falls back to the linear-chain plan if every /// attempt fails. /// public static RoomGraphAssembler.Plan Build( DungeonLayoutDef layout, IReadOnlyList 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 typeTemplates, int roomCount, SeededRng rng) { // Group templates by eligible role for fast picking. var byRole = new Dictionary>(); 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(); list.Add(t); } // Required roles must each be satisfiable. var requiredRoles = new List(); 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 { entryPick }; var roles = new List { RoomRole.Entry }; // 2. Reserve required-role slots (excluding entry, which is implicit). var deferred = new List(); 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(); 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 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 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 { PickWeighted(entryPool, rng) }; var roles = new List { 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(), only.FootprintWTiles + 2 * pad, only.FootprintHTiles + 2 * pad, entrance); } return plan; } }