using System.Text.Json; namespace Theriapolis.Core.Data; /// /// Loads and validates all JSON content files from the Data directory. /// Fails loudly on any missing file, broken reference, or malformed data. /// 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(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(path); if (biomes.Length == 0) Fail(path, "Biome list is empty"); var ids = new HashSet(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(path); if (factions.Length == 0) Fail(path, "Faction list is empty"); var ids = new HashSet(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(path); if (clades.Length == 0) Fail(path, "Clade list is empty"); var ids = new HashSet(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 clades) { string path = Path.Combine(DataDirectory, "species.json"); var species = Load(path); if (species.Length == 0) Fail(path, "Species list is empty"); var cladeIds = new HashSet(clades.Select(c => c.Id), StringComparer.OrdinalIgnoreCase); var ids = new HashSet(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(path); if (classes.Length == 0) Fail(path, "Class list is empty"); var ids = new HashSet(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 classes) { string path = Path.Combine(DataDirectory, "subclasses.json"); var subs = Load(path); // Empty allowed: subclasses are flavor in Phase 5; mechanics deferred. var classIds = new HashSet(classes.Select(c => c.Id), StringComparer.OrdinalIgnoreCase); var ids = new HashSet(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(path); if (bgs.Length == 0) Fail(path, "Background list is empty"); var ids = new HashSet(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(path); if (items.Length == 0) Fail(path, "Item list is empty"); var ids = new HashSet(StringComparer.OrdinalIgnoreCase); var validKinds = new HashSet { "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? items = null) { string path = Path.Combine(DataDirectory, "loot_tables.json"); if (!File.Exists(path)) return System.Array.Empty(); var tables = Load(path); var ids = new HashSet(StringComparer.OrdinalIgnoreCase); var itemIds = items is null ? null : new HashSet(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 ───────────────────────────── /// /// Load every JSON file in Content/Data/building_templates/. Each /// file is one . Returns an empty /// array if the directory doesn't exist (allows running tools/tests /// against installs that haven't authored templates yet). /// public BuildingTemplateDef[] LoadBuildingTemplates() { string dir = Path.Combine(DataDirectory, "building_templates"); if (!Directory.Exists(dir)) return Array.Empty(); var files = Directory.EnumerateFiles(dir, "*.json").OrderBy(p => p, StringComparer.Ordinal).ToArray(); var defs = new List(files.Length); var ids = new HashSet(StringComparer.OrdinalIgnoreCase); var validDecos = new HashSet(StringComparer.OrdinalIgnoreCase) { "counter", "bed", "hearth", "sign" }; foreach (var path in files) { var def = Load(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(); } /// /// Load every JSON file in Content/Data/settlement_layouts/. /// Files are individual s. /// public SettlementLayoutDef[] LoadSettlementLayouts(IReadOnlyCollection? buildings = null) { string dir = Path.Combine(DataDirectory, "settlement_layouts"); if (!Directory.Exists(dir)) return Array.Empty(); var files = Directory.EnumerateFiles(dir, "*.json").OrderBy(p => p, StringComparer.Ordinal).ToArray(); var defs = new List(files.Length); var ids = new HashSet(StringComparer.OrdinalIgnoreCase); var buildingIds = buildings is null ? null : new HashSet(buildings.Select(b => b.Id), StringComparer.OrdinalIgnoreCase); foreach (var path in files) { var def = Load(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(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(); 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? clades = null, IReadOnlyCollection? factions = null) { string path = Path.Combine(DataDirectory, "bias_profiles.json"); if (!File.Exists(path)) return Array.Empty(); var defs = Load(path); var ids = new HashSet(StringComparer.OrdinalIgnoreCase); var cladeIds = clades is null ? null : new HashSet(clades.Select(c => c.Id), StringComparer.OrdinalIgnoreCase); var factionIds = factions is null ? null : new HashSet(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? biasProfiles = null, IReadOnlyCollection? clades = null, IReadOnlyCollection? species = null, IReadOnlyCollection? factions = null) { string path = Path.Combine(DataDirectory, "resident_templates.json"); if (!File.Exists(path)) return Array.Empty(); var defs = Load(path); var ids = new HashSet(StringComparer.OrdinalIgnoreCase); var biasIds = biasProfiles is null ? null : new HashSet(biasProfiles.Select(b => b.Id), StringComparer.OrdinalIgnoreCase); var cladeIds = clades is null ? null : new HashSet(clades.Select(c => c.Id), StringComparer.OrdinalIgnoreCase); var speciesIds = species is null ? null : new HashSet(species.Select(s => s.Id), StringComparer.OrdinalIgnoreCase); var factionIds = factions is null ? null : new HashSet(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(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 ────────────────────────────────────── /// /// Load every JSON file in Content/Data/dialogues/. Each file is /// one . Returns an empty array when the /// directory doesn't exist (early-stage installs that haven't authored /// trees yet). /// public DialogueDef[] LoadDialogues(IReadOnlyCollection? items = null) { string dir = Path.Combine(DataDirectory, "dialogues"); if (!Directory.Exists(dir)) return Array.Empty(); var files = Directory.EnumerateFiles(dir, "*.json").OrderBy(p => p, StringComparer.Ordinal).ToArray(); var defs = new List(files.Length); var ids = new HashSet(StringComparer.OrdinalIgnoreCase); var itemIds = items is null ? null : new HashSet(items.Select(i => i.Id), StringComparer.OrdinalIgnoreCase); var validConditions = new HashSet(StringComparer.OrdinalIgnoreCase) { "rep_at_least", "rep_below", "has_item", "not_has_item", "has_flag", "not_has_flag", "ability_min", }; var validEffects = new HashSet(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(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(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, "", StringComparison.OrdinalIgnoreCase)) return null; return raw; } private void ValidateEffect(string path, string dialogueId, string nodeId, DialogueEffectDef eff, HashSet validKinds, HashSet? 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 ───────────────────────────────────────── /// /// Load every JSON file in Content/Data/quests/. Each file is /// one . Validates structure + cross-refs. /// public QuestDef[] LoadQuests(IReadOnlyCollection? items = null) { string dir = Path.Combine(DataDirectory, "quests"); if (!Directory.Exists(dir)) return Array.Empty(); var files = Directory.EnumerateFiles(dir, "*.json").OrderBy(p => p, StringComparer.Ordinal).ToArray(); var defs = new List(files.Length); var ids = new HashSet(StringComparer.OrdinalIgnoreCase); var itemIds = items is null ? null : new HashSet(items.Select(i => i.Id), StringComparer.OrdinalIgnoreCase); var validConditions = new HashSet(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(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(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(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, "", 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, "", StringComparison.OrdinalIgnoreCase)) return null; return raw; } private void ValidateQuestCondition(string path, string questId, string where, QuestConditionDef c, HashSet validKinds, HashSet? 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 validKinds, HashSet? 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? items = null, IReadOnlyCollection? factions = null) { string path = Path.Combine(DataDirectory, "npc_templates.json"); var content = Load(path); if (content.Templates.Length == 0) Fail(path, "NPC template list is empty"); var ids = new HashSet(StringComparer.OrdinalIgnoreCase); var factionIds = factions is null ? null : new HashSet(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(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 ───────────────────── /// /// Load every JSON file under Content/Data/room_templates/<type>/ /// (recursive scan). Each file is one . /// Validates id uniqueness, grid dimensions vs declared footprint, /// perimeter wall completeness, and that every D/@/ /// C/T 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). /// public RoomTemplateDef[] LoadRoomTemplates() { string dir = Path.Combine(DataDirectory, "room_templates"); if (!Directory.Exists(dir)) return Array.Empty(); // 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(files.Length); var ids = new HashSet(StringComparer.OrdinalIgnoreCase); var validTypes = new HashSet(StringComparer.OrdinalIgnoreCase) { "imperium", "mine", "cult", "cave", "overgrown" }; var validBuiltBy = new HashSet(StringComparer.OrdinalIgnoreCase) { "canid", "felid", "mustelid", "ursid", "cervid", "bovid", "leporid", "imperium", "none" }; var validSizes = new HashSet(StringComparer.OrdinalIgnoreCase) { "small", "medium", "large" }; var validRoles = new HashSet(StringComparer.OrdinalIgnoreCase) { "entry", "transit", "narrative", "loot", "boss", "dead-end" }; var validDecos = new HashSet(StringComparer.OrdinalIgnoreCase) { "pillar", "brazier", "mosaic", "imperium_statue" }; var validLockTiers = new HashSet(StringComparer.OrdinalIgnoreCase) { "", "trivial", "easy", "medium", "hard" }; var validBands = new HashSet(StringComparer.OrdinalIgnoreCase) { "t1", "t2", "t3" }; var validSpawnKinds = new HashSet(StringComparer.OrdinalIgnoreCase) { "PoiGuard", "WildAnimal", "Brigand", "Boss" }; foreach (var path in files) { var def = Load(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}'"); } /// /// Load every JSON file in Content/Data/dungeon_layouts/. Each /// file is one . Validates dungeon-type /// + size-band + branching + room-count band ranges + loot-table /// references against when supplied. /// public DungeonLayoutDef[] LoadDungeonLayouts(IReadOnlyCollection? rooms = null, IReadOnlyCollection? lootTables = null) { string dir = Path.Combine(DataDirectory, "dungeon_layouts"); if (!Directory.Exists(dir)) return Array.Empty(); var files = Directory.EnumerateFiles(dir, "*.json") .OrderBy(p => p, StringComparer.Ordinal).ToArray(); var defs = new List(files.Length); var ids = new HashSet(StringComparer.OrdinalIgnoreCase); var validTypes = new HashSet(StringComparer.OrdinalIgnoreCase) { "ImperiumRuin", "AbandonedMine", "CultDen", "NaturalCave", "OvergrownSettlement" }; var validBands = new HashSet(StringComparer.OrdinalIgnoreCase) { "small", "medium", "large" }; var validBranching = new HashSet(StringComparer.OrdinalIgnoreCase) { "linear", "branching", "loop" }; var validRoles = new HashSet(StringComparer.OrdinalIgnoreCase) { "entry", "transit", "narrative", "loot", "boss", "dead-end" }; var validLootBands = new HashSet(StringComparer.OrdinalIgnoreCase) { "t1", "t2", "t3" }; var roomIds = rooms is null ? null : new HashSet(rooms.Select(r => r.Id), StringComparer.OrdinalIgnoreCase); var lootTableIds = lootTables is null ? null : new HashSet(lootTables.Select(t => t.Id), StringComparer.OrdinalIgnoreCase); foreach (var path in files) { var def = Load(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(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(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(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}"); }