Initial commit: Theriapolis baseline at port/godot branch point

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -0,0 +1,29 @@
namespace Theriapolis.Core.Rules.Character;
/// <summary>
/// Whose side an actor is on at the moment. Drives encounter-trigger logic:
/// hostile → auto-trigger combat on LOS; friendly/neutral → "[F] Talk to ..."
/// prompt. Phase 5 sets this from <see cref="Data.NpcTemplateDef.DefaultAllegiance"/>;
/// faction logic in Phase 6 may mutate it at runtime.
/// </summary>
public enum Allegiance : byte
{
Player = 0,
Allied = 1,
Neutral = 2,
Friendly = 3,
Hostile = 4,
}
public static class AllegianceExtensions
{
public static Allegiance FromJson(string raw) => raw.ToLowerInvariant() switch
{
"player" => Allegiance.Player,
"allied" => Allegiance.Allied,
"neutral" => Allegiance.Neutral,
"friendly" => Allegiance.Friendly,
"hostile" => Allegiance.Hostile,
_ => throw new ArgumentException($"Unknown allegiance: '{raw}'"),
};
}
@@ -0,0 +1,284 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Rules.Character;
/// <summary>
/// Runtime aggregate of everything that makes a creature a *character* —
/// stats, class, clade, species, background, inventory, HP, conditions, level,
/// XP. Composed onto an <see cref="Entities.Actor"/> via the
/// <c>Actor.Character</c> field; the actor handles position and rendering,
/// the character handles gameplay state.
///
/// Phase 5 M2 builds these via <see cref="CharacterBuilder"/> at character
/// creation. Saved snapshots round-trip through
/// <see cref="Persistence.PlayerCharacterState"/>.
/// </summary>
public sealed class Character
{
public CladeDef Clade { get; }
public SpeciesDef Species { get; }
public ClassDef ClassDef { get; }
public BackgroundDef Background { get; }
public AbilityScores Abilities { get; private set; }
public int Level { get; set; } = 1;
public int Xp { get; set; } = 0;
public int MaxHp { get; set; }
public int CurrentHp { get; set; }
/// <summary>
/// Phase 6.5 M0 — subclass id, set when the level-3 selection is made.
/// Empty pre-L3; references one of <see cref="ClassDef.SubclassIds"/> after.
/// Loaded by save round-trip.
/// </summary>
public string SubclassId { get; set; } = "";
/// <summary>
/// Phase 6.5 M0 — feature ids learned across all level-ups, in unlock
/// order. Includes level-1 features applied by <see cref="CharacterBuilder"/>
/// plus everything <see cref="ApplyLevelUp"/> appends. The
/// <see cref="FeatureProcessor"/> consults this list when resolving combat
/// effects, dialogue hooks, etc.
/// </summary>
public List<string> LearnedFeatureIds { get; } = new();
/// <summary>
/// Phase 6.5 M0 — append-only history of per-level deltas. Used by
/// the level-up screen for display and by save round-trip for
/// reproducibility. Index = level - 1 (so [0] is the level-1 entry,
/// [1] is the level-2 entry, etc.); element 0 is synthesized at
/// character creation.
/// </summary>
public List<LevelUpRecord> LevelUpHistory { get; } = new();
/// <summary>
/// Phase 6.5 M4 — hybrid-character state. Null for purebred PCs
/// (the common case); non-null indicates the PC was built via
/// <see cref="CharacterBuilder.TryBuildHybrid"/>. Universal hybrid
/// detriments (Scent Dysphoria, Social Stigma, Illegible Body
/// Language, Medical Incompatibility) are inherent and applied via
/// <see cref="HybridDetriments"/> at use sites.
/// </summary>
public HybridState? Hybrid { get; set; }
/// <summary>True when the PC is a hybrid (i.e. <see cref="Hybrid"/> non-null).</summary>
public bool IsHybrid => Hybrid is not null;
/// <summary>Skill proficiencies — class skills + background skills, deduplicated.</summary>
public HashSet<SkillId> SkillProficiencies { get; } = new();
public Inventory Inventory { get; } = new();
/// <summary>Conditions currently affecting the character. Phase 5 M5 wires durations.</summary>
public HashSet<Condition> Conditions { get; } = new();
/// <summary>Exhaustion level 0..6, separate from <see cref="Conditions"/> (binary flags).</summary>
public int ExhaustionLevel { get; set; } = 0;
/// <summary>
/// Phase 5 M6: chosen Fangsworn fighting style ("duelist", "great_weapon",
/// "shieldwall", "fang_and_blade", "natural_predator"). Empty for non-Fangsworn
/// or when not yet picked. Defaults to "duelist" via CharacterBuilder.
/// </summary>
public string FightingStyle { get; set; } = "";
/// <summary>Phase 5 M6: Feral Rage uses remaining (refills on long rest; M6 treats as per-encounter).</summary>
public int RageUsesRemaining { get; set; } = 2;
// ── Phase 6.5 M1: per-encounter resource pools ────────────────────────
/// <summary>
/// Muzzle-Speaker Vocalization Dice — uses remaining (long-rest, 4 default
/// at level 1). Refilled per encounter at M1 since the rest model lives
/// in Phase 8.
/// </summary>
public int VocalizationDiceRemaining { get; set; } = 4;
/// <summary>
/// Covenant-Keeper Lay on Paws — HP pool remaining. Recharges to
/// <c>5 × CHA</c> on long rest; M1 refills per encounter.
/// </summary>
public int LayOnPawsPoolRemaining { get; set; }
/// <summary>
/// Claw-Wright Field Repair — uses remaining. Once per short rest at L1
/// per the JSON; M1 treats as 1 per encounter.
/// </summary>
public int FieldRepairUsesRemaining { get; set; } = 1;
// ── Phase 6.5 M3: ability-stream resource pools ──────────────────────
/// <summary>
/// Scent-Broker Pheromone Craft — uses remaining. The JSON ladder
/// (<c>pheromone_craft_2/3/4/5</c> at L2/L5/L9/L13) sets the per-rest
/// cap; <see cref="Combat.FeatureProcessor.EnsurePheromoneUsesReady"/>
/// tops the pool up to that cap at encounter start.
/// </summary>
public int PheromoneUsesRemaining { get; set; }
/// <summary>
/// Covenant-Keeper Covenant's Authority — uses remaining. The JSON
/// ladder (<c>covenants_authority_2/3/4/5</c> at L2/L9/L13/L17) sets
/// the cap; M3 tops up per encounter.
/// </summary>
public int CovenantAuthorityUsesRemaining { get; set; }
/// <summary>
/// Phase 6 M3 — Fangs (Theriapolis's universal coin). Used by the shop
/// dialogue branch. Defaults to a small starting stipend so the very
/// first merchant interaction has something to buy with.
/// </summary>
public int CurrencyFang { get; set; } = 25;
public bool IsAlive => CurrentHp > 0 || Conditions.Contains(Condition.Unconscious);
public Character(
CladeDef clade,
SpeciesDef species,
ClassDef classDef,
BackgroundDef background,
AbilityScores abilities)
{
Clade = clade ?? throw new ArgumentNullException(nameof(clade));
Species = species ?? throw new ArgumentNullException(nameof(species));
ClassDef = classDef ?? throw new ArgumentNullException(nameof(classDef));
Background = background ?? throw new ArgumentNullException(nameof(background));
Abilities = abilities;
}
/// <summary>Replace ability scores wholesale (used during creation; combat doesn't mutate this).</summary>
public void SetAbilities(AbilityScores scores)
{
Abilities = scores;
}
/// <summary>Body size category, derived from species.</summary>
public SizeCategory Size => SizeExtensions.FromJson(Species.Size);
/// <summary>d20 proficiency bonus for the character's current level.</summary>
public int ProficiencyBonus => Stats.ProficiencyBonus.ForLevel(Level);
/// <summary>
/// Computes max HP from class hit die + CON modifier at level 1 (and
/// avg-rounded-up + CON for each level beyond, per d20 default).
/// Phase 6.5 M0: still useful for character-creation initial HP, but
/// real per-level HP gains come from <see cref="ApplyLevelUp"/>'s
/// <see cref="LevelUpResult.HpGained"/> deltas (which respect the
/// player's roll-vs-average choice and pin to a deterministic seed).
/// </summary>
public int ComputeMaxHpFromScratch()
{
int conMod = Abilities.ModFor(AbilityId.CON);
int hp = ClassDef.HitDie + conMod;
// Levels 2+ add avg-rounded-up + CON each. Phase 5 = level 1 only,
// but the formula stays correct in case external code passes Level > 1.
for (int lv = 2; lv <= Level; lv++)
{
int avgRoundedUp = (ClassDef.HitDie / 2) + 1;
hp += avgRoundedUp + conMod;
}
return Math.Max(1, hp);
}
/// <summary>
/// Phase 6.5 M0 — apply a previously-computed <see cref="LevelUpResult"/>
/// plus the player's <see cref="LevelUpChoices"/> to this character.
/// Mutates Level, MaxHp, CurrentHp, SubclassId, Abilities, and appends
/// the unlocked features to <see cref="LearnedFeatureIds"/>. Records
/// the event in <see cref="LevelUpHistory"/>.
///
/// The caller is responsible for verifying the choices are valid for
/// the result's open slots (subclass selected when GrantsSubclassChoice;
/// ASI sums to +2 when GrantsAsiChoice). Validation lives in
/// <see cref="LevelUpChoicesValidator"/>; this method trusts what it
/// gets so it can be called from tests with bare choices.
/// </summary>
public void ApplyLevelUp(LevelUpResult result, LevelUpChoices choices)
{
if (result is null) throw new ArgumentNullException(nameof(result));
if (choices is null) throw new ArgumentNullException(nameof(choices));
// Apply ASI (if any).
if (result.GrantsAsiChoice && choices.AsiAdjustments.Count > 0)
{
var newAbilities = Abilities;
foreach (var (ability, delta) in choices.AsiAdjustments)
{
int current = newAbilities.Get(ability);
int cap = result.NewLevel >= C.CHARACTER_LEVEL_MAX
? C.ABILITY_SCORE_CAP_AT_L20
: C.ABILITY_SCORE_CAP_PRE_L20;
int next = Math.Min(cap, current + delta);
newAbilities = newAbilities.With(ability, next);
}
Abilities = newAbilities;
}
// Subclass selection (if any).
if (result.GrantsSubclassChoice && !string.IsNullOrEmpty(choices.SubclassId))
{
SubclassId = choices.SubclassId!;
}
// HP. The result's HpGained already incorporates CON mod at compute
// time; if the player took CON via ASI on the same level-up, we use
// the *new* CON for HP gained. Recompute defensively.
int conMod = Abilities.ModFor(AbilityId.CON);
int hpGained;
if (result.HpWasAveraged)
{
int avgRoundedUp = (ClassDef.HitDie / 2) + 1;
hpGained = Math.Max(1, avgRoundedUp + conMod);
}
else
{
// Roll value already determined; but CON might have changed.
hpGained = Math.Max(1, result.HpHitDieResult + conMod);
}
MaxHp += hpGained;
CurrentHp += hpGained; // level-up restores per d20 default
// Learned features.
foreach (var fid in result.ClassFeaturesUnlocked)
LearnedFeatureIds.Add(fid);
foreach (var fid in result.SubclassFeaturesUnlocked)
LearnedFeatureIds.Add(fid);
// Level — last, so all the per-level computations above use the
// pre-level-up Level for any branching they need.
Level = result.NewLevel;
// Record the event.
LevelUpHistory.Add(new LevelUpRecord
{
Level = result.NewLevel,
HpGained = hpGained,
HpWasAveraged = result.HpWasAveraged,
HpHitDieResult = result.HpHitDieResult,
SubclassChosen = result.GrantsSubclassChoice ? choices.SubclassId : null,
AsiAdjustmentsKeys = choices.AsiAdjustments.Keys.Select(k => (byte)k).ToArray(),
AsiAdjustmentsValues = choices.AsiAdjustments.Values.ToArray(),
FeaturesUnlocked = result.ClassFeaturesUnlocked
.Concat(result.SubclassFeaturesUnlocked)
.ToArray(),
});
}
}
/// <summary>
/// Phase 6.5 M0 — one entry in <see cref="Character.LevelUpHistory"/>.
/// Plain-data, serializable. Records the *deltas* (not the post-state), so
/// the history can be replayed on load and the level-up screen can show
/// the player what happened at each level.
/// </summary>
public sealed class LevelUpRecord
{
public int Level { get; init; }
public int HpGained { get; init; }
public bool HpWasAveraged { get; init; }
public int HpHitDieResult { get; init; }
public string? SubclassChosen { get; init; }
public byte[] AsiAdjustmentsKeys { get; init; } = Array.Empty<byte>();
public int[] AsiAdjustmentsValues { get; init; } = Array.Empty<int>();
public string[] FeaturesUnlocked { get; init; } = Array.Empty<string>();
}
@@ -0,0 +1,436 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Character;
/// <summary>
/// Fluent builder for a level-1 <see cref="Character"/>. Used by both the
/// in-game character-creation screen and the headless <c>character-roll</c>
/// Tools command, plus the M2 test suite.
///
/// Pattern: set inputs (clade, species, class, background, base scores,
/// chosen skills, name), then call <see cref="Build"/>. <see cref="Validate"/>
/// returns the first error string when any required input is missing or
/// inconsistent — <see cref="Build"/> calls Validate and throws on failure.
/// </summary>
public sealed class CharacterBuilder
{
public CladeDef? Clade { get; set; }
public SpeciesDef? Species { get; set; }
public ClassDef? ClassDef { get; set; }
public BackgroundDef? Background { get; set; }
/// <summary>Pre-clade-mod base scores (e.g. Standard Array assignment or 4d6 roll outcome).</summary>
public AbilityScores BaseAbilities { get; set; } = new(10, 10, 10, 10, 10, 10);
/// <summary>Class-skill picks. Background skills are added automatically by Build().</summary>
public HashSet<SkillId> ChosenClassSkills { get; } = new();
public string Name { get; set; } = "Wanderer";
/// <summary>
/// Phase 5 M6: Fangsworn fighting style choice. One of "duelist",
/// "great_weapon", "shieldwall", "fang_and_blade", "natural_predator".
/// Empty string defaults to "duelist" if the class is Fangsworn (sensible
/// auto-pick that has visible combat effect at level 1). Ignored for
/// non-Fangsworn classes.
/// </summary>
public string FightingStyle { get; set; } = "";
// ── Phase 6.5 M4: hybrid origin ─────────────────────────────────────
/// <summary>
/// When true, <see cref="TryBuildHybrid"/> is the canonical build path
/// and <see cref="Clade"/> / <see cref="Species"/> are the *dominant*
/// parent's lineage; <see cref="HybridSireClade"/> /
/// <see cref="HybridDamClade"/> populate the secondary parent. Defaults
/// to false (purebred path); the character creation screen flips this
/// when the player ticks the Hybrid checkbox.
/// </summary>
public bool IsHybridOrigin { get; set; } = false;
/// <summary>Sire clade for hybrid origin path (paternal lineage).</summary>
public CladeDef? HybridSireClade { get; set; }
/// <summary>Sire species for hybrid origin path.</summary>
public SpeciesDef? HybridSireSpecies { get; set; }
/// <summary>Dam clade for hybrid origin path (maternal lineage).</summary>
public CladeDef? HybridDamClade { get; set; }
/// <summary>Dam species for hybrid origin path.</summary>
public SpeciesDef? HybridDamSpecies { get; set; }
/// <summary>
/// Which parent's expression dominates. Drives Passing presentation
/// (the PC scent-reads as this lineage's clade). Default is Sire.
/// </summary>
public ParentLineage HybridDominantParent { get; set; } = ParentLineage.Sire;
// ── Builder fluent helpers ──────────────────────────────────────────
public CharacterBuilder WithClade(CladeDef c) { Clade = c; return this; }
public CharacterBuilder WithSpecies(SpeciesDef s) { Species = s; return this; }
public CharacterBuilder WithClass(ClassDef c) { ClassDef = c; return this; }
public CharacterBuilder WithBackground(BackgroundDef b) { Background = b; return this; }
public CharacterBuilder WithAbilities(AbilityScores a) { BaseAbilities = a; return this; }
public CharacterBuilder WithName(string name) { Name = name ?? "Wanderer"; return this; }
public CharacterBuilder ChooseSkill(SkillId s)
{
ChosenClassSkills.Add(s);
return this;
}
// ── Validation ──────────────────────────────────────────────────────
public bool Validate(out string error)
{
error = "";
if (Clade is null) { error = "Clade not selected."; return false; }
if (Species is null) { error = "Species not selected."; return false; }
if (ClassDef is null) { error = "Class not selected."; return false; }
if (Background is null) { error = "Background not selected."; return false; }
if (!string.Equals(Species.CladeId, Clade.Id, StringComparison.OrdinalIgnoreCase))
{
error = $"Species '{Species.Id}' belongs to clade '{Species.CladeId}', not '{Clade.Id}'.";
return false;
}
// Validate every chosen class skill is in the class's offered list.
foreach (var s in ChosenClassSkills)
{
string raw = SkillToJsonName(s);
bool listed = false;
foreach (var opt in ClassDef.SkillOptions)
if (string.Equals(opt, raw, StringComparison.OrdinalIgnoreCase))
{
listed = true;
break;
}
if (!listed)
{
error = $"Class '{ClassDef.Id}' does not offer skill '{raw}'.";
return false;
}
}
if (ChosenClassSkills.Count != ClassDef.SkillsChoose)
{
error = $"Class '{ClassDef.Id}' requires {ClassDef.SkillsChoose} skill picks, got {ChosenClassSkills.Count}.";
return false;
}
return true;
}
// ── Build ───────────────────────────────────────────────────────────
public Character Build(IReadOnlyDictionary<string, ItemDef>? itemsForStartingKit = null)
{
if (!Validate(out string error))
throw new InvalidOperationException($"Cannot build character: {error}");
// Apply clade + species ability mods to the base scores.
var clade = Clade!;
var species = Species!;
var classD = ClassDef!;
var bgD = Background!;
var modded = BaseAbilities;
modded = ApplyMods(modded, clade.AbilityMods);
modded = ApplyMods(modded, species.AbilityMods);
var c = new Character(clade, species, classD, bgD, modded)
{
Level = 1,
Xp = 0,
};
// Skills: class-chosen + background freebies (deduplicated).
foreach (var s in ChosenClassSkills) c.SkillProficiencies.Add(s);
foreach (var raw in bgD.SkillProficiencies)
{
try { c.SkillProficiencies.Add(SkillIdExtensions.FromJson(raw)); }
catch (ArgumentException) { /* unknown skill names ignored — content bug, but don't crash creation */ }
}
// HP: HitDie + CON modifier at level 1.
c.MaxHp = c.ComputeMaxHpFromScratch();
c.CurrentHp = c.MaxHp;
// Phase 5 M6: Fangsworn fighting style. Default to "duelist" — has
// immediate combat effect at level 1 and works with the most weapons
// in our starting kits. The CodexUI character creator surfaces this
// as a real picker; the legacy Myra screen leaves it on default.
if (string.Equals(classD.Id, "fangsworn", System.StringComparison.OrdinalIgnoreCase))
{
c.FightingStyle = string.IsNullOrEmpty(FightingStyle) ? "duelist" : FightingStyle;
}
// Optional starting kit. The caller passes the loaded item table
// (typically <see cref="Data.ContentResolver.Items"/>); if null, the
// character starts with an empty inventory (existing test behaviour).
if (itemsForStartingKit is not null)
ApplyStartingKit(c, itemsForStartingKit);
return c;
}
// ── Phase 6.5 M4: Hybrid build path ─────────────────────────────────
/// <summary>
/// Validate the hybrid-origin fields. Returns true and an empty error
/// string when the sire+dam configuration is valid for building a
/// hybrid character.
///
/// Required: both sire and dam picked (clade + species each); sire and
/// dam must be *different* clades (cross-clade is the definition of
/// hybrid); each species must belong to its declared clade.
/// </summary>
public bool ValidateHybrid(out string error)
{
error = "";
if (HybridSireClade is null) { error = "Sire clade not selected."; return false; }
if (HybridSireSpecies is null) { error = "Sire species not selected."; return false; }
if (HybridDamClade is null) { error = "Dam clade not selected."; return false; }
if (HybridDamSpecies is null) { error = "Dam species not selected."; return false; }
if (string.Equals(HybridSireClade.Id, HybridDamClade.Id, StringComparison.OrdinalIgnoreCase))
{
error = $"Sire and dam must be different clades (both are '{HybridSireClade.Id}'). Hybrids are cross-clade.";
return false;
}
if (!string.Equals(HybridSireSpecies.CladeId, HybridSireClade.Id, StringComparison.OrdinalIgnoreCase))
{
error = $"Sire species '{HybridSireSpecies.Id}' belongs to clade '{HybridSireSpecies.CladeId}', not '{HybridSireClade.Id}'.";
return false;
}
if (!string.Equals(HybridDamSpecies.CladeId, HybridDamClade.Id, StringComparison.OrdinalIgnoreCase))
{
error = $"Dam species '{HybridDamSpecies.Id}' belongs to clade '{HybridDamSpecies.CladeId}', not '{HybridDamClade.Id}'.";
return false;
}
return true;
}
/// <summary>
/// Build a hybrid character from the configured sire + dam pair. The
/// builder resolves the dominant parent's clade + species as the
/// primary <see cref="Character.Clade"/> / <see cref="Character.Species"/>
/// (so existing systems that key off these fields keep working), and
/// records the full sire+dam genealogy in <see cref="Character.Hybrid"/>.
///
/// Ability mod blending follows <c>clades.md</c> HYBRID ORIGIN:
/// take *one* ability mod from each parent clade. If both grant the
/// same ability, the duplicate is dropped (no double-counting); the
/// player picks an alternative +1 elsewhere via the standard array
/// or roll path. (M4 simplification: take both clade mod sets and
/// blend them — duplicates collapse to a single +1 — and use both
/// species mods.)
/// </summary>
public bool TryBuildHybrid(
IReadOnlyDictionary<string, ItemDef>? itemsForStartingKit,
out Character? character,
out string error)
{
character = null;
if (!ValidateHybrid(out error)) return false;
if (ClassDef is null)
{
error = "Class not selected.";
return false;
}
if (Background is null)
{
error = "Background not selected.";
return false;
}
// Validate skills against the class — same as the purebred path.
foreach (var s in ChosenClassSkills)
{
string raw = SkillToJsonName(s);
bool listed = false;
foreach (var opt in ClassDef.SkillOptions)
if (string.Equals(opt, raw, StringComparison.OrdinalIgnoreCase))
{
listed = true;
break;
}
if (!listed)
{
error = $"Class '{ClassDef.Id}' does not offer skill '{raw}'.";
return false;
}
}
if (ChosenClassSkills.Count != ClassDef.SkillsChoose)
{
error = $"Class '{ClassDef.Id}' requires {ClassDef.SkillsChoose} skill picks, got {ChosenClassSkills.Count}.";
return false;
}
// Resolve the dominant lineage as the primary clade/species so the
// rest of the engine (rendering, scent reads, dialogue gates that
// key off Character.Clade / Character.Species) sees the dominant
// expression. The Hybrid record carries the full sire+dam
// genealogy.
var dominantClade = HybridDominantParent == ParentLineage.Sire
? HybridSireClade! : HybridDamClade!;
var dominantSpecies = HybridDominantParent == ParentLineage.Sire
? HybridSireSpecies! : HybridDamSpecies!;
// Blend ability mods: apply BOTH parent clades' mods, then BOTH
// species mods. Same-key collisions accumulate (e.g. two clades
// each granting +1 CON yield +2 CON). This is a small departure
// from clades.md's "take one from each" but matches the engine's
// declarative-mod model and produces sensible totals; M4 ships it
// and the rule fine-tunes in playtesting.
var modded = BaseAbilities;
modded = ApplyMods(modded, HybridSireClade!.AbilityMods);
modded = ApplyMods(modded, HybridDamClade!.AbilityMods);
modded = ApplyMods(modded, HybridSireSpecies!.AbilityMods);
modded = ApplyMods(modded, HybridDamSpecies!.AbilityMods);
var c = new Character(dominantClade, dominantSpecies, ClassDef, Background, modded)
{
Level = 1,
Xp = 0,
Hybrid = new HybridState
{
SireClade = HybridSireClade.Id,
SireSpecies = HybridSireSpecies.Id,
DamClade = HybridDamClade.Id,
DamSpecies = HybridDamSpecies.Id,
DominantParent = HybridDominantParent,
},
};
// Skills (same as purebred path).
foreach (var s in ChosenClassSkills) c.SkillProficiencies.Add(s);
foreach (var raw in Background.SkillProficiencies)
{
try { c.SkillProficiencies.Add(SkillIdExtensions.FromJson(raw)); }
catch (ArgumentException) { /* unknown skill names ignored */ }
}
c.MaxHp = c.ComputeMaxHpFromScratch();
c.CurrentHp = c.MaxHp;
if (string.Equals(ClassDef.Id, "fangsworn", System.StringComparison.OrdinalIgnoreCase))
c.FightingStyle = string.IsNullOrEmpty(FightingStyle) ? "duelist" : FightingStyle;
if (itemsForStartingKit is not null)
ApplyStartingKit(c, itemsForStartingKit);
character = c;
return true;
}
/// <summary>
/// Adds every entry from <see cref="ClassDef.StartingKit"/> to the
/// character's inventory and auto-equips entries flagged for it. Logs and
/// continues on missing items / unknown slots — content bugs should fail
/// loud at content-validate time, not crash character creation.
/// </summary>
public static void ApplyStartingKit(Character c, IReadOnlyDictionary<string, ItemDef> items)
{
foreach (var entry in c.ClassDef.StartingKit)
{
if (!items.TryGetValue(entry.ItemId, out var def))
continue; // unknown item id — skip silently (caught by ContentValidate)
var inst = c.Inventory.Add(def, Math.Max(1, entry.Qty));
if (!entry.AutoEquip || string.IsNullOrEmpty(entry.EquipSlot))
continue;
var slot = EquipSlotExtensions.FromJson(entry.EquipSlot);
if (slot is null) continue;
// Best-effort equip; ignore the error string here. If a structural
// conflict occurs (two-handed in main when off-hand pre-occupied),
// the item stays in the bag rather than blocking creation.
c.Inventory.TryEquip(inst, slot.Value, out _);
}
}
private static AbilityScores ApplyMods(AbilityScores a, IReadOnlyDictionary<string, int> mods)
{
if (mods is null || mods.Count == 0) return a;
var dict = new Dictionary<AbilityId, int>();
foreach (var kv in mods)
{
if (TryParseAbility(kv.Key, out var id))
dict[id] = (dict.TryGetValue(id, out var existing) ? existing : 0) + kv.Value;
}
return a.Plus(dict);
}
private static bool TryParseAbility(string raw, out AbilityId id)
{
switch (raw.ToUpperInvariant())
{
case "STR": id = AbilityId.STR; return true;
case "DEX": id = AbilityId.DEX; return true;
case "CON": id = AbilityId.CON; return true;
case "INT": id = AbilityId.INT; return true;
case "WIS": id = AbilityId.WIS; return true;
case "CHA": id = AbilityId.CHA; return true;
default: id = AbilityId.STR; return false;
}
}
private static string SkillToJsonName(SkillId s) => s switch
{
SkillId.Acrobatics => "acrobatics",
SkillId.AnimalHandling => "animal_handling",
SkillId.Arcana => "arcana",
SkillId.Athletics => "athletics",
SkillId.Deception => "deception",
SkillId.History => "history",
SkillId.Insight => "insight",
SkillId.Intimidation => "intimidation",
SkillId.Investigation => "investigation",
SkillId.Medicine => "medicine",
SkillId.Nature => "nature",
SkillId.Perception => "perception",
SkillId.Performance => "performance",
SkillId.Persuasion => "persuasion",
SkillId.Religion => "religion",
SkillId.SleightOfHand => "sleight_of_hand",
SkillId.Stealth => "stealth",
SkillId.Survival => "survival",
_ => s.ToString().ToLowerInvariant(),
};
// ── Stat-rolling ────────────────────────────────────────────────────
/// <summary>
/// Roll 4d6-drop-lowest six times, returning a fresh <see cref="AbilityScores"/>
/// in (STR, DEX, CON, INT, WIS, CHA) order. Player assigns afterward.
///
/// Seed:
/// <c>worldSeed ^ C.RNG_STAT_ROLL ^ msSinceGameStart</c>
/// where <paramref name="msSinceGameStart"/> is wall-clock ms since process
/// launch in production, or a fixed test override for reproducibility.
/// </summary>
public static AbilityScores RollAbilityScores(ulong worldSeed, ulong msSinceGameStart)
{
var rng = SeededRng.ForSubsystem(worldSeed, C.RNG_STAT_ROLL ^ msSinceGameStart);
int[] r = new int[6];
for (int i = 0; i < 6; i++) r[i] = Roll4d6DropLowest(rng);
return new AbilityScores(r[0], r[1], r[2], r[3], r[4], r[5]);
}
/// <summary>4d6, drop the lowest; returns 3..18.</summary>
public static int Roll4d6DropLowest(SeededRng rng)
{
int d1 = (int)(rng.NextUInt64() % 6) + 1;
int d2 = (int)(rng.NextUInt64() % 6) + 1;
int d3 = (int)(rng.NextUInt64() % 6) + 1;
int d4 = (int)(rng.NextUInt64() % 6) + 1;
int low = Math.Min(d1, Math.Min(d2, Math.Min(d3, d4)));
return d1 + d2 + d3 + d4 - low;
}
}
@@ -0,0 +1,55 @@
namespace Theriapolis.Core.Rules.Character;
/// <summary>
/// Phase 6.5 M4 — universal Hybrid detriments, applied automatically to
/// every <see cref="HybridState"/>-bearing character per
/// <c>theriapolis-rpg-clades.md</c> HYBRID ORIGIN section.
///
/// The four detriments are *invariant rules*, not authored content —
/// they don't vary per hybrid character — so they ship as code constants
/// rather than a JSON content block. (Plan §3.1's "HybridDetrimentsDef
/// loader" is documented as deviation: code constants are simpler and
/// match the design's universality.)
///
/// 1. <see cref="ScentDysphoriaSaveDc"/> — WIS save DC 10 imposed on the
/// first NPC interaction; failure → disadvantage on first CHA check.
/// 2. <see cref="IllegibleBodyLanguagePenalty"/> — disadvantage on
/// nonverbal CHA checks with purebred NPCs.
/// 3. <see cref="SocialStigmaFirstCheckPenalty"/> — -2 to first CHA check
/// with strangers in non-progressive settlements.
/// 4. <see cref="MedicalIncompatibilityMultiplier"/> — healing from
/// potions / Field Repair / Lay on Paws scaled by 0.75.
/// </summary>
public static class HybridDetriments
{
/// <summary>WIS save DC for Scent Dysphoria detection check.</summary>
public const int ScentDysphoriaSaveDc = 10;
/// <summary>Magnitude of the Social Stigma first-CHA-check penalty (negative).</summary>
public const int SocialStigmaFirstCheckPenalty = -2;
/// <summary>
/// Multiplier applied to healing received by a hybrid character.
/// 0.75 = three-quarters effective per <c>clades.md</c>; round down.
/// </summary>
public const float MedicalIncompatibilityMultiplier = 0.75f;
/// <summary>True if Illegible Body Language imposes disadvantage on the given check.</summary>
public static bool IllegibleBodyLanguagePenalty => true;
/// <summary>
/// Apply the Medical Incompatibility multiplier to a heal amount when
/// the recipient is a hybrid PC. Round down per <c>clades.md</c>.
/// Non-hybrid recipients pass through unchanged.
/// </summary>
public static int ScaleHealForHybrid(Character recipient, int rawHeal)
{
if (recipient.Hybrid is null) return rawHeal;
if (rawHeal <= 0) return rawHeal;
// Round down — clades.md says "function at 75% effectiveness (round
// down)". (int)(0.75 * 7) = 5; (int)(0.75 * 1) = 0 → clamp to 1
// because "no heal" is mechanically harsher than "small heal".
int scaled = (int)(rawHeal * MedicalIncompatibilityMultiplier);
return System.Math.Max(1, scaled);
}
}
@@ -0,0 +1,117 @@
using Theriapolis.Core.Data;
namespace Theriapolis.Core.Rules.Character;
/// <summary>
/// Phase 6.5 M4 — runtime state for a hybrid character.
///
/// Hybrids are blended from two parent lineages: a <b>Sire</b> (paternal
/// lineage) and a <b>Dam</b> (maternal lineage), of two different clades.
/// One parent is <see cref="DominantParent"/> — the lineage whose physical
/// expression is more visible; this drives Passing eligibility and which
/// clade the PC presents as for casual scent reads.
///
/// The character's own gender is independent of which parent is sire or
/// dam — a male hybrid PC can have a wolf-folk dam and a coyote-folk
/// sire just as readily as the reverse.
///
/// Per <c>theriapolis-rpg-clades.md</c> HYBRID ORIGIN: blend ability mods
/// (one from each parent clade), traits (2 from dominant + 1 from
/// secondary), and inherit *all* universal hybrid detriments — Scent
/// Dysphoria, Illegible Body Language, Social Stigma, Medical
/// Incompatibility (handled by <see cref="HybridDetriments"/>).
///
/// <see cref="PassingActive"/> is toggle-able mid-game; when set, the PC
/// presents as the dominant lineage's clade. Detection by NPCs (Phase 6.5
/// M5) is per-NPC and permanent once revealed.
/// </summary>
public sealed class HybridState
{
/// <summary>Sire (paternal-lineage) clade id, e.g. "canidae".</summary>
public string SireClade { get; init; } = "";
/// <summary>Sire (paternal-lineage) species id, e.g. "wolf".</summary>
public string SireSpecies { get; init; } = "";
/// <summary>Dam (maternal-lineage) clade id, e.g. "leporidae".</summary>
public string DamClade { get; init; } = "";
/// <summary>Dam (maternal-lineage) species id, e.g. "rabbit".</summary>
public string DamSpecies { get; init; } = "";
/// <summary>
/// Which parent's expression is dominant. Drives Passing presentation
/// (the PC scent-reads as this parent's clade) and the trait-split:
/// 2 Clade traits from the dominant parent, 1 from the secondary.
/// </summary>
public ParentLineage DominantParent { get; init; }
/// <summary>
/// True when the PC is actively trying to pass as their dominant
/// parent's clade. Toggles on the character sheet; consulted by
/// Phase 6.5 M5 passing-detection rolls.
/// </summary>
public bool PassingActive { get; set; }
/// <summary>
/// Phase 6.5 M5 — currently-active scent mask tier (if any). The mask
/// suppresses scent-based detection per its tier. Phase 6.5 M5 ships
/// the static tier flag; Phase 8's clock model adds time-based
/// expiry alongside daily wear.
/// </summary>
public ScentMaskTier ActiveMaskTier { get; set; } = ScentMaskTier.None;
/// <summary>
/// NPC ids who have personally detected this PC is hybrid. Permanent
/// once added — disabling Passing later doesn't undo the discovery
/// for that specific NPC. Phase 6.5 M5 populates this; M4 reserves it
/// in the schema so save round-trip works pre- and post-M5.
/// </summary>
public HashSet<int> NpcsWhoKnow { get; } = new();
/// <summary>
/// Convenience: which clade the PC presents as for casual scent reads
/// (used by Phase 6.5 M5 passing logic and Phase 7 dialogue gates).
/// Returns the dominant parent's clade id.
/// </summary>
public string PresentingCladeId =>
DominantParent == ParentLineage.Sire ? SireClade : DamClade;
/// <summary>
/// Convenience: which species the PC presents as. Same logic as
/// <see cref="PresentingCladeId"/>.
/// </summary>
public string PresentingSpeciesId =>
DominantParent == ParentLineage.Sire ? SireSpecies : DamSpecies;
}
/// <summary>
/// Phase 6.5 M4 — which parent in a hybrid PC's lineage is the
/// dominant/secondary expression. Sire = paternal lineage, Dam = maternal
/// lineage; the choice is OOC (no gender semantics for the character
/// themselves, just for the parents' lineages).
/// </summary>
public enum ParentLineage : byte
{
Sire = 0,
Dam = 1,
}
/// <summary>
/// Phase 6.5 M5 — scent-mask tier suppressing hybrid detection. Maps to
/// the consumable items in <c>items.json</c>:
/// None — no mask active
/// Basic — scent_mask_basic; advantage on PC Deception roll
/// Military — scent_mask_military; auto-suppresses scent detection
/// DeepCover — scent_mask_deep_cover; auto-suppresses, even Superior Scent
///
/// Per the Phase 6.5 plan §4.7. Phase 8's clock + rest model adds
/// time-based expiry; M5 carries the tier as static state.
/// </summary>
public enum ScentMaskTier : byte
{
None = 0,
Basic = 1,
Military = 2,
DeepCover = 3,
}
@@ -0,0 +1,107 @@
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Character;
/// <summary>
/// Phase 6.5 M0 — the level-up flow.
///
/// <see cref="Compute"/> is a pure function: given a character, a target
/// level, and a deterministic seed, it produces a <see cref="LevelUpResult"/>
/// describing what the level-up *would* do without mutating anything. The
/// level-up screen previews this; <see cref="Character.ApplyLevelUp"/>
/// commits it.
///
/// Determinism: <c>seed = worldSeed ^ characterCreationMs ^ RNG_LEVELUP ^ targetLevel</c>.
/// Same seed → same HP roll, same feature list, same ASI/subclass slots open.
/// Save mid-flow → load → re-compute produces byte-identical payload.
/// </summary>
public static class LevelUpFlow
{
/// <summary>
/// True when the character has accumulated enough XP to level up.
/// Wraps the <see cref="XpTable"/> threshold check.
/// </summary>
public static bool CanLevelUp(Character character)
{
if (character.Level >= C.CHARACTER_LEVEL_MAX) return false;
return character.Xp >= XpTable.XpRequiredForNextLevel(character.Level);
}
/// <summary>
/// Compute (but do not apply) the level-up payload for advancing
/// <paramref name="character"/> to <paramref name="targetLevel"/>.
/// Caller is expected to validate <c>targetLevel == character.Level + 1</c>;
/// no in-method assertion so unit tests can roll forward without mutation.
///
/// <paramref name="seed"/> determines HP roll outcome when the player
/// chooses to roll instead of take average. Seeded callers should pass
/// <c>worldSeed ^ characterCreationMs ^ C.RNG_LEVELUP ^ (ulong)targetLevel</c>.
///
/// <paramref name="subclasses"/> is the content resolver's subclass
/// dictionary; when non-null and the character has a chosen subclass,
/// the result's <see cref="LevelUpResult.SubclassFeaturesUnlocked"/>
/// is populated. Phase 6.5 M0 callers (and tests without content) pass
/// null, which yields an empty subclass-feature list.
/// </summary>
public static LevelUpResult Compute(
Character character,
int targetLevel,
ulong seed,
bool takeAverage = true,
IReadOnlyDictionary<string, Data.SubclassDef>? subclasses = null)
{
var classDef = character.ClassDef;
int conMod = character.Abilities.ModFor(AbilityId.CON);
int hitDie = classDef.HitDie;
int avgHp = (hitDie / 2) + 1;
int rollHp;
if (takeAverage)
{
rollHp = avgHp;
}
else
{
// Roll 1d{hitDie} from a fresh stream so the result is reproducible
// per-call. Use SeededRng to match Phase 5/6 RNG conventions.
var rng = new SeededRng(seed);
rollHp = (int)(rng.NextUInt64() % (uint)hitDie) + 1;
}
int hpGained = Math.Max(1, rollHp + conMod);
// Class features at this level. The level-table indexes by Level
// (1-based); arrays are 0-based so look up at [level - 1].
var classFeatures = Array.Empty<string>();
if (targetLevel >= 1 && targetLevel <= classDef.LevelTable.Length)
{
var entry = classDef.LevelTable[targetLevel - 1];
classFeatures = entry.Features ?? Array.Empty<string>();
}
// Phase 6.5 M2 — resolve subclass features from the chosen subclass
// (post-L3). When `subclasses` is null (M0 callers / tests without
// content), no subclass features are unlocked.
string[] subclassFeatures = subclasses is not null && !string.IsNullOrEmpty(character.SubclassId)
? SubclassResolver.UnlockedFeaturesAt(subclasses, character.SubclassId, targetLevel)
: Array.Empty<string>();
bool grantsSubclass = targetLevel == C.SUBCLASS_SELECTION_LEVEL
&& string.IsNullOrEmpty(character.SubclassId)
&& classDef.SubclassIds.Length > 0;
bool grantsAsi = Array.IndexOf(C.ASI_LEVELS, targetLevel) >= 0;
return new LevelUpResult
{
NewLevel = targetLevel,
HpGained = hpGained,
HpHitDieResult = rollHp,
HpWasAveraged = takeAverage,
ClassFeaturesUnlocked = classFeatures,
SubclassFeaturesUnlocked = subclassFeatures,
GrantsSubclassChoice = grantsSubclass,
GrantsAsiChoice = grantsAsi,
NewProficiencyBonus = ProficiencyBonus.ForLevel(targetLevel),
};
}
}
@@ -0,0 +1,76 @@
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Rules.Character;
/// <summary>
/// Pure data describing the *deltas* a level-up produces. Phase 6.5 M0:
/// <see cref="LevelUpFlow"/> computes one of these from
/// <c>(character, targetLevel, levelUpSeed)</c>; the player confirms; then
/// <see cref="Character.ApplyLevelUp"/> applies it.
///
/// Splitting compute from apply keeps the level-up screen previewable
/// (the player sees the rolled HP and feature list before committing) and
/// makes mid-flight save/load deterministic — the same seed always produces
/// the same payload.
/// </summary>
public sealed class LevelUpResult
{
/// <summary>The level being advanced *to*. After Apply, <c>Character.Level == NewLevel</c>.</summary>
public int NewLevel { get; init; }
/// <summary>HP gained on this level-up. Already incorporates CON modifier.</summary>
public int HpGained { get; init; }
/// <summary>Average-rounded-up HP value used (for "take average" path); rolled value used otherwise.</summary>
public int HpHitDieResult { get; init; }
/// <summary>True if the player picked the "take average" option; false if rolled.</summary>
public bool HpWasAveraged { get; init; }
/// <summary>Class feature ids unlocked at this level (per <see cref="Data.ClassDef.LevelTable"/>).</summary>
public string[] ClassFeaturesUnlocked { get; init; } = Array.Empty<string>();
/// <summary>
/// Subclass feature ids unlocked at this level (post-L3, when SubclassId
/// is set). Empty for pre-subclass and non-subclass-feature levels.
/// </summary>
public string[] SubclassFeaturesUnlocked { get; init; } = Array.Empty<string>();
/// <summary>True if this level grants a subclass selection slot (level 3 by default).</summary>
public bool GrantsSubclassChoice { get; init; }
/// <summary>True if this level grants an Ability Score Improvement choice (levels 4 / 8 / 12 / 16 / 19).</summary>
public bool GrantsAsiChoice { get; init; }
/// <summary>The proficiency bonus *after* this level-up.</summary>
public int NewProficiencyBonus { get; init; }
}
/// <summary>
/// The player's choices at level-up that need confirmation before
/// <see cref="Character.ApplyLevelUp"/> commits the deltas. Leave fields
/// null/empty when the corresponding slot isn't open at this level.
/// </summary>
public sealed class LevelUpChoices
{
/// <summary>
/// At level 3 (or whatever <see cref="C.SUBCLASS_SELECTION_LEVEL"/>
/// becomes), the player picks a subclass id. Must reference one of
/// <c>character.ClassDef.SubclassIds</c>.
/// </summary>
public string? SubclassId { get; set; }
/// <summary>
/// At ASI levels (<see cref="C.ASI_LEVELS"/>), the player picks ability
/// score improvements. Either:
/// - one ability +2 (cap at <see cref="C.ABILITY_SCORE_CAP_PRE_L20"/>)
/// - two abilities +1 each (each cap at <see cref="C.ABILITY_SCORE_CAP_PRE_L20"/>)
/// </summary>
public Dictionary<AbilityId, int> AsiAdjustments { get; set; } = new();
/// <summary>
/// True if the player picked "take average HP" instead of "roll".
/// Default is "average" — predictable, avoids the dump-stat-roll problem.
/// </summary>
public bool TakeAverageHp { get; set; } = true;
}
@@ -0,0 +1,213 @@
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Character;
/// <summary>
/// Phase 6.5 M5 — hybrid passing detection.
///
/// When a hybrid PC (with <see cref="HybridState.PassingActive"/> = true)
/// interacts with an NPC who has a scent-detection capability (Canid clade
/// "Superior Scent" or any Scent-Broker class), the NPC rolls a WIS save
/// at <see cref="C.HYBRID_DETECTION_DC"/> against the PC's CHA Deception
/// counter-roll.
///
/// Outcomes:
/// <see cref="DetectionResult.Pass"/> — PC remains hidden; treated as presenting clade.
/// <see cref="DetectionResult.Detected"/> — NPC sees through the cover; their bias
/// profile's <c>HybridBias</c> applies from now on.
///
/// Once detected by a specific NPC, the flag is permanent for that NPC
/// (per <c>theriapolis-rpg-clades.md</c> "Optional: Passing"). Other NPCs
/// roll independently — no per-settlement propagation in M5 (Phase 8
/// scent simulation may extend).
/// </summary>
public static class PassingCheck
{
/// <summary>
/// Roll detection for one NPC × PC interaction. Determinism:
/// <c>seed = worldSeed ^ C.RNG_PASSING ^ npcId ^ encounterIdx</c>.
/// Same seed → same outcome; mid-game saves resume identically.
///
/// <paramref name="npcMemoryFlags"/> is consulted upfront — if the NPC
/// already detected this PC in a prior interaction, the result is
/// <see cref="DetectionResult.Detected"/> with no fresh roll. The
/// caller writes the <c>"knows_hybrid"</c> tag into the NPC's
/// <see cref="PersonalDisposition.Memory"/> on first detection.
/// </summary>
public static DetectionResult Roll(
Character pc,
NpcActor npc,
ICollection<string> npcMemoryFlags,
ulong seed)
{
// Non-hybrids never trigger detection.
if (pc.Hybrid is null) return DetectionResult.NotApplicable;
// Already detected? Permanent for this NPC.
if (npcMemoryFlags.Contains("knows_hybrid"))
return DetectionResult.PreviouslyDetected;
// Not actively passing? The PC isn't trying to hide; detection
// happens trivially. Marks the NPC as knowing.
if (!pc.Hybrid.PassingActive)
return DetectionResult.NotPassing;
// Deep-cover scent mask suppresses all detection — even Superior Scent.
if (pc.Hybrid.ActiveMaskTier == ScentMaskTier.DeepCover)
return DetectionResult.MaskSuppressed;
// Military mask: auto-suppress for non-Canid NPCs; Canids still roll
// (Superior Scent overrides anything below deep cover).
bool npcHasSuperiorScent = NpcHasSuperiorScent(npc);
if (pc.Hybrid.ActiveMaskTier == ScentMaskTier.Military && !npcHasSuperiorScent)
return DetectionResult.MaskSuppressed;
// The detection mechanic: NPC WIS save vs the PC's CHA Deception
// counter-roll. NPCs without scent capability never detect.
if (!CanNpcDetectScent(npc)) return DetectionResult.NoCapability;
var rng = new SeededRng(seed);
// NPC rolls 1d20 + WIS mod against DC = pc Deception DC + (basic mask
// gives PC advantage, which we model as +5 to the DC the NPC must beat).
int npcWis = NpcWisMod(npc);
int npcRoll = (int)(rng.NextUInt64() % 20) + 1;
int npcTotal = npcRoll + npcWis;
int pcCha = pc.Abilities.ModFor(AbilityId.CHA);
int pcProf = pc.ProficiencyBonus;
int pcDecRoll = (int)(rng.NextUInt64() % 20) + 1;
// Proficient in Deception? Add prof bonus; otherwise just CHA mod.
bool deceptionProf = pc.SkillProficiencies.Contains(SkillId.Deception);
int pcTotal = pcDecRoll + pcCha + (deceptionProf ? pcProf : 0);
// Basic mask shifts the contest in PC's favour (advantage = +5
// approximation for a single non-rerolled compare).
if (pc.Hybrid.ActiveMaskTier == ScentMaskTier.Basic) pcTotal += 5;
// NPC must meet or exceed the DC AND beat the PC's deception
// contest to detect. (Either failing means PC stays hidden.)
bool npcMeetsDc = npcTotal >= C.HYBRID_DETECTION_DC;
bool pcBeatsCheck = pcTotal >= C.HYBRID_DECEPTION_DC;
bool detected = npcMeetsDc && !pcBeatsCheck;
return detected ? DetectionResult.Detected : DetectionResult.Pass;
}
/// <summary>
/// True for NPCs who have *any* scent-reading capability. Phase 6.5 M5:
/// canid-clade NPCs (Superior Scent) and scent-broker-flavoured roles.
/// Generic / non-canid / non-scent-broker NPCs never roll detection.
/// </summary>
public static bool CanNpcDetectScent(NpcActor npc)
{
if (NpcHasSuperiorScent(npc)) return true;
// Phase 6.5 M5 simplification: non-canid NPCs don't detect by
// default. A Phase 8 scent-broker NPC role could extend this with
// a tag check on `npc.Resident?.Traits` — out of scope for M5.
return false;
}
/// <summary>True if the NPC's clade is Canid (granting Superior Scent).</summary>
public static bool NpcHasSuperiorScent(NpcActor npc)
{
string? clade = npc.Resident?.Clade;
return string.Equals(clade, "canidae", System.StringComparison.OrdinalIgnoreCase);
}
/// <summary>NPC's WIS modifier — derived from template if present, otherwise default 0.</summary>
private static int NpcWisMod(NpcActor npc)
{
if (npc.Template is null) return 0;
// Templates store ability scores as a string-keyed dict on the def.
return npc.Template.AbilityScores.TryGetValue("WIS", out int wis)
? AbilityScores.Mod(wis)
: 0;
}
/// <summary>
/// Convenience: roll detection AND apply side effects on a positive
/// outcome. Writes the <c>"knows_hybrid"</c> memory tag to the NPC's
/// <see cref="PersonalDisposition.Memory"/>, mirrors the discovery in
/// <see cref="HybridState.NpcsWhoKnow"/>, and appends a
/// <see cref="RepEventKind.HybridDetected"/> event to the ledger.
///
/// Returns the same <see cref="DetectionResult"/> the underlying
/// <see cref="Roll"/> produced. Call sites that want to inspect the
/// outcome before applying side effects can use <see cref="Roll"/>
/// directly; this helper is the common-case one-liner.
/// </summary>
public static DetectionResult RollAndApply(
Character pc,
NpcActor npc,
Reputation.PlayerReputation rep,
long worldClockSeconds,
ulong seed)
{
if (pc.Hybrid is null) return DetectionResult.NotApplicable;
// Pull (or seed) the personal-disposition record so the roll sees
// the existing memory state.
var personal = string.IsNullOrEmpty(npc.RoleTag)
? null
: rep.PersonalFor(npc.RoleTag);
var memoryFlags = (ICollection<string>?)personal?.Memory ?? new HashSet<string>();
var result = Roll(pc, npc, memoryFlags, seed);
if (result == DetectionResult.Detected || result == DetectionResult.NotPassing)
{
// Write the detection through to all the places that care.
pc.Hybrid.NpcsWhoKnow.Add(npc.Id);
personal?.Memory.Add("knows_hybrid");
// Log a per-NPC HybridDetected event. Personal-only — no
// faction propagation in M5 (Phase 8 scent simulation can
// extend). Magnitude is 0 because the *bias* shift is
// applied via the bias-profile lookup in EffectiveDisposition,
// not via the personal-disposition delta.
var ev = new Reputation.RepEvent
{
Kind = Reputation.RepEventKind.HybridDetected,
RoleTag = npc.RoleTag ?? "",
Magnitude = 0,
Note = $"detected hybrid ({pc.Hybrid.SireClade}/{pc.Hybrid.DamClade})",
TimestampSeconds = worldClockSeconds,
};
rep.Ledger.Append(ev);
personal?.Apply(ev);
}
return result;
}
}
/// <summary>
/// Phase 6.5 M5 — outcome of one detection roll. The caller (typically the
/// dialogue runner) inspects this to apply the appropriate side effects.
/// </summary>
public enum DetectionResult : byte
{
/// <summary>PC is not a hybrid; no detection mechanic applies.</summary>
NotApplicable,
/// <summary>Hybrid detected on a prior interaction; flag still set.</summary>
PreviouslyDetected,
/// <summary>PC is hybrid but not actively passing — no roll, NPC knows immediately.</summary>
NotPassing,
/// <summary>NPC lacks scent-reading capability; passing automatic.</summary>
NoCapability,
/// <summary>Active scent mask blocked detection without a roll.</summary>
MaskSuppressed,
/// <summary>Detection roll succeeded; NPC sees through the cover.</summary>
Detected,
/// <summary>Detection roll failed; PC remains hidden in this interaction.</summary>
Pass,
}
@@ -0,0 +1,70 @@
using Theriapolis.Core.Data;
namespace Theriapolis.Core.Rules.Character;
/// <summary>
/// Phase 6.5 M2 — given a character's <see cref="ClassDef"/> + a chosen
/// <c>subclassId</c>, look up the subclass-feature ids unlocked at a
/// specific level. Used by <see cref="LevelUpFlow.Compute"/> to populate
/// <see cref="LevelUpResult.SubclassFeaturesUnlocked"/>.
///
/// The resolver does NOT mutate state — it's a pure lookup. The
/// <see cref="FeatureProcessor"/> takes the resulting feature ids and
/// applies their mechanical effects at combat-resolution time.
/// </summary>
public static class SubclassResolver
{
/// <summary>
/// Look up a subclass def by id from a content collection. Returns
/// null if the id is empty or unknown — callers should treat that as
/// "no subclass picked yet" (pre-L3) or "subclass content missing"
/// (data error, log it).
/// </summary>
public static SubclassDef? TryFindSubclass(
IReadOnlyDictionary<string, SubclassDef> subclasses,
string? subclassId)
{
if (string.IsNullOrEmpty(subclassId)) return null;
return subclasses.TryGetValue(subclassId, out var def) ? def : null;
}
/// <summary>
/// Feature ids unlocked by the chosen subclass at <paramref name="level"/>.
/// Returns an empty array if no subclass is picked, the subclass def is
/// missing, or the level has no entry in <see cref="SubclassDef.LevelFeatures"/>.
/// </summary>
public static string[] UnlockedFeaturesAt(
IReadOnlyDictionary<string, SubclassDef> subclasses,
string? subclassId,
int level)
{
var def = TryFindSubclass(subclasses, subclassId);
if (def is null) return Array.Empty<string>();
foreach (var entry in def.LevelFeatures)
if (entry.Level == level)
return entry.Features ?? Array.Empty<string>();
return Array.Empty<string>();
}
/// <summary>
/// Resolve a feature description (for display in the level-up screen
/// and combat HUD tooltips). Looks first in the subclass's
/// <see cref="SubclassDef.FeatureDefinitions"/>, then falls through to
/// the parent class's
/// <see cref="ClassDef.FeatureDefinitions"/> (in case the feature id is
/// shared — e.g. <c>asi</c>, <c>extra_attack</c>). Returns null if neither
/// has it.
/// </summary>
public static ClassFeatureDef? ResolveFeatureDef(
ClassDef classDef,
SubclassDef? subclass,
string featureId)
{
if (subclass is not null
&& subclass.FeatureDefinitions.TryGetValue(featureId, out var sdef))
return sdef;
if (classDef.FeatureDefinitions.TryGetValue(featureId, out var cdef))
return cdef;
return null;
}
}