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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user