Initial commit: Theriapolis baseline at port/godot branch point

Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Immutable background definition loaded from backgrounds.json.
/// Phase 5 grants the listed skill / tool proficiencies but does not
/// apply the named feature's mechanical effect — those resolve to
/// dialogue / quest / faction systems shipped in Phase 6.
/// </summary>
public sealed record BackgroundDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("name")]
public string Name { get; init; } = "";
[JsonPropertyName("flavor")]
public string Flavor { get; init; } = "";
[JsonPropertyName("skill_proficiencies")]
public string[] SkillProficiencies { get; init; } = Array.Empty<string>();
[JsonPropertyName("tool_proficiencies")]
public string[] ToolProficiencies { get; init; } = Array.Empty<string>();
[JsonPropertyName("feature_name")]
public string FeatureName { get; init; } = "";
[JsonPropertyName("feature_description")]
public string FeatureDescription { get; init; } = "";
[JsonPropertyName("suggested_personality")]
public string SuggestedPersonality { get; init; } = "";
}
+44
View File
@@ -0,0 +1,44 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Phase 6 M1 — pre-meeting prejudice template per
/// <c>theriapolis-rpg-reputation.md §I-1</c>. Each NPC carries a
/// <c>BiasProfileId</c> that points at one of these; the runtime
/// disposition formula adds <see cref="CladeBias"/>[pc.clade] (plus the
/// universal size-differential modifier) to the personal/faction
/// components when computing how an NPC reacts to the player.
///
/// 12 profiles ship with the game: pack-loyal Canid traditionalists,
/// herd-cautious Cervids, urban progressives, hybrid survivors, etc.
/// Adding new profiles is a content-only edit.
/// </summary>
public sealed record BiasProfileDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("name")]
public string Name { get; init; } = "";
[JsonPropertyName("description")]
public string Description { get; init; } = "";
/// <summary>Modifier for the player's clade. Keys are clade ids ("canidae", "felidae", ...).</summary>
[JsonPropertyName("clade_bias")]
public Dictionary<string, int> CladeBias { get; init; } = new();
/// <summary>Modifier when the player is detected as hybrid. Negative = stigma, positive = solidarity.</summary>
[JsonPropertyName("hybrid_bias")]
public int HybridBias { get; init; } = 0;
/// <summary>
/// Optional faction-affinity hints. Map of faction id → +integer (faction
/// the NPC favours) or -integer (faction the NPC distrusts). Phase 6 M5
/// uses these to decide how an NPC reacts to the player's faction
/// standing; M1/M2 only display them in the disposition tooltip.
/// </summary>
[JsonPropertyName("faction_affinity")]
public Dictionary<string, int> FactionAffinity { get; init; } = new();
}
+80
View File
@@ -0,0 +1,80 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Immutable biome definition loaded from biomes.json.
/// Defines the biome's identity, visual representation, and the (e,m,t) ranges
/// that can produce it during BiomeAssign.
/// </summary>
public sealed record BiomeDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("display_name")]
public string DisplayName { get; init; } = "";
/// <summary>Single capital letter used in placeholder tile rendering.</summary>
[JsonPropertyName("letter")]
public char Letter { get; init; } = '?';
/// <summary>Hex color string (#RRGGBB) for the placeholder tile background.</summary>
[JsonPropertyName("color")]
public string Color { get; init; } = "#888888";
[JsonPropertyName("placeholder_sprite")]
public string PlaceholderSprite { get; init; } = "";
// ── Assignment thresholds ────────────────────────────────────────────────
// These are used only for "natural" biome assignment.
// Ocean is handled separately (elevation < sea_level).
[JsonPropertyName("elevation_min")] public float ElevationMin { get; init; } = 0f;
[JsonPropertyName("elevation_max")] public float ElevationMax { get; init; } = 1f;
[JsonPropertyName("moisture_min")] public float MoistureMin { get; init; } = 0f;
[JsonPropertyName("moisture_max")] public float MoistureMax { get; init; } = 1f;
[JsonPropertyName("temp_min")] public float TempMin { get; init; } = 0f;
[JsonPropertyName("temp_max")] public float TempMax { get; init; } = 1f;
/// <summary>Priority — higher-priority biomes win when multiple match.</summary>
[JsonPropertyName("priority")]
public int Priority { get; init; } = 0;
/// <summary>True if this is a transition/mixed biome (not assignable from base rules).</summary>
[JsonPropertyName("is_transition")]
public bool IsTransition { get; init; } = false;
// ── Parsed color cache ───────────────────────────────────────────────────
private (byte R, byte G, byte B)? _parsedColor;
public (byte R, byte G, byte B) ParsedColor()
{
if (_parsedColor.HasValue) return _parsedColor.Value;
string hex = Color.TrimStart('#');
byte r = Convert.ToByte(hex[..2], 16);
byte g = Convert.ToByte(hex[2..4], 16);
byte b = Convert.ToByte(hex[4..6], 16);
_parsedColor = (r, g, b);
return _parsedColor.Value;
}
/// <summary>How well this biome matches the given (e, m, t) values. Returns 0 if outside range.</summary>
public float Score(float e, float m, float t)
{
if (e < ElevationMin || e > ElevationMax) return 0f;
if (m < MoistureMin || m > MoistureMax) return 0f;
if (t < TempMin || t > TempMax) return 0f;
// Score = how close the values are to the center of the range (prefer tighter fits)
float eMid = (ElevationMin + ElevationMax) * 0.5f;
float mMid = (MoistureMin + MoistureMax) * 0.5f;
float tMid = (TempMin + TempMax) * 0.5f;
float eHalf = (ElevationMax - ElevationMin) * 0.5f + 0.001f;
float mHalf = (MoistureMax - MoistureMin) * 0.5f + 0.001f;
float tHalf = (TempMax - TempMin) * 0.5f + 0.001f;
float closeness = 1f - (MathF.Abs(e - eMid)/eHalf + MathF.Abs(m - mMid)/mHalf + MathF.Abs(t - tMid)/tHalf) / 3f;
return closeness + Priority * 0.5f;
}
}
@@ -0,0 +1,108 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Phase 6 M0 — definition of a single stampable building (inn, shop, house,
/// magistrate, etc.). Loaded from <c>Content/Data/building_templates/*.json</c>.
///
/// A template describes:
/// - The building's footprint in tactical tiles.
/// - Where doors sit on the perimeter.
/// - Which interior cells get specific furniture (counter, bed, hearth, sign).
/// - Which "roles" the building offers (innkeeper, shopkeeper, guard) and
/// where each role's resident NPC stands when the player walks in.
///
/// Stamping draws walls along the perimeter, floors inside, doors at the
/// declared door cells, and decorations at the declared deco cells. Spawn
/// records for roles are emitted into the chunk's <see cref="Tactical.TacticalSpawn"/>
/// list as <see cref="Tactical.SpawnKind.Resident"/> (Phase 6 M1).
/// </summary>
public sealed record BuildingTemplateDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("name")]
public string Name { get; init; } = "";
/// <summary>Footprint width in tactical tiles. Includes perimeter walls.</summary>
[JsonPropertyName("footprint_w_tiles")]
public int FootprintWTiles { get; init; } = 1;
/// <summary>Footprint height in tactical tiles. Includes perimeter walls.</summary>
[JsonPropertyName("footprint_h_tiles")]
public int FootprintHTiles { get; init; } = 1;
/// <summary>Lowest settlement tier this template is eligible for (4 = village+).</summary>
[JsonPropertyName("min_tier_eligible")]
public int MinTierEligible { get; init; } = 5;
/// <summary>Door positions in template-local coords (0..W-1, 0..H-1).</summary>
[JsonPropertyName("doors")]
public BuildingDoor[] Doors { get; init; } = Array.Empty<BuildingDoor>();
/// <summary>Decorations in template-local coords.</summary>
[JsonPropertyName("decos")]
public BuildingDecoPlacement[] Decos { get; init; } = Array.Empty<BuildingDecoPlacement>();
/// <summary>Resident roles (innkeeper, shopkeeper, guard, ...).</summary>
[JsonPropertyName("roles")]
public BuildingRole[] Roles { get; init; } = Array.Empty<BuildingRole>();
/// <summary>
/// Optional biome filter. Empty = eligible everywhere. Otherwise the
/// settlement's home tile must be one of these biome ids.
/// </summary>
[JsonPropertyName("biome_filter")]
public string[] BiomeFilter { get; init; } = Array.Empty<string>();
/// <summary>Selection weight in procedural Tier 25 layout rolls. Default 1.0.</summary>
[JsonPropertyName("weight")]
public float Weight { get; init; } = 1f;
/// <summary>"civic" / "shop" / "house" / "inn" / "infrastructure" — used by procedural layout role mix.</summary>
[JsonPropertyName("category")]
public string Category { get; init; } = "house";
}
public sealed record BuildingDoor
{
[JsonPropertyName("x")]
public int X { get; init; }
[JsonPropertyName("y")]
public int Y { get; init; }
/// <summary>Compass facing: "N" / "E" / "S" / "W". Door always sits on a perimeter cell.</summary>
[JsonPropertyName("facing")]
public string Facing { get; init; } = "S";
}
public sealed record BuildingDecoPlacement
{
[JsonPropertyName("x")]
public int X { get; init; }
[JsonPropertyName("y")]
public int Y { get; init; }
/// <summary>Deco kind name: "counter" / "bed" / "hearth" / "sign".</summary>
[JsonPropertyName("deco")]
public string Deco { get; init; } = "";
}
public sealed record BuildingRole
{
/// <summary>Role tag inside the template (e.g. "innkeeper", "shopkeeper", "guard").</summary>
[JsonPropertyName("tag")]
public string Tag { get; init; } = "";
/// <summary>Spawn point in template-local coords. Must be an interior cell.</summary>
[JsonPropertyName("spawn_at")]
public int[] SpawnAt { get; init; } = new[] { 1, 1 };
/// <summary>True if this role may be omitted in a procedural layout (slot left empty).</summary>
[JsonPropertyName("optional")]
public bool Optional { get; init; } = false;
}
+39
View File
@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Immutable clade (race-equivalent) record loaded from clades.json.
/// Defines the broad biological family — Canidae, Felidae, etc. —
/// plus the ability mods, traits, and detriments shared by all member
/// species. See clades.md for the authoritative content.
/// </summary>
public sealed record CladeDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("name")]
public string Name { get; init; } = "";
/// <summary>STR/DEX/CON/INT/WIS/CHA → modifier (typically +1 each on two abilities).</summary>
[JsonPropertyName("ability_mods")]
public Dictionary<string, int> AbilityMods { get; init; } = new();
[JsonPropertyName("traits")]
public TraitDef[] Traits { get; init; } = Array.Empty<TraitDef>();
[JsonPropertyName("detriments")]
public TraitDef[] Detriments { get; init; } = Array.Empty<TraitDef>();
[JsonPropertyName("languages")]
public string[] Languages { get; init; } = Array.Empty<string>();
/// <summary>
/// "Predator" / "Prey" — surfaces in dialogue + faction-affinity logic
/// (Phase 6) and gates a few class features in Phase 5 (e.g. Feral
/// level-20 Apex Predator vs Apex Prey).
/// </summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = "predator";
}
+129
View File
@@ -0,0 +1,129 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Immutable class definition loaded from classes.json. Phase 5 reads
/// every field — including the full level table — but only level-1
/// features have runtime effect; higher-level entries are forward-compat
/// scaffolding for the level-up flow shipped in Phase 5.5 / 6.
/// </summary>
public sealed record ClassDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("name")]
public string Name { get; init; } = "";
/// <summary>Hit die size: 6 / 8 / 10 / 12.</summary>
[JsonPropertyName("hit_die")]
public int HitDie { get; init; } = 8;
/// <summary>Primary ability key(s) (STR / DEX / CON / INT / WIS / CHA).</summary>
[JsonPropertyName("primary_ability")]
public string[] PrimaryAbility { get; init; } = Array.Empty<string>();
/// <summary>Saving-throw proficiencies.</summary>
[JsonPropertyName("saves")]
public string[] Saves { get; init; } = Array.Empty<string>();
/// <summary>Armor proficiency tags: "light", "medium", "heavy", "shields".</summary>
[JsonPropertyName("armor_proficiencies")]
public string[] ArmorProficiencies { get; init; } = Array.Empty<string>();
/// <summary>Weapon proficiency tags: "simple", "martial", "natural", or specific item ids.</summary>
[JsonPropertyName("weapon_proficiencies")]
public string[] WeaponProficiencies { get; init; } = Array.Empty<string>();
/// <summary>Tool proficiency tags.</summary>
[JsonPropertyName("tool_proficiencies")]
public string[] ToolProficiencies { get; init; } = Array.Empty<string>();
[JsonPropertyName("skills_choose")]
public int SkillsChoose { get; init; } = 0;
[JsonPropertyName("skill_options")]
public string[] SkillOptions { get; init; } = Array.Empty<string>();
/// <summary>
/// Per-level entries. Level 1..20. Phase 5 only consults level 1, but
/// the full table loads so the level-up flow doesn't need a schema bump.
/// </summary>
[JsonPropertyName("level_table")]
public ClassLevelEntry[] LevelTable { get; init; } = Array.Empty<ClassLevelEntry>();
/// <summary>Description of each named feature referenced from level_table.</summary>
[JsonPropertyName("feature_definitions")]
public Dictionary<string, ClassFeatureDef> FeatureDefinitions { get; init; } = new();
/// <summary>Allowed subclass ids (cross-reference into subclasses.json).</summary>
[JsonPropertyName("subclass_ids")]
public string[] SubclassIds { get; init; } = Array.Empty<string>();
/// <summary>
/// Items handed to a level-1 character of this class at creation time.
/// <see cref="Rules.Character.CharacterBuilder"/> adds each entry to the
/// inventory and, if <see cref="StartingKitItem.AutoEquip"/> is true,
/// equips it into <see cref="StartingKitItem.EquipSlot"/>.
/// </summary>
[JsonPropertyName("starting_kit")]
public StartingKitItem[] StartingKit { get; init; } = Array.Empty<StartingKitItem>();
}
/// <summary>
/// One row in <see cref="ClassDef.StartingKit"/>: the item id, quantity, and
/// optional auto-equip target. ItemId must resolve against items.json.
/// </summary>
public sealed record StartingKitItem
{
[JsonPropertyName("item_id")]
public string ItemId { get; init; } = "";
[JsonPropertyName("qty")]
public int Qty { get; init; } = 1;
/// <summary>If true, the item is equipped into <see cref="EquipSlot"/> at creation.</summary>
[JsonPropertyName("auto_equip")]
public bool AutoEquip { get; init; } = false;
/// <summary>"main_hand" / "off_hand" / "body" / "helm" / "cloak" / "boots" / "adaptive_pack" / etc.</summary>
[JsonPropertyName("equip_slot")]
public string EquipSlot { get; init; } = "";
}
public sealed record ClassLevelEntry
{
[JsonPropertyName("level")]
public int Level { get; init; } = 1;
[JsonPropertyName("prof")]
public int ProficiencyBonus { get; init; } = 2;
/// <summary>Feature ids unlocked at this level. Resolves into <see cref="ClassDef.FeatureDefinitions"/>.</summary>
[JsonPropertyName("features")]
public string[] Features { get; init; } = Array.Empty<string>();
}
public sealed record ClassFeatureDef
{
[JsonPropertyName("name")]
public string Name { get; init; } = "";
[JsonPropertyName("description")]
public string Description { get; init; } = "";
/// <summary>"passive", "active", "choice", "bonus_action", "reaction", "stub".</summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = "passive";
[JsonPropertyName("uses_per_short_rest")]
public int? UsesPerShortRest { get; init; }
[JsonPropertyName("uses_per_long_rest")]
public int? UsesPerLongRest { get; init; }
/// <summary>For "choice" features: the available pick ids.</summary>
[JsonPropertyName("options")]
public string[]? Options { get; init; }
}
File diff suppressed because it is too large Load Diff
+134
View File
@@ -0,0 +1,134 @@
namespace Theriapolis.Core.Data;
/// <summary>
/// Pre-loaded content lookup tables. Constructing one calls every loader
/// exactly once and indexes results by id, so subsequent
/// <c>resolver.Clades["canidae"]</c> lookups are O(1).
///
/// Used by character creation, save/load (id → def resolution), and Phase 5 M5
/// NPC instantiation. Shared across screens that need any combination of
/// these tables.
/// </summary>
public sealed class ContentResolver
{
public IReadOnlyDictionary<string, CladeDef> Clades { get; }
public IReadOnlyDictionary<string, SpeciesDef> Species { get; }
public IReadOnlyDictionary<string, ClassDef> Classes { get; }
public IReadOnlyDictionary<string, SubclassDef> Subclasses { get; }
public IReadOnlyDictionary<string, BackgroundDef> Backgrounds { get; }
public IReadOnlyDictionary<string, ItemDef> Items { get; }
public IReadOnlyDictionary<string, LootTableDef> LootTables { get; }
public NpcTemplateContent Npcs { get; }
/// <summary>Phase 6 M0 — building templates + settlement layouts.</summary>
public SettlementContent Settlements { get; }
/// <summary>Phase 6 M1 — pre-meeting bias profiles per <c>reputation.md §I-1</c>.</summary>
public IReadOnlyDictionary<string, BiasProfileDef> BiasProfiles { get; }
/// <summary>Phase 6 M2 — faction definitions including opposition matrix entries.</summary>
public IReadOnlyDictionary<string, FactionDef> Factions { get; }
/// <summary>Phase 6 M1 — generic + named friendly/neutral resident templates.</summary>
public IReadOnlyDictionary<string, ResidentTemplateDef> Residents { get; }
/// <summary>
/// Phase 6 M1 — fast lookup of named residents by anchor-prefixed role
/// tag (e.g. "millhaven.innkeeper" → ResidentTemplateDef). Generic
/// templates live in <see cref="Residents"/>; this index only holds
/// the entries with <c>named: true</c>.
/// </summary>
public IReadOnlyDictionary<string, ResidentTemplateDef> ResidentsByRoleTag { get; }
/// <summary>Phase 6 M3 — dialogue trees indexed by id.</summary>
public IReadOnlyDictionary<string, DialogueDef> Dialogues { get; }
/// <summary>Phase 6 M4 — quest trees indexed by id.</summary>
public IReadOnlyDictionary<string, QuestDef> Quests { get; }
/// <summary>Phase 7 M0 — room templates indexed by id (every dungeon type).</summary>
public IReadOnlyDictionary<string, RoomTemplateDef> RoomTemplates { get; }
/// <summary>
/// Phase 7 M0 — room templates indexed by dungeon type (e.g. <c>imperium</c>
/// → IList of all imperium-typed templates). Used by the layout matcher
/// to filter candidates without a linear scan.
/// </summary>
public IReadOnlyDictionary<string, IReadOnlyList<RoomTemplateDef>> RoomTemplatesByType { get; }
/// <summary>Phase 7 M0 — dungeon layouts indexed by id.</summary>
public IReadOnlyDictionary<string, DungeonLayoutDef> DungeonLayouts { get; }
/// <summary>
/// Phase 7 M0 — anchor-locked layouts indexed by anchor id (e.g.
/// <c>OldHowlMine</c> → the pinned 3-room layout). Procedural pipeline
/// never picks anchor-locked layouts; the anchor resolver consults this
/// dict directly.
/// </summary>
public IReadOnlyDictionary<string, DungeonLayoutDef> DungeonLayoutsByAnchor { get; }
public ContentResolver(ContentLoader loader)
{
var clades = loader.LoadClades();
Clades = clades.ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase);
var speciesArr = loader.LoadSpecies(clades);
Species = speciesArr.ToDictionary(s => s.Id, StringComparer.OrdinalIgnoreCase);
var classes = loader.LoadClasses();
Classes = classes.ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase);
Subclasses = loader.LoadSubclasses(classes).ToDictionary(s => s.Id, StringComparer.OrdinalIgnoreCase);
Backgrounds = loader.LoadBackgrounds().ToDictionary(b => b.Id, StringComparer.OrdinalIgnoreCase);
var items = loader.LoadItems();
Items = items.ToDictionary(i => i.Id, StringComparer.OrdinalIgnoreCase);
LootTables = loader.LoadLootTables(items).ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase);
// Phase 6 M5 — factions loaded early so NpcTemplates can validate
// their faction field references against the canonical list.
var factionsArr = loader.LoadFactions();
Factions = factionsArr.ToDictionary(f => f.Id, StringComparer.OrdinalIgnoreCase);
Npcs = loader.LoadNpcTemplates(items, factionsArr);
// Phase 6 M0 — building/layout content.
var buildings = loader.LoadBuildingTemplates();
var layouts = loader.LoadSettlementLayouts(buildings);
var byId = buildings.ToDictionary(b => b.Id, StringComparer.OrdinalIgnoreCase);
var preset = layouts.Where(l => l.Kind == "preset")
.ToDictionary(l => l.Anchor, StringComparer.OrdinalIgnoreCase);
var proc = layouts.Where(l => l.Kind == "procedural")
.ToDictionary(l => l.Tier);
Settlements = new SettlementContent(byId, preset, proc);
// Phase 6 M1 — bias profiles + resident templates.
// (factionsArr already loaded above for NpcTemplates validation.)
var biasArr = loader.LoadBiasProfiles(clades, factionsArr);
BiasProfiles = biasArr.ToDictionary(b => b.Id, StringComparer.OrdinalIgnoreCase);
var residentArr = loader.LoadResidentTemplates(biasArr, clades, speciesArr, factionsArr);
Residents = residentArr.ToDictionary(r => r.Id, StringComparer.OrdinalIgnoreCase);
ResidentsByRoleTag = residentArr
.Where(r => r.Named)
.ToDictionary(r => r.RoleTag, StringComparer.OrdinalIgnoreCase);
// Phase 6 M3 — dialogue trees.
Dialogues = loader.LoadDialogues(items)
.ToDictionary(d => d.Id, StringComparer.OrdinalIgnoreCase);
// Phase 6 M4 — quest trees.
Quests = loader.LoadQuests(items)
.ToDictionary(q => q.Id, StringComparer.OrdinalIgnoreCase);
// Phase 7 M0 — room templates + dungeon layouts.
var roomArr = loader.LoadRoomTemplates();
RoomTemplates = roomArr.ToDictionary(r => r.Id, StringComparer.OrdinalIgnoreCase);
RoomTemplatesByType = roomArr
.GroupBy(r => r.Type, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => (IReadOnlyList<RoomTemplateDef>)g.ToArray(),
StringComparer.OrdinalIgnoreCase);
var layoutArr = loader.LoadDungeonLayouts(roomArr, LootTables.Values.ToArray());
DungeonLayouts = layoutArr.ToDictionary(l => l.Id, StringComparer.OrdinalIgnoreCase);
DungeonLayoutsByAnchor = layoutArr
.Where(l => !string.IsNullOrEmpty(l.Anchor))
.ToDictionary(l => l.Anchor, StringComparer.OrdinalIgnoreCase);
}
}
+186
View File
@@ -0,0 +1,186 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Phase 6 M3 — JSON-loaded dialogue tree. Each tree is a directed graph
/// of nodes; the runner walks from <see cref="Root"/> per option choice.
/// Nodes are addressed by a string id local to the tree.
///
/// Author convention: keep one tree per file in
/// <c>Content/Data/dialogues/*.json</c>. <see cref="Id"/> matches the
/// filename (sans extension) so authors can find the file by id.
/// </summary>
public sealed record DialogueDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
/// <summary>Starting node id when the dialogue opens.</summary>
[JsonPropertyName("root")]
public string Root { get; init; } = "";
[JsonPropertyName("nodes")]
public DialogueNodeDef[] Nodes { get; init; } = System.Array.Empty<DialogueNodeDef>();
}
public sealed record DialogueNodeDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
/// <summary>"npc" / "pc" / "narration".</summary>
[JsonPropertyName("speaker")]
public string Speaker { get; init; } = "npc";
/// <summary>Display text. Supports placeholders {pc.name}, {npc.role}, {disposition_label}.</summary>
[JsonPropertyName("text")]
public string Text { get; init; } = "";
/// <summary>Effects applied automatically when the runner enters this node.</summary>
[JsonPropertyName("on_enter")]
public DialogueEffectDef[] OnEnter { get; init; } = System.Array.Empty<DialogueEffectDef>();
[JsonPropertyName("options")]
public DialogueOptionDef[] Options { get; init; } = System.Array.Empty<DialogueOptionDef>();
}
public sealed record DialogueOptionDef
{
[JsonPropertyName("text")]
public string Text { get; init; } = "";
/// <summary>Visibility predicates. Option is hidden if any condition fails.</summary>
[JsonPropertyName("conditions")]
public DialogueConditionDef[] Conditions { get; init; } = System.Array.Empty<DialogueConditionDef>();
/// <summary>
/// When set, selecting this option rolls the named skill against
/// <see cref="DialogueSkillCheckDef.Dc"/>. The runner branches into
/// <see cref="EffectsOnSuccess"/>+<see cref="NextOnSuccess"/> on success
/// or <see cref="EffectsOnFailure"/>+<see cref="NextOnFailure"/> on
/// failure. <see cref="Effects"/> and <see cref="Next"/> are ignored
/// when a skill check is present.
/// </summary>
[JsonPropertyName("skill_check")]
public DialogueSkillCheckDef? SkillCheck { get; init; }
/// <summary>Node id to jump to when this option is selected. Empty / "&lt;end&gt;" closes the dialogue.</summary>
[JsonPropertyName("next")]
public string Next { get; init; } = "";
[JsonPropertyName("effects")]
public DialogueEffectDef[] Effects { get; init; } = System.Array.Empty<DialogueEffectDef>();
[JsonPropertyName("next_on_success")]
public string NextOnSuccess { get; init; } = "";
[JsonPropertyName("next_on_failure")]
public string NextOnFailure { get; init; } = "";
[JsonPropertyName("effects_on_success")]
public DialogueEffectDef[] EffectsOnSuccess { get; init; } = System.Array.Empty<DialogueEffectDef>();
[JsonPropertyName("effects_on_failure")]
public DialogueEffectDef[] EffectsOnFailure { get; init; } = System.Array.Empty<DialogueEffectDef>();
}
public sealed record DialogueSkillCheckDef
{
/// <summary>Skill id (snake_case, matches SkillId.FromJson — e.g. "intimidation", "persuasion").</summary>
[JsonPropertyName("skill")]
public string Skill { get; init; } = "";
[JsonPropertyName("dc")]
public int Dc { get; init; }
}
/// <summary>Visibility predicate evaluated when the option is offered.</summary>
public sealed record DialogueConditionDef
{
/// <summary>
/// One of: "rep_at_least", "rep_below", "has_item", "not_has_item",
/// "has_flag", "not_has_flag", "ability_min".
/// </summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = "";
/// <summary>Faction id (rep_at_least / rep_below). Empty = effective disposition vs current NPC.</summary>
[JsonPropertyName("faction")]
public string Faction { get; init; } = "";
/// <summary>Item id (has_item / not_has_item).</summary>
[JsonPropertyName("id")]
public string Id { get; init; } = "";
/// <summary>Flag id (has_flag / not_has_flag).</summary>
[JsonPropertyName("flag")]
public string Flag { get; init; } = "";
/// <summary>Ability id (ability_min): "STR" / "DEX" / "CON" / "INT" / "WIS" / "CHA".</summary>
[JsonPropertyName("ability")]
public string Ability { get; init; } = "";
/// <summary>Numeric threshold for rep / ability. Inclusive lower bound for *_at_least and *_min.</summary>
[JsonPropertyName("value")]
public int Value { get; init; }
}
/// <summary>Side effect applied on option selection (or on node enter).</summary>
public sealed record DialogueEffectDef
{
/// <summary>
/// One of: "set_flag", "clear_flag", "give_item", "take_item",
/// "rep_event", "open_shop", "start_quest", "give_xp".
/// </summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = "";
/// <summary>Flag id for set_flag/clear_flag.</summary>
[JsonPropertyName("flag")]
public string Flag { get; init; } = "";
/// <summary>Integer value for set_flag (defaults to 1).</summary>
[JsonPropertyName("value")]
public int Value { get; init; } = 1;
/// <summary>Item id for give_item/take_item.</summary>
[JsonPropertyName("id")]
public string Id { get; init; } = "";
/// <summary>Quantity for give_item/take_item (defaults to 1).</summary>
[JsonPropertyName("qty")]
public int Qty { get; init; } = 1;
/// <summary>RepEvent payload for rep_event.</summary>
[JsonPropertyName("event")]
public DialogueRepEventDef? Event { get; init; }
/// <summary>Quest id for start_quest. Phase 6 M3 ignores; M4 wires the quest engine.</summary>
[JsonPropertyName("quest")]
public string Quest { get; init; } = "";
/// <summary>XP magnitude for give_xp.</summary>
[JsonPropertyName("xp")]
public int Xp { get; init; }
}
public sealed record DialogueRepEventDef
{
/// <summary>RepEventKind name: "Dialogue" / "Quest" / "Combat" / "Rescue" / "Betrayal" / "Gift" / "Trade" / "Aid" / "Crime" / "Misc".</summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = "Dialogue";
[JsonPropertyName("magnitude")]
public int Magnitude { get; init; }
[JsonPropertyName("faction")]
public string Faction { get; init; } = "";
/// <summary>If empty, defaults to the current NPC's role tag.</summary>
[JsonPropertyName("role_tag")]
public string RoleTag { get; init; } = "";
[JsonPropertyName("note")]
public string Note { get; init; } = "";
}
+116
View File
@@ -0,0 +1,116 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Phase 7 M0 — per-dungeon-type rules for assembling rooms into a complete
/// dungeon. Loaded from <c>Content/Data/dungeon_layouts/*.json</c>.
///
/// Each layout declares: which dungeon type + size band it covers, the
/// room-count band, branching policy (linear / branching / loop), required
/// vs optional special-room roles (entry / narrative / boss / loot /
/// dead-end), and the mapping from PoI level-band → loot-table tier.
///
/// <see cref="ContentLoader.LoadDungeonLayouts"/> validates ranges,
/// branching enum, and loot-table-per-band references against the loaded
/// <c>loot_tables.json</c>.
///
/// Anchor-locked dungeons (Old Howl mine, Imperium Ruin showcase) ship as
/// special layouts whose <see cref="Anchor"/> field is set — these
/// override the procedural pipeline so the experience is identical across
/// seeds.
/// </summary>
public sealed record DungeonLayoutDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
/// <summary>
/// Dungeon type: <c>ImperiumRuin</c>, <c>AbandonedMine</c>,
/// <c>CultDen</c>, <c>NaturalCave</c>, <c>OvergrownSettlement</c>.
/// Matches the <see cref="World.PoiType"/> enum names exactly.
/// </summary>
[JsonPropertyName("dungeon_type")]
public string DungeonType { get; init; } = "";
/// <summary>Size band: <c>small</c> / <c>medium</c> / <c>large</c>.</summary>
[JsonPropertyName("size_band")]
public string SizeBand { get; init; } = "small";
/// <summary>
/// Optional anchor id. When set, this layout is the canonical fixed
/// layout for the named anchor (Old Howl mine, Imperium Ruin showcase).
/// The procedural pipeline never picks anchor-locked layouts via
/// <see cref="DungeonType"/> + <see cref="SizeBand"/>; only the anchor
/// resolver consumes them.
/// </summary>
[JsonPropertyName("anchor")]
public string Anchor { get; init; } = "";
[JsonPropertyName("room_count_min")]
public int RoomCountMin { get; init; } = 3;
[JsonPropertyName("room_count_max")]
public int RoomCountMax { get; init; } = 5;
/// <summary>
/// Branching policy: <c>linear</c> (each room connects to the previous;
/// chain), <c>branching</c> (each room past entry connects to one prior
/// room — variable degree), <c>loop</c> (branching plus one extra
/// connection that closes a loop).
/// </summary>
[JsonPropertyName("branching")]
public string Branching { get; init; } = "linear";
/// <summary>Special-room roles that must be present in any successful assembly.</summary>
[JsonPropertyName("required_roles")]
public string[] RequiredRoles { get; init; } = Array.Empty<string>();
/// <summary>Special-room roles eligible for inclusion if there's room left over.</summary>
[JsonPropertyName("optional_roles")]
public string[] OptionalRoles { get; init; } = Array.Empty<string>();
/// <summary>
/// Map from loot-table band (<c>t1</c>/<c>t2</c>/<c>t3</c>) to a real
/// loot-table id (e.g. <c>loot_dungeon_imperium_t2</c>). Looked up by
/// the dungeon populator when filling container slots.
/// </summary>
[JsonPropertyName("loot_table_per_band")]
public Dictionary<string, string> LootTablePerBand { get; init; } = new();
/// <summary>
/// Spawn-kind distribution for filling generic encounter slots — keys
/// are spawn-kind names (<c>PoiGuard</c> / <c>WildAnimal</c> /
/// <c>Brigand</c>), values are weights that sum to ~1.0.
/// </summary>
[JsonPropertyName("spawn_kind_distribution")]
public Dictionary<string, float> SpawnKindDistribution { get; init; } = new();
/// <summary>
/// Map from PoI <c>LevelBand</c> (0..3) to a loot-table band
/// (<c>t1</c>/<c>t2</c>/<c>t3</c>). Keys are stringified ints
/// because <see cref="System.Text.Json"/> rejects integer dictionary
/// keys without a custom converter.
/// </summary>
[JsonPropertyName("level_band_to_loot_band")]
public Dictionary<string, string> LevelBandToLootBand { get; init; } = new();
/// <summary>
/// Optional anchor-pinned room sequence: when <see cref="Anchor"/>
/// is set, this array names the exact templates to use, in order.
/// Empty for non-anchor layouts (procedural pipeline picks instead).
/// </summary>
[JsonPropertyName("pinned_rooms")]
public PinnedRoomEntry[] PinnedRooms { get; init; } = Array.Empty<PinnedRoomEntry>();
}
public sealed record PinnedRoomEntry
{
/// <summary>Room template id. Must reference a real <see cref="RoomTemplateDef"/>.</summary>
[JsonPropertyName("template")]
public string Template { get; init; } = "";
/// <summary>Role assigned to this room slot: entry / transit / narrative / loot / boss / dead-end.</summary>
[JsonPropertyName("role")]
public string Role { get; init; } = "transit";
}
+43
View File
@@ -0,0 +1,43 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Definition record for a named faction, loaded from factions.json.
/// </summary>
public sealed class FactionDef
{
/// <summary>Machine-readable id matching FactionId enum (e.g. "covenant_enforcers").</summary>
public string Id { get; set; } = "";
/// <summary>Display name shown in UI.</summary>
public string Name { get; set; } = "";
/// <summary>Abbreviated name for tight spaces.</summary>
public string ShortName { get; set; } = "";
/// <summary>Hex color string for map overlay (e.g. "#4455AA").</summary>
public string Color { get; set; } = "#FFFFFF";
/// <summary>Brief description used in tooltips/codex.</summary>
public string Description { get; set; } = "";
/// <summary>
/// Phase 6 M2 — opposition multipliers per <c>reputation.md §I-2</c>.
/// When the player gains <c>+N</c> with this faction, every entry
/// <c>{ otherFactionId: m }</c> here applies <c>+N × m</c> to that other
/// faction's standing. Multipliers are negative for rivals (helping
/// Inheritors hurts you with Enforcers), positive for allies, 0 for
/// neutrals. Asymmetry is by design — see the design doc.
/// </summary>
[JsonPropertyName("opposition")]
public Dictionary<string, float> Opposition { get; set; } = new();
/// <summary>
/// Phase 6 M2 — when true, this faction is hidden from the reputation
/// screen until the player learns it exists (the Maw, in Act I climax).
/// The faction still exists internally and accumulates standing.
/// </summary>
[JsonPropertyName("hidden")]
public bool Hidden { get; set; } = false;
}
+108
View File
@@ -0,0 +1,108 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Immutable item definition loaded from items.json. Covers weapons,
/// armor, shields, consumables, adventuring gear, and natural-weapon
/// enhancers. Phase 5 ships a curated subset focused on combat readiness;
/// the remaining catalog from equipment.md fills in over later phases.
/// </summary>
public sealed record ItemDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("name")]
public string Name { get; init; } = "";
/// <summary>"weapon", "armor", "shield", "consumable", "gear", "natural_weapon_enhancer".</summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = "gear";
[JsonPropertyName("cost_fang")]
public float CostFang { get; init; } = 0f;
[JsonPropertyName("weight_lb")]
public float WeightLb { get; init; } = 0f;
/// <summary>Sizes this item is manufactured for: "small" / "medium" / "large".</summary>
[JsonPropertyName("sizes")]
public string[] Sizes { get; init; } = new[] { "medium" };
/// <summary>Free-text properties from equipment.md (e.g. "finesse", "light", "two_handed", "versatile", "heavy", "loading", "thrown", "reach", "ammunition").</summary>
[JsonPropertyName("properties")]
public string[] Properties { get; init; } = Array.Empty<string>();
/// <summary>Free-text description for tooltips and codex.</summary>
[JsonPropertyName("description")]
public string Description { get; init; } = "";
// ── Weapon fields (kind = "weapon") ─────────────────────────────────
/// <summary>Weapon proficiency category: "simple", "martial", "natural", "firearm".</summary>
[JsonPropertyName("proficiency")]
public string Proficiency { get; init; } = "";
/// <summary>Damage dice expression (e.g. "1d6", "2d6", "1d8+2"). Empty for non-weapons.</summary>
[JsonPropertyName("damage")]
public string Damage { get; init; } = "";
/// <summary>Versatile two-handed damage dice (e.g. "1d10" when used two-handed). Empty if not versatile.</summary>
[JsonPropertyName("damage_versatile")]
public string DamageVersatile { get; init; } = "";
[JsonPropertyName("damage_type")]
public string DamageType { get; init; } = "";
/// <summary>Melee reach in tactical tiles. 0 / unset = default (1 for M, 2 for L).</summary>
[JsonPropertyName("reach_tiles")]
public int ReachTiles { get; init; } = 0;
/// <summary>Ranged: short / long ranges in tactical tiles. (0,0) for melee.</summary>
[JsonPropertyName("range_short_tiles")]
public int RangeShortTiles { get; init; } = 0;
[JsonPropertyName("range_long_tiles")]
public int RangeLongTiles { get; init; } = 0;
// ── Armor / shield fields (kind = "armor" | "shield") ──────────────
/// <summary>Base AC value (armor) or AC bonus (shield).</summary>
[JsonPropertyName("ac_base")]
public int AcBase { get; init; } = 0;
/// <summary>Max DEX modifier added to AC (medium = 2, heavy = 0). -1 = unlimited.</summary>
[JsonPropertyName("ac_max_dex")]
public int AcMaxDex { get; init; } = -1;
/// <summary>"light", "medium", "heavy" — for armor only.</summary>
[JsonPropertyName("armor_class")]
public string ArmorClass { get; init; } = "";
[JsonPropertyName("min_str")]
public int MinStr { get; init; } = 0;
[JsonPropertyName("stealth_disadvantage")]
public bool StealthDisadvantage { get; init; } = false;
// ── Natural-weapon-enhancer fields (kind = "natural_weapon_enhancer") ─
/// <summary>Which natural-weapon location this enhancer attaches to: "fang", "claw", "hoof", "antler", "horn", "tail".</summary>
[JsonPropertyName("enhancer_slot")]
public string EnhancerSlot { get; init; } = "";
/// <summary>Damage modifier added to the natural attack (e.g. +1 or +2).</summary>
[JsonPropertyName("damage_bonus")]
public int DamageBonus { get; init; } = 0;
/// <summary>Clades this enhancer is fitted for. Empty = universal.</summary>
[JsonPropertyName("clade_fit")]
public string[] CladeFit { get; init; } = Array.Empty<string>();
// ── Consumable fields (kind = "consumable") ─────────────────────────
/// <summary>"healing", "poison", "pheromone", "performance", "scent_mask", etc.</summary>
[JsonPropertyName("consumable_kind")]
public string ConsumableKind { get; init; } = "";
/// <summary>Healing dice expression for healing consumables (e.g. "1d4", "2d6").</summary>
[JsonPropertyName("healing")]
public string Healing { get; init; } = "";
}
+37
View File
@@ -0,0 +1,37 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Loot table — list of weighted drop entries rolled when an NPC with
/// matching <see cref="NpcTemplateDef.LootTable"/> id falls in combat.
/// Each drop entry rolls independently against its <see cref="LootDrop.Chance"/>;
/// successful drops contribute (qty_min..qty_max) of the item.
///
/// Phase 5 M6 keeps this stingy by design — most level-1 fights net 1-3
/// items at most, never enough to obsolete the starting kit.
/// </summary>
public sealed record LootTableDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("drops")]
public LootDrop[] Drops { get; init; } = System.Array.Empty<LootDrop>();
}
public sealed record LootDrop
{
[JsonPropertyName("item_id")]
public string ItemId { get; init; } = "";
[JsonPropertyName("qty_min")]
public int QtyMin { get; init; } = 1;
[JsonPropertyName("qty_max")]
public int QtyMax { get; init; } = 1;
/// <summary>0..1 probability this drop fires. Independent of other drops in the table.</summary>
[JsonPropertyName("chance")]
public float Chance { get; init; } = 1.0f;
}
+112
View File
@@ -0,0 +1,112 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// A single cell in the 32×32 authored macro-template grid.
/// Defines the biome character and generation constraints for a 32×32-tile region.
/// </summary>
public sealed class MacroCell
{
[JsonPropertyName("biome_type")]
public string BiomeType { get; set; } = "temperate_grassland";
[JsonPropertyName("clade_affinities")]
public string[] CladeAffinities { get; set; } = Array.Empty<string>();
[JsonPropertyName("development")]
public string Development { get; set; } = "agricultural";
/// <summary>strong | moderate | weak | nominal</summary>
[JsonPropertyName("covenant")]
public string Covenant { get; set; } = "moderate";
// Elevation constraints (01 range). Floor = minimum, Ceiling = maximum.
[JsonPropertyName("elevation_floor")]
public float ElevationFloor { get; set; } = 0f;
[JsonPropertyName("elevation_ceiling")]
public float ElevationCeiling { get; set; } = 1f;
// Moisture constraints
[JsonPropertyName("moisture_floor")]
public float MoistureFloor { get; set; } = 0f;
[JsonPropertyName("moisture_ceiling")]
public float MoistureCeiling { get; set; } = 1f;
// Temperature modifiers (added to base latitude temperature)
[JsonPropertyName("temp_modifier")]
public float TempModifier { get; set; } = 0f;
}
/// <summary>Root structure of macro_template.json.</summary>
public sealed class MacroTemplate
{
[JsonPropertyName("width")]
public int Width { get; set; } = C.MACRO_GRID_WIDTH;
[JsonPropertyName("height")]
public int Height { get; set; } = C.MACRO_GRID_HEIGHT;
[JsonPropertyName("default_cell")]
public MacroCell DefaultCell { get; set; } = new();
[JsonPropertyName("regions")]
public MacroRegion[] Regions { get; set; } = Array.Empty<MacroRegion>();
/// <summary>Expand regions into a flat [width, height] grid. Later regions overwrite earlier ones.</summary>
public MacroCell[,] Build()
{
var grid = new MacroCell[Width, Height];
// Fill with default
for (int y = 0; y < Height; y++)
for (int x = 0; x < Width; x++)
grid[x, y] = DefaultCell;
// Paint regions in order (later regions win)
foreach (var r in Regions)
{
var cell = r.ToCell();
int x1 = Math.Min(r.X + r.W, Width);
int y1 = Math.Min(r.Y + r.H, Height);
for (int y = Math.Max(0, r.Y); y < y1; y++)
for (int x = Math.Max(0, r.X); x < x1; x++)
grid[x, y] = cell;
}
return grid;
}
}
/// <summary>A rectangular block in the macro template, painted over the default.</summary>
public sealed class MacroRegion
{
[JsonPropertyName("x")] public int X { get; set; }
[JsonPropertyName("y")] public int Y { get; set; }
[JsonPropertyName("w")] public int W { get; set; }
[JsonPropertyName("h")] public int H { get; set; }
/// <summary>Human-readable annotation in the JSON file — ignored at runtime.</summary>
[JsonPropertyName("comment")] public string? Comment { get; set; }
[JsonPropertyName("biome_type")] public string BiomeType { get; set; } = "temperate_grassland";
[JsonPropertyName("clade_affinities")] public string[] CladeAffinities { get; set; } = Array.Empty<string>();
[JsonPropertyName("development")] public string Development { get; set; } = "agricultural";
[JsonPropertyName("covenant")] public string Covenant { get; set; } = "moderate";
[JsonPropertyName("elevation_floor")] public float ElevationFloor { get; set; } = 0f;
[JsonPropertyName("elevation_ceiling")]public float ElevationCeiling { get; set; } = 1f;
[JsonPropertyName("moisture_floor")] public float MoistureFloor { get; set; } = 0f;
[JsonPropertyName("moisture_ceiling")] public float MoistureCeiling { get; set; } = 1f;
[JsonPropertyName("temp_modifier")] public float TempModifier { get; set; } = 0f;
public MacroCell ToCell() => new()
{
BiomeType = BiomeType,
CladeAffinities = CladeAffinities,
Development = Development,
Covenant = Covenant,
ElevationFloor = ElevationFloor,
ElevationCeiling = ElevationCeiling,
MoistureFloor = MoistureFloor,
MoistureCeiling = MoistureCeiling,
TempModifier = TempModifier,
};
}
+121
View File
@@ -0,0 +1,121 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Immutable NPC stat-block template loaded from npc_templates.json.
/// Phase 5 instantiates one per <see cref="Tactical.SpawnKind"/> per
/// chunk, with the actual template id chosen via the per-zone lookup
/// table on <see cref="NpcTemplateContent.SpawnKindToTemplateByZone"/>.
/// </summary>
public sealed record NpcTemplateDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("name")]
public string Name { get; init; } = "";
/// <summary>Body size category, snake_case (small / medium / medium_large / large).</summary>
[JsonPropertyName("size")]
public string Size { get; init; } = "medium";
/// <summary>STR/DEX/CON/INT/WIS/CHA absolute values (10 = average).</summary>
[JsonPropertyName("ability_scores")]
public Dictionary<string, int> AbilityScores { get; init; } = new();
[JsonPropertyName("hp")]
public int Hp { get; init; } = 1;
[JsonPropertyName("ac")]
public int Ac { get; init; } = 10;
[JsonPropertyName("speed_ft")]
public int SpeedFt { get; init; } = 30;
[JsonPropertyName("attacks")]
public NpcAttack[] Attacks { get; init; } = Array.Empty<NpcAttack>();
/// <summary>Behavior id ("brigand", "wild_animal", "poi_guard"). Maps to <c>INpcBehavior</c> in Phase 5 M5.</summary>
[JsonPropertyName("behavior")]
public string Behavior { get; init; } = "brigand";
/// <summary>Starts as Hostile / Neutral / Friendly. Phase 5 reads this on instantiation.</summary>
[JsonPropertyName("default_allegiance")]
public string DefaultAllegiance { get; init; } = "hostile";
/// <summary>
/// Phase 6 M5 — faction id this NPC owes allegiance to (matches
/// FactionDef.Id). Empty for unaligned templates (wild animals,
/// brigands). Drives M5 patrol-aggression: a non-hostile NPC with a
/// faction flips to Hostile when the player's local standing with
/// that faction crosses the HOSTILE threshold.
/// </summary>
[JsonPropertyName("faction")]
public string Faction { get; init; } = "";
/// <summary>Loot table id (Phase 5 ships ~5; lookup deferred to M6).</summary>
[JsonPropertyName("loot_table")]
public string LootTable { get; init; } = "";
[JsonPropertyName("xp_award")]
public int XpAward { get; init; } = 0;
}
public sealed record NpcAttack
{
[JsonPropertyName("name")]
public string Name { get; init; } = "";
[JsonPropertyName("to_hit")]
public int ToHit { get; init; } = 0;
/// <summary>Damage dice expression (e.g. "1d6+2", "2d8").</summary>
[JsonPropertyName("damage")]
public string Damage { get; init; } = "";
[JsonPropertyName("damage_type")]
public string DamageType { get; init; } = "bludgeoning";
[JsonPropertyName("reach_tiles")]
public int ReachTiles { get; init; } = 1;
[JsonPropertyName("range_short_tiles")]
public int RangeShortTiles { get; init; } = 0;
[JsonPropertyName("range_long_tiles")]
public int RangeLongTiles { get; init; } = 0;
}
/// <summary>
/// Top-level wrapper for npc_templates.json: the template list plus the
/// per-spawnkind, per-zone template-id lookup table.
/// </summary>
public sealed record NpcTemplateContent
{
[JsonPropertyName("templates")]
public NpcTemplateDef[] Templates { get; init; } = Array.Empty<NpcTemplateDef>();
/// <summary>
/// SpawnKind name (e.g. "Brigand") → array of template ids indexed by
/// DangerZone (0..4). Length should equal <c>C.DANGER_ZONE_MAX + 1</c>.
/// </summary>
[JsonPropertyName("spawn_kind_to_template_by_zone")]
public Dictionary<string, string[]> SpawnKindToTemplateByZone { get; init; } = new();
/// <summary>
/// Phase 7 M2 — per-dungeon-type override. Resolves a
/// <c>(PoiType, SpawnKind)</c> pair to a single template id, used by
/// <see cref="Theriapolis.Core.Dungeons.DungeonPopulator"/> when filling
/// in-room encounter slots. Per Phase 7 plan §10 open-decision #6:
/// DungeonType supersedes DangerZone entirely once the player is inside
/// a dungeon, so this map's value is a single template id (no zone
/// indexing). Outer key matches the <see cref="World.PoiType"/> enum
/// name (e.g. <c>"ImperiumRuin"</c>); inner key is the spawn-kind name
/// (e.g. <c>"PoiGuard"</c> / <c>"WildAnimal"</c> / <c>"Brigand"</c> /
/// <c>"Boss"</c>). Missing keys fall back to
/// <see cref="SpawnKindToTemplateByZone"/> at <c>DangerZone</c>=2 mid.
/// </summary>
[JsonPropertyName("spawn_kind_to_template_by_dungeon_type")]
public Dictionary<string, Dictionary<string, string>> SpawnKindToTemplateByDungeonType { get; init; } = new();
}
+177
View File
@@ -0,0 +1,177 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Phase 6 M4 — JSON-loaded quest definition. A quest is a directed graph
/// of steps; the engine starts at <see cref="EntryStep"/>, evaluates each
/// active step's <see cref="QuestStepDef.TriggerConditions"/> per tick,
/// and runs <see cref="QuestStepDef.OnEnter"/> + <see cref="QuestStepDef.Outcomes"/>
/// when the step fires.
///
/// Author convention: one tree per file in
/// <c>Content/Data/quests/*.json</c>. <see cref="Id"/> matches the
/// filename. Step ids are strings local to the tree.
/// </summary>
public sealed record QuestDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("title")]
public string Title { get; init; } = "";
[JsonPropertyName("description")]
public string Description { get; init; } = "";
/// <summary>True when the quest doesn't appear in the journal until first activation (Maw discovery, etc.).</summary>
[JsonPropertyName("hidden")]
public bool Hidden { get; init; } = false;
/// <summary>Step id the engine activates on quest start.</summary>
[JsonPropertyName("entry_step")]
public string EntryStep { get; init; } = "";
[JsonPropertyName("steps")]
public QuestStepDef[] Steps { get; init; } = System.Array.Empty<QuestStepDef>();
/// <summary>
/// Optional: triggers that auto-start this quest. The engine checks
/// these against world state on every tick; when any fires, the quest
/// activates at <see cref="EntryStep"/>. Empty = manual-start (e.g.
/// dialogue's <c>start_quest</c> effect).
/// </summary>
[JsonPropertyName("auto_start_when")]
public QuestConditionDef[] AutoStartWhen { get; init; } = System.Array.Empty<QuestConditionDef>();
}
public sealed record QuestStepDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("title")]
public string Title { get; init; } = "";
[JsonPropertyName("description")]
public string Description { get; init; } = "";
/// <summary>Optional waypoint hint — anchor or role tag the player should head toward.</summary>
[JsonPropertyName("waypoint")]
public string Waypoint { get; init; } = "";
/// <summary>Conditions that fire this step's onEnter + outcomes when ALL true.</summary>
[JsonPropertyName("trigger_conditions")]
public QuestConditionDef[] TriggerConditions { get; init; } = System.Array.Empty<QuestConditionDef>();
/// <summary>Effects applied once when the step fires.</summary>
[JsonPropertyName("on_enter")]
public QuestEffectDef[] OnEnter { get; init; } = System.Array.Empty<QuestEffectDef>();
/// <summary>Step ids this step transitions into (any one is selected via outcome conditions).</summary>
[JsonPropertyName("outcomes")]
public QuestOutcomeDef[] Outcomes { get; init; } = System.Array.Empty<QuestOutcomeDef>();
/// <summary>True if reaching this step completes the quest (success).</summary>
[JsonPropertyName("completes_quest")]
public bool CompletesQuest { get; init; } = false;
/// <summary>True if reaching this step fails the quest.</summary>
[JsonPropertyName("fails_quest")]
public bool FailsQuest { get; init; } = false;
}
public sealed record QuestOutcomeDef
{
/// <summary>Step id to transition to. <c>"&lt;end&gt;"</c> closes the quest.</summary>
[JsonPropertyName("next")]
public string Next { get; init; } = "";
/// <summary>Conditions for THIS outcome to be selected. Empty = always.</summary>
[JsonPropertyName("when")]
public QuestConditionDef[] When { get; init; } = System.Array.Empty<QuestConditionDef>();
[JsonPropertyName("effects")]
public QuestEffectDef[] Effects { get; init; } = System.Array.Empty<QuestEffectDef>();
}
/// <summary>Trigger / outcome predicate.</summary>
public sealed record QuestConditionDef
{
/// <summary>
/// One of: "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".
/// </summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = "";
[JsonPropertyName("flag")]
public string Flag { get; init; } = "";
[JsonPropertyName("anchor")]
public string Anchor { get; init; } = "";
[JsonPropertyName("role")]
public string Role { get; init; } = "";
[JsonPropertyName("npc")]
public string Npc { get; init; } = "";
[JsonPropertyName("faction")]
public string Faction { get; init; } = "";
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("quest")]
public string Quest { get; init; } = "";
[JsonPropertyName("value")]
public int Value { get; init; }
[JsonPropertyName("seconds")]
public long Seconds { get; init; }
}
/// <summary>Quest-step side effect.</summary>
public sealed record QuestEffectDef
{
/// <summary>
/// One of: "set_flag", "clear_flag", "give_item", "take_item",
/// "give_xp", "rep_event", "spawn_npc", "despawn_npc",
/// "start_quest", "end_quest", "fail_quest".
/// </summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = "";
[JsonPropertyName("flag")]
public string Flag { get; init; } = "";
[JsonPropertyName("value")]
public int Value { get; init; } = 1;
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("qty")]
public int Qty { get; init; } = 1;
[JsonPropertyName("xp")]
public int Xp { get; init; }
[JsonPropertyName("event")]
public DialogueRepEventDef? Event { get; init; }
[JsonPropertyName("quest")]
public string Quest { get; init; } = "";
/// <summary>For spawn_npc: resident template id (named takes precedence).</summary>
[JsonPropertyName("template")]
public string Template { get; init; } = "";
/// <summary>For spawn_npc/despawn_npc: which named role tag is being mutated.</summary>
[JsonPropertyName("role")]
public string Role { get; init; } = "";
}
@@ -0,0 +1,83 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Phase 6 M1 — definition of a friendly/neutral resident NPC inhabiting a
/// settlement. Two flavours, both loaded from <c>resident_templates.json</c>:
///
/// 1. **Generic** (<c>Named == false</c>) — matches by <see cref="RoleTag"/>
/// prefix. <see cref="ResidentInstantiator"/> picks the highest-weight
/// generic whose RoleTag equals the building-role tag (e.g. "innkeeper",
/// "shopkeeper", "guard").
///
/// 2. **Named** (<c>Named == true</c>) — matches by exact <see cref="RoleTag"/>
/// (e.g. "millhaven.innkeeper"). Used when a settlement preset's
/// <c>role_overrides</c> qualifies a building role with a specific
/// anchor-prefixed tag, locking that NPC to a hand-authored species,
/// name, and bias profile.
///
/// Combat stats are minimal in M1 — residents are non-combatants by
/// default. They have a token <see cref="Hp"/>/<see cref="Ac"/> in case the
/// player attacks them; engagement promotes them to a derived combatant
/// using the existing <see cref="NpcTemplateDef"/>-style stat block.
/// </summary>
public sealed record ResidentTemplateDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
/// <summary>
/// The role tag this template matches. Generic templates use bare
/// occupations ("innkeeper"); named templates use anchor-prefixed
/// "settlement.role" ids ("millhaven.innkeeper").
/// </summary>
[JsonPropertyName("role_tag")]
public string RoleTag { get; init; } = "";
/// <summary>True when this template is hand-authored for a specific named NPC. Always wins over generic when role_tag matches.</summary>
[JsonPropertyName("named")]
public bool Named { get; init; } = false;
/// <summary>Display name shown in dialogue + tooltip. Empty for generics → resolved from <see cref="RoleTag"/>.</summary>
[JsonPropertyName("name")]
public string Name { get; init; } = "";
/// <summary>Clade id (e.g. "canidae"). Required for named templates; generics may leave empty to roll from settlement biome.</summary>
[JsonPropertyName("clade")]
public string Clade { get; init; } = "";
/// <summary>Species id (e.g. "wolf"). Required for named; generics roll from clade.</summary>
[JsonPropertyName("species")]
public string Species { get; init; } = "";
/// <summary>Bias profile id this NPC carries (matches BiasProfileDef.Id).</summary>
[JsonPropertyName("bias_profile")]
public string BiasProfile { get; init; } = "URBAN_PROGRESSIVE";
/// <summary>Faction affiliation id (matches FactionDef.Id), or empty for unaffiliated.</summary>
[JsonPropertyName("faction")]
public string Faction { get; init; } = "";
/// <summary>Dialogue tree id (matches dialogues/*.json id). Empty → fall back to a generic-by-role placeholder.</summary>
[JsonPropertyName("dialogue")]
public string Dialogue { get; init; } = "";
/// <summary>"friendly" or "neutral". Defaults to friendly. Hostile residents go through the npc_templates path instead.</summary>
[JsonPropertyName("default_allegiance")]
public string DefaultAllegiance { get; init; } = "friendly";
/// <summary>Stat block for combat fallback. Defaults are commoner-ish (HP 8, AC 10).</summary>
[JsonPropertyName("hp")]
public int Hp { get; init; } = 8;
[JsonPropertyName("ac")]
public int Ac { get; init; } = 10;
[JsonPropertyName("ability_scores")]
public Dictionary<string, int> AbilityScores { get; init; } = new();
/// <summary>Selection weight when multiple generic templates match the same role tag.</summary>
[JsonPropertyName("weight")]
public float Weight { get; init; } = 1f;
}
+223
View File
@@ -0,0 +1,223 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Phase 7 M0 — a single hand-authored dungeon room. Loaded from
/// <c>Content/Data/room_templates/&lt;type&gt;/*.json</c>.
///
/// A template describes:
/// - The room's footprint in tactical tiles (perimeter walls included).
/// - The 2D ASCII <see cref="Grid"/>: one char per tactical tile.
/// Legend (per Phase 7 plan §5.1):
/// <c>#</c> wall, <c>.</c> floor, <c>,</c> rubble, <c>D</c> door slot,
/// <c>@</c> encounter slot, <c>C</c> container slot, <c>T</c> trap slot,
/// <c>P</c> pillar, <c>B</c> brazier, <c>M</c> mosaic (narrative),
/// <c>S</c> stairs (entry/exit only).
/// - Door positions on the perimeter (one entry per <c>D</c> in the grid).
/// - Encounter / container / trap slot positions (which the dungeon
/// populator fills with NPCs and loot).
/// - Optional narrative text surfaced by Scent Literacy / room-clear coda.
/// - The clade that <see cref="BuiltBy"/> the room — drives the
/// clade-responsive movement multiplier (Phase 7 plan §5.4).
///
/// Templates are designer-friendly to author: edit ASCII art + a couple
/// of metadata blocks. <see cref="ContentLoader.LoadRoomTemplates"/>
/// validates grid dimensions vs declared footprint, perimeter walls,
/// and that every <c>D</c>/<c>@</c>/<c>C</c>/<c>T</c> in the grid has
/// a matching slot record.
/// </summary>
public sealed record RoomTemplateDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("name")]
public string Name { get; init; } = "";
/// <summary>
/// Dungeon type the template belongs to: <c>imperium</c>, <c>mine</c>,
/// <c>cult</c>, <c>cave</c>, <c>overgrown</c>. Layout matchers filter
/// templates by type when assembling a dungeon.
/// </summary>
[JsonPropertyName("type")]
public string Type { get; init; } = "";
/// <summary>
/// Clade that built or originally inhabited the room. Drives Phase 7
/// clade-responsive movement (a Large PC in a Mustelid tunnel takes 2×
/// movement points). Allowed: <c>canid</c>, <c>felid</c>, <c>mustelid</c>,
/// <c>ursid</c>, <c>cervid</c>, <c>bovid</c>, <c>leporid</c>,
/// <c>imperium</c>, <c>none</c>. Phase-7 Imperium templates use
/// <c>imperium</c>; templates that would be at home in any dungeon use
/// <c>none</c>.
/// </summary>
[JsonPropertyName("built_by")]
public string BuiltBy { get; init; } = "none";
/// <summary>
/// Size class — <c>small</c>, <c>medium</c>, <c>large</c>. Used by
/// the layout matcher to pick room mixes appropriate to the dungeon's
/// size band (small dungeons prefer small rooms, etc.).
/// </summary>
[JsonPropertyName("size_class")]
public string SizeClass { get; init; } = "medium";
/// <summary>
/// Roles this template is eligible for: <c>entry</c>, <c>transit</c>,
/// <c>narrative</c>, <c>loot</c>, <c>boss</c>, <c>dead-end</c>. A
/// template can be eligible for multiple roles (a "pillar room" can
/// serve as transit OR as a loot stash).
/// </summary>
[JsonPropertyName("roles_eligible")]
public string[] RolesEligible { get; init; } = Array.Empty<string>();
/// <summary>Footprint width in tactical tiles. Includes perimeter walls. Must equal Grid[*].Length.</summary>
[JsonPropertyName("footprint_w_tiles")]
public int FootprintWTiles { get; init; } = 1;
/// <summary>Footprint height in tactical tiles. Includes perimeter walls. Must equal Grid.Length.</summary>
[JsonPropertyName("footprint_h_tiles")]
public int FootprintHTiles { get; init; } = 1;
/// <summary>
/// 2D ASCII art: one entry per row, one char per tactical tile.
/// Validated for perimeter wall completeness and slot-coordinate
/// matches at content-load time.
/// </summary>
[JsonPropertyName("grid")]
public string[] Grid { get; init; } = Array.Empty<string>();
/// <summary>Door positions in template-local coords (matches <c>D</c> chars in <see cref="Grid"/>).</summary>
[JsonPropertyName("doors")]
public RoomDoor[] Doors { get; init; } = Array.Empty<RoomDoor>();
/// <summary>Encounter slot positions (matches <c>@</c> chars).</summary>
[JsonPropertyName("encounter_slots")]
public RoomEncounterSlot[] EncounterSlots { get; init; } = Array.Empty<RoomEncounterSlot>();
/// <summary>Container slot positions (matches <c>C</c> chars).</summary>
[JsonPropertyName("container_slots")]
public RoomContainerSlot[] ContainerSlots { get; init; } = Array.Empty<RoomContainerSlot>();
/// <summary>Trap slot positions (matches <c>T</c> chars). Phase 7 ships only tripwire traps.</summary>
[JsonPropertyName("trap_slots")]
public RoomTrapSlot[] TrapSlots { get; init; } = Array.Empty<RoomTrapSlot>();
/// <summary>Decoration placements for non-slot decos (P pillar, B brazier, M mosaic).</summary>
[JsonPropertyName("decos")]
public RoomDecoPlacement[] Decos { get; init; } = Array.Empty<RoomDecoPlacement>();
/// <summary>
/// Environmental-story prose surfaced by Scent Literacy (Phase 6.5 M1)
/// in the InteractionScreen scent-overlay panel and by the dungeon-
/// clear coda. Null/empty for non-narrative templates.
/// </summary>
[JsonPropertyName("narrative_text")]
public string? NarrativeText { get; init; } = null;
/// <summary>Selection weight in layout assembly. Default 1.0.</summary>
[JsonPropertyName("weight")]
public float Weight { get; init; } = 1f;
}
public sealed record RoomDoor
{
[JsonPropertyName("x")]
public int X { get; init; }
[JsonPropertyName("y")]
public int Y { get; init; }
/// <summary>Compass facing: "N" / "E" / "S" / "W". Door always sits on a perimeter cell.</summary>
[JsonPropertyName("facing")]
public string Facing { get; init; } = "S";
/// <summary>
/// Optional lock difficulty for this door. Empty = unlocked. Allowed:
/// <c>trivial</c>, <c>easy</c>, <c>medium</c>, <c>hard</c> — mapped to
/// <c>LOCK_DC_*</c> constants in code.
/// </summary>
[JsonPropertyName("lock")]
public string Lock { get; init; } = "";
}
public sealed record RoomEncounterSlot
{
[JsonPropertyName("x")]
public int X { get; init; }
[JsonPropertyName("y")]
public int Y { get; init; }
/// <summary>
/// Spawn kind: <c>PoiGuard</c> / <c>WildAnimal</c> / <c>Brigand</c> /
/// <c>Boss</c>. Resolved against <c>npc_templates.json</c>'s
/// per-dungeon-type spawn-kind override map at populate time.
/// </summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = "PoiGuard";
/// <summary>Likelihood the slot fires when a layout calls for variability. 1.0 = always.</summary>
[JsonPropertyName("weight")]
public float Weight { get; init; } = 1f;
}
public sealed record RoomContainerSlot
{
[JsonPropertyName("x")]
public int X { get; init; }
[JsonPropertyName("y")]
public int Y { get; init; }
/// <summary>
/// Loot-table band: <c>t1</c> / <c>t2</c> / <c>t3</c>. The dungeon's
/// layout maps a band to a real loot-table id at populate time
/// (<see cref="DungeonLayoutDef.LootTablePerBand"/>).
/// </summary>
[JsonPropertyName("loot_table_band")]
public string LootTableBand { get; init; } = "t1";
/// <summary>True when the container is locked (key required, or STR/DEX check).</summary>
[JsonPropertyName("locked")]
public bool Locked { get; init; } = false;
/// <summary>Optional lock difficulty if <see cref="Locked"/>: trivial/easy/medium/hard.</summary>
[JsonPropertyName("lock")]
public string Lock { get; init; } = "";
}
public sealed record RoomTrapSlot
{
[JsonPropertyName("x")]
public int X { get; init; }
[JsonPropertyName("y")]
public int Y { get; init; }
/// <summary>Trap kind. Phase 7 ships only <c>tripwire</c>.</summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = "tripwire";
/// <summary>Disarm DC tier: <c>trivial</c>, <c>easy</c>, <c>medium</c>.</summary>
[JsonPropertyName("disarm_dc")]
public string DisarmDc { get; init; } = "easy";
}
public sealed record RoomDecoPlacement
{
[JsonPropertyName("x")]
public int X { get; init; }
[JsonPropertyName("y")]
public int Y { get; init; }
/// <summary>
/// Deco kind name. Allowed: <c>pillar</c>, <c>brazier</c>, <c>mosaic</c>,
/// <c>imperium_statue</c>. Trap / container / door / stairs decos are
/// declared via their respective slot collections, not here.
/// </summary>
[JsonPropertyName("deco")]
public string Deco { get; init; } = "";
}
@@ -0,0 +1,42 @@
namespace Theriapolis.Core.Data;
/// <summary>
/// Phase 6 M0 — bundle of all settlement-stamp content needed to drive
/// <see cref="World.Settlements.SettlementStamper"/>. Held by
/// <see cref="ContentResolver"/>; passed into <see cref="Tactical.TacticalChunkGen"/>
/// as an optional argument so headless tools that don't need full content
/// (e.g. worldgen-dump) can run without it.
/// </summary>
public sealed class SettlementContent
{
public IReadOnlyDictionary<string, BuildingTemplateDef> Buildings { get; }
public IReadOnlyDictionary<string, SettlementLayoutDef> PresetByAnchor { get; }
/// <summary>Tier 1..5 → procedural layout (or null if no procedural layout for that tier).</summary>
public IReadOnlyDictionary<int, SettlementLayoutDef> ProceduralByTier { get; }
public SettlementContent(
IReadOnlyDictionary<string, BuildingTemplateDef> buildings,
IReadOnlyDictionary<string, SettlementLayoutDef> presetByAnchor,
IReadOnlyDictionary<int, SettlementLayoutDef> proceduralByTier)
{
Buildings = buildings;
PresetByAnchor = presetByAnchor;
ProceduralByTier = proceduralByTier;
}
/// <summary>
/// Look up the layout to use for a given settlement, preferring the
/// hand-authored preset for its anchor (if any) and falling back to the
/// procedural layout for its tier.
/// </summary>
public SettlementLayoutDef? ResolveFor(World.Settlement settlement)
{
if (settlement.Anchor is { } anchor &&
PresetByAnchor.TryGetValue(anchor.ToString(), out var preset))
return preset;
if (ProceduralByTier.TryGetValue(settlement.Tier, out var proc))
return proc;
return null;
}
}
@@ -0,0 +1,90 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Phase 6 M0 — describes how to lay buildings inside a settlement footprint.
///
/// Two flavours:
/// 1. **Hand-authored preset** — `kind == "preset"`. The <see cref="Buildings"/>
/// array specifies each building by template id and offset from the
/// settlement centre. Used for narrative anchors (Millhaven, Thornfield).
/// 2. **Procedural rule-based** — `kind == "procedural"`. The <see cref="Roles"/>
/// array specifies a mix of category weights ("inn" 0.1, "shop" 0.3,
/// "house" 0.6) and a target building count; <see cref="World.Settlements.SettlementStamper"/>
/// rolls templates from the matching categories until the target count is
/// met or no more building slots fit inside the plaza radius.
///
/// Bound to a settlement either by anchor name (preset) or by tier
/// (procedural fallback for any non-anchor settlement of that tier).
/// </summary>
public sealed record SettlementLayoutDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
/// <summary>"preset" or "procedural".</summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = "procedural";
/// <summary>For preset layouts: matches Settlement.Anchor.ToString() (e.g. "Millhaven").</summary>
[JsonPropertyName("anchor")]
public string Anchor { get; init; } = "";
/// <summary>For procedural layouts: matches Settlement.Tier (15).</summary>
[JsonPropertyName("tier")]
public int Tier { get; init; } = 0;
// ── Preset payload ─────────────────────────────────────────────────────
/// <summary>Building placements for preset layouts. Ignored when kind == "procedural".</summary>
[JsonPropertyName("buildings")]
public SettlementBuildingPlacement[] Buildings { get; init; } = Array.Empty<SettlementBuildingPlacement>();
// ── Procedural payload ─────────────────────────────────────────────────
/// <summary>Category mix for procedural layouts. Ignored when kind == "preset".</summary>
[JsonPropertyName("category_weights")]
public Dictionary<string, float> CategoryWeights { get; init; } = new();
/// <summary>Target building count for procedural layouts.</summary>
[JsonPropertyName("target_building_count")]
public int TargetBuildingCount { get; init; } = 5;
/// <summary>
/// Plaza radius in tactical tiles to search for building slots (procedural).
/// If 0, the stamper picks one based on tier.
/// </summary>
[JsonPropertyName("plaza_radius_tiles")]
public int PlazaRadiusTiles { get; init; } = 0;
}
public sealed record SettlementBuildingPlacement
{
/// <summary>BuildingTemplateDef.Id to stamp.</summary>
[JsonPropertyName("template")]
public string Template { get; init; } = "";
/// <summary>
/// Offset from settlement centre in tactical tiles. (0,0) places the
/// building's centre on the settlement centre tile.
/// </summary>
[JsonPropertyName("offset")]
public int[] Offset { get; init; } = new[] { 0, 0 };
/// <summary>
/// Optional rotation: 0 / 90 / 180 / 270. Phase 6 M0 ignores; reserved
/// so layouts don't have to be re-authored when rotation lands.
/// </summary>
[JsonPropertyName("rotation_deg")]
public int RotationDeg { get; init; } = 0;
/// <summary>
/// Override the role tag for one or more roles in this building. E.g.
/// the innkeeper template has role "innkeeper"; this preset assigns
/// it the named role "millhaven.innkeeper" so quest scripts can
/// reference the specific NPC.
/// </summary>
[JsonPropertyName("role_overrides")]
public Dictionary<string, string> RoleOverrides { get; init; } = new();
}
+38
View File
@@ -0,0 +1,38 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Immutable species (subrace-equivalent) record loaded from species.json.
/// Refines the parent <see cref="CladeDef"/>: adds a body size, additional
/// ability mods, species-specific traits, and species-specific detriments.
/// </summary>
public sealed record SpeciesDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("clade_id")]
public string CladeId { get; init; } = "";
[JsonPropertyName("name")]
public string Name { get; init; } = "";
/// <summary>Body size category, snake_case (small / medium / medium_large / large).</summary>
[JsonPropertyName("size")]
public string Size { get; init; } = "medium";
/// <summary>Additional ability mods on top of the clade's mods.</summary>
[JsonPropertyName("ability_mods")]
public Dictionary<string, int> AbilityMods { get; init; } = new();
/// <summary>Base movement speed in feet per turn (5 ft. = 1 tactical tile per d20 standard).</summary>
[JsonPropertyName("base_speed_ft")]
public int BaseSpeedFt { get; init; } = 30;
[JsonPropertyName("traits")]
public TraitDef[] Traits { get; init; } = Array.Empty<TraitDef>();
[JsonPropertyName("detriments")]
public TraitDef[] Detriments { get; init; } = Array.Empty<TraitDef>();
}
+42
View File
@@ -0,0 +1,42 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Immutable subclass definition loaded from subclasses.json. Phase 5
/// stores these but does not apply mechanics — subclass selection is the
/// level-3 flow that ships with Phase 5.5 / 6 leveling. Loaded so
/// <c>ContentValidate</c> can verify referential integrity from
/// <see cref="ClassDef.SubclassIds"/>.
/// </summary>
public sealed record SubclassDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("class_id")]
public string ClassId { get; init; } = "";
[JsonPropertyName("name")]
public string Name { get; init; } = "";
[JsonPropertyName("flavor")]
public string Flavor { get; init; } = "";
/// <summary>Level → feature ids unlocked. Same shape as the class table.</summary>
[JsonPropertyName("level_features")]
public SubclassLevelEntry[] LevelFeatures { get; init; } = Array.Empty<SubclassLevelEntry>();
/// <summary>Subclass-specific feature descriptions, keyed by feature id.</summary>
[JsonPropertyName("feature_definitions")]
public Dictionary<string, ClassFeatureDef> FeatureDefinitions { get; init; } = new();
}
public sealed record SubclassLevelEntry
{
[JsonPropertyName("level")]
public int Level { get; init; } = 3;
[JsonPropertyName("features")]
public string[] Features { get; init; } = Array.Empty<string>();
}
+21
View File
@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Trait or detriment entry shared by clades, species, and class features.
/// Phase 5 mostly stores these as descriptive text — only a handful have
/// real runtime mechanics (level-1 combat-touching features). The rest
/// surface as flavor in tooltips and the character sheet UI.
/// </summary>
public sealed record TraitDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("name")]
public string Name { get; init; } = "";
[JsonPropertyName("description")]
public string Description { get; init; } = "";
}