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

1001 lines
51 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
namespace Theriapolis.Core.Data;
/// <summary>
/// Loads and validates all JSON content files from the Data directory.
/// Fails loudly on any missing file, broken reference, or malformed data.
/// </summary>
public sealed class ContentLoader
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
PropertyNameCaseInsensitive = true,
};
public string DataDirectory { get; }
public ContentLoader(string dataDirectory)
{
DataDirectory = dataDirectory;
}
public MacroTemplate LoadMacroTemplate()
{
string path = Path.Combine(DataDirectory, "macro_template.json");
var template = Load<MacroTemplate>(path);
if (template.Width != C.MACRO_GRID_WIDTH || template.Height != C.MACRO_GRID_HEIGHT)
Fail(path, $"Expected {C.MACRO_GRID_WIDTH}×{C.MACRO_GRID_HEIGHT}, got {template.Width}×{template.Height}");
return template;
}
public BiomeDef[] LoadBiomes()
{
string path = Path.Combine(DataDirectory, "biomes.json");
var biomes = Load<BiomeDef[]>(path);
if (biomes.Length == 0)
Fail(path, "Biome list is empty");
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var b in biomes)
{
if (string.IsNullOrWhiteSpace(b.Id))
Fail(path, "Biome entry has empty id");
if (!ids.Add(b.Id))
Fail(path, $"Duplicate biome id: {b.Id}");
}
return biomes;
}
public FactionDef[] LoadFactions()
{
string path = Path.Combine(DataDirectory, "factions.json");
var factions = Load<FactionDef[]>(path);
if (factions.Length == 0)
Fail(path, "Faction list is empty");
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var f in factions)
{
if (string.IsNullOrWhiteSpace(f.Id))
Fail(path, "Faction entry has empty id");
if (!ids.Add(f.Id))
Fail(path, $"Duplicate faction id: {f.Id}");
}
return factions;
}
// ── Phase 5: character + content loaders ─────────────────────────────
public CladeDef[] LoadClades()
{
string path = Path.Combine(DataDirectory, "clades.json");
var clades = Load<CladeDef[]>(path);
if (clades.Length == 0)
Fail(path, "Clade list is empty");
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var c in clades)
{
if (string.IsNullOrWhiteSpace(c.Id))
Fail(path, "Clade entry has empty id");
if (!ids.Add(c.Id))
Fail(path, $"Duplicate clade id: {c.Id}");
if (c.Kind != "predator" && c.Kind != "prey")
Fail(path, $"Clade '{c.Id}' has invalid kind '{c.Kind}' (expected 'predator' or 'prey')");
}
return clades;
}
public SpeciesDef[] LoadSpecies(IReadOnlyCollection<CladeDef> clades)
{
string path = Path.Combine(DataDirectory, "species.json");
var species = Load<SpeciesDef[]>(path);
if (species.Length == 0)
Fail(path, "Species list is empty");
var cladeIds = new HashSet<string>(clades.Select(c => c.Id), StringComparer.OrdinalIgnoreCase);
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var s in species)
{
if (string.IsNullOrWhiteSpace(s.Id))
Fail(path, "Species entry has empty id");
if (!ids.Add(s.Id))
Fail(path, $"Duplicate species id: {s.Id}");
if (!cladeIds.Contains(s.CladeId))
Fail(path, $"Species '{s.Id}' references unknown clade_id '{s.CladeId}'");
}
return species;
}
public ClassDef[] LoadClasses()
{
string path = Path.Combine(DataDirectory, "classes.json");
var classes = Load<ClassDef[]>(path);
if (classes.Length == 0)
Fail(path, "Class list is empty");
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var c in classes)
{
if (string.IsNullOrWhiteSpace(c.Id))
Fail(path, "Class entry has empty id");
if (!ids.Add(c.Id))
Fail(path, $"Duplicate class id: {c.Id}");
if (c.HitDie != 6 && c.HitDie != 8 && c.HitDie != 10 && c.HitDie != 12)
Fail(path, $"Class '{c.Id}' has invalid hit_die {c.HitDie} (expected 6, 8, 10, or 12)");
if (c.LevelTable.Length == 0)
Fail(path, $"Class '{c.Id}' has empty level_table");
// Level 1 must be present and have at least one feature
var lv1 = Array.Find(c.LevelTable, e => e.Level == 1);
if (lv1 is null)
Fail(path, $"Class '{c.Id}' has no level 1 entry in level_table");
// Cross-check feature ids against the feature_definitions dictionary
foreach (var entry in c.LevelTable)
foreach (var feat in entry.Features)
if (!c.FeatureDefinitions.ContainsKey(feat))
Fail(path, $"Class '{c.Id}' level {entry.Level} references undefined feature '{feat}'");
}
return classes;
}
public SubclassDef[] LoadSubclasses(IReadOnlyCollection<ClassDef> classes)
{
string path = Path.Combine(DataDirectory, "subclasses.json");
var subs = Load<SubclassDef[]>(path);
// Empty allowed: subclasses are flavor in Phase 5; mechanics deferred.
var classIds = new HashSet<string>(classes.Select(c => c.Id), StringComparer.OrdinalIgnoreCase);
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var s in subs)
{
if (string.IsNullOrWhiteSpace(s.Id))
Fail(path, "Subclass entry has empty id");
if (!ids.Add(s.Id))
Fail(path, $"Duplicate subclass id: {s.Id}");
if (!classIds.Contains(s.ClassId))
Fail(path, $"Subclass '{s.Id}' references unknown class_id '{s.ClassId}'");
}
return subs;
}
public BackgroundDef[] LoadBackgrounds()
{
string path = Path.Combine(DataDirectory, "backgrounds.json");
var bgs = Load<BackgroundDef[]>(path);
if (bgs.Length == 0)
Fail(path, "Background list is empty");
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var b in bgs)
{
if (string.IsNullOrWhiteSpace(b.Id))
Fail(path, "Background entry has empty id");
if (!ids.Add(b.Id))
Fail(path, $"Duplicate background id: {b.Id}");
}
return bgs;
}
public ItemDef[] LoadItems()
{
string path = Path.Combine(DataDirectory, "items.json");
var items = Load<ItemDef[]>(path);
if (items.Length == 0)
Fail(path, "Item list is empty");
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var validKinds = new HashSet<string> {
"weapon", "armor", "shield", "consumable", "gear", "natural_weapon_enhancer"
};
foreach (var i in items)
{
if (string.IsNullOrWhiteSpace(i.Id))
Fail(path, "Item entry has empty id");
if (!ids.Add(i.Id))
Fail(path, $"Duplicate item id: {i.Id}");
if (!validKinds.Contains(i.Kind))
Fail(path, $"Item '{i.Id}' has invalid kind '{i.Kind}'");
if (i.Kind == "weapon" && string.IsNullOrWhiteSpace(i.Damage))
Fail(path, $"Weapon item '{i.Id}' has empty damage expression");
if (i.Kind == "armor" && i.AcBase <= 0)
Fail(path, $"Armor item '{i.Id}' has non-positive ac_base {i.AcBase}");
}
return items;
}
public LootTableDef[] LoadLootTables(IReadOnlyCollection<ItemDef>? items = null)
{
string path = Path.Combine(DataDirectory, "loot_tables.json");
if (!File.Exists(path)) return System.Array.Empty<LootTableDef>();
var tables = Load<LootTableDef[]>(path);
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var itemIds = items is null
? null
: new HashSet<string>(items.Select(i => i.Id), StringComparer.OrdinalIgnoreCase);
foreach (var t in tables)
{
if (string.IsNullOrWhiteSpace(t.Id))
Fail(path, "Loot table has empty id");
if (!ids.Add(t.Id))
Fail(path, $"Duplicate loot table id: {t.Id}");
foreach (var d in t.Drops)
{
if (string.IsNullOrWhiteSpace(d.ItemId))
Fail(path, $"Loot table '{t.Id}' has empty item_id in a drop");
if (itemIds is not null && !itemIds.Contains(d.ItemId))
Fail(path, $"Loot table '{t.Id}' references unknown item '{d.ItemId}'");
if (d.Chance < 0f || d.Chance > 1f)
Fail(path, $"Loot table '{t.Id}' drop '{d.ItemId}' has chance {d.Chance} outside 0..1");
if (d.QtyMin < 1 || d.QtyMax < d.QtyMin)
Fail(path, $"Loot table '{t.Id}' drop '{d.ItemId}' has invalid qty range {d.QtyMin}..{d.QtyMax}");
}
}
return tables;
}
// ── Phase 6 M0: settlement stamp content ─────────────────────────────
/// <summary>
/// Load every JSON file in <c>Content/Data/building_templates/</c>. Each
/// file is one <see cref="BuildingTemplateDef"/>. Returns an empty
/// array if the directory doesn't exist (allows running tools/tests
/// against installs that haven't authored templates yet).
/// </summary>
public BuildingTemplateDef[] LoadBuildingTemplates()
{
string dir = Path.Combine(DataDirectory, "building_templates");
if (!Directory.Exists(dir)) return Array.Empty<BuildingTemplateDef>();
var files = Directory.EnumerateFiles(dir, "*.json").OrderBy(p => p, StringComparer.Ordinal).ToArray();
var defs = new List<BuildingTemplateDef>(files.Length);
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var validDecos = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "counter", "bed", "hearth", "sign" };
foreach (var path in files)
{
var def = Load<BuildingTemplateDef>(path);
if (string.IsNullOrWhiteSpace(def.Id))
Fail(path, "Building template has empty id");
if (!ids.Add(def.Id))
Fail(path, $"Duplicate building template id: {def.Id}");
if (def.FootprintWTiles < 3 || def.FootprintHTiles < 3)
Fail(path, $"Building '{def.Id}' footprint {def.FootprintWTiles}×{def.FootprintHTiles} is too small (need ≥3 each so an interior cell exists)");
if (def.Doors.Length == 0)
Fail(path, $"Building '{def.Id}' has no doors");
foreach (var d in def.Doors)
{
if (d.X < 0 || d.X >= def.FootprintWTiles || d.Y < 0 || d.Y >= def.FootprintHTiles)
Fail(path, $"Building '{def.Id}' door ({d.X},{d.Y}) outside footprint");
bool perimeter = d.X == 0 || d.Y == 0 || d.X == def.FootprintWTiles - 1 || d.Y == def.FootprintHTiles - 1;
if (!perimeter)
Fail(path, $"Building '{def.Id}' door ({d.X},{d.Y}) is not on the perimeter");
}
foreach (var deco in def.Decos)
{
if (deco.X <= 0 || deco.Y <= 0 || deco.X >= def.FootprintWTiles - 1 || deco.Y >= def.FootprintHTiles - 1)
Fail(path, $"Building '{def.Id}' deco at ({deco.X},{deco.Y}) is on perimeter (interior only)");
if (!validDecos.Contains(deco.Deco))
Fail(path, $"Building '{def.Id}' deco '{deco.Deco}' invalid (counter/bed/hearth/sign)");
}
foreach (var role in def.Roles)
{
if (string.IsNullOrWhiteSpace(role.Tag))
Fail(path, $"Building '{def.Id}' has a role with empty tag");
if (role.SpawnAt.Length != 2)
Fail(path, $"Building '{def.Id}' role '{role.Tag}' spawn_at must be [x,y]");
int rx = role.SpawnAt[0], ry = role.SpawnAt[1];
if (rx <= 0 || ry <= 0 || rx >= def.FootprintWTiles - 1 || ry >= def.FootprintHTiles - 1)
Fail(path, $"Building '{def.Id}' role '{role.Tag}' spawn at ({rx},{ry}) is on perimeter (interior only)");
}
defs.Add(def);
}
return defs.ToArray();
}
/// <summary>
/// Load every JSON file in <c>Content/Data/settlement_layouts/</c>.
/// Files are individual <see cref="SettlementLayoutDef"/>s.
/// </summary>
public SettlementLayoutDef[] LoadSettlementLayouts(IReadOnlyCollection<BuildingTemplateDef>? buildings = null)
{
string dir = Path.Combine(DataDirectory, "settlement_layouts");
if (!Directory.Exists(dir)) return Array.Empty<SettlementLayoutDef>();
var files = Directory.EnumerateFiles(dir, "*.json").OrderBy(p => p, StringComparer.Ordinal).ToArray();
var defs = new List<SettlementLayoutDef>(files.Length);
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var buildingIds = buildings is null
? null
: new HashSet<string>(buildings.Select(b => b.Id), StringComparer.OrdinalIgnoreCase);
foreach (var path in files)
{
var def = Load<SettlementLayoutDef>(path);
if (string.IsNullOrWhiteSpace(def.Id))
Fail(path, "Settlement layout has empty id");
if (!ids.Add(def.Id))
Fail(path, $"Duplicate settlement layout id: {def.Id}");
if (def.Kind != "preset" && def.Kind != "procedural")
Fail(path, $"Settlement layout '{def.Id}' kind '{def.Kind}' invalid (preset/procedural)");
if (def.Kind == "preset")
{
if (string.IsNullOrWhiteSpace(def.Anchor))
Fail(path, $"Preset layout '{def.Id}' missing anchor");
if (def.Buildings.Length == 0)
Fail(path, $"Preset layout '{def.Id}' has no buildings");
foreach (var b in def.Buildings)
{
if (string.IsNullOrWhiteSpace(b.Template))
Fail(path, $"Preset layout '{def.Id}' has a placement with empty template");
if (b.Offset.Length != 2)
Fail(path, $"Preset layout '{def.Id}' placement of '{b.Template}' offset must be [x,y]");
if (buildingIds is not null && !buildingIds.Contains(b.Template))
Fail(path, $"Preset layout '{def.Id}' references unknown building template '{b.Template}'");
}
}
else
{
if (def.Tier < 1 || def.Tier > 5)
Fail(path, $"Procedural layout '{def.Id}' tier {def.Tier} must be 1..5");
if (def.CategoryWeights.Count == 0)
Fail(path, $"Procedural layout '{def.Id}' missing category_weights");
if (def.TargetBuildingCount < 1)
Fail(path, $"Procedural layout '{def.Id}' target_building_count must be ≥ 1");
}
defs.Add(def);
}
// Anchor uniqueness
var anchorIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var d in defs.Where(d => d.Kind == "preset"))
if (!anchorIds.Add(d.Anchor))
Fail(Path.Combine(dir, d.Id + ".json"), $"Multiple preset layouts target anchor '{d.Anchor}'");
// Procedural tier uniqueness
var tiers = new HashSet<int>();
foreach (var d in defs.Where(d => d.Kind == "procedural"))
if (!tiers.Add(d.Tier))
Fail(Path.Combine(dir, d.Id + ".json"), $"Multiple procedural layouts for tier {d.Tier}");
return defs.ToArray();
}
// ── Phase 6 M1: bias profiles + resident templates ──────────────────
public BiasProfileDef[] LoadBiasProfiles(IReadOnlyCollection<CladeDef>? clades = null,
IReadOnlyCollection<FactionDef>? factions = null)
{
string path = Path.Combine(DataDirectory, "bias_profiles.json");
if (!File.Exists(path)) return Array.Empty<BiasProfileDef>();
var defs = Load<BiasProfileDef[]>(path);
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var cladeIds = clades is null
? null
: new HashSet<string>(clades.Select(c => c.Id), StringComparer.OrdinalIgnoreCase);
var factionIds = factions is null
? null
: new HashSet<string>(factions.Select(f => f.Id), StringComparer.OrdinalIgnoreCase);
foreach (var p in defs)
{
if (string.IsNullOrWhiteSpace(p.Id))
Fail(path, "Bias profile has empty id");
if (!ids.Add(p.Id))
Fail(path, $"Duplicate bias profile id: {p.Id}");
if (cladeIds is not null)
foreach (var cid in p.CladeBias.Keys)
if (!cladeIds.Contains(cid))
Fail(path, $"Bias profile '{p.Id}' references unknown clade '{cid}'");
if (factionIds is not null)
foreach (var fid in p.FactionAffinity.Keys)
if (!factionIds.Contains(fid))
Fail(path, $"Bias profile '{p.Id}' references unknown faction '{fid}'");
}
return defs;
}
public ResidentTemplateDef[] LoadResidentTemplates(
IReadOnlyCollection<BiasProfileDef>? biasProfiles = null,
IReadOnlyCollection<CladeDef>? clades = null,
IReadOnlyCollection<SpeciesDef>? species = null,
IReadOnlyCollection<FactionDef>? factions = null)
{
string path = Path.Combine(DataDirectory, "resident_templates.json");
if (!File.Exists(path)) return Array.Empty<ResidentTemplateDef>();
var defs = Load<ResidentTemplateDef[]>(path);
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var biasIds = biasProfiles is null
? null
: new HashSet<string>(biasProfiles.Select(b => b.Id), StringComparer.OrdinalIgnoreCase);
var cladeIds = clades is null
? null
: new HashSet<string>(clades.Select(c => c.Id), StringComparer.OrdinalIgnoreCase);
var speciesIds = species is null
? null
: new HashSet<string>(species.Select(s => s.Id), StringComparer.OrdinalIgnoreCase);
var factionIds = factions is null
? null
: new HashSet<string>(factions.Select(f => f.Id), StringComparer.OrdinalIgnoreCase);
foreach (var r in defs)
{
if (string.IsNullOrWhiteSpace(r.Id))
Fail(path, "Resident template has empty id");
if (!ids.Add(r.Id))
Fail(path, $"Duplicate resident template id: {r.Id}");
if (string.IsNullOrWhiteSpace(r.RoleTag))
Fail(path, $"Resident template '{r.Id}' has empty role_tag");
if (biasIds is not null && !biasIds.Contains(r.BiasProfile))
Fail(path, $"Resident template '{r.Id}' references unknown bias profile '{r.BiasProfile}'");
if (!string.IsNullOrEmpty(r.Clade) && cladeIds is not null && !cladeIds.Contains(r.Clade))
Fail(path, $"Resident template '{r.Id}' references unknown clade '{r.Clade}'");
if (!string.IsNullOrEmpty(r.Species) && speciesIds is not null && !speciesIds.Contains(r.Species))
Fail(path, $"Resident template '{r.Id}' references unknown species '{r.Species}'");
if (!string.IsNullOrEmpty(r.Faction) && factionIds is not null && !factionIds.Contains(r.Faction))
Fail(path, $"Resident template '{r.Id}' references unknown faction '{r.Faction}'");
if (r.Named && (string.IsNullOrEmpty(r.Clade) || string.IsNullOrEmpty(r.Species)))
Fail(path, $"Named resident template '{r.Id}' must declare both clade and species");
if (r.Named && string.IsNullOrEmpty(r.Name))
Fail(path, $"Named resident template '{r.Id}' must declare a display name");
}
// Named role_tags must be unique — only one NPC can occupy "millhaven.innkeeper".
var namedTags = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var r in defs)
{
if (!r.Named) continue;
if (!namedTags.Add(r.RoleTag))
Fail(path, $"Multiple named templates target role_tag '{r.RoleTag}'");
}
return defs;
}
// ── Phase 6 M3: dialogue trees ──────────────────────────────────────
/// <summary>
/// Load every JSON file in <c>Content/Data/dialogues/</c>. Each file is
/// one <see cref="DialogueDef"/>. Returns an empty array when the
/// directory doesn't exist (early-stage installs that haven't authored
/// trees yet).
/// </summary>
public DialogueDef[] LoadDialogues(IReadOnlyCollection<ItemDef>? items = null)
{
string dir = Path.Combine(DataDirectory, "dialogues");
if (!Directory.Exists(dir)) return Array.Empty<DialogueDef>();
var files = Directory.EnumerateFiles(dir, "*.json").OrderBy(p => p, StringComparer.Ordinal).ToArray();
var defs = new List<DialogueDef>(files.Length);
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var itemIds = items is null
? null
: new HashSet<string>(items.Select(i => i.Id), StringComparer.OrdinalIgnoreCase);
var validConditions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"rep_at_least", "rep_below", "has_item", "not_has_item",
"has_flag", "not_has_flag", "ability_min",
};
var validEffects = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"set_flag", "clear_flag", "give_item", "take_item",
"rep_event", "open_shop", "start_quest", "give_xp",
};
foreach (var path in files)
{
var def = Load<DialogueDef>(path);
if (string.IsNullOrWhiteSpace(def.Id))
Fail(path, "Dialogue tree has empty id");
if (!ids.Add(def.Id))
Fail(path, $"Duplicate dialogue id: {def.Id}");
if (string.IsNullOrWhiteSpace(def.Root))
Fail(path, $"Dialogue '{def.Id}' has empty root id");
if (def.Nodes.Length == 0)
Fail(path, $"Dialogue '{def.Id}' has no nodes");
// Node id uniqueness + reachability + reference checks.
var nodeIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var node in def.Nodes)
{
if (string.IsNullOrWhiteSpace(node.Id))
Fail(path, $"Dialogue '{def.Id}' node has empty id");
if (!nodeIds.Add(node.Id))
Fail(path, $"Dialogue '{def.Id}' duplicate node id '{node.Id}'");
if (node.Options.Length > C.DIALOGUE_MAX_OPTIONS_PER_NODE)
Fail(path, $"Dialogue '{def.Id}' node '{node.Id}' has {node.Options.Length} options (max {C.DIALOGUE_MAX_OPTIONS_PER_NODE})");
}
if (!nodeIds.Contains(def.Root))
Fail(path, $"Dialogue '{def.Id}' root id '{def.Root}' not in node list");
foreach (var node in def.Nodes)
{
foreach (var eff in node.OnEnter) ValidateEffect(path, def.Id, node.Id, eff, validEffects, itemIds);
foreach (var opt in node.Options)
{
foreach (var c in opt.Conditions)
{
if (!validConditions.Contains(c.Kind))
Fail(path, $"Dialogue '{def.Id}' node '{node.Id}' has unknown condition kind '{c.Kind}'");
if ((c.Kind == "has_item" || c.Kind == "not_has_item") &&
itemIds is not null && !string.IsNullOrEmpty(c.Id) && !itemIds.Contains(c.Id))
Fail(path, $"Dialogue '{def.Id}' references unknown item '{c.Id}'");
}
foreach (var eff in opt.Effects) ValidateEffect(path, def.Id, node.Id, eff, validEffects, itemIds);
foreach (var eff in opt.EffectsOnSuccess) ValidateEffect(path, def.Id, node.Id, eff, validEffects, itemIds);
foreach (var eff in opt.EffectsOnFailure) ValidateEffect(path, def.Id, node.Id, eff, validEffects, itemIds);
string? next = NormaliseNext(opt.Next);
if (next is not null && !nodeIds.Contains(next))
Fail(path, $"Dialogue '{def.Id}' option in '{node.Id}' references unknown node '{next}'");
string? nextS = NormaliseNext(opt.NextOnSuccess);
if (nextS is not null && !nodeIds.Contains(nextS))
Fail(path, $"Dialogue '{def.Id}' option in '{node.Id}' references unknown node '{nextS}' (success)");
string? nextF = NormaliseNext(opt.NextOnFailure);
if (nextF is not null && !nodeIds.Contains(nextF))
Fail(path, $"Dialogue '{def.Id}' option in '{node.Id}' references unknown node '{nextF}' (failure)");
if (opt.SkillCheck is not null && string.IsNullOrEmpty(opt.SkillCheck.Skill))
Fail(path, $"Dialogue '{def.Id}' skill_check option in '{node.Id}' has empty skill");
}
}
defs.Add(def);
}
return defs.ToArray();
}
private static string? NormaliseNext(string raw)
{
if (string.IsNullOrEmpty(raw)) return null;
if (string.Equals(raw, "<end>", StringComparison.OrdinalIgnoreCase)) return null;
return raw;
}
private void ValidateEffect(string path, string dialogueId, string nodeId, DialogueEffectDef eff,
HashSet<string> validKinds, HashSet<string>? itemIds)
{
if (!validKinds.Contains(eff.Kind))
Fail(path, $"Dialogue '{dialogueId}' node '{nodeId}' has unknown effect kind '{eff.Kind}'");
if ((eff.Kind == "give_item" || eff.Kind == "take_item")
&& itemIds is not null && !string.IsNullOrEmpty(eff.Id) && !itemIds.Contains(eff.Id))
Fail(path, $"Dialogue '{dialogueId}' references unknown item '{eff.Id}'");
if ((eff.Kind == "set_flag" || eff.Kind == "clear_flag") && string.IsNullOrEmpty(eff.Flag))
Fail(path, $"Dialogue '{dialogueId}' set_flag/clear_flag missing flag id in '{nodeId}'");
}
// ── Phase 6 M4: quest trees ─────────────────────────────────────────
/// <summary>
/// Load every JSON file in <c>Content/Data/quests/</c>. Each file is
/// one <see cref="QuestDef"/>. Validates structure + cross-refs.
/// </summary>
public QuestDef[] LoadQuests(IReadOnlyCollection<ItemDef>? items = null)
{
string dir = Path.Combine(DataDirectory, "quests");
if (!Directory.Exists(dir)) return Array.Empty<QuestDef>();
var files = Directory.EnumerateFiles(dir, "*.json").OrderBy(p => p, StringComparer.Ordinal).ToArray();
var defs = new List<QuestDef>(files.Length);
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var itemIds = items is null
? null
: new HashSet<string>(items.Select(i => i.Id), StringComparer.OrdinalIgnoreCase);
var validConditions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"flag_set", "flag_clear", "flag_at_least",
"enter_anchor", "enter_role_proximity",
"npc_dead", "npc_alive",
"time_elapsed_seconds",
"rep_at_least", "rep_below",
"has_item", "not_has_item",
"quest_complete", "quest_active",
"dialogue_choice",
};
var validEffects = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"set_flag", "clear_flag", "give_item", "take_item",
"give_xp", "rep_event",
"spawn_npc", "despawn_npc",
"start_quest", "end_quest", "fail_quest",
};
foreach (var path in files)
{
var def = Load<QuestDef>(path);
if (string.IsNullOrWhiteSpace(def.Id))
Fail(path, "Quest has empty id");
if (!ids.Add(def.Id))
Fail(path, $"Duplicate quest id: {def.Id}");
if (string.IsNullOrWhiteSpace(def.EntryStep))
Fail(path, $"Quest '{def.Id}' has empty entry_step");
if (def.Steps.Length == 0)
Fail(path, $"Quest '{def.Id}' has no steps");
var stepIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var step in def.Steps)
{
if (string.IsNullOrWhiteSpace(step.Id))
Fail(path, $"Quest '{def.Id}' has a step with empty id");
if (!stepIds.Add(step.Id))
Fail(path, $"Quest '{def.Id}' duplicate step id '{step.Id}'");
}
if (!stepIds.Contains(def.EntryStep))
Fail(path, $"Quest '{def.Id}' entry_step '{def.EntryStep}' not in step list");
foreach (var cond in def.AutoStartWhen)
ValidateQuestCondition(path, def.Id, "<auto_start>", cond, validConditions, itemIds);
foreach (var step in def.Steps)
{
foreach (var cond in step.TriggerConditions)
ValidateQuestCondition(path, def.Id, step.Id, cond, validConditions, itemIds);
foreach (var eff in step.OnEnter)
ValidateQuestEffect(path, def.Id, step.Id, eff, validEffects, itemIds);
foreach (var outcome in step.Outcomes)
{
foreach (var cond in outcome.When)
ValidateQuestCondition(path, def.Id, step.Id, cond, validConditions, itemIds);
foreach (var eff in outcome.Effects)
ValidateQuestEffect(path, def.Id, step.Id, eff, validEffects, itemIds);
string? n = NormaliseQuestNext(outcome.Next);
if (n is not null && !stepIds.Contains(n))
Fail(path, $"Quest '{def.Id}' step '{step.Id}' outcome.next '{n}' not in step list");
}
}
defs.Add(def);
}
return defs.ToArray();
}
private static string? NormaliseQuestNext(string raw)
{
if (string.IsNullOrEmpty(raw)) return null;
if (string.Equals(raw, "<end>", StringComparison.OrdinalIgnoreCase)) return null;
return raw;
}
private void ValidateQuestCondition(string path, string questId, string where, QuestConditionDef c,
HashSet<string> validKinds, HashSet<string>? itemIds)
{
if (!validKinds.Contains(c.Kind))
Fail(path, $"Quest '{questId}' [{where}] has unknown condition kind '{c.Kind}'");
if ((c.Kind == "has_item" || c.Kind == "not_has_item")
&& itemIds is not null && !string.IsNullOrEmpty(c.Id) && !itemIds.Contains(c.Id))
Fail(path, $"Quest '{questId}' [{where}] references unknown item '{c.Id}'");
}
private void ValidateQuestEffect(string path, string questId, string stepId, QuestEffectDef e,
HashSet<string> validKinds, HashSet<string>? itemIds)
{
if (!validKinds.Contains(e.Kind))
Fail(path, $"Quest '{questId}' step '{stepId}' has unknown effect kind '{e.Kind}'");
if ((e.Kind == "give_item" || e.Kind == "take_item")
&& itemIds is not null && !string.IsNullOrEmpty(e.Id) && !itemIds.Contains(e.Id))
Fail(path, $"Quest '{questId}' references unknown item '{e.Id}'");
if ((e.Kind == "set_flag" || e.Kind == "clear_flag") && string.IsNullOrEmpty(e.Flag))
Fail(path, $"Quest '{questId}' step '{stepId}' set_flag/clear_flag missing flag id");
}
public NpcTemplateContent LoadNpcTemplates(IReadOnlyCollection<ItemDef>? items = null,
IReadOnlyCollection<FactionDef>? factions = null)
{
string path = Path.Combine(DataDirectory, "npc_templates.json");
var content = Load<NpcTemplateContent>(path);
if (content.Templates.Length == 0)
Fail(path, "NPC template list is empty");
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var factionIds = factions is null
? null
: new HashSet<string>(factions.Select(f => f.Id), StringComparer.OrdinalIgnoreCase);
foreach (var t in content.Templates)
{
if (string.IsNullOrWhiteSpace(t.Id))
Fail(path, "NPC template has empty id");
if (!ids.Add(t.Id))
Fail(path, $"Duplicate NPC template id: {t.Id}");
if (t.Hp <= 0)
Fail(path, $"NPC template '{t.Id}' has non-positive hp {t.Hp}");
if (t.Ac <= 0)
Fail(path, $"NPC template '{t.Id}' has non-positive ac {t.Ac}");
if (!string.IsNullOrEmpty(t.Faction) && factionIds is not null && !factionIds.Contains(t.Faction))
Fail(path, $"NPC template '{t.Id}' references unknown faction '{t.Faction}'");
}
// Per-zone lookup must reference real templates if present
foreach (var (kind, byZone) in content.SpawnKindToTemplateByZone)
foreach (var tid in byZone)
if (!ids.Contains(tid))
Fail(path, $"spawn_kind_to_template_by_zone['{kind}'] references unknown template '{tid}'");
// Phase 7 M2 — per-dungeon-type lookup must also reference real templates.
var validDungeonTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "ImperiumRuin", "AbandonedMine", "CultDen", "NaturalCave", "OvergrownSettlement" };
foreach (var (dungeonType, byKind) in content.SpawnKindToTemplateByDungeonType)
{
if (!validDungeonTypes.Contains(dungeonType))
Fail(path, $"spawn_kind_to_template_by_dungeon_type has invalid type '{dungeonType}'");
foreach (var (kind, tid) in byKind)
if (!ids.Contains(tid))
Fail(path, $"spawn_kind_to_template_by_dungeon_type['{dungeonType}']['{kind}'] references unknown template '{tid}'");
}
return content;
}
// ── Phase 7 M0: room templates + dungeon layouts ─────────────────────
/// <summary>
/// Load every JSON file under <c>Content/Data/room_templates/&lt;type&gt;/</c>
/// (recursive scan). Each file is one <see cref="RoomTemplateDef"/>.
/// Validates id uniqueness, grid dimensions vs declared footprint,
/// perimeter wall completeness, and that every <c>D</c>/<c>@</c>/
/// <c>C</c>/<c>T</c> char in the grid has a matching slot record.
/// Returns an empty array when the directory doesn't exist (early-stage
/// installs that haven't authored templates yet).
/// </summary>
public RoomTemplateDef[] LoadRoomTemplates()
{
string dir = Path.Combine(DataDirectory, "room_templates");
if (!Directory.Exists(dir)) return Array.Empty<RoomTemplateDef>();
// Recursive — typical layout is room_templates/imperium/*.json,
// room_templates/mine/*.json, etc.
var files = Directory.EnumerateFiles(dir, "*.json", SearchOption.AllDirectories)
.OrderBy(p => p, StringComparer.Ordinal).ToArray();
var defs = new List<RoomTemplateDef>(files.Length);
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var validTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "imperium", "mine", "cult", "cave", "overgrown" };
var validBuiltBy = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "canid", "felid", "mustelid", "ursid", "cervid", "bovid", "leporid", "imperium", "none" };
var validSizes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "small", "medium", "large" };
var validRoles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "entry", "transit", "narrative", "loot", "boss", "dead-end" };
var validDecos = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "pillar", "brazier", "mosaic", "imperium_statue" };
var validLockTiers = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "", "trivial", "easy", "medium", "hard" };
var validBands = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "t1", "t2", "t3" };
var validSpawnKinds = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "PoiGuard", "WildAnimal", "Brigand", "Boss" };
foreach (var path in files)
{
var def = Load<RoomTemplateDef>(path);
if (string.IsNullOrWhiteSpace(def.Id))
Fail(path, "Room template has empty id");
if (!ids.Add(def.Id))
Fail(path, $"Duplicate room template id: {def.Id}");
if (!validTypes.Contains(def.Type))
Fail(path, $"Room template '{def.Id}' has invalid type '{def.Type}'");
if (!validBuiltBy.Contains(def.BuiltBy))
Fail(path, $"Room template '{def.Id}' has invalid built_by '{def.BuiltBy}'");
if (!validSizes.Contains(def.SizeClass))
Fail(path, $"Room template '{def.Id}' has invalid size_class '{def.SizeClass}'");
foreach (var role in def.RolesEligible)
if (!validRoles.Contains(role))
Fail(path, $"Room template '{def.Id}' references invalid role '{role}'");
if (def.RolesEligible.Length == 0)
Fail(path, $"Room template '{def.Id}' has no roles_eligible (must list at least one)");
if (def.FootprintWTiles < 4 || def.FootprintHTiles < 4)
Fail(path, $"Room template '{def.Id}' footprint {def.FootprintWTiles}×{def.FootprintHTiles} is too small (need ≥4 each)");
if (def.Grid.Length != def.FootprintHTiles)
Fail(path, $"Room template '{def.Id}' grid has {def.Grid.Length} rows but footprint_h_tiles is {def.FootprintHTiles}");
for (int y = 0; y < def.Grid.Length; y++)
if (def.Grid[y].Length != def.FootprintWTiles)
Fail(path, $"Room template '{def.Id}' grid row {y} has {def.Grid[y].Length} chars but footprint_w_tiles is {def.FootprintWTiles}");
// Perimeter walls — each border cell must be '#'. Doors carve
// through walls (tracked via the doors[] list, not the grid).
int w = def.FootprintWTiles, h = def.FootprintHTiles;
for (int x = 0; x < w; x++)
{
if (def.Grid[0][x] != '#' && def.Grid[0][x] != 'D' && def.Grid[0][x] != 'S')
Fail(path, $"Room template '{def.Id}' top perimeter cell ({x},0) is '{def.Grid[0][x]}' (expected '#'/'D'/'S')");
if (def.Grid[h - 1][x] != '#' && def.Grid[h - 1][x] != 'D' && def.Grid[h - 1][x] != 'S')
Fail(path, $"Room template '{def.Id}' bottom perimeter cell ({x},{h - 1}) is '{def.Grid[h - 1][x]}' (expected '#'/'D'/'S')");
}
for (int y = 0; y < h; y++)
{
if (def.Grid[y][0] != '#' && def.Grid[y][0] != 'D' && def.Grid[y][0] != 'S')
Fail(path, $"Room template '{def.Id}' left perimeter cell (0,{y}) is '{def.Grid[y][0]}' (expected '#'/'D'/'S')");
if (def.Grid[y][w - 1] != '#' && def.Grid[y][w - 1] != 'D' && def.Grid[y][w - 1] != 'S')
Fail(path, $"Room template '{def.Id}' right perimeter cell ({w - 1},{y}) is '{def.Grid[y][w - 1]}' (expected '#'/'D'/'S')");
}
// Slot-record consistency — every D/@/C/T char in the grid
// must have a matching slot record at the same coords, and
// vice versa.
CheckSlotMatches(path, def, 'D', def.Doors.Select(d => (d.X, d.Y)), "doors");
CheckSlotMatches(path, def, '@', def.EncounterSlots.Select(s => (s.X, s.Y)), "encounter_slots");
CheckSlotMatches(path, def, 'C', def.ContainerSlots.Select(s => (s.X, s.Y)), "container_slots");
CheckSlotMatches(path, def, 'T', def.TrapSlots.Select(s => (s.X, s.Y)), "trap_slots");
// Door perimeter check
foreach (var d in def.Doors)
{
if (d.X < 0 || d.X >= w || d.Y < 0 || d.Y >= h)
Fail(path, $"Room template '{def.Id}' door ({d.X},{d.Y}) outside footprint");
bool perimeter = d.X == 0 || d.Y == 0 || d.X == w - 1 || d.Y == h - 1;
if (!perimeter)
Fail(path, $"Room template '{def.Id}' door ({d.X},{d.Y}) is not on the perimeter");
if (!validLockTiers.Contains(d.Lock))
Fail(path, $"Room template '{def.Id}' door ({d.X},{d.Y}) has invalid lock tier '{d.Lock}'");
}
foreach (var s in def.EncounterSlots)
{
if (s.X <= 0 || s.Y <= 0 || s.X >= w - 1 || s.Y >= h - 1)
Fail(path, $"Room template '{def.Id}' encounter slot ({s.X},{s.Y}) is on perimeter (interior only)");
if (!validSpawnKinds.Contains(s.Kind))
Fail(path, $"Room template '{def.Id}' encounter slot kind '{s.Kind}' invalid");
}
foreach (var s in def.ContainerSlots)
{
if (s.X <= 0 || s.Y <= 0 || s.X >= w - 1 || s.Y >= h - 1)
Fail(path, $"Room template '{def.Id}' container slot ({s.X},{s.Y}) is on perimeter (interior only)");
if (!validBands.Contains(s.LootTableBand))
Fail(path, $"Room template '{def.Id}' container slot band '{s.LootTableBand}' invalid");
if (!validLockTiers.Contains(s.Lock))
Fail(path, $"Room template '{def.Id}' container slot ({s.X},{s.Y}) lock tier '{s.Lock}' invalid");
}
foreach (var s in def.TrapSlots)
{
if (s.X <= 0 || s.Y <= 0 || s.X >= w - 1 || s.Y >= h - 1)
Fail(path, $"Room template '{def.Id}' trap slot ({s.X},{s.Y}) is on perimeter (interior only)");
if (s.Kind != "tripwire")
Fail(path, $"Room template '{def.Id}' trap kind '{s.Kind}' invalid (Phase 7 only ships tripwire)");
if (s.DisarmDc != "trivial" && s.DisarmDc != "easy" && s.DisarmDc != "medium")
Fail(path, $"Room template '{def.Id}' trap disarm_dc '{s.DisarmDc}' invalid (trivial/easy/medium)");
}
foreach (var d in def.Decos)
{
if (d.X <= 0 || d.Y <= 0 || d.X >= w - 1 || d.Y >= h - 1)
Fail(path, $"Room template '{def.Id}' deco at ({d.X},{d.Y}) is on perimeter (interior only)");
if (!validDecos.Contains(d.Deco))
Fail(path, $"Room template '{def.Id}' deco '{d.Deco}' invalid");
}
defs.Add(def);
}
return defs.ToArray();
}
private static void CheckSlotMatches(string path, RoomTemplateDef def, char ch,
IEnumerable<(int x, int y)> slotCoords, string slotName)
{
// Collect every (x,y) position of `ch` in the grid.
var gridPositions = new HashSet<(int, int)>();
for (int y = 0; y < def.FootprintHTiles; y++)
for (int x = 0; x < def.FootprintWTiles; x++)
if (def.Grid[y][x] == ch) gridPositions.Add((x, y));
var slotPositions = new HashSet<(int, int)>(slotCoords);
foreach (var pos in gridPositions)
if (!slotPositions.Contains(pos))
Fail(path, $"Room template '{def.Id}' grid char '{ch}' at ({pos.Item1},{pos.Item2}) has no matching {slotName} record");
foreach (var pos in slotPositions)
if (!gridPositions.Contains(pos))
Fail(path, $"Room template '{def.Id}' {slotName} record at ({pos.Item1},{pos.Item2}) has no matching grid '{ch}'");
}
/// <summary>
/// Load every JSON file in <c>Content/Data/dungeon_layouts/</c>. Each
/// file is one <see cref="DungeonLayoutDef"/>. Validates dungeon-type
/// + size-band + branching + room-count band ranges + loot-table
/// references against <paramref name="lootTables"/> when supplied.
/// </summary>
public DungeonLayoutDef[] LoadDungeonLayouts(IReadOnlyCollection<RoomTemplateDef>? rooms = null,
IReadOnlyCollection<LootTableDef>? lootTables = null)
{
string dir = Path.Combine(DataDirectory, "dungeon_layouts");
if (!Directory.Exists(dir)) return Array.Empty<DungeonLayoutDef>();
var files = Directory.EnumerateFiles(dir, "*.json")
.OrderBy(p => p, StringComparer.Ordinal).ToArray();
var defs = new List<DungeonLayoutDef>(files.Length);
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var validTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "ImperiumRuin", "AbandonedMine", "CultDen", "NaturalCave", "OvergrownSettlement" };
var validBands = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "small", "medium", "large" };
var validBranching = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "linear", "branching", "loop" };
var validRoles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "entry", "transit", "narrative", "loot", "boss", "dead-end" };
var validLootBands = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "t1", "t2", "t3" };
var roomIds = rooms is null
? null
: new HashSet<string>(rooms.Select(r => r.Id), StringComparer.OrdinalIgnoreCase);
var lootTableIds = lootTables is null
? null
: new HashSet<string>(lootTables.Select(t => t.Id), StringComparer.OrdinalIgnoreCase);
foreach (var path in files)
{
var def = Load<DungeonLayoutDef>(path);
if (string.IsNullOrWhiteSpace(def.Id))
Fail(path, "Dungeon layout has empty id");
if (!ids.Add(def.Id))
Fail(path, $"Duplicate dungeon layout id: {def.Id}");
if (!validTypes.Contains(def.DungeonType))
Fail(path, $"Dungeon layout '{def.Id}' has invalid dungeon_type '{def.DungeonType}'");
if (!validBands.Contains(def.SizeBand))
Fail(path, $"Dungeon layout '{def.Id}' has invalid size_band '{def.SizeBand}'");
if (!validBranching.Contains(def.Branching))
Fail(path, $"Dungeon layout '{def.Id}' has invalid branching '{def.Branching}'");
if (def.RoomCountMin < 1)
Fail(path, $"Dungeon layout '{def.Id}' room_count_min {def.RoomCountMin} must be ≥ 1");
if (def.RoomCountMax < def.RoomCountMin)
Fail(path, $"Dungeon layout '{def.Id}' room_count_max {def.RoomCountMax} < room_count_min {def.RoomCountMin}");
foreach (var role in def.RequiredRoles)
if (!validRoles.Contains(role))
Fail(path, $"Dungeon layout '{def.Id}' required_role '{role}' invalid");
foreach (var role in def.OptionalRoles)
if (!validRoles.Contains(role))
Fail(path, $"Dungeon layout '{def.Id}' optional_role '{role}' invalid");
foreach (var (band, table) in def.LootTablePerBand)
{
if (!validLootBands.Contains(band))
Fail(path, $"Dungeon layout '{def.Id}' loot_table_per_band has invalid key '{band}'");
if (lootTableIds is not null && !lootTableIds.Contains(table))
Fail(path, $"Dungeon layout '{def.Id}' loot_table_per_band['{band}'] references unknown table '{table}'");
}
foreach (var (lvl, band) in def.LevelBandToLootBand)
{
if (!int.TryParse(lvl, out int lvlInt) || lvlInt < 0 || lvlInt > 3)
Fail(path, $"Dungeon layout '{def.Id}' level_band_to_loot_band key '{lvl}' must be 0..3");
if (!validLootBands.Contains(band))
Fail(path, $"Dungeon layout '{def.Id}' level_band_to_loot_band[{lvl}] = '{band}' invalid");
}
float spawnSum = def.SpawnKindDistribution.Values.Sum();
if (spawnSum > 0f && (spawnSum < 0.95f || spawnSum > 1.05f))
Fail(path, $"Dungeon layout '{def.Id}' spawn_kind_distribution sums to {spawnSum:F3} (expected ≈1.0)");
// Pinned rooms (anchor-locked layouts) must reference real templates.
foreach (var pin in def.PinnedRooms)
{
if (string.IsNullOrWhiteSpace(pin.Template))
Fail(path, $"Dungeon layout '{def.Id}' pinned_room has empty template");
if (!validRoles.Contains(pin.Role))
Fail(path, $"Dungeon layout '{def.Id}' pinned_room role '{pin.Role}' invalid");
if (roomIds is not null && !roomIds.Contains(pin.Template))
Fail(path, $"Dungeon layout '{def.Id}' pinned_room references unknown template '{pin.Template}'");
}
// Anchor-locked layouts skip the random-room-count contract; the
// pinned list IS the layout. Still: if PinnedRooms is set, its
// length should fall in [min, max] for sanity.
if (def.PinnedRooms.Length > 0)
{
if (def.PinnedRooms.Length < def.RoomCountMin || def.PinnedRooms.Length > def.RoomCountMax)
Fail(path, $"Dungeon layout '{def.Id}' has {def.PinnedRooms.Length} pinned rooms outside [{def.RoomCountMin},{def.RoomCountMax}] range");
}
defs.Add(def);
}
// Anchor uniqueness — only one layout per anchor.
var anchorIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var d in defs.Where(d => !string.IsNullOrEmpty(d.Anchor)))
if (!anchorIds.Add(d.Anchor))
Fail(Path.Combine(dir, d.Id + ".json"), $"Multiple dungeon layouts target anchor '{d.Anchor}'");
return defs.ToArray();
}
private T Load<T>(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"Required content file not found: {path}");
try
{
using var fs = File.OpenRead(path);
var result = JsonSerializer.Deserialize<T>(fs, JsonOptions);
if (result is null)
Fail(path, "Deserialized to null");
return result!;
}
catch (JsonException ex)
{
throw new InvalidDataException($"JSON parse error in {path}: {ex.Message}", ex);
}
}
private static void Fail(string path, string reason) =>
throw new InvalidDataException($"Content validation failed for {path}: {reason}");
}