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:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -0,0 +1,106 @@
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Dungeons;
/// <summary>
/// Phase 7 M2 — clade-responsive dungeon movement cost. Per <c>procgen.md</c>
/// Layer 5 final paragraph and Phase 7 plan §5.4: a Large PC squeezing
/// through a Mustelid tunnel takes 2× movement points per tile; a Small
/// PC in an Ursid hall is exposed (×1.5); etc.
///
/// Cost multiplier applies to tactical-tile movement budget per turn —
/// combat reach + LOS unchanged; <em>only</em> movement budget. The
/// caller (<see cref="Rules.Combat.TacticalMovementRules"/> or equivalent)
/// looks up the room the actor is currently in and consults
/// <see cref="GetCostMultiplier"/>.
///
/// Hybrid PCs use their <em>dominant lineage</em>'s clade-implied size
/// for the lookup — matches the Phase 6.5 hybrid passing / presenting-clade
/// contract. A Wolf-Folk × Hare-Folk hybrid with <c>DominantParent: Sire</c>
/// reads as Wolf-Folk (MediumLarge); with <c>DominantParent: Dam</c> reads
/// as Hare-Folk (Medium). Outside dungeons the multiplier is always 1.0.
///
/// Reference table (Phase 7 plan §5.4):
/// Player size | Built by Mustelid | Ursid | Cervid | Bovid | Imperium/None
/// Small | 1.0 | 1.5 | 1.0 | 1.2 | 1.0
/// Medium | 1.2 | 1.0 | 1.0 | 1.0 | 1.0
/// MediumLarge | 1.5 | 1.0 | 1.0 | 1.0 | 1.0
/// Large | 2.0 | 1.0 | 1.2 | 1.0 | 1.0
/// </summary>
public static class ClademorphicMovement
{
/// <summary>
/// Multiplier on movement-cost-per-tile for a player of the given size
/// in a room built by the given clade. Returns 1.0 when no mismatch
/// applies. Unknown <paramref name="builtBy"/> values default to 1.0
/// (no penalty).
/// </summary>
public static float GetCostMultiplier(SizeCategory playerSize, string builtBy)
{
if (string.IsNullOrEmpty(builtBy)) return 1.0f;
// Normalise — JSON ships lowercase tags.
return builtBy.ToLowerInvariant() switch
{
"mustelid" => playerSize switch
{
SizeCategory.Small => 1.0f,
SizeCategory.Medium => C.MOVE_COST_MISMATCH_LIGHT, // 1.2 — slight squeeze
SizeCategory.MediumLarge => C.MOVE_COST_MISMATCH_MED, // 1.5
SizeCategory.Large => C.MOVE_COST_MISMATCH_HEAVY, // 2.0 — squeezing
_ => 1.0f,
},
"ursid" => playerSize switch
{
SizeCategory.Small => C.MOVE_COST_MISMATCH_MED, // exposed in cavernous halls
_ => 1.0f,
},
"cervid" => playerSize switch
{
SizeCategory.Large => C.MOVE_COST_MISMATCH_LIGHT, // antler clearance
_ => 1.0f,
},
"bovid" => playerSize switch
{
SizeCategory.Small => C.MOVE_COST_MISMATCH_LIGHT,
_ => 1.0f,
},
// Canid / Felid / Leporid / Imperium / "none" / unknown:
_ => 1.0f,
};
}
/// <summary>
/// Convenience wrapper that resolves a character's effective size for
/// the lookup (handles Phase 6.5 hybrid dominant-lineage rules).
/// </summary>
public static float GetCostMultiplier(Character character, string builtBy)
{
if (character is null) return 1.0f;
var effectiveSize = EffectiveSize(character);
return GetCostMultiplier(effectiveSize, builtBy);
}
/// <summary>
/// Resolve the size category that drives the clade-responsive lookup.
/// For purebred PCs, this is just <see cref="Character.Size"/>. For
/// hybrid PCs, it's the size implied by the dominant-lineage species
/// — and we expose this as a separate helper so callers (e.g. NPC
/// mechanics that *also* need the presenting size) can reuse it.
/// </summary>
public static SizeCategory EffectiveSize(Character character)
{
if (character is null) throw new System.ArgumentNullException(nameof(character));
if (!character.IsHybrid) return character.Size;
// Hybrid: pick the size implied by the dominant parent's species.
// The Hybrid record carries the species name only (string); the
// species-to-size mapping lives on Character.Species (the
// *presenting* species set at character creation per the dominant
// lineage). So for a hybrid PC the simplest (and load-bearing-
// correct) answer is the presenting species — which is exactly
// what <c>character.Size</c> already returns. Documented here so
// future agents don't replace this with a parent-species lookup
// and break the contract.
return character.Size;
}
}
+68
View File
@@ -0,0 +1,68 @@
using Theriapolis.Core.Tactical;
using Theriapolis.Core.World;
namespace Theriapolis.Core.Dungeons;
/// <summary>
/// Phase 7 M1 — runtime view of an interior dungeon. Generated lazily on
/// the player's first entry to a PoI; persisted modifications live on
/// <see cref="Theriapolis.Core.Persistence.DungeonStateSnapshot"/> and
/// re-apply on reload.
///
/// The dungeon owns its own bounded tactical-tile array (the scene-swap
/// model from Phase 7 plan §4.2): movement, combat, dialogue, and save/load
/// all work the same as the surface, but the renderer reads tiles from
/// <see cref="Tiles"/> instead of the chunk streamer.
///
/// Coordinate space: <c>Tiles[x, y]</c> where <c>x ∈ [0, W)</c> and
/// <c>y ∈ [0, H)</c>. Every <see cref="Room"/>'s AABB falls within these
/// bounds; corridor tiles between rooms are also in this array.
/// </summary>
public sealed class Dungeon
{
/// <summary>Source PoI id. Identity for save lookups.</summary>
public int PoiId { get; }
/// <summary>Dungeon type — drives art family, default loot tier, etc.</summary>
public PoiType Type { get; }
/// <summary>The tactical-tile grid in dungeon-local coordinates.</summary>
public TacticalTile[,] Tiles { get; }
/// <summary>Every room in layout order. <c>Rooms[i].Id == i</c>.</summary>
public Room[] Rooms { get; }
/// <summary>
/// Door-anchored connections between rooms. Authoritative for
/// reachability — the graph is undirected (a connection from A→B is
/// implicit B→A; do not double-store).
/// </summary>
public RoomConnection[] Connections { get; }
/// <summary>
/// Dungeon-local tile coords of the entrance. The player spawns here
/// on enter and exits when they cross this tile inbound from inside.
/// </summary>
public (int X, int Y) EntranceTile { get; }
/// <summary>Width of the tile array (dungeon-local tiles).</summary>
public int W => Tiles.GetLength(0);
/// <summary>Height of the tile array (dungeon-local tiles).</summary>
public int H => Tiles.GetLength(1);
public Dungeon(
int poiId,
PoiType type,
TacticalTile[,] tiles,
Room[] rooms,
RoomConnection[] connections,
(int X, int Y) entranceTile)
{
PoiId = poiId;
Type = type;
Tiles = tiles;
Rooms = rooms;
Connections = connections;
EntranceTile = entranceTile;
}
}
@@ -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;
}
}
@@ -0,0 +1,226 @@
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;
}
}
@@ -0,0 +1,68 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
namespace Theriapolis.Core.Dungeons;
/// <summary>
/// Phase 7 M2 — pre-instantiated population of a generated dungeon. Holds:
/// - The list of <see cref="DungeonSpawn"/> entries (one per encounter
/// slot in any room) — coordinates + template + role tag.
/// - The list of <see cref="DungeonContainer"/> entries (one per
/// container slot) — coordinates + table id + pre-rolled item drops.
///
/// Generated alongside (and from) a <see cref="Dungeon"/> via
/// <see cref="DungeonPopulator.Populate"/>. Living combatants and item
/// pickups derive from these records on first dungeon entry; the
/// <see cref="Theriapolis.Core.Persistence.DungeonStateSnapshot"/>
/// persists which entries have been resolved (cleared / looted) so
/// re-entry doesn't re-spawn them.
/// </summary>
public sealed class DungeonPopulation
{
public DungeonSpawn[] Spawns { get; }
public DungeonContainer[] Containers { get; }
public DungeonPopulation(DungeonSpawn[] spawns, DungeonContainer[] containers)
{
Spawns = spawns;
Containers = containers;
}
}
/// <summary>
/// One <c>@</c> encounter slot in a generated dungeon — resolved to the
/// concrete NPC template that fills it. The dungeon coords are absolute
/// (within the dungeon's tile array, not template-local).
/// </summary>
public readonly record struct DungeonSpawn(
/// <summary>Index into <see cref="Dungeon.Rooms"/> the spawn lives in.</summary>
int RoomId,
/// <summary>Dungeon-local tile-X.</summary>
int X,
/// <summary>Dungeon-local tile-Y.</summary>
int Y,
/// <summary>The chosen NPC template. Caller instantiates from this.</summary>
NpcTemplateDef Template,
/// <summary>Spawn-kind tag the slot declared (PoiGuard / WildAnimal / Brigand / Boss).</summary>
string Kind);
/// <summary>
/// One <c>C</c> container slot in a generated dungeon — resolved to the
/// concrete loot drop. Populated at generation time so the same
/// <c>(worldSeed, poiId, slotIdx)</c> always rolls identical items.
/// </summary>
public readonly record struct DungeonContainer(
/// <summary>Index into <see cref="Dungeon.Rooms"/> the container is in.</summary>
int RoomId,
/// <summary>Dungeon-local tile-X.</summary>
int X,
/// <summary>Dungeon-local tile-Y.</summary>
int Y,
/// <summary>Loot-table id consulted at populate time.</summary>
string TableId,
/// <summary>Pre-rolled item drops, ready to transfer on player loot.</summary>
ItemInstance[] Drops,
/// <summary>True when the slot's grid char declared <c>locked</c>.</summary>
bool Locked,
/// <summary>Lock difficulty tier ("trivial"/"easy"/"medium"/"hard"; empty when unlocked).</summary>
string LockTier);
@@ -0,0 +1,195 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Loot;
using Theriapolis.Core.World;
namespace Theriapolis.Core.Dungeons;
/// <summary>
/// Phase 7 M2 — populate a generated <see cref="Dungeon"/>'s encounter
/// and container slots. Pure deterministic given the same inputs:
/// <c>(worldSeed, poiId, dungeon, content, levelBand)</c>.
///
/// For each room:
/// - Walk the room's source <see cref="RoomTemplateDef.EncounterSlots"/>
/// and resolve each slot to a concrete <see cref="NpcTemplateDef"/> via
/// <see cref="NpcTemplateContent.SpawnKindToTemplateByDungeonType"/>
/// (with a fallback to <see cref="NpcTemplateContent.SpawnKindToTemplateByZone"/>
/// middle tier if the per-dungeon-type table doesn't list that kind).
/// - Walk the source <see cref="RoomTemplateDef.ContainerSlots"/> and
/// pre-roll each container's loot via
/// <see cref="LootGenerator.RollContainer"/>, mapping the container's
/// <c>loot_table_band</c> through the layout's
/// <c>loot_table_per_band</c> to a concrete table id.
///
/// Boss-room encounter slots use the dungeon-type's <c>"Boss"</c>
/// template if available, otherwise fall back to the <c>"PoiGuard"</c>
/// template — no boss → strong-guard graceful degradation.
///
/// Per-NPC-spawn RNG sub-seed:
/// <c>populateSeed = dungeonLayoutSeed ^ C.RNG_DUNGEON_POPULATE ^ roomId ^ slotIdx</c>
/// — present so future variant rolls (e.g. random which-of-N-equivalent-
/// templates) stay deterministic. M2 doesn't read it (per-kind template
/// is fixed by the override map) but the helper is wired up so M3+
/// content can use it without a contract change.
/// </summary>
public static class DungeonPopulator
{
/// <summary>
/// Populate a freshly-generated dungeon. <paramref name="layoutId"/>
/// is the dungeon-layout id (from <see cref="DungeonLayoutDef.Id"/>);
/// the populator looks it up via <paramref name="content"/> to read
/// loot-band → table mappings. <paramref name="levelBand"/> is the
/// PoI's authored level band (0..3) and selects which loot tier
/// each container slot rolls.
/// </summary>
public static DungeonPopulation Populate(
Dungeon dungeon,
DungeonLayoutDef layout,
ContentResolver content,
int levelBand,
ulong worldSeed)
{
if (dungeon is null) throw new System.ArgumentNullException(nameof(dungeon));
if (layout is null) throw new System.ArgumentNullException(nameof(layout));
if (content is null) throw new System.ArgumentNullException(nameof(content));
ulong dungeonLayoutSeed = worldSeed ^ C.RNG_DUNGEON_LAYOUT ^ (ulong)dungeon.PoiId;
// Resolve loot-band → table-id mapping for this layout + level band.
string lootBand = ResolveLootBand(layout, levelBand);
layout.LootTablePerBand.TryGetValue(lootBand, out var lootTableForBand);
var spawns = new List<DungeonSpawn>();
var containers = new List<DungeonContainer>();
// Per-dungeon spawn-kind resolver lookup.
string typeKey = dungeon.Type.ToString();
content.Npcs.SpawnKindToTemplateByDungeonType.TryGetValue(typeKey, out var kindMap);
int globalContainerIdx = 0;
int globalSpawnIdx = 0;
foreach (var room in dungeon.Rooms)
{
if (!content.RoomTemplates.TryGetValue(room.TemplateId, out var def))
continue;
// Encounter slots → concrete spawns.
foreach (var slot in def.EncounterSlots)
{
var template = ResolveSpawnTemplate(slot.Kind, room.Role, kindMap, content.Npcs, dungeon.Type);
if (template is null) { globalSpawnIdx++; continue; }
int absX = room.AabbX + slot.X;
int absY = room.AabbY + slot.Y;
spawns.Add(new DungeonSpawn(
RoomId: room.Id,
X: absX,
Y: absY,
Template: template,
Kind: slot.Kind));
globalSpawnIdx++;
}
// Container slots → pre-rolled loot.
foreach (var slot in def.ContainerSlots)
{
int absX = room.AabbX + slot.X;
int absY = room.AabbY + slot.Y;
// Per-container band: room's container slot may declare its
// own band (e.g. boss room slot says "t3"); otherwise we
// use the layout's level-band → loot-band lookup.
string slotBand = !string.IsNullOrEmpty(slot.LootTableBand) ? slot.LootTableBand : lootBand;
layout.LootTablePerBand.TryGetValue(slotBand, out var slotTableId);
slotTableId ??= lootTableForBand ?? "";
var drops = string.IsNullOrEmpty(slotTableId)
? System.Array.Empty<ItemInstance>()
: LootGenerator.RollContainer(
tableId: slotTableId,
dungeonLayoutSeed: dungeonLayoutSeed,
slotIdx: globalContainerIdx,
tables: content.LootTables,
items: content.Items);
containers.Add(new DungeonContainer(
RoomId: room.Id,
X: absX,
Y: absY,
TableId: slotTableId ?? "",
Drops: drops,
Locked: slot.Locked,
LockTier: slot.Lock));
globalContainerIdx++;
}
}
return new DungeonPopulation(spawns.ToArray(), containers.ToArray());
}
private static string ResolveLootBand(DungeonLayoutDef layout, int levelBand)
{
var key = levelBand.ToString(System.Globalization.CultureInfo.InvariantCulture);
if (layout.LevelBandToLootBand.TryGetValue(key, out var band) && !string.IsNullOrEmpty(band))
return band;
// Default per the Phase 7 plan §5.5 thresholds:
// levelBand 0..1 → t1, 2 → t2, 3+ → t3.
return levelBand switch
{
<= 1 => "t1",
2 => "t2",
_ => "t3",
};
}
/// <summary>
/// Resolve a slot's spawn-kind tag to a concrete NPC template. Boss
/// slots get the dungeon-type's "Boss" template if listed; otherwise
/// the slot's own kind, with a graceful fall-through to the
/// existing per-zone table at the mid tier.
/// </summary>
private static NpcTemplateDef? ResolveSpawnTemplate(
string slotKind,
RoomRole roomRole,
IReadOnlyDictionary<string, string>? kindMap,
NpcTemplateContent npcs,
PoiType dungeonType)
{
// Boss-role rooms with a boss kind: prefer the per-dungeon-type
// "Boss" entry. If neither the slot says "Boss" nor the room is
// a boss room, this branch doesn't fire.
string effectiveKind = slotKind;
if (roomRole == RoomRole.Boss && string.Equals(slotKind, "Boss", System.StringComparison.OrdinalIgnoreCase))
{
effectiveKind = "Boss";
}
// 1. Per-dungeon-type override.
if (kindMap is not null && kindMap.TryGetValue(effectiveKind, out var tplId))
{
foreach (var t in npcs.Templates)
if (string.Equals(t.Id, tplId, System.StringComparison.OrdinalIgnoreCase))
return t;
}
// 2. Boss kind unmapped → fall back to PoiGuard for the same dungeon type.
if (effectiveKind == "Boss" && kindMap is not null && kindMap.TryGetValue("PoiGuard", out var guardId))
{
foreach (var t in npcs.Templates)
if (string.Equals(t.Id, guardId, System.StringComparison.OrdinalIgnoreCase))
return t;
}
// 3. Final fallback: the per-zone table at zone 2 (mid).
if (npcs.SpawnKindToTemplateByZone.TryGetValue(slotKind, out var byZone) && byZone.Length > 0)
{
int z = System.Math.Min(2, byZone.Length - 1);
string id = byZone[z];
foreach (var t in npcs.Templates)
if (string.Equals(t.Id, id, System.StringComparison.OrdinalIgnoreCase))
return t;
}
return null;
}
}
+49
View File
@@ -0,0 +1,49 @@
namespace Theriapolis.Core.Dungeons;
/// <summary>
/// Phase 7 M1 — runtime state for a single room inside a generated dungeon.
///
/// A Room records its template id, its dungeon-local axis-aligned bounding
/// box (top-left corner + dimensions, in tactical tiles), the clade that
/// built it (drives clade-responsive movement cost per Phase 7 plan §5.4),
/// and the role it occupies in the dungeon's layout. Mutable runtime state
/// (cleared / looted flags) lives on the <see cref="DungeonState"/>
/// snapshot, not here, so this record stays a deterministic baseline view.
/// </summary>
public sealed class Room
{
/// <summary>Stable id within a dungeon (0..Dungeon.Rooms.Length-1).</summary>
public int Id { get; init; }
/// <summary>Reference back to the source <see cref="Theriapolis.Core.Data.RoomTemplateDef.Id"/>.</summary>
public string TemplateId { get; init; } = "";
/// <summary>Dungeon-local AABB top-left X (tile units). Inclusive.</summary>
public int AabbX { get; init; }
/// <summary>Dungeon-local AABB top-left Y (tile units). Inclusive.</summary>
public int AabbY { get; init; }
/// <summary>AABB width in tactical tiles (matches the source template's <c>footprint_w_tiles</c>).</summary>
public int AabbW { get; init; }
/// <summary>AABB height in tactical tiles (matches the source template's <c>footprint_h_tiles</c>).</summary>
public int AabbH { get; init; }
/// <summary>
/// Builder clade — drives the clade-responsive movement multiplier
/// (<c>ClademorphicMovement.GetCostMultiplier</c>) the player pays
/// while moving through this room. Empty / "none" means no penalty.
/// </summary>
public string BuiltBy { get; init; } = "none";
/// <summary>Role assigned at layout-build time.</summary>
public RoomRole Role { get; init; }
/// <summary>
/// Optional environmental-storytelling prose surfaced by the
/// InteractionScreen scent-overlay panel (Phase 6.5 M1) and the
/// dungeon-clear coda. Null when the source template doesn't carry one.
/// </summary>
public string? NarrativeText { get; init; }
public override string ToString()
=> $"Room[id={Id} role={Role} aabb=({AabbX},{AabbY},{AabbW}x{AabbH}) tpl={TemplateId}]";
}
@@ -0,0 +1,23 @@
namespace Theriapolis.Core.Dungeons;
/// <summary>
/// Phase 7 M1 — a connection between two rooms in a dungeon, anchored by
/// the door tile on each side. Connections are created by
/// <see cref="RoomGraphAssembler"/> when matching one room's outgoing door
/// to another's incoming door; they're authoritative for reachability
/// (BFS over connections, not over the painted tile array).
///
/// Door coordinates are in dungeon-local tile space and address the
/// *door tile itself* (carved out of the perimeter wall). The door state
/// (open / closed / locked) lives in <see cref="Theriapolis.Core.Persistence.DungeonStateSnapshot"/>
/// — this record carries only the deterministic baseline.
/// </summary>
public readonly record struct RoomConnection(
int RoomA,
int DoorAx,
int DoorAy,
int RoomB,
int DoorBx,
int DoorBy,
/// <summary>Lock difficulty tier or "" for unlocked. Matches RoomDoor.Lock values.</summary>
string Lock = "");
@@ -0,0 +1,376 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Dungeons;
/// <summary>
/// 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 <see cref="RoomConnection"/> binding rooms via matched
/// door tiles
///
/// The assembler does NOT paint tiles — it only decides geometry. Tile
/// painting + corridor stamping is <see cref="RoomTilePainter"/>'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 <see cref="C.DUNGEON_LAYOUT_MAX_ATTEMPTS"/> times then
/// falls back to the linear policy.
/// </summary>
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;
}
}
/// <summary>
/// Try to assemble. <paramref name="branching"/> is one of
/// <c>linear</c> / <c>branching</c> / <c>loop</c>. Returns null on
/// any geometric failure; caller retries.
/// </summary>
public static Plan? TryAssemble(
IReadOnlyList<RoomTemplateDef> picks,
IReadOnlyList<RoomRole> 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<RoomConnection>(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<RoomConnection> 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<RoomConnection> connections)
{
if (roomCount == 0) return true;
var adj = new List<int>[roomCount];
for (int i = 0; i < roomCount; i++) adj[i] = new List<int>();
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<int>();
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;
}
/// <summary>
/// Match door tiles between two rooms placed adjacent in
/// <paramref name="direction"/>. 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.
/// </summary>
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, ""),
};
}
}
+51
View File
@@ -0,0 +1,51 @@
namespace Theriapolis.Core.Dungeons;
/// <summary>
/// The slot a room occupies in a dungeon's role mix.
///
/// Each <see cref="Theriapolis.Core.Data.RoomTemplateDef"/> declares a list
/// of role-eligibility tags via <c>roles_eligible</c>. The layout assembler
/// pairs templates to roles when filling a dungeon's required + optional
/// role slots; the resulting <see cref="Room"/> records its assigned role
/// for content-distribution decisions (loot tier, encounter density, etc.).
/// </summary>
public enum RoomRole : byte
{
/// <summary>The dungeon's surface entrance. Always one per dungeon.</summary>
Entry,
/// <summary>Generic in-between room — most rooms in a typical dungeon are transit.</summary>
Transit,
/// <summary>Carries environmental-storytelling prose; usually no encounter, often unique decor.</summary>
Narrative,
/// <summary>Optional reward room with a container slot (sometimes locked).</summary>
Loot,
/// <summary>The dungeon's set-piece final room. Always one in dungeons that declare it.</summary>
Boss,
/// <summary>A side room off the critical path — exists for exploration reward.</summary>
DeadEnd,
}
internal static class RoomRoleExtensions
{
public static RoomRole Parse(string raw) => raw switch
{
"entry" => RoomRole.Entry,
"transit" => RoomRole.Transit,
"narrative" => RoomRole.Narrative,
"loot" => RoomRole.Loot,
"boss" => RoomRole.Boss,
"dead-end" => RoomRole.DeadEnd,
_ => throw new System.ArgumentException($"Unknown room role: '{raw}'"),
};
public static string ToTag(this RoomRole r) => r switch
{
RoomRole.Entry => "entry",
RoomRole.Transit => "transit",
RoomRole.Narrative => "narrative",
RoomRole.Loot => "loot",
RoomRole.Boss => "boss",
RoomRole.DeadEnd => "dead-end",
_ => "transit",
};
}
@@ -0,0 +1,184 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.World;
namespace Theriapolis.Core.Dungeons;
/// <summary>
/// Phase 7 M1 — paints the rooms + corridors of a planned dungeon into a
/// tactical-tile array. Pure, deterministic given the same plan input.
///
/// The painter:
/// 1. Allocates a <see cref="TacticalTile"/>[w, h] array of the planned
/// dungeon size.
/// 2. For each room, copies its <see cref="RoomTemplateDef.Grid"/> char
/// by char into the array at the room's AABB top-left, mapping each
/// char to a <see cref="TacticalSurface"/> + <see cref="TacticalDeco"/>
/// pair via <see cref="MapChar"/>.
/// 3. For each connection, runs a Manhattan-path corridor between the
/// two door tiles, writing <see cref="TacticalSurface.DungeonFloor"/>
/// and ensuring the door tiles themselves are walkable
/// (<see cref="TacticalDeco.DungeonDoor"/>).
///
/// The dungeon's surface family (DungeonFloor vs Cave vs MineFloor) is
/// chosen by dungeon type so different dungeons feel visually distinct
/// even before art lands.
/// </summary>
internal static class RoomTilePainter
{
public static TacticalTile[,] Paint(
RoomGraphAssembler.Plan plan,
IReadOnlyDictionary<string, RoomTemplateDef> templatesById,
PoiType dungeonType)
{
var tiles = new TacticalTile[plan.DungeonW, plan.DungeonH];
// Default fill: solid wall (so any unpainted gap is automatically
// impassable). The painter then carves rooms + corridors out of it.
for (int y = 0; y < plan.DungeonH; y++)
for (int x = 0; x < plan.DungeonW; x++)
{
tiles[x, y] = new TacticalTile
{
Surface = TacticalSurface.Wall,
Deco = TacticalDeco.None,
Variant = 0,
Flags = 0,
};
}
TacticalSurface defaultFloor = SurfaceForDungeonType(dungeonType);
// Paint each room's grid.
foreach (var room in plan.Rooms)
{
if (!templatesById.TryGetValue(room.TemplateId, out var def))
continue; // shouldn't happen — ContentLoader validates these
for (int gy = 0; gy < def.FootprintHTiles; gy++)
{
for (int gx = 0; gx < def.FootprintWTiles; gx++)
{
int dx = room.AabbX + gx;
int dy = room.AabbY + gy;
if (dx < 0 || dy < 0 || dx >= plan.DungeonW || dy >= plan.DungeonH)
continue;
char ch = def.Grid[gy][gx];
var (surface, deco) = MapChar(ch, defaultFloor);
tiles[dx, dy].Surface = surface;
tiles[dx, dy].Deco = deco;
}
}
}
// Carve corridors between door tiles.
foreach (var conn in plan.Connections)
{
CarveCorridor(tiles, plan, conn, defaultFloor);
}
// Mark the entrance tile so the renderer can highlight it. Stairs
// is the canonical "interactable enter / exit" deco.
if (plan.EntranceTile.X >= 0 && plan.EntranceTile.X < plan.DungeonW
&& plan.EntranceTile.Y >= 0 && plan.EntranceTile.Y < plan.DungeonH)
{
tiles[plan.EntranceTile.X, plan.EntranceTile.Y].Surface = defaultFloor;
tiles[plan.EntranceTile.X, plan.EntranceTile.Y].Deco = TacticalDeco.Stairs;
}
return tiles;
}
private static (TacticalSurface surface, TacticalDeco deco) MapChar(char ch, TacticalSurface defaultFloor) => ch switch
{
'#' => (TacticalSurface.Wall, TacticalDeco.None),
'.' => (defaultFloor, TacticalDeco.None),
',' => (TacticalSurface.DungeonRubble, TacticalDeco.None),
'D' => (defaultFloor, TacticalDeco.DungeonDoor),
'@' => (defaultFloor, TacticalDeco.None), // encounter slot — spawn placed by populator
'C' => (defaultFloor, TacticalDeco.Container),
'T' => (defaultFloor, TacticalDeco.Trap),
'P' => (defaultFloor, TacticalDeco.Pillar),
'B' => (defaultFloor, TacticalDeco.Brazier),
'M' => (TacticalSurface.DungeonTile, TacticalDeco.None), // mosaic / narrative inlay
'S' => (defaultFloor, TacticalDeco.Stairs),
' ' => (TacticalSurface.None, TacticalDeco.None),
_ => (defaultFloor, TacticalDeco.None), // unknown → walkable floor
};
private static TacticalSurface SurfaceForDungeonType(PoiType type) => type switch
{
PoiType.AbandonedMine => TacticalSurface.MineFloor,
PoiType.NaturalCave => TacticalSurface.Cave,
PoiType.CultDen => TacticalSurface.Cave,
PoiType.OvergrownSettlement => TacticalSurface.DungeonFloor,
PoiType.ImperiumRuin => TacticalSurface.DungeonFloor,
_ => TacticalSurface.DungeonFloor,
};
/// <summary>
/// Manhattan corridor from door A to door B. Picks one of the two L-bends
/// deterministically (vertical-first if the connection is "more vertical"
/// than horizontal; horizontal-first otherwise) so two seeds don't
/// produce different routes through the same plan.
/// </summary>
private static void CarveCorridor(
TacticalTile[,] tiles,
RoomGraphAssembler.Plan plan,
RoomConnection conn,
TacticalSurface defaultFloor)
{
int x0 = conn.DoorAx, y0 = conn.DoorAy;
int x1 = conn.DoorBx, y1 = conn.DoorBy;
// Ensure the door tiles themselves are walkable.
SafeSet(tiles, plan, x0, y0, defaultFloor, TacticalDeco.DungeonDoor);
SafeSet(tiles, plan, x1, y1, defaultFloor, TacticalDeco.DungeonDoor);
// Decide bend axis.
int dx = Math.Abs(x1 - x0);
int dy = Math.Abs(y1 - y0);
bool horizontalFirst = dx >= dy;
if (horizontalFirst)
{
int xa = Math.Min(x0, x1);
int xb = Math.Max(x0, x1);
for (int x = xa; x <= xb; x++)
SafeSet(tiles, plan, x, y0, defaultFloor, TacticalDeco.None, preserveDoor: true);
int ya = Math.Min(y0, y1);
int yb = Math.Max(y0, y1);
for (int y = ya; y <= yb; y++)
SafeSet(tiles, plan, x1, y, defaultFloor, TacticalDeco.None, preserveDoor: true);
}
else
{
int ya = Math.Min(y0, y1);
int yb = Math.Max(y0, y1);
for (int y = ya; y <= yb; y++)
SafeSet(tiles, plan, x0, y, defaultFloor, TacticalDeco.None, preserveDoor: true);
int xa = Math.Min(x0, x1);
int xb = Math.Max(x0, x1);
for (int x = xa; x <= xb; x++)
SafeSet(tiles, plan, x, y1, defaultFloor, TacticalDeco.None, preserveDoor: true);
}
}
private static void SafeSet(
TacticalTile[,] tiles, RoomGraphAssembler.Plan plan,
int x, int y, TacticalSurface surface, TacticalDeco deco, bool preserveDoor = false)
{
if (x < 0 || y < 0 || x >= plan.DungeonW || y >= plan.DungeonH) return;
// Don't bulldoze a room's interior decoration when the corridor
// happens to clip through (carry-over from straightline paths
// that cross a room edge). The painter already laid the room
// first, so corridor only needs to convert Wall/None → Floor.
var existing = tiles[x, y];
if (existing.Surface != TacticalSurface.Wall
&& existing.Surface != TacticalSurface.None
&& !(preserveDoor && existing.Deco == TacticalDeco.DungeonDoor))
return;
tiles[x, y].Surface = surface;
if (!preserveDoor || existing.Deco != TacticalDeco.DungeonDoor)
tiles[x, y].Deco = deco;
}
}