b451f83174
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>
1001 lines
51 KiB
C#
1001 lines
51 KiB
C#
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/<type>/</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}");
|
||
}
|