using Theriapolis.Core.Data; using Theriapolis.Core.Util; namespace Theriapolis.Core.Dungeons; /// /// Phase 7 M1 — pure deterministic room-graph assembly. Given a list of /// picked templates (in order: entry first, others in pick order) and a /// branching policy, returns: /// - Per-room placement (AABB top-left + Role) /// - List of binding rooms via matched /// door tiles /// /// The assembler does NOT paint tiles — it only decides geometry. Tile /// painting + corridor stamping is 's job. /// /// Placement algorithm: rooms snap to a 16-tile grid, placed left-to-right /// in chains; each non-entry room picks an existing-room neighbour from /// the eligible set (linear → previous; branching → uniform-random prior; /// loop → branching + one extra closing connection). The neighbour's /// available-side pool determines which AABB slot the new room takes. /// /// Returns null on failure (overlap, unreachable). Caller retries with a /// fresh seed up to times then /// falls back to the linear policy. /// internal static class RoomGraphAssembler { public sealed class Plan { public Room[] Rooms; public RoomConnection[] Connections; public int DungeonW; public int DungeonH; public (int X, int Y) EntranceTile; public Plan(Room[] rooms, RoomConnection[] connections, int w, int h, (int, int) entranceTile) { Rooms = rooms; Connections = connections; DungeonW = w; DungeonH = h; EntranceTile = entranceTile; } } /// /// Try to assemble. is one of /// linear / branching / loop. Returns null on /// any geometric failure; caller retries. /// public static Plan? TryAssemble( IReadOnlyList picks, IReadOnlyList roles, string branching, SeededRng rng) { if (picks.Count == 0) return null; if (picks.Count != roles.Count) throw new ArgumentException("picks and roles length mismatch"); // Step 1: place the entry room at the origin, padded by AABB padding. int pad = C.DUNGEON_AABB_PADDING; int gap = C.ROOM_INTER_ROOM_GAP_TILES; var rooms = new Room[picks.Count]; // Track absolute AABBs for overlap testing. var bounds = new (int x, int y, int w, int h)[picks.Count]; var entry = picks[0]; rooms[0] = new Room { Id = 0, TemplateId = entry.Id, AabbX = pad, AabbY = pad, AabbW = entry.FootprintWTiles, AabbH = entry.FootprintHTiles, BuiltBy = entry.BuiltBy, Role = roles[0], NarrativeText = entry.NarrativeText, }; bounds[0] = (pad, pad, entry.FootprintWTiles, entry.FootprintHTiles); var connections = new List(picks.Count); // Step 2: place each subsequent room next to a chosen prior room. for (int i = 1; i < picks.Count; i++) { int parentIdx = ChooseParent(branching, i, rng); var parent = bounds[parentIdx]; var tpl = picks[i]; // Try each cardinal direction in a deterministic order; pick the // first that doesn't overlap. Order rotates per `i` so different // seeds produce different-looking layouts. int[] dirOrder = RotateDirOrder(i, rng); (int x, int y, int dir)? placement = null; foreach (int d in dirOrder) { var topLeft = TryPlaceAdjacent(parent, tpl.FootprintWTiles, tpl.FootprintHTiles, d, gap); if (topLeft is null) continue; var candBounds = (topLeft.Value.x, topLeft.Value.y, tpl.FootprintWTiles, tpl.FootprintHTiles); if (Overlaps(candBounds, bounds, i)) continue; placement = (topLeft.Value.x, topLeft.Value.y, d); break; } if (placement is null) return null; // ran out of room sides rooms[i] = new Room { Id = i, TemplateId = tpl.Id, AabbX = placement.Value.x, AabbY = placement.Value.y, AabbW = tpl.FootprintWTiles, AabbH = tpl.FootprintHTiles, BuiltBy = tpl.BuiltBy, Role = roles[i], NarrativeText = tpl.NarrativeText, }; bounds[i] = (placement.Value.x, placement.Value.y, tpl.FootprintWTiles, tpl.FootprintHTiles); // Pick the door pair: parent's near-side door, this room's // far-side door. If neither template has a matching door we // synthesize a door at the AABB midpoint of the touching edge — // the painter will carve it through the wall. var conn = MatchDoors(rooms[parentIdx], picks[parentIdx], rooms[i], tpl, placement.Value.dir); connections.Add(conn); } // Step 3 (loop policy only): add one extra closing connection if a // suitable pair exists. The closing connection must not duplicate // an existing edge. if (branching == "loop" && rooms.Length >= 4) { // Pick room i (not 0) and j > 1, j != i, with no existing edge. int triesLeft = 8; while (triesLeft-- > 0) { int i = rng.NextInt(2, rooms.Length); int j = rng.NextInt(2, rooms.Length); if (i == j) continue; if (HasEdge(connections, i, j)) continue; var dir = AdjacentDirection(bounds[i], bounds[j], gap + 2); if (dir is null) continue; connections.Add(MatchDoors(rooms[i], picks[i], rooms[j], picks[j], dir.Value)); break; } } // Step 4: BFS reachability — every room must be reachable from room 0. if (!IsReachable(rooms.Length, connections)) return null; // Step 5: compute dungeon bounds. int minX = int.MaxValue, minY = int.MaxValue, maxX = 0, maxY = 0; foreach (var b in bounds) { if (b.x < minX) minX = b.x; if (b.y < minY) minY = b.y; if (b.x + b.w > maxX) maxX = b.x + b.w; if (b.y + b.h > maxY) maxY = b.y + b.h; } // Translate everything so origin is (pad, pad). int dx = pad - minX; int dy = pad - minY; if (dx != 0 || dy != 0) { for (int i = 0; i < rooms.Length; i++) { rooms[i] = new Room { Id = rooms[i].Id, TemplateId = rooms[i].TemplateId, AabbX = rooms[i].AabbX + dx, AabbY = rooms[i].AabbY + dy, AabbW = rooms[i].AabbW, AabbH = rooms[i].AabbH, BuiltBy = rooms[i].BuiltBy, Role = rooms[i].Role, NarrativeText = rooms[i].NarrativeText, }; } for (int k = 0; k < connections.Count; k++) { var c = connections[k]; connections[k] = c with { DoorAx = c.DoorAx + dx, DoorAy = c.DoorAy + dy, DoorBx = c.DoorBx + dx, DoorBy = c.DoorBy + dy, }; } } int dungeonW = maxX - minX + 2 * pad; int dungeonH = maxY - minY + 2 * pad; // Entrance tile — pick the entry room's first declared door if any, // otherwise the centre of its top edge. (M1 always has a door because // every authored entry template declares at least one.) var entryDoor = picks[0].Doors.Length > 0 ? picks[0].Doors[0] : null; (int X, int Y) entranceTile; if (entryDoor is not null) entranceTile = (rooms[0].AabbX + entryDoor.X, rooms[0].AabbY + entryDoor.Y); else entranceTile = (rooms[0].AabbX + rooms[0].AabbW / 2, rooms[0].AabbY); return new Plan(rooms, connections.ToArray(), dungeonW, dungeonH, entranceTile); } // ── Helpers ────────────────────────────────────────────────────────── private static int ChooseParent(string branching, int childIdx, SeededRng rng) => branching switch { "linear" => childIdx - 1, "branching" => rng.NextInt(0, childIdx), "loop" => rng.NextInt(0, childIdx), _ => childIdx - 1, }; private static int[] RotateDirOrder(int i, SeededRng rng) { // Rotate base order by `i % 4` so adjacent rooms don't pile up on // the same axis. Add a small RNG-driven secondary shuffle so seeds // diverge. int[] baseOrder = new[] { 0, 1, 2, 3 }; // 0=E, 1=S, 2=W, 3=N int rot = i % 4; var rotated = new int[4]; for (int k = 0; k < 4; k++) rotated[k] = baseOrder[(k + rot) % 4]; // 50% chance to swap pairs (small variation) if ((rng.NextUInt64() & 1) == 1) { (rotated[0], rotated[2]) = (rotated[2], rotated[0]); } return rotated; } private static (int x, int y)? TryPlaceAdjacent( (int x, int y, int w, int h) parent, int childW, int childH, int direction, int gap) { // direction: 0=E, 1=S, 2=W, 3=N. Child’s top-left is offset from // parent's outer edge by `gap` tiles so a corridor segment fits. return direction switch { 0 => (parent.x + parent.w + gap, parent.y), // east 1 => (parent.x, parent.y + parent.h + gap), // south 2 => (parent.x - childW - gap, parent.y), // west 3 => (parent.x, parent.y - childH - gap), // north _ => null, }; } private static bool Overlaps( (int x, int y, int w, int h) cand, (int x, int y, int w, int h)[] bounds, int countSoFar) { for (int k = 0; k < countSoFar; k++) { var b = bounds[k]; // AABB overlap: not (cand right of b OR cand left of b OR cand below b OR cand above b) bool noOverlap = cand.x + cand.w <= b.x || b.x + b.w <= cand.x || cand.y + cand.h <= b.y || b.y + b.h <= cand.y; if (!noOverlap) return true; } return false; } private static int? AdjacentDirection( (int x, int y, int w, int h) a, (int x, int y, int w, int h) b, int slack) { // Returns a direction code (0=E,1=S,2=W,3=N) for "b is to the X of // a within `slack` tiles" — used for loop-policy closing edges. if (Math.Abs((a.x + a.w) - b.x) <= slack && OverlapsRange(a.y, a.h, b.y, b.h)) return 0; if (Math.Abs((a.y + a.h) - b.y) <= slack && OverlapsRange(a.x, a.w, b.x, b.w)) return 1; if (Math.Abs(a.x - (b.x + b.w)) <= slack && OverlapsRange(a.y, a.h, b.y, b.h)) return 2; if (Math.Abs(a.y - (b.y + b.h)) <= slack && OverlapsRange(a.x, a.w, b.x, b.w)) return 3; return null; } private static bool OverlapsRange(int a0, int aLen, int b0, int bLen) => !(a0 + aLen <= b0 || b0 + bLen <= a0); private static bool HasEdge(List conns, int a, int b) { foreach (var c in conns) if ((c.RoomA == a && c.RoomB == b) || (c.RoomA == b && c.RoomB == a)) return true; return false; } private static bool IsReachable(int roomCount, List connections) { if (roomCount == 0) return true; var adj = new List[roomCount]; for (int i = 0; i < roomCount; i++) adj[i] = new List(); foreach (var c in connections) { adj[c.RoomA].Add(c.RoomB); adj[c.RoomB].Add(c.RoomA); } var visited = new bool[roomCount]; var queue = new Queue(); queue.Enqueue(0); visited[0] = true; int reached = 1; while (queue.Count > 0) { int n = queue.Dequeue(); foreach (int m in adj[n]) { if (visited[m]) continue; visited[m] = true; reached++; queue.Enqueue(m); } } return reached == roomCount; } /// /// Match door tiles between two rooms placed adjacent in /// . Returns the connection record with /// dungeon-local door coords on each side. Falls back to the AABB /// midpoint of the touching edge when neither template declares a door /// on the relevant side. /// private static RoomConnection MatchDoors( Room a, RoomTemplateDef aDef, Room b, RoomTemplateDef bDef, int direction) { // Direction is from A's perspective: 0=B east of A; 1=B south; 2=B west; 3=B north. // Pick A's door on the matching side; pick B's door on the opposite side. string aFacing = direction switch { 0 => "E", 1 => "S", 2 => "W", 3 => "N", _ => "E" }; string bFacing = direction switch { 0 => "W", 1 => "N", 2 => "E", 3 => "S", _ => "W" }; var aDoor = FindDoorByFacing(aDef, aFacing) ?? AabbEdgeMidpoint(aDef, aFacing); var bDoor = FindDoorByFacing(bDef, bFacing) ?? AabbEdgeMidpoint(bDef, bFacing); return new RoomConnection( RoomA: a.Id, DoorAx: a.AabbX + aDoor.x, DoorAy: a.AabbY + aDoor.y, RoomB: b.Id, DoorBx: b.AabbX + bDoor.x, DoorBy: b.AabbY + bDoor.y, Lock: aDoor.lockTier); } private static (int x, int y, string lockTier)? FindDoorByFacing(RoomTemplateDef def, string facing) { foreach (var d in def.Doors) if (string.Equals(d.Facing, facing, StringComparison.OrdinalIgnoreCase)) return (d.X, d.Y, d.Lock); return null; } private static (int x, int y, string lockTier) AabbEdgeMidpoint(RoomTemplateDef def, string facing) { int w = def.FootprintWTiles, h = def.FootprintHTiles; return facing switch { "E" => (w - 1, h / 2, ""), "W" => (0, h / 2, ""), "N" => (w / 2, 0, ""), "S" => (w / 2, h - 1, ""), _ => (w / 2, h / 2, ""), }; } }