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;
}
}
@@ -0,0 +1,27 @@
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// One attack a combatant can attempt — a weapon, a natural attack, or an
/// NPC stat-block entry. Built once at combat-start; the resolver rolls
/// against it. Distinct from <see cref="AttackProfile"/>, which is the
/// per-attempt struct that bakes in attacker/defender/situation.
/// </summary>
public sealed record AttackOption
{
public string Name { get; init; } = "";
/// <summary>Total +N to add to the d20 attack roll.</summary>
public int ToHitBonus { get; init; }
public DamageRoll Damage { get; init; } = new(0, 0, 0, DamageType.Bludgeoning);
/// <summary>Reach in tactical tiles. 1 = 5 ft. melee; 2 = 10 ft. polearm or Large reach.</summary>
public int ReachTiles { get; init; } = 1;
/// <summary>Short-range tiles for ranged attacks (0 = melee-only).</summary>
public int RangeShortTiles { get; init; } = 0;
/// <summary>Long-range tiles (disadvantage past short, can't fire past long).</summary>
public int RangeLongTiles { get; init; } = 0;
/// <summary>Crit-range threshold (default 20; razored weapons crit on 19+).</summary>
public int CritOnNatural { get; init; } = 20;
public bool IsRanged => RangeShortTiles > 0;
}
@@ -0,0 +1,30 @@
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Outcome of a single <see cref="Resolver.AttemptAttack"/> call. Captures
/// every dice value for log reconstruction and test assertions.
/// </summary>
public sealed record AttackResult
{
public required int AttackerId { get; init; }
public required int TargetId { get; init; }
public required string AttackName { get; init; }
public required int D20Roll { get; init; } // the kept d20 (post advantage/disadvantage)
public int? D20Other { get; init; } // the other d20 when adv/disadv was rolled
public required int ToHitBonus { get; init; }
public required int AttackTotal { get; init; } // D20Roll + ToHitBonus
public required int TargetAc { get; init; } // includes cover
public required bool Hit { get; init; }
public required bool Crit { get; init; }
public required int DamageRolled { get; init; } // 0 if missed
public required int TargetHpAfter { get; init; }
public required SituationFlags Situation { get; init; }
public string FormatLog(string attackerName, string targetName)
{
if (!Hit)
return $"{attackerName} → {targetName}: miss ({AttackName} {AttackTotal} vs AC {TargetAc})";
string critTag = Crit ? " [CRIT]" : "";
return $"{attackerName} → {targetName}: {DamageRolled} dmg ({AttackName} {AttackTotal} vs AC {TargetAc}){critTag} → HP {TargetHpAfter}";
}
}
@@ -0,0 +1,29 @@
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// One human-readable line in the encounter log. Combat-resolver actions
/// (attacks, saves, conditions, deaths) each emit one of these so test
/// scenarios can assert on the log content as a whole.
/// </summary>
public sealed record CombatLogEntry
{
public enum Kind : byte
{
Note = 0, // generic flavour line ("Round 1 begins.")
Attack = 1,
Save = 2,
Damage = 3, // direct damage that wasn't an attack roll
ConditionApplied = 4,
ConditionEnded = 5,
Death = 6,
Initiative = 7,
TurnStart = 8,
Move = 9,
EncounterEnd = 10,
}
public required int Round { get; init; }
public required int Turn { get; init; }
public required Kind Type { get; init; }
public required string Message { get; init; }
}
+319
View File
@@ -0,0 +1,319 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
// NOTE: deliberately NOT importing Theriapolis.Core.Rules.Character because
// the namespace name collides with the Character class inside it. Fully
// qualify Character; use Allegiance via Rules.Character.Allegiance below.
using Allegiance = Theriapolis.Core.Rules.Character.Allegiance;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Runtime adapter the resolver works with. Wraps either a
/// <see cref="Character"/> (player + future allies) or an
/// <see cref="NpcTemplateDef"/> (NPCs spawned from chunk lists). Carries
/// the mutable per-encounter state — HP, position, conditions — so the
/// source records aren't touched until the encounter ends and results
/// are written back.
/// </summary>
public sealed class Combatant
{
public int Id { get; }
public string Name { get; }
public Allegiance Allegiance { get; }
public SizeCategory Size { get; }
public AbilityScores Abilities { get; }
public int ProficiencyBonus { get; }
public int ArmorClass { get; }
public int MaxHp { get; }
public int SpeedFt { get; }
public int InitiativeBonus { get; }
public IReadOnlyList<AttackOption> AttackOptions { get; }
/// <summary>Source <see cref="Character"/> if built from one (player or ally). Null for NPC-template combatants.</summary>
public Theriapolis.Core.Rules.Character.Character? SourceCharacter { get; }
/// <summary>Source <see cref="NpcTemplateDef"/> if built from one. Null for character combatants.</summary>
public NpcTemplateDef? SourceTemplate { get; }
// ── Mutable per-encounter state ───────────────────────────────────────
public int CurrentHp { get; set; }
public Vec2 Position { get; set; }
public HashSet<Condition> Conditions { get; } = new();
/// <summary>Phase 5 M6: present only on player combatants while at 0 HP. Tracks the death-save loop.</summary>
public DeathSaveTracker? DeathSaves { get; set; }
// ── Phase 5 M6: per-encounter feature flags ──────────────────────────
/// <summary>True while Feral Rage is active. Bonus action toggle.</summary>
public bool RageActive { get; set; }
/// <summary>True while Bulwark Sentinel Stance is active. Halves speed; +2 AC.</summary>
public bool SentinelStanceActive { get; set; }
/// <summary>Set when Sneak Attack damage has fired this turn — once-per-turn limit.</summary>
public bool SneakAttackUsedThisTurn { get; set; }
// ── Phase 6.5 M1: per-encounter feature state ───────────────────────
/// <summary>
/// Pending Vocalization-Dice inspiration die granted by a Muzzle-Speaker.
/// 0 = none. When non-zero, the next attack/check/save this combatant
/// rolls adds 1d&lt;value&gt; to the result; the field then resets to 0.
/// Sides match the Vocalization Dice ladder: 6 / 8 / 10 / 12.
/// </summary>
public int InspirationDieSides { get; set; }
// ── Phase 6.5 M2: subclass-feature per-encounter state ───────────────
/// <summary>
/// Phase 6.5 M2 — Pack-Forged "Packmate's Howl" mark. Set on the target
/// when a Pack-Forged Fangsworn lands a melee hit; the next attack by
/// any *ally* of the Pack-Forged on this target gains advantage. The
/// mark expires when the marker's turn comes around again — tracked
/// here as the round number the mark was placed; resolver checks
/// <c>currentRound == HowlMarkRound + 0</c> (current round) or
/// <c>currentRound == HowlMarkRound + 1</c> (next round, before
/// marker's turn). Cleared on consume.
/// </summary>
public int? HowlMarkRound { get; set; }
/// <summary>The Pack-Forged combatant id that placed the howl mark.</summary>
public int? HowlMarkBy { get; set; }
/// <summary>
/// Phase 6.5 M2 — Blood Memory "Predatory Surge" trigger. Set when this
/// raging Feral kills a creature with a melee attack; consumed by the
/// HUD on the next bonus-action prompt (free extra melee attack).
/// </summary>
public bool PredatorySurgePending { get; set; }
// ── Phase 6.5 M3: Covenant Authority oath mark ───────────────────────
/// <summary>
/// Round number when an oath was placed on this combatant (Covenant-
/// Keeper Covenant's Authority). While the mark is live, the combatant
/// suffers -2 to attack rolls vs. its marker. Expires 10 rounds after
/// placement (= 1 minute in d20 round time).
/// </summary>
public int? OathMarkRound { get; set; }
/// <summary>The Covenant-Keeper combatant id who placed the oath mark.</summary>
public int? OathMarkBy { get; set; }
// ── Phase 7 M0: subclass per-turn / per-encounter flags ──────────────
/// <summary>
/// Phase 7 M0 — Stampede-Heart "Trampling Charge". Set when this turn's
/// first melee attack adds the +1d8 bludgeoning bonus; prevents the
/// bonus from firing twice in one turn. Resets at turn start.
/// </summary>
public bool TramplingChargeUsedThisTurn { get; set; }
/// <summary>
/// Phase 7 M0 — Ambush-Artist "Opening Strike". Set after the
/// first melee attack in this encounter consumes the +2d6 bonus; the
/// bonus only fires once per encounter. Lasts the encounter
/// (no per-turn reset).
/// </summary>
public bool OpeningStrikeUsed { get; set; }
/// <summary>Reset per-turn flags. Called by Encounter.EndTurn for the incoming actor.</summary>
public void OnTurnStart()
{
SneakAttackUsedThisTurn = false;
TramplingChargeUsedThisTurn = false;
}
/// <summary>True once HP ≤ 0. NPC-template combatants are removed from initiative; character combatants enter death-save mode.</summary>
public bool IsDown => CurrentHp <= 0;
/// <summary>True if either alive (HP &gt; 0) or downed-but-not-dead (rolling death saves).</summary>
public bool IsAlive => !IsDown || (DeathSaves is not null && !DeathSaves.Dead);
private Combatant(
int id, string name, Allegiance allegiance,
SizeCategory size, AbilityScores abilities, int profBonus,
int armorClass, int maxHp, int speedFt, int initiativeBonus,
IReadOnlyList<AttackOption> attacks,
Theriapolis.Core.Rules.Character.Character? sourceCharacter, NpcTemplateDef? sourceTemplate,
Vec2 position)
{
Id = id;
Name = name;
Allegiance = allegiance;
Size = size;
Abilities = abilities;
ProficiencyBonus= profBonus;
ArmorClass = armorClass;
MaxHp = maxHp;
SpeedFt = speedFt;
InitiativeBonus = initiativeBonus;
AttackOptions = attacks;
SourceCharacter = sourceCharacter;
SourceTemplate = sourceTemplate;
CurrentHp = maxHp;
Position = position;
}
/// <summary>
/// Build a combatant from a <see cref="Character"/>. Pulls AC, HP, and
/// the primary attack from equipped MainHand (or unarmed strike if none).
/// </summary>
public static Combatant FromCharacter(Theriapolis.Core.Rules.Character.Character c, int id, Vec2 position)
{
int ac = DerivedStats.ArmorClass(c);
int speed = DerivedStats.SpeedFt(c);
int initBonus = DerivedStats.Initiative(c);
var attacks = BuildCharacterAttacks(c);
return new Combatant(
id, c.Background?.Name is { Length: > 0 } ? $"PC-{id}" : $"PC-{id}",
c.SourceCharacterAllegiance(), c.Size, c.Abilities, c.ProficiencyBonus,
ac, c.MaxHp, speed, initBonus, attacks,
sourceCharacter: c, sourceTemplate: null, position: position);
}
/// <summary>
/// Build a combatant from a <see cref="Character"/> with an explicit
/// display name (typically the player's chosen name).
/// </summary>
public static Combatant FromCharacter(Theriapolis.Core.Rules.Character.Character c, int id, string name, Vec2 position, Allegiance allegiance)
{
int ac = DerivedStats.ArmorClass(c);
int speed = DerivedStats.SpeedFt(c);
int initBonus = DerivedStats.Initiative(c);
var attacks = BuildCharacterAttacks(c);
return new Combatant(
id, name, allegiance, c.Size, c.Abilities, c.ProficiencyBonus,
ac, c.MaxHp, speed, initBonus, attacks,
sourceCharacter: c, sourceTemplate: null, position: position);
}
/// <summary>
/// Build a combatant from an NPC template. AC and HP come straight from
/// the template; attacks are mapped 1:1 from <see cref="NpcTemplateDef.Attacks"/>.
/// </summary>
public static Combatant FromNpcTemplate(NpcTemplateDef def, int id, Vec2 position)
{
var size = SizeExtensions.FromJson(def.Size);
var abilities = new AbilityScores(
Score(def.AbilityScores, "STR", 10),
Score(def.AbilityScores, "DEX", 10),
Score(def.AbilityScores, "CON", 10),
Score(def.AbilityScores, "INT", 10),
Score(def.AbilityScores, "WIS", 10),
Score(def.AbilityScores, "CHA", 10));
// NPC profs default to +2 (CR ≤ 4 baseline).
const int npcProf = 2;
int initBonus = AbilityScores.Mod(abilities.DEX);
var attacks = new List<AttackOption>(def.Attacks.Length);
foreach (var atk in def.Attacks) attacks.Add(BuildNpcAttack(atk));
// 5 ft. = 1 tactical tile; convert NPC speed_ft to tiles.
int speedFt = def.SpeedFt;
var allegiance = Theriapolis.Core.Rules.Character.AllegianceExtensions.FromJson(def.DefaultAllegiance);
return new Combatant(
id, def.Name, allegiance, size, abilities, npcProf,
armorClass: def.Ac, maxHp: def.Hp, speedFt: speedFt, initiativeBonus: initBonus,
attacks: attacks,
sourceCharacter: null, sourceTemplate: def, position: position);
}
/// <summary>Distance to another combatant in tactical tiles, edge-to-edge Chebyshev.</summary>
public int DistanceTo(Combatant other) => ReachAndCover.EdgeToEdgeChebyshev(this, other);
private static int Score(IReadOnlyDictionary<string, int> dict, string key, int fallback)
=> dict.TryGetValue(key, out int v) ? v : fallback;
/// <summary>Builds the attack option list for a character: equipped weapon if any, else an unarmed strike.</summary>
private static List<AttackOption> BuildCharacterAttacks(Theriapolis.Core.Rules.Character.Character c)
{
var list = new List<AttackOption>();
var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
if (main is not null && string.Equals(main.Def.Kind, "weapon", System.StringComparison.OrdinalIgnoreCase))
{
list.Add(BuildWeaponAttack(c, main.Def));
}
else
{
list.Add(BuildUnarmedStrike(c));
}
return list;
}
private static AttackOption BuildWeaponAttack(Theriapolis.Core.Rules.Character.Character c, ItemDef weapon)
{
// Finesse weapons use the higher of STR/DEX; ranged weapons use DEX.
bool isFinesse = HasProperty(weapon, "finesse");
bool isRanged = weapon.RangeShortTiles > 0 || HasProperty(weapon, "ammunition") || HasProperty(weapon, "thrown");
AbilityId abil = isRanged
? AbilityId.DEX
: (isFinesse
? (c.Abilities.ModFor(AbilityId.STR) >= c.Abilities.ModFor(AbilityId.DEX)
? AbilityId.STR : AbilityId.DEX)
: AbilityId.STR);
int abilMod = c.Abilities.ModFor(abil);
// Proficiency: assume the character is proficient with all weapons their class lists.
// For Phase 5 M4 we apply proficiency unconditionally (every combat-touching class
// is proficient with their starting weapon). Wrong-proficiency disadvantage lands in M6.
int toHit = c.ProficiencyBonus + abilMod;
var damage = DamageRoll.Parse(
string.IsNullOrEmpty(weapon.Damage) ? "1d4" : weapon.Damage,
string.IsNullOrEmpty(weapon.DamageType)
? DamageType.Bludgeoning
: DamageTypeExtensions.FromJson(weapon.DamageType));
damage = damage with { FlatMod = damage.FlatMod + abilMod };
int reach = weapon.ReachTiles > 0 ? weapon.ReachTiles : c.Size.DefaultReachTiles();
return new AttackOption
{
Name = weapon.Name,
ToHitBonus = toHit,
Damage = damage,
ReachTiles = isRanged ? 0 : reach,
RangeShortTiles = isRanged ? (weapon.RangeShortTiles > 0 ? weapon.RangeShortTiles : 6) : 0,
RangeLongTiles = isRanged ? (weapon.RangeLongTiles > 0 ? weapon.RangeLongTiles : 24) : 0,
CritOnNatural = 20,
};
}
private static AttackOption BuildUnarmedStrike(Theriapolis.Core.Rules.Character.Character c)
{
int strMod = c.Abilities.ModFor(AbilityId.STR);
int toHit = c.ProficiencyBonus + strMod;
return new AttackOption
{
Name = "Unarmed Strike",
ToHitBonus = toHit,
Damage = new DamageRoll(0, 0, System.Math.Max(1, 1 + strMod), DamageType.Bludgeoning),
ReachTiles = c.Size.DefaultReachTiles(),
CritOnNatural = 20,
};
}
private static AttackOption BuildNpcAttack(NpcAttack atk)
{
var dmg = DamageRoll.Parse(
string.IsNullOrEmpty(atk.Damage) ? "1d4" : atk.Damage,
string.IsNullOrEmpty(atk.DamageType)
? DamageType.Bludgeoning
: DamageTypeExtensions.FromJson(atk.DamageType));
return new AttackOption
{
Name = atk.Name,
ToHitBonus = atk.ToHit,
Damage = dmg,
ReachTiles = atk.ReachTiles > 0 ? atk.ReachTiles : 1,
RangeShortTiles = atk.RangeShortTiles,
RangeLongTiles = atk.RangeLongTiles,
CritOnNatural = 20,
};
}
private static bool HasProperty(ItemDef def, string prop)
{
foreach (var p in def.Properties)
if (string.Equals(p, prop, System.StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
}
/// <summary>Convenience extension so callers needn't know whether a Character has Allegiance attached.</summary>
internal static class CharacterCombatExtensions
{
public static Allegiance SourceCharacterAllegiance(this Theriapolis.Core.Rules.Character.Character _)
=> Allegiance.Player;
}
+100
View File
@@ -0,0 +1,100 @@
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Parsed damage expression: <c>NdM+B</c> where N = dice count, M = die
/// sides, B = flat modifier (can be negative). Examples: "1d6", "2d8+2",
/// "1d4-1". <see cref="Roll"/> takes a function that returns 1..M for each
/// dice and aggregates with the flat modifier.
/// </summary>
public sealed record DamageRoll(int DiceCount, int DiceSides, int FlatMod, DamageType DamageType)
{
/// <summary>
/// Roll the damage dice. <paramref name="rollDie"/> takes the die size
/// (e.g. 6) and returns 1..size. On crit, dice double per d20 rules
/// (the flat modifier does NOT double).
/// </summary>
public int Roll(System.Func<int, int> rollDie, bool isCrit = false)
{
int diceToRoll = isCrit ? DiceCount * 2 : DiceCount;
int total = FlatMod;
for (int i = 0; i < diceToRoll; i++)
total += rollDie(DiceSides);
return System.Math.Max(0, total);
}
/// <summary>Theoretical maximum (every die rolls its top face) + flat mod.</summary>
public int Max(bool isCrit = false)
{
int dice = isCrit ? DiceCount * 2 : DiceCount;
return dice * DiceSides + FlatMod;
}
/// <summary>Theoretical minimum (every die rolls 1) + flat mod, clamped to 0.</summary>
public int Min(bool isCrit = false)
{
int dice = isCrit ? DiceCount * 2 : DiceCount;
return System.Math.Max(0, dice * 1 + FlatMod);
}
public override string ToString()
{
string mod = FlatMod == 0 ? "" : (FlatMod > 0 ? $"+{FlatMod}" : $"{FlatMod}");
return $"{DiceCount}d{DiceSides}{mod} {DamageType.ToString().ToLowerInvariant()}";
}
/// <summary>
/// Parses an expression like "1d6", "2d8+2", "1d4-1", "5" (flat 5),
/// or "0" (no damage). Whitespace is allowed. Throws on malformed input.
/// </summary>
public static DamageRoll Parse(string expr, DamageType damageType)
{
if (string.IsNullOrWhiteSpace(expr))
throw new System.ArgumentException("Damage expression is empty", nameof(expr));
string s = expr.Replace(" ", "").ToLowerInvariant();
int dIdx = s.IndexOf('d');
if (dIdx < 0)
{
// No dice — pure flat (e.g. "5" or "-1").
if (!int.TryParse(s, out int flat))
throw new System.FormatException($"Cannot parse damage '{expr}' as flat int.");
return new DamageRoll(0, 0, flat, damageType);
}
// Split into "<count>" "d" "<sides>[modifier]"
string countStr = s.Substring(0, dIdx);
if (countStr.Length == 0) countStr = "1"; // "d6" → 1d6
if (!int.TryParse(countStr, out int diceCount))
throw new System.FormatException($"Bad dice count in '{expr}'");
string rest = s.Substring(dIdx + 1);
int signIdx = -1;
for (int i = 0; i < rest.Length; i++)
{
if (rest[i] == '+' || rest[i] == '-') { signIdx = i; break; }
}
int sides;
int flatMod;
if (signIdx < 0)
{
if (!int.TryParse(rest, out sides))
throw new System.FormatException($"Bad dice sides in '{expr}'");
flatMod = 0;
}
else
{
if (!int.TryParse(rest.Substring(0, signIdx), out sides))
throw new System.FormatException($"Bad dice sides in '{expr}'");
if (!int.TryParse(rest.Substring(signIdx), out flatMod))
throw new System.FormatException($"Bad flat mod in '{expr}'");
}
if (diceCount < 0 || sides < 0)
throw new System.FormatException($"Negative dice count or sides in '{expr}'");
return new DamageRoll(diceCount, sides, flatMod, damageType);
}
}
@@ -0,0 +1,79 @@
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Phase 5 M6 player death-save loop. d20 every turn while at 0 HP:
/// - 1 → 2 failures
/// - 2..9 → 1 failure
/// - 10..19 → 1 success
/// - 20 → revive at 1 HP (zero out failures + successes)
///
/// 3 cumulative successes (≥10) → stabilised at 0 HP (cleared on heal).
/// 3 cumulative failures (<10) → dead. CombatHUDScreen pushes
/// <see cref="Game.Screens.DefeatedScreen"/> when this fires.
///
/// Tracker lives on <see cref="Combatant"/> only for the player; NPC
/// combatants skip death saves and are removed at 0 HP.
/// </summary>
public sealed class DeathSaveTracker
{
public int Successes { get; private set; }
public int Failures { get; private set; }
public bool Stabilised { get; private set; }
public bool Dead { get; private set; }
/// <summary>Roll a death save and update counters. Returns the outcome.</summary>
public DeathSaveOutcome Roll(Encounter enc, Combatant target)
{
if (Dead || Stabilised) return DeathSaveOutcome.NoOp;
int d20 = enc.RollD20();
DeathSaveOutcome outcome;
if (d20 == 20)
{
// Critical success — revive at 1 HP.
target.CurrentHp = 1;
target.Conditions.Remove(Condition.Unconscious);
Successes = 0;
Failures = 0;
outcome = DeathSaveOutcome.CriticalRevive;
}
else if (d20 >= 10)
{
Successes++;
outcome = Successes >= 3 ? DeathSaveOutcome.Stabilised : DeathSaveOutcome.Success;
if (Successes >= 3) Stabilised = true;
}
else
{
int failsThisRoll = d20 == 1 ? 2 : 1;
Failures += failsThisRoll;
outcome = Failures >= 3 ? DeathSaveOutcome.Dead : DeathSaveOutcome.Failure;
if (Failures >= 3) Dead = true;
}
enc.AppendLog(CombatLogEntry.Kind.Save,
$"{target.Name} death save: {d20} → {outcome} ({Successes}S/{Failures}F)");
return outcome;
}
/// <summary>Called when the character is healed above 0 HP — cancels the loop.</summary>
public void Reset()
{
Successes = 0;
Failures = 0;
Stabilised = false;
// Don't reset Dead — once dead, stays dead.
}
}
public enum DeathSaveOutcome
{
NoOp = 0,
Success = 1,
Failure = 2,
Stabilised = 3,
Dead = 4,
CriticalRevive = 5,
}
+217
View File
@@ -0,0 +1,217 @@
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// One combat encounter. Owns the participants, initiative order, current
/// turn pointer, log, and a per-encounter <see cref="SeededRng"/> seeded
/// from <c>worldSeed ^ C.RNG_COMBAT ^ encounterId</c>. Save/load can resume
/// mid-combat by capturing <see cref="EncounterSeed"/> +
/// <see cref="RollCount"/> and replaying the dice stream from the same
/// sequence point — see <see cref="ResumeRolls"/>.
/// </summary>
public sealed class Encounter
{
public ulong EncounterId { get; }
public ulong EncounterSeed { get; }
public IReadOnlyList<Combatant> Participants => _participants;
public IReadOnlyList<int> InitiativeOrder => _initiativeOrder;
public int CurrentTurnIndex { get; private set; }
public int RoundNumber { get; private set; } = 1;
public Turn CurrentTurn { get; private set; }
public IReadOnlyList<CombatLogEntry> Log => _log;
public bool IsOver => _isOver;
/// <summary>How many dice rolls have been drawn from this encounter's RNG.</summary>
public int RollCount { get; private set; }
private readonly List<Combatant> _participants;
private readonly List<int> _initiativeOrder;
private readonly List<CombatLogEntry> _log = new();
private SeededRng _rng;
private bool _isOver;
public Encounter(ulong worldSeed, ulong encounterId, IEnumerable<Combatant> combatants)
{
EncounterId = encounterId;
EncounterSeed = worldSeed ^ C.RNG_COMBAT ^ encounterId;
_rng = new SeededRng(EncounterSeed);
_participants = new List<Combatant>(combatants);
if (_participants.Count == 0)
throw new System.ArgumentException("Encounter requires at least one combatant.", nameof(combatants));
_initiativeOrder = RollInitiative();
CurrentTurnIndex = 0;
CurrentTurn = Turn.FreshFor(CurrentActor.Id, CurrentActor.SpeedFt);
AppendLog(CombatLogEntry.Kind.Initiative, FormatInitiativeOrder());
AppendLog(CombatLogEntry.Kind.TurnStart, $"Round 1 — {CurrentActor.Name}'s turn.");
}
public Combatant CurrentActor => _participants[_initiativeOrder[CurrentTurnIndex]];
public Combatant? GetById(int id)
{
foreach (var c in _participants) if (c.Id == id) return c;
return null;
}
/// <summary>
/// Advances to the next living combatant. Wraps the round counter when
/// we cycle past the last initiative slot. Marks the encounter over if
/// only one allegiance has living combatants.
/// </summary>
public void EndTurn()
{
if (_isOver) return;
int n = _initiativeOrder.Count;
for (int step = 0; step < n; step++)
{
CurrentTurnIndex++;
if (CurrentTurnIndex >= n)
{
CurrentTurnIndex = 0;
RoundNumber++;
}
var next = CurrentActor;
if (next.IsAlive)
{
CurrentTurn = Turn.FreshFor(next.Id, next.SpeedFt);
next.OnTurnStart(); // Phase 5 M6: reset per-turn feature flags (Sneak Attack)
AppendLog(CombatLogEntry.Kind.TurnStart, $"Round {RoundNumber} — {next.Name}'s turn.");
CheckForVictory();
return;
}
}
// No one is alive.
EndEncounter("No combatants remain.");
}
/// <summary>
/// Returns true and ends the encounter if only one allegiance has
/// living combatants left. Called automatically at end-of-turn.
/// </summary>
public bool CheckForVictory()
{
var living = new HashSet<Rules.Character.Allegiance>();
foreach (var c in _participants)
if (c.IsAlive && !c.IsDown) living.Add(c.Allegiance);
// Allies and Players count as the same side for victory purposes.
bool playerSide = living.Contains(Rules.Character.Allegiance.Player) || living.Contains(Rules.Character.Allegiance.Allied);
bool hostileSide = living.Contains(Rules.Character.Allegiance.Hostile);
if (!playerSide || !hostileSide)
{
string verdict = playerSide ? "Player side wins." : (hostileSide ? "Hostile side wins." : "Mutual annihilation.");
EndEncounter(verdict);
return true;
}
return false;
}
private void EndEncounter(string verdict)
{
_isOver = true;
AppendLog(CombatLogEntry.Kind.EncounterEnd, $"Encounter ends after {RoundNumber} round(s). {verdict}");
}
// ── Dice ──────────────────────────────────────────────────────────────
/// <summary>
/// Draw a uniform integer in [1, sides]. Increments
/// <see cref="RollCount"/>; save/load uses that count to resume.
/// </summary>
public int RollDie(int sides)
{
if (sides < 1) return 0;
RollCount++;
return (int)(_rng.NextUInt64() % (ulong)sides) + 1;
}
public int RollD20() => RollDie(20);
/// <summary>
/// Roll d20 with advantage (best of two) or disadvantage (worst of two).
/// Returns (kept, other) so the caller can log both.
/// </summary>
public (int kept, int other) RollD20WithMode(SituationFlags flags)
{
if (flags.RollsAdvantage())
{
int a = RollD20();
int b = RollD20();
return a >= b ? (a, b) : (b, a);
}
if (flags.RollsDisadvantage())
{
int a = RollD20();
int b = RollD20();
return a <= b ? (a, b) : (b, a);
}
return (RollD20(), -1);
}
/// <summary>
/// Re-create the RNG and skip <paramref name="rollCount"/> rolls.
/// Used by the save layer to resume mid-combat encounters: capture
/// (encounterId, rollCount) on save; recreate Encounter with same
/// participants and call ResumeRolls(savedRollCount) on load.
/// </summary>
public void ResumeRolls(int rollCount)
{
_rng = new SeededRng(EncounterSeed);
for (int i = 0; i < rollCount; i++) _rng.NextUInt64();
RollCount = rollCount;
}
// ── Logging ───────────────────────────────────────────────────────────
public void AppendLog(CombatLogEntry.Kind kind, string message)
{
_log.Add(new CombatLogEntry
{
Round = RoundNumber,
Turn = CurrentTurnIndex,
Type = kind,
Message = message,
});
}
// ── Initiative ────────────────────────────────────────────────────────
private List<int> RollInitiative()
{
var rolls = new (int idx, int total, int initBonus, int dexMod)[_participants.Count];
for (int i = 0; i < _participants.Count; i++)
{
var c = _participants[i];
int d20 = RollD20();
rolls[i] = (i, d20 + c.InitiativeBonus, c.InitiativeBonus,
Stats.AbilityScores.Mod(c.Abilities.DEX));
}
// Sort descending by total; ties broken by DEX mod descending; final tiebreaker by id ascending.
System.Array.Sort(rolls, (a, b) =>
{
int byTotal = b.total.CompareTo(a.total);
if (byTotal != 0) return byTotal;
int byDex = b.dexMod.CompareTo(a.dexMod);
if (byDex != 0) return byDex;
return _participants[a.idx].Id.CompareTo(_participants[b.idx].Id);
});
var order = new List<int>(rolls.Length);
foreach (var r in rolls) order.Add(r.idx);
return order;
}
private string FormatInitiativeOrder()
{
var parts = new List<string>(_initiativeOrder.Count);
foreach (int idx in _initiativeOrder)
{
var c = _participants[idx];
parts.Add($"{c.Name} (init+{c.InitiativeBonus})");
}
return "Initiative: " + string.Join(", ", parts);
}
}
@@ -0,0 +1,82 @@
using Theriapolis.Core.Entities;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Per-tick check used by <see cref="Game.Screens.PlayScreen"/>:
/// "is there a hostile NPC within encounter trigger range that has line of
/// sight?" Returns the closest qualifying actor (or null) so the caller can
/// kick off an encounter.
///
/// Friendly / Neutral proximity is the same shape but uses a tighter radius
/// — see <see cref="FindInteractCandidate"/>.
/// </summary>
public static class EncounterTrigger
{
/// <summary>
/// Find the closest live <em>hostile</em> NPC within
/// <see cref="C.ENCOUNTER_TRIGGER_TILES"/> of the player that the
/// <paramref name="losBlocked"/> predicate can see (no blocking tile
/// between). Returns null if none found.
/// </summary>
public static NpcActor? FindHostileTrigger(
ActorManager actors,
System.Func<int, int, bool>? losBlocked = null)
{
var player = actors.Player;
if (player is null) return null;
var blocked = losBlocked ?? LineOfSight.AlwaysClear;
NpcActor? best = null;
int bestDistSq = C.ENCOUNTER_TRIGGER_TILES * C.ENCOUNTER_TRIGGER_TILES;
foreach (var npc in actors.Npcs)
{
if (!npc.IsAlive) continue;
if (npc.Allegiance != Rules.Character.Allegiance.Hostile) continue;
int distSq = ChebyshevDistSq(player.Position, npc.Position);
if (distSq > bestDistSq) continue;
if (!LineOfSight.HasLine(player.Position, npc.Position, blocked)) continue;
if (best is null || distSq < bestDistSq) { best = npc; bestDistSq = distSq; }
}
return best;
}
/// <summary>
/// Friendly / Neutral NPCs within
/// <see cref="C.INTERACT_PROMPT_TILES"/> of the player. The HUD shows
/// "[F] Talk to ..." for the closest match.
/// </summary>
public static NpcActor? FindInteractCandidate(
ActorManager actors,
System.Func<int, int, bool>? losBlocked = null)
{
var player = actors.Player;
if (player is null) return null;
var blocked = losBlocked ?? LineOfSight.AlwaysClear;
NpcActor? best = null;
int bestDistSq = C.INTERACT_PROMPT_TILES * C.INTERACT_PROMPT_TILES;
foreach (var npc in actors.Npcs)
{
if (!npc.IsAlive) continue;
if (npc.Allegiance != Rules.Character.Allegiance.Friendly &&
npc.Allegiance != Rules.Character.Allegiance.Neutral) continue;
int distSq = ChebyshevDistSq(player.Position, npc.Position);
if (distSq > bestDistSq) continue;
if (!LineOfSight.HasLine(player.Position, npc.Position, blocked)) continue;
if (best is null || distSq < bestDistSq) { best = npc; bestDistSq = distSq; }
}
return best;
}
private static int ChebyshevDistSq(Vec2 a, Vec2 b)
{
int dx = (int)System.Math.Abs(a.X - b.X);
int dy = (int)System.Math.Abs(a.Y - b.Y);
int d = System.Math.Max(dx, dy);
return d * d;
}
}
@@ -0,0 +1,781 @@
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Phase 5 M6: dispatches class-feature combat effects at hook points the
/// resolver and DerivedStats call into. Hand-coded switch-on-class-id; the
/// alternative would be a feature-registration system — overkill for the
/// half-dozen combat-touching level-1 features we actually ship.
///
/// Implemented features:
/// - Fangsworn fighting styles: Duelist (+2 dmg one-handed), Great Weapon (re-roll 1s/2s on dmg)
/// - Feral: Unarmored Defense (10 + DEX + CON when no body armor), Feral Rage (+2 dmg, resistance)
/// - Bulwark: Sentinel Stance (+2 AC), Guardian's Mark (UI hook only — full effect M6.5)
/// - Shadow-Pelt: Sneak Attack (+1d6 first hit per turn with finesse/ranged weapon)
///
/// Stubs (no combat effect at M6 — flagged for later wiring):
/// - Scent-Broker, Covenant-Keeper, Muzzle-Speaker, Claw-Wright level-1
/// features. They appear in level_table but don't alter dice yet.
/// </summary>
public static class FeatureProcessor
{
/// <summary>
/// Returns the raw AC for a character, factoring in class features.
/// Called by <see cref="DerivedStats.ArmorClass"/> *after* the standard
/// armor/shield/DEX computation so this layer can either replace
/// (Unarmored Defense) or add (Sentinel Stance) to the base.
///
/// Returns the *new* AC value to use; pass back <paramref name="baseAc"/>
/// when no feature applies.
/// </summary>
public static int ApplyAcFeatures(Theriapolis.Core.Rules.Character.Character c, int baseAc)
{
int ac = baseAc;
// Feral Unarmored Defense replaces base if no body armor.
if (c.ClassDef.Id == "feral" && c.Inventory.GetEquipped(EquipSlot.Body) is null)
{
int dex = c.Abilities.ModFor(AbilityId.DEX);
int con = c.Abilities.ModFor(AbilityId.CON);
int unarmoredAc = 10 + dex + con;
// Take whichever is higher — Feral may pick up a buckler offhand etc. that pushes baseAc higher.
if (unarmoredAc > ac) ac = unarmoredAc;
}
return ac;
}
/// <summary>
/// AC bonus from per-encounter combat-time features (Sentinel Stance, etc).
/// Combat resolver adds this to the combatant's base AC at attack-resolution time.
///
/// Phase 6.5 M2 layers in subclass passive AC bonuses — caller passes
/// the encounter so the resolver can consult positional state for
/// adjacency-driven features (Herd-Wall Interlock Shields, Lone Fang
/// Isolation Bonus).
/// </summary>
public static int ApplyAcBonus(Combatant target, Encounter? enc = null)
{
int bonus = 0;
if (target.SentinelStanceActive) bonus += 2;
// Phase 6.5 M2 subclass passives.
var c = target.SourceCharacter;
if (c is not null && enc is not null && !string.IsNullOrEmpty(c.SubclassId))
{
switch (c.SubclassId)
{
case "lone_fang":
if (HasLoneFangIsolation(target, enc)) bonus += 1;
break;
case "herd_wall":
if (HasHerdWallAdjacentAlly(target, enc)) bonus += 1;
break;
}
}
return bonus;
}
/// <summary>
/// Phase 6.5 M2 — to-hit bonus from subclass features that boost
/// attack rolls (e.g. Lone Fang Isolation Bonus). Resolver adds this
/// to <c>attackTotal</c> alongside the base attack bonus.
/// </summary>
public static int ApplyToHitBonus(Combatant attacker, Encounter enc)
{
int bonus = 0;
var c = attacker.SourceCharacter;
if (c is null || string.IsNullOrEmpty(c.SubclassId)) return 0;
switch (c.SubclassId)
{
case "lone_fang":
if (HasLoneFangIsolation(attacker, enc)) bonus += 2;
break;
}
return bonus;
}
/// <summary>
/// True when the Lone Fang's "Isolation Bonus" applies — no allied
/// combatant within 10 ft. <see cref="ReachAndCover.EdgeToEdgeChebyshev"/>
/// returns the number of *empty tiles between* two footprints, so:
/// 0 = touching (5 ft. away), 1 = one empty tile (10 ft.), etc.
/// "Within 10 ft" means edge-to-edge ≤ 1.
/// </summary>
private static bool HasLoneFangIsolation(Combatant self, Encounter enc)
{
foreach (var c in enc.Participants)
{
if (c.Id == self.Id) continue;
if (c.IsDown) continue;
bool sameSide = (self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|| self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
&& (c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|| c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied);
if (!sameSide) continue;
if (ReachAndCover.EdgeToEdgeChebyshev(self, c) <= 1) return false;
}
return true;
}
/// <summary>
/// True when the Herd-Wall has at least one allied combatant adjacent.
/// "Adjacent" in the d20 sense = sharing an edge or corner; with the
/// edge-to-edge "empty tiles between" metric that's distance 0.
/// </summary>
private static bool HasHerdWallAdjacentAlly(Combatant self, Encounter enc)
{
foreach (var c in enc.Participants)
{
if (c.Id == self.Id) continue;
if (c.IsDown) continue;
bool sameSide = (self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|| self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
&& (c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|| c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied);
if (!sameSide) continue;
if (ReachAndCover.EdgeToEdgeChebyshev(self, c) == 0) return true;
}
return false;
}
/// <summary>
/// Phase 6.5 M2 — Pack-Forged "Packmate's Howl". Called from the
/// resolver when a Pack-Forged hits a target with a melee attack:
/// marks the target so the next *ally* attack against it gains
/// advantage (until the marker's next turn).
/// </summary>
public static void OnPackForgedHit(Encounter enc, Combatant attacker, Combatant target, AttackOption attack)
{
var c = attacker.SourceCharacter;
if (c is null || c.SubclassId != "pack_forged") return;
if (attack.IsRanged) return; // melee only per the description
target.HowlMarkRound = enc.RoundNumber;
target.HowlMarkBy = attacker.Id;
enc.AppendLog(CombatLogEntry.Kind.Note,
$" Packmate's Howl: {target.Name} marked — next ally attack has advantage.");
}
/// <summary>
/// Phase 6.5 M2 — Pack-Forged consumption hook. If the target carries
/// a Howl mark from one of the attacker's *allies* (not self), and the
/// mark hasn't expired, returns true (advantage on this attack) and
/// clears the mark. Resolver calls this before rolling the d20.
/// </summary>
public static bool ConsumeHowlAdvantage(Encounter enc, Combatant attacker, Combatant target)
{
if (target.HowlMarkRound is not int markRound) return false;
if (target.HowlMarkBy is not int markBy) return false;
if (markBy == attacker.Id) return false; // can't consume your own mark
// Mark expires once the marker's next turn begins. Approximation: a
// mark placed on round N consumed on round N or N+1 (before marker
// gets to act) is valid; round > markRound + 1 = expired.
if (enc.RoundNumber > markRound + 1) return false;
// Allies only: the marker must be on the same side as the attacker.
var marker = enc.GetById(markBy);
if (marker is null) return false;
bool sameSide = (attacker.Allegiance == marker.Allegiance)
|| (attacker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
&& marker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
|| (attacker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied
&& marker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player);
if (!sameSide) return false;
// Consume.
target.HowlMarkRound = null;
target.HowlMarkBy = null;
enc.AppendLog(CombatLogEntry.Kind.Note,
$" {attacker.Name} consumes Packmate's Howl — advantage on this attack.");
return true;
}
/// <summary>
/// Phase 6.5 M2 — Blood Memory "Predatory Surge". Called by the
/// resolver when a raging Feral with this subclass reduces a target
/// to 0 HP with a melee attack. Sets the surge-pending flag; the HUD
/// can offer the player a free bonus melee attack (M2 wires the flag;
/// the bonus-action consumption is the player's job via the existing
/// attack input).
/// </summary>
public static void OnBloodMemoryKill(Encounter enc, Combatant attacker, AttackOption attack)
{
var c = attacker.SourceCharacter;
if (c is null || c.SubclassId != "blood_memory") return;
if (!attacker.RageActive) return;
if (attack.IsRanged) return;
attacker.PredatorySurgePending = true;
enc.AppendLog(CombatLogEntry.Kind.Note,
$" Predatory Surge: {attacker.Name} can take a free melee attack.");
}
/// <summary>
/// Damage bonus from feature effects (Fighting Style, Rage, Sneak Attack).
/// Returns extra damage to add to the rolled total. Side effects: marks
/// <see cref="Combatant.SneakAttackUsedThisTurn"/> when sneak attack fires.
/// </summary>
public static int ApplyDamageBonus(
Encounter enc,
Combatant attacker,
Combatant target,
AttackOption attack,
bool isCrit)
{
int bonus = 0;
var c = attacker.SourceCharacter;
// Feral Rage — +2 damage on melee attacks while raging.
if (attacker.RageActive && !attack.IsRanged) bonus += 2;
// Fangsworn fighting styles.
if (c is not null && c.ClassDef.Id == "fangsworn")
{
if (c.FightingStyle == "duelist" && IsOneHanded(c))
bonus += 2;
// Great Weapon and Natural Predator handled elsewhere (re-roll
// and to-hit respectively).
}
// Shadow-Pelt Sneak Attack — once per turn, +1d6 with finesse/ranged.
if (c is not null && c.ClassDef.Id == "shadow_pelt"
&& !attacker.SneakAttackUsedThisTurn
&& IsFinesseOrRanged(attacker, attack))
{
int d6 = enc.RollDie(6);
if (isCrit) d6 += enc.RollDie(6); // crit doubles the sneak attack die
bonus += d6;
attacker.SneakAttackUsedThisTurn = true;
enc.AppendLog(CombatLogEntry.Kind.Note, $" Sneak Attack: +{d6}");
}
// Phase 7 M0 — Ambush-Artist "Opening Strike". First melee attack of
// round 1 in the encounter (the "ambush" round) deals +2d6 sneak
// damage. Stacks with base Sneak Attack — opening strike represents
// a different surprise mechanism.
if (c is not null && c.SubclassId == "ambush_artist"
&& !attacker.OpeningStrikeUsed
&& enc.RoundNumber == 1
&& !attack.IsRanged)
{
int d6a = enc.RollDie(6);
int d6b = enc.RollDie(6);
int extra = d6a + d6b;
if (isCrit) extra += enc.RollDie(6) + enc.RollDie(6);
bonus += extra;
attacker.OpeningStrikeUsed = true;
enc.AppendLog(CombatLogEntry.Kind.Note, $" Opening Strike: +{extra}");
}
// Phase 7 M0 — Stampede-Heart "Trampling Charge". First melee attack
// each turn while raging deals +1d8 bludgeoning. Phase 7 simplifies
// the JSON's "moved 20+ ft. straight" geometry constraint to "first
// melee attack while raging" — captures the spirit of the charge
// without requiring a movement-vector tracker the tactical layer
// doesn't yet expose. Phase 8 / 9 polish can refine.
if (c is not null && c.SubclassId == "stampede_heart"
&& attacker.RageActive
&& !attacker.TramplingChargeUsedThisTurn
&& !attack.IsRanged)
{
int d8 = enc.RollDie(8);
if (isCrit) d8 += enc.RollDie(8);
bonus += d8;
attacker.TramplingChargeUsedThisTurn = true;
enc.AppendLog(CombatLogEntry.Kind.Note, $" Trampling Charge: +{d8}");
}
return bonus;
}
/// <summary>
/// Phase 7 M0 — Antler-Guard "Retaliatory Strike". Called from
/// <see cref="Resolver.AttemptAttack"/> after damage applies on a melee
/// hit. If the target is an Antler-Guard Bulwark in Sentinel Stance,
/// the attacker takes 1d8 + CON (the target's CON) automatic damage.
/// Phase 7 contract: deterrence-style return-damage, no save, no roll —
/// the attack itself is the trigger. Doesn't fire on ranged attacks
/// (the JSON specifies "from a melee attack").
/// </summary>
public static int OnAntlerGuardHit(Encounter enc, Combatant attacker, Combatant target, AttackOption attack)
{
var c = target.SourceCharacter;
if (c is null || c.SubclassId != "antler_guard") return 0;
if (!target.SentinelStanceActive) return 0;
if (attack.IsRanged) return 0;
// 1d8 + CON-mod return damage; min 1.
int d8 = enc.RollDie(8);
int con = AbilityScores.Mod(target.Abilities.Get(AbilityId.CON));
int retaliation = System.Math.Max(1, d8 + con);
Resolver.ApplyDamage(attacker, retaliation);
enc.AppendLog(CombatLogEntry.Kind.Note,
$" Retaliatory Strike: {target.Name} returns {retaliation} ({d8}+{con}) to {attacker.Name}.");
return retaliation;
}
/// <summary>
/// Re-roll 1s and 2s on damage dice for Fangsworn Great Weapon style.
/// Called by DamageRoll.Roll only if the attacker has the style + a
/// two-handed weapon. Returns the (possibly adjusted) dice value.
/// </summary>
public static int GreatWeaponReroll(Encounter enc, Combatant attacker, AttackOption attack, int rolledDie, int sides)
{
var c = attacker.SourceCharacter;
if (c is null || c.ClassDef.Id != "fangsworn" || c.FightingStyle != "great_weapon") return rolledDie;
if (!IsTwoHanded(c)) return rolledDie;
if (rolledDie > 2) return rolledDie;
// Re-roll once and take the new value (even if also 1 or 2).
int rerolled = enc.RollDie(sides);
return rerolled;
}
/// <summary>
/// True if the damage type is fully resisted (half-damage). Phase 5 M6:
/// Feral Rage gives resistance to bludgeoning/piercing/slashing while active.
/// </summary>
public static bool IsResisted(Combatant target, DamageType damageType)
{
if (target.RageActive)
{
return damageType == DamageType.Bludgeoning
|| damageType == DamageType.Piercing
|| damageType == DamageType.Slashing;
}
return false;
}
/// <summary>
/// Activate Feral Rage. Returns true if the rage started (had uses
/// remaining); false if the character has no uses left.
/// </summary>
public static bool TryActivateRage(Encounter enc, Combatant attacker)
{
var c = attacker.SourceCharacter;
if (c is null || c.ClassDef.Id != "feral") return false;
if (attacker.RageActive) return false;
if (c.RageUsesRemaining <= 0) return false;
attacker.RageActive = true;
c.RageUsesRemaining--;
enc.AppendLog(CombatLogEntry.Kind.Note, $"{attacker.Name} enters a rage. ({c.RageUsesRemaining} use(s) left)");
return true;
}
/// <summary>Toggle Bulwark Sentinel Stance.</summary>
public static bool ToggleSentinelStance(Encounter enc, Combatant attacker)
{
var c = attacker.SourceCharacter;
if (c is null || c.ClassDef.Id != "bulwark") return false;
attacker.SentinelStanceActive = !attacker.SentinelStanceActive;
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{attacker.Name} {(attacker.SentinelStanceActive ? "enters" : "leaves")} Sentinel Stance.");
return true;
}
// ── Phase 6.5 M1: level-1 active class features ──────────────────────
/// <summary>
/// Claw-Wright <c>field_repair</c>. Action; heals <c>1d8 + INT mod</c>
/// HP to the target. Hybrid heal-target effectiveness (75%) applies if
/// the target is a hybrid PC (Phase 6.5 M5 schema-stub for now — no
/// hybrids exist yet, so the multiplier is gated by future data).
/// </summary>
public static bool TryFieldRepair(Encounter enc, Combatant healer, Combatant target)
{
var c = healer.SourceCharacter;
if (c is null || c.ClassDef.Id != "claw_wright") return false;
if (c.FieldRepairUsesRemaining <= 0)
{
enc.AppendLog(CombatLogEntry.Kind.Note, $"{healer.Name}: Field Repair exhausted (rest to recover).");
return false;
}
if (target.IsDown) return false;
int intMod = c.Abilities.ModFor(AbilityId.INT);
// Phase 7 M0 — Body-Wright "Combat Medic" rolls 2d8 + INT instead of
// the base 1d8 + INT. The bonus-action treatment described in the
// JSON is a HUD-side concern (the resource economy is unchanged);
// this hook adjusts only the dice.
int rolled;
if (c.SubclassId == "body_wright")
{
rolled = enc.RollDie(8) + enc.RollDie(8);
}
else
{
rolled = enc.RollDie(8);
}
int healed = Math.Max(1, rolled + intMod);
// Phase 6.5 M4 — Medical Incompatibility: hybrid recipients heal at
// 75% effectiveness (round down, min 1). Non-hybrids pass through.
int delivered = healed;
if (target.SourceCharacter is { } targetCharacter)
delivered = Theriapolis.Core.Rules.Character.HybridDetriments.ScaleHealForHybrid(
targetCharacter, healed);
target.CurrentHp = Math.Min(target.MaxHp, target.CurrentHp + delivered);
c.FieldRepairUsesRemaining--;
string scaledNote = (target.SourceCharacter?.IsHybrid ?? false) && delivered != healed
? $" (hybrid → {delivered})"
: "";
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{healer.Name} Field Repair on {target.Name}: rolled {rolled} + INT {intMod:+#;-#;0} = {healed} HP{scaledNote}. ({target.CurrentHp}/{target.MaxHp})");
return true;
}
/// <summary>
/// Covenant-Keeper <c>lay_on_paws</c>. Action; spend up to a fixed
/// amount from a pool of <c>5 × CHA</c> HP per long rest (per-encounter
/// at M1) to heal a target. Pool tops up via
/// <see cref="EnsureLayOnPawsPoolReady"/>; spending one point cures
/// disease — not modelled here yet (no disease subsystem).
/// </summary>
public static bool TryLayOnPaws(Encounter enc, Combatant healer, Combatant target, int requestHp)
{
var c = healer.SourceCharacter;
if (c is null || c.ClassDef.Id != "covenant_keeper") return false;
if (target.IsDown) return false;
if (requestHp <= 0) return false;
int spend = Math.Min(requestHp, c.LayOnPawsPoolRemaining);
if (spend <= 0)
{
enc.AppendLog(CombatLogEntry.Kind.Note, $"{healer.Name}: Lay on Paws pool empty (rest to refill).");
return false;
}
// Phase 6.5 M4 — Medical Incompatibility scales hybrid heal received,
// but the *cost* to the pool is the requested amount. (Hybrid pays
// the same cost; the inefficiency models the body resisting the
// calibration, not the healer wasting effort.)
int delivered = spend;
if (target.SourceCharacter is { } targetCharacter)
delivered = Theriapolis.Core.Rules.Character.HybridDetriments.ScaleHealForHybrid(
targetCharacter, spend);
target.CurrentHp = Math.Min(target.MaxHp, target.CurrentHp + delivered);
c.LayOnPawsPoolRemaining -= spend;
string scaledNote = (target.SourceCharacter?.IsHybrid ?? false) && delivered != spend
? $" (hybrid → {delivered})"
: "";
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{healer.Name} channels Lay on Paws → {target.Name} +{spend} HP{scaledNote}. ({target.CurrentHp}/{target.MaxHp}, pool {c.LayOnPawsPoolRemaining})");
return true;
}
/// <summary>
/// Initialise / refresh the Lay on Paws pool to <c>5 × CHA mod</c> if
/// the character has the <c>lay_on_paws</c> feature. Called at
/// encounter start so M1 (no rest model) treats every encounter as
/// fully rested. CHA mod ≤ 0 yields a 1-point minimum so a low-CHA
/// Covenant-Keeper still has a token pool.
/// </summary>
public static void EnsureLayOnPawsPoolReady(Theriapolis.Core.Rules.Character.Character c)
{
if (c.ClassDef.Id != "covenant_keeper") return;
int chaMod = c.Abilities.ModFor(AbilityId.CHA);
int target = Math.Max(1, 5 * Math.Max(1, chaMod));
if (c.LayOnPawsPoolRemaining < target)
c.LayOnPawsPoolRemaining = target;
}
/// <summary>
/// Refresh the Field Repair use to 1 if it's been spent. Encounter-rest
/// equivalence per the Phase 5 contract.
/// </summary>
public static void EnsureFieldRepairReady(Theriapolis.Core.Rules.Character.Character c)
{
if (c.ClassDef.Id != "claw_wright") return;
if (c.FieldRepairUsesRemaining < 1) c.FieldRepairUsesRemaining = 1;
}
/// <summary>
/// Refresh Vocalization Dice to 4 if any have been spent. Encounter-rest
/// equivalence per the Phase 5 contract.
/// </summary>
public static void EnsureVocalizationDiceReady(Theriapolis.Core.Rules.Character.Character c)
{
if (c.ClassDef.Id != "muzzle_speaker") return;
if (c.VocalizationDiceRemaining < 4) c.VocalizationDiceRemaining = 4;
}
// ── Phase 6.5 M3: Pheromone Craft (Scent-Broker) ─────────────────────
/// <summary>
/// Pheromone Craft uses-per-encounter cap based on character level. The
/// JSON ladder unlocks more uses at higher levels:
/// L14 → 0 (feature not unlocked yet),
/// L58 → 2 (pheromone_craft_2 from L2 entry retains here, but the L5
/// entry brings pheromone_craft_3),
/// L912 → 4, L13+ → 5. The granted-at-each-level structure in
/// <c>classes.json</c> uses the highest-tier feature unlocked.
/// </summary>
public static int PheromoneUsesAtLevel(int level) => level switch
{
>= 13 => 5,
>= 9 => 4,
>= 5 => 3,
>= 2 => 2,
_ => 0,
};
/// <summary>
/// Refill the Scent-Broker's Pheromone Craft pool to the per-level cap.
/// Encounter-rest equivalence; Phase 8 replaces with real short-rest.
/// </summary>
public static void EnsurePheromoneUsesReady(Theriapolis.Core.Rules.Character.Character c)
{
if (c.ClassDef.Id != "scent_broker") return;
int cap = PheromoneUsesAtLevel(c.Level);
if (c.PheromoneUsesRemaining < cap) c.PheromoneUsesRemaining = cap;
}
/// <summary>
/// Scent-Broker <c>pheromone_craft_*</c>. Bonus action; emits a 10-ft
/// (= 2 tactical tile) cloud centred on the caster. Every creature in
/// range that the caster considers hostile must make a CON save vs.
/// <c>DC = 8 + prof + WIS mod</c>; on failure, the pheromone-mapped
/// <see cref="Theriapolis.Core.Rules.Stats.Condition"/> is applied
/// (<see cref="PheromoneTypeExtensions.AppliedCondition"/>).
/// Consumes one Pheromone Use.
/// </summary>
public static bool TryEmitPheromone(Encounter enc, Combatant caster, PheromoneType type)
{
var c = caster.SourceCharacter;
if (c is null || c.ClassDef.Id != "scent_broker") return false;
if (c.Level < 2)
{
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name}: Pheromone Craft unlocks at level 2.");
return false;
}
if (c.PheromoneUsesRemaining <= 0)
{
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name}: Pheromone Craft exhausted (rest to recover).");
return false;
}
int wisMod = c.Abilities.ModFor(Theriapolis.Core.Rules.Stats.AbilityId.WIS);
int dc = 8 + c.ProficiencyBonus + wisMod;
var applied = type.AppliedCondition();
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name} emits {type.DisplayName()} pheromone (DC {dc}).");
int affected = 0;
foreach (var t in enc.Participants)
{
if (t.Id == caster.Id) continue;
if (t.IsDown) continue;
// 10 ft. cloud = within 1 empty tile (≤ 1 edge-to-edge).
if (ReachAndCover.EdgeToEdgeChebyshev(caster, t) > 1) continue;
// Only target hostiles for offensive pheromones; calm targets
// hostiles too (charmed-toward-source is the desired effect).
bool sameSide = (caster.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|| caster.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
&& (t.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|| t.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied);
if (sameSide) continue;
// Roll CON save: 1d20 + CON mod.
int conMod = Theriapolis.Core.Rules.Stats.AbilityScores.Mod(t.Abilities.CON);
int saveRoll = enc.RollD20();
int saveTotal = saveRoll + conMod;
bool saved = saveTotal >= dc;
enc.AppendLog(CombatLogEntry.Kind.Save,
$" {t.Name} CON save: {saveRoll}{conMod:+#;-#;0} = {saveTotal} vs DC {dc} → {(saved ? "saved" : "FAILED")}");
if (!saved && applied != Theriapolis.Core.Rules.Stats.Condition.None)
{
Resolver.ApplyCondition(enc, t, applied);
affected++;
}
}
c.PheromoneUsesRemaining--;
if (affected == 0)
enc.AppendLog(CombatLogEntry.Kind.Note,
$" No hostiles affected by the pheromone.");
return true;
}
// ── Phase 6.5 M3: Covenant Authority (Covenant-Keeper) ───────────────
/// <summary>
/// Covenant Authority uses-per-encounter cap based on level. JSON
/// ladder: <c>covenants_authority_2/3/4/5</c> at L2/L9/L13/L17 →
/// 2 / 3 / 4 / 5.
/// </summary>
public static int CovenantAuthorityUsesAtLevel(int level) => level switch
{
>= 17 => 5,
>= 13 => 4,
>= 9 => 3,
>= 2 => 2,
_ => 0,
};
/// <summary>
/// Refill the Covenant-Keeper's Authority pool to the per-level cap.
/// </summary>
public static void EnsureCovenantAuthorityReady(Theriapolis.Core.Rules.Character.Character c)
{
if (c.ClassDef.Id != "covenant_keeper") return;
int cap = CovenantAuthorityUsesAtLevel(c.Level);
if (c.CovenantAuthorityUsesRemaining < cap) c.CovenantAuthorityUsesRemaining = cap;
}
/// <summary>
/// Covenant-Keeper <c>covenants_authority_*</c>. Bonus action; declares
/// an oath against a target hostile; for 10 rounds (1 minute), the
/// oath-marked creature suffers -2 to attack rolls against the
/// Covenant-Keeper. Consumes one use. The full three-option
/// description (Compel Truth / Rebuke Predation / Shield the Innocent)
/// is plan-deferred to Phase 8/9 dialogue + AoE polish; M3 ships the
/// simple combat-marker mechanic per the Phase 6.5 plan §4.4.
/// </summary>
public static bool TryDeclareOath(Encounter enc, Combatant caster, Combatant target)
{
var c = caster.SourceCharacter;
if (c is null || c.ClassDef.Id != "covenant_keeper") return false;
if (c.Level < 2)
{
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name}: Covenant's Authority unlocks at level 2.");
return false;
}
if (c.CovenantAuthorityUsesRemaining <= 0)
{
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name}: Covenant's Authority exhausted (rest to recover).");
return false;
}
if (target.IsDown) return false;
if (target.Id == caster.Id) return false;
target.OathMarkRound = enc.RoundNumber;
target.OathMarkBy = caster.Id;
c.CovenantAuthorityUsesRemaining--;
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name} pronounces an oath against {target.Name} — -2 attack vs caster for 1 minute.");
return true;
}
/// <summary>
/// Phase 6.5 M3 — to-hit penalty applied to a marked attacker rolling
/// against the Covenant-Keeper who marked them. Returns 0 when no
/// active oath, -2 when the marked attacker targets the marker, and 0
/// for any other target (the oath is target-specific).
/// </summary>
public static int OathAttackPenalty(Encounter enc, Combatant attacker, Combatant defender)
{
if (attacker.OathMarkRound is not int markRound) return 0;
if (attacker.OathMarkBy is not int markBy) return 0;
// Expire after 10 rounds.
if (enc.RoundNumber > markRound + 9)
{
attacker.OathMarkRound = null;
attacker.OathMarkBy = null;
return 0;
}
if (markBy != defender.Id) return 0; // penalty only when attacking the marker
return -2;
}
/// <summary>
/// Muzzle-Speaker Vocalization Dice (level-1 d6, scaling to d8/d10/d12
/// at L5/L9/L15). Bonus action; consumes one die. The target combatant
/// gains <see cref="Combatant.InspirationDieSides"/> = the current die
/// size; the next attack/check/save they make rolls that bonus.
/// </summary>
public static bool TryGrantVocalizationDie(Encounter enc, Combatant caster, Combatant ally)
{
var c = caster.SourceCharacter;
if (c is null || c.ClassDef.Id != "muzzle_speaker") return false;
if (caster.Id == ally.Id) return false; // can't inspire yourself
if (c.VocalizationDiceRemaining <= 0)
{
enc.AppendLog(CombatLogEntry.Kind.Note, $"{caster.Name}: Vocalization Dice spent (rest to recover).");
return false;
}
if (ally.InspirationDieSides > 0)
{
// Already inspired — overlapping inspirations don't stack at L1.
enc.AppendLog(CombatLogEntry.Kind.Note, $"{caster.Name}: {ally.Name} already inspired.");
return false;
}
// Range gate: 60 ft. = 12 tactical tiles per the standard 5-ft tile.
int dist = caster.DistanceTo(ally);
if (dist > 12)
{
enc.AppendLog(CombatLogEntry.Kind.Note, $"{caster.Name}: {ally.Name} too far for Vocalization Dice ({dist}/12).");
return false;
}
int sides = VocalizationDieSidesFor(c.Level);
ally.InspirationDieSides = sides;
c.VocalizationDiceRemaining--;
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name} grants {ally.Name} a Vocalization Die (1d{sides}). ({c.VocalizationDiceRemaining} left)");
return true;
}
/// <summary>
/// Standard d20 Bardic Inspiration die ladder, mapped to Vocalization
/// Dice per <c>classes.json</c> level table:
/// 14 → d6; 58 → d8; 914 → d10; 15+ → d12.
/// </summary>
public static int VocalizationDieSidesFor(int level) => level switch
{
>= 15 => 12,
>= 9 => 10,
>= 5 => 8,
_ => 6,
};
/// <summary>
/// Consume an inspiration die (if any) on a d20 roll. Adds 1d&lt;sides&gt;
/// to the d20 result and clears the field. Returns the bonus added (0 if
/// no inspiration was active).
/// </summary>
public static int ConsumeInspirationDie(Encounter enc, Combatant roller)
{
if (roller.InspirationDieSides <= 0) return 0;
int sides = roller.InspirationDieSides;
int rolled = enc.RollDie(sides);
roller.InspirationDieSides = 0;
enc.AppendLog(CombatLogEntry.Kind.Note,
$" {roller.Name} adds Vocalization Die (1d{sides} = {rolled}).");
return rolled;
}
// ── Helpers ──────────────────────────────────────────────────────────
private static bool IsOneHanded(Theriapolis.Core.Rules.Character.Character c)
{
var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
if (main is null) return false;
if (HasProp(main.Def, "two_handed")) return false;
var off = c.Inventory.GetEquipped(EquipSlot.OffHand);
// Duelist requires the off hand to be empty (shields don't count as another weapon, but the d20 spec says "no other weapon" — for M6 we treat shields as OK).
if (off is null) return true;
return string.Equals(off.Def.Kind, "shield", System.StringComparison.OrdinalIgnoreCase);
}
private static bool IsTwoHanded(Theriapolis.Core.Rules.Character.Character c)
{
var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
return main is not null && HasProp(main.Def, "two_handed");
}
private static bool IsFinesseOrRanged(Combatant attacker, AttackOption attack)
{
if (attack.IsRanged) return true;
var c = attacker.SourceCharacter;
if (c is null) return false;
var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
if (main is null) return false;
return HasProp(main.Def, "finesse");
}
private static bool HasProp(Theriapolis.Core.Data.ItemDef def, string prop)
{
foreach (var p in def.Properties)
if (string.Equals(p, prop, System.StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
}
@@ -0,0 +1,48 @@
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Tactical-tile line-of-sight via Bresenham. The caller supplies a
/// "blocked at (x, y)?" predicate so this helper stays free of a hard
/// dependency on TacticalChunk / WorldState — Phase 5 M4 tests use a flat
/// arena (always-clear); M5 plugs in the live tactical-tile sampler.
/// </summary>
public static class LineOfSight
{
/// <summary>
/// True if a straight line from <paramref name="from"/> to
/// <paramref name="to"/> traverses only un-blocked tiles. Endpoints
/// themselves are NOT consulted — only the intermediate tiles.
/// </summary>
public static bool HasLine(Vec2 from, Vec2 to, System.Func<int, int, bool> isBlockedAt)
{
int x0 = (int)System.Math.Floor(from.X);
int y0 = (int)System.Math.Floor(from.Y);
int x1 = (int)System.Math.Floor(to.X);
int y1 = (int)System.Math.Floor(to.Y);
int dx = System.Math.Abs(x1 - x0);
int dy = System.Math.Abs(y1 - y0);
int sx = x0 < x1 ? 1 : -1;
int sy = y0 < y1 ? 1 : -1;
int err = dx - dy;
int x = x0, y = y0;
while (true)
{
// Skip the endpoint itself
if (!(x == x0 && y == y0) && !(x == x1 && y == y1))
{
if (isBlockedAt(x, y)) return false;
}
if (x == x1 && y == y1) return true;
int e2 = 2 * err;
if (e2 > -dy) { err -= dy; x += sx; }
if (e2 < dx) { err += dx; y += sy; }
}
}
/// <summary>Convenience: always-clear arena. Used by combat-duel and most M4 tests.</summary>
public static readonly System.Func<int, int, bool> AlwaysClear = (_, _) => false;
}
@@ -0,0 +1,36 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Tactical;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Maps a <see cref="TacticalSpawn"/> + chunk's <see cref="TacticalChunk.DangerZone"/>
/// to the actual <see cref="NpcTemplateDef"/> that should spawn there.
/// Lookup table lives in <c>npc_templates.json</c>'s
/// <c>spawn_kind_to_template_by_zone</c> map (loaded into
/// <see cref="NpcTemplateContent.SpawnKindToTemplateByZone"/>).
///
/// Returns null when no template is configured for the spawn kind/zone (the
/// caller should skip that spawn — chunk is silently denser, that's OK).
/// </summary>
public static class NpcInstantiator
{
public static NpcTemplateDef? PickTemplate(
SpawnKind kind,
int dangerZone,
NpcTemplateContent content)
{
if (kind == SpawnKind.None) return null;
string kindKey = kind.ToString();
if (!content.SpawnKindToTemplateByZone.TryGetValue(kindKey, out var byZone))
return null;
if (byZone.Length == 0) return null;
// Clamp the zone index to the table's length.
int zoneIdx = System.Math.Clamp(dangerZone, 0, byZone.Length - 1);
string templateId = byZone[zoneIdx];
foreach (var t in content.Templates)
if (string.Equals(t.Id, templateId, System.StringComparison.OrdinalIgnoreCase))
return t;
return null;
}
}
@@ -0,0 +1,45 @@
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Phase 6.5 M3 — pheromone compounds a Scent-Broker can deploy via
/// Pheromone Craft. Each maps to a <see cref="Theriapolis.Core.Rules.Stats.Condition"/>
/// applied to creatures in the radius that fail their CON save.
///
/// The four compounds match <c>theriapolis-rpg-equipment.md</c>'s pheromone
/// vials, but here they're emitted directly via the class feature without
/// the consumable.
/// </summary>
public enum PheromoneType : byte
{
/// <summary>Failed save → <see cref="Theriapolis.Core.Rules.Stats.Condition.Frightened"/>.</summary>
Fear = 0,
/// <summary>Failed save → <see cref="Theriapolis.Core.Rules.Stats.Condition.Charmed"/> (won't attack source).</summary>
Calm = 1,
/// <summary>Failed save → <see cref="Theriapolis.Core.Rules.Stats.Condition.Dazed"/> (loss of focus).</summary>
Arousal = 2,
/// <summary>Failed save → <see cref="Theriapolis.Core.Rules.Stats.Condition.Poisoned"/> (debuff).</summary>
Nausea = 3,
}
public static class PheromoneTypeExtensions
{
/// <summary>Maps a <see cref="PheromoneType"/> to the condition it applies on a failed save.</summary>
public static Theriapolis.Core.Rules.Stats.Condition AppliedCondition(this PheromoneType t) => t switch
{
PheromoneType.Fear => Theriapolis.Core.Rules.Stats.Condition.Frightened,
PheromoneType.Calm => Theriapolis.Core.Rules.Stats.Condition.Charmed,
PheromoneType.Arousal => Theriapolis.Core.Rules.Stats.Condition.Dazed,
PheromoneType.Nausea => Theriapolis.Core.Rules.Stats.Condition.Poisoned,
_ => Theriapolis.Core.Rules.Stats.Condition.None,
};
/// <summary>Human-readable display name for combat log entries.</summary>
public static string DisplayName(this PheromoneType t) => t switch
{
PheromoneType.Fear => "Fear",
PheromoneType.Calm => "Calm",
PheromoneType.Arousal => "Arousal",
PheromoneType.Nausea => "Nausea",
_ => "Unknown",
};
}
@@ -0,0 +1,67 @@
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Size-aware spatial helpers for combat. Combatants occupy
/// <see cref="Stats.SizeExtensions.FootprintTiles"/>² tactical tiles
/// anchored at their integer <see cref="Combatant.Position"/>; this helper
/// computes edge-to-edge Chebyshev distance and reach predicates.
/// </summary>
public static class ReachAndCover
{
/// <summary>
/// Edge-to-edge Chebyshev distance — number of empty tiles between two
/// footprints. Adjacent (sharing an edge or corner) returns 0; one
/// empty tile between returns 1; overlapping returns 0.
/// </summary>
public static int EdgeToEdgeChebyshev(Combatant a, Combatant b)
{
int aSize = a.Size.FootprintTiles();
int bSize = b.Size.FootprintTiles();
int aMinX = (int)System.Math.Floor(a.Position.X);
int aMinY = (int)System.Math.Floor(a.Position.Y);
int aMaxX = aMinX + aSize - 1;
int aMaxY = aMinY + aSize - 1;
int bMinX = (int)System.Math.Floor(b.Position.X);
int bMinY = (int)System.Math.Floor(b.Position.Y);
int bMaxX = bMinX + bSize - 1;
int bMaxY = bMinY + bSize - 1;
// Per-axis gap: positive = number of tile-steps to bring edges to
// touching (then -1 because touching = 0 empty tiles between).
int dx = System.Math.Max(0, System.Math.Max(aMinX - bMaxX, bMinX - aMaxX) - 1);
int dy = System.Math.Max(0, System.Math.Max(aMinY - bMaxY, bMinY - aMaxY) - 1);
return System.Math.Max(dx, dy);
}
/// <summary>True if <paramref name="defender"/> is within the attack's reach (melee) or short range (ranged).</summary>
public static bool IsInReach(Combatant attacker, Combatant defender, AttackOption attack)
{
int dist = EdgeToEdgeChebyshev(attacker, defender);
if (attack.IsRanged)
return dist <= attack.RangeLongTiles;
return dist <= attack.ReachTiles;
}
/// <summary>True if the defender sits past short range (disadvantage on the attack).</summary>
public static bool IsLongRange(Combatant attacker, Combatant defender, AttackOption attack)
{
if (!attack.IsRanged) return false;
int dist = EdgeToEdgeChebyshev(attacker, defender);
return dist > attack.RangeShortTiles && dist <= attack.RangeLongTiles;
}
/// <summary>
/// One step of greedy movement toward <paramref name="goal"/>. Returns
/// the new position one tile closer in 8-connected (Chebyshev) space.
/// Movement budget is ignored — the caller is responsible for charging it.
/// </summary>
public static Vec2 StepToward(Vec2 from, Vec2 goal)
{
int dx = System.Math.Sign(goal.X - from.X);
int dy = System.Math.Sign(goal.Y - from.Y);
return new Vec2((int)from.X + dx, (int)from.Y + dy);
}
}
@@ -0,0 +1,171 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Settlements;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Phase 6 M1 — turns a chunk's <see cref="SpawnKind.Resident"/> records
/// into live <see cref="NpcActor"/>s.
///
/// Each <see cref="TacticalSpawn"/> with kind Resident sits at a
/// world-pixel position that <see cref="SettlementStamper"/> emitted from a
/// <see cref="BuildingResidentSlot"/>. Resolution:
///
/// 1. Walk the world's settlements. Find the one whose
/// <see cref="Settlement.Buildings"/> contains a building footprint
/// that contains this spawn point. Within that building, find the
/// slot whose <c>SpawnX/SpawnY</c> match — that's the role tag.
/// 2. Look up the resident template. Named (anchor-prefixed) tags hit
/// <see cref="ContentResolver.ResidentsByRoleTag"/> directly. Generic
/// tags hit <see cref="ContentResolver.Residents"/> filtered by
/// <see cref="ResidentTemplateDef.RoleTag"/> equality, weighted by
/// <see cref="ResidentTemplateDef.Weight"/>.
/// 3. Build an <see cref="NpcActor"/> from the chosen template. Register
/// named-role NPCs in the <see cref="AnchorRegistry"/> so quest
/// scripts can resolve them by symbolic id.
///
/// The lookup is a linear walk over settlements (small N — &lt; 100) but is
/// deterministic for a given (worldSeed, chunk, spawnIndex).
/// </summary>
public static class ResidentInstantiator
{
/// <summary>
/// Resolve and spawn an NpcActor for a single Resident spawn record.
/// Returns null when the world has no resident template configured for
/// this slot's role tag (the spawn is silently dropped — the building
/// just stays empty, which is fine).
/// </summary>
public static NpcActor? Spawn(
ulong worldSeed,
TacticalChunk chunk,
int spawnIndex,
TacticalSpawn spawn,
WorldState world,
ContentResolver content,
ActorManager actors,
AnchorRegistry? registry = null)
{
if (spawn.Kind != SpawnKind.Resident) return null;
int worldPxX = chunk.OriginX + spawn.LocalX;
int worldPxY = chunk.OriginY + spawn.LocalY;
if (!TryFindSlot(world, worldPxX, worldPxY, out var settlement, out var building, out var slot))
return null;
var template = ResolveTemplate(slot.RoleTag, content, worldSeed, settlement!.Id, building!.Id, spawnIndex);
if (template is null) return null;
var npc = new NpcActor(template)
{
Id = -1, // ActorManager assigns
Position = new Vec2(worldPxX, worldPxY),
SourceChunk = chunk.Coord,
SourceSpawnIndex = spawnIndex,
// The named role tag wins over the generic one declared on the
// template — preserves "millhaven.innkeeper" identity even when
// the generic "innkeeper" template is what spawned.
RoleTag = string.IsNullOrEmpty(slot.RoleTag) ? template.RoleTag : slot.RoleTag,
// Phase 6 M5 — anchor the resident to its host settlement so
// RepPropagation can compute their local faction standing.
HomeSettlementId = settlement.Id,
};
var spawned = actors.SpawnNpc(npc);
if (registry is not null)
{
if (settlement.Anchor is not null)
registry.RegisterAnchor(settlement.Anchor.Value, settlement.Id);
registry.RegisterRole(spawned.RoleTag, spawned.Id);
}
return spawned;
}
/// <summary>
/// Pick the resident template for a given role tag. Named anchor-
/// prefixed tags ("millhaven.innkeeper") prefer named templates;
/// generic tags ("innkeeper") roll among matching generics by weight.
/// </summary>
public static ResidentTemplateDef? ResolveTemplate(
string roleTag,
ContentResolver content,
ulong worldSeed,
int settlementId,
int buildingId,
int spawnIndex)
{
// Named, anchor-prefixed: prefer the exact match.
if (content.ResidentsByRoleTag.TryGetValue(roleTag, out var named))
return named;
// Generic: collect all unnamed templates whose RoleTag equals the
// suffix-stripped tag (e.g. "millhaven.innkeeper" → "innkeeper").
string suffix = roleTag;
int dot = roleTag.LastIndexOf('.');
if (dot >= 0) suffix = roleTag[(dot + 1)..];
var pool = new List<ResidentTemplateDef>();
foreach (var r in content.Residents.Values)
if (!r.Named && string.Equals(r.RoleTag, suffix, System.StringComparison.OrdinalIgnoreCase))
pool.Add(r);
if (pool.Count == 0) return null;
if (pool.Count == 1) return pool[0];
// Weighted roll, deterministic per (worldSeed, settlementId, buildingId, spawnIndex).
var rng = SeededRng.ForSubsystem(worldSeed,
unchecked(C.RNG_NPC_SPAWN ^ (ulong)settlementId
^ ((ulong)buildingId << 16)
^ ((ulong)spawnIndex << 32)));
// Sort for stable iteration before the RNG roll.
pool.Sort(static (a, b) => string.Compare(a.Id, b.Id, System.StringComparison.Ordinal));
float total = 0f;
foreach (var t in pool) total += System.Math.Max(0f, t.Weight);
if (total <= 0f) return pool[0];
float roll = rng.NextFloat() * total;
float acc = 0f;
foreach (var t in pool)
{
acc += System.Math.Max(0f, t.Weight);
if (roll <= acc) return t;
}
return pool[^1];
}
/// <summary>
/// Walk the world's settlements to find the one whose building footprint
/// contains <paramref name="worldPxX"/>/<paramref name="worldPxY"/> AND
/// whose resident slot sits exactly on that point.
/// </summary>
public static bool TryFindSlot(
WorldState world, int worldPxX, int worldPxY,
out Settlement? settlement, out BuildingFootprint? building, out BuildingResidentSlot slot)
{
settlement = null;
building = null;
slot = default;
foreach (var s in world.Settlements)
{
if (!s.BuildingsResolved) continue;
foreach (var b in s.Buildings)
{
if (!b.ContainsTile(worldPxX, worldPxY)) continue;
foreach (var r in b.Residents)
{
if (r.SpawnX == worldPxX && r.SpawnY == worldPxY)
{
settlement = s;
building = b;
slot = r;
return true;
}
}
}
}
return false;
}
}
+208
View File
@@ -0,0 +1,208 @@
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Pure d20-adjacent rule evaluator. Static — no per-encounter state lives
/// here; everything flows through <see cref="Encounter"/> (dice + log) and
/// the supplied <see cref="Combatant"/> instances (HP + conditions).
///
/// Phase 5 M4 ships AttemptAttack, MakeSave, ApplyDamage, ApplyCondition.
/// Class-feature combat effects (Sneak Attack damage, Rage damage bonus,
/// fighting-style modifiers, etc.) are layered on at M6 by inspecting the
/// attacker's <see cref="Character"/> features in <see cref="AttemptAttack"/>.
/// </summary>
public static class Resolver
{
/// <summary>
/// Roll an attack from <paramref name="attacker"/> against
/// <paramref name="target"/>. Logs the outcome on
/// <paramref name="enc"/>'s log. Mutates target HP if the attack hits.
/// </summary>
public static AttackResult AttemptAttack(
Encounter enc,
Combatant attacker,
Combatant target,
AttackOption attack,
SituationFlags situation = SituationFlags.None)
{
// Range/long-range disadvantage decoration: if the attack is ranged
// and the target is past short range, OR the calling code is firing
// a ranged attack into melee, fold those in.
if (attack.IsRanged && ReachAndCover.IsLongRange(attacker, target, attack))
situation |= SituationFlags.LongRange;
// Phase 6.5 M2 — Pack-Forged "Packmate's Howl" consumption: if the
// target is howl-marked by an ally of this attacker, force advantage
// on this attack roll.
if (FeatureProcessor.ConsumeHowlAdvantage(enc, attacker, target))
situation |= SituationFlags.Advantage;
// Phase 6.5 M3 — Frightened attackers roll at disadvantage.
if (attacker.Conditions.Contains(Condition.Frightened))
situation |= SituationFlags.Disadvantage;
var (kept, other) = enc.RollD20WithMode(situation);
// Phase 5 M6: stack Sentinel Stance and other per-combatant AC bonuses.
// Phase 6.5 M2 — pass the encounter so passive subclass AC features
// (Herd-Wall Interlock Shields, Lone Fang Isolation Bonus) can read
// positional state.
int totalAc = target.ArmorClass + situation.CoverAcBonus()
+ FeatureProcessor.ApplyAcBonus(target, enc);
// Phase 6.5 M1: consume an inspiration die (Vocalization Dice) on
// attack rolls. The bonus applies to the d20 total *before* compare;
// crits/fumbles still trigger off the natural d20.
int inspirationBonus = FeatureProcessor.ConsumeInspirationDie(enc, attacker);
// Phase 6.5 M2 — subclass to-hit bonuses (Lone Fang Isolation Bonus).
int subclassToHit = FeatureProcessor.ApplyToHitBonus(attacker, enc);
// Phase 6.5 M3 — Covenant's Authority oath mark: -2 attack vs. marker.
int oathPenalty = FeatureProcessor.OathAttackPenalty(enc, attacker, target);
int attackTotal = kept + attack.ToHitBonus + inspirationBonus + subclassToHit + oathPenalty;
bool natural1 = kept == 1;
bool natural20 = kept >= attack.CritOnNatural;
bool isCrit = natural20;
bool hit = !natural1 && (natural20 || attackTotal >= totalAc);
int damage = 0;
if (hit)
{
// Damage roll wraps the per-die source so Great Weapon style
// can re-roll 1s/2s on damage dice without changing the resolver
// contract. The per-die delegate consumes RNG via the encounter.
damage = attack.Damage.Roll(
sides => FeatureProcessor.GreatWeaponReroll(enc, attacker, attack, enc.RollDie(sides), sides),
isCrit);
// Per-feature damage bonuses (Duelist, Rage, Sneak Attack).
damage += FeatureProcessor.ApplyDamageBonus(enc, attacker, target, attack, isCrit);
// Resistance halves damage (Rage vs phys).
if (FeatureProcessor.IsResisted(target, attack.Damage.DamageType))
damage = damage / 2;
ApplyDamage(target, damage);
// Phase 6.5 M2 — subclass on-hit triggers.
// Pack-Forged: melee hit marks the target so allies' next attack
// gets advantage.
if (!attack.IsRanged)
FeatureProcessor.OnPackForgedHit(enc, attacker, target, attack);
// Blood Memory: melee kill while raging triggers Predatory Surge.
if (target.IsDown && !attack.IsRanged && attacker.RageActive)
FeatureProcessor.OnBloodMemoryKill(enc, attacker, attack);
// Phase 7 M0 — Antler-Guard Retaliatory Strike. Returns 1d8+CON
// to the attacker when the target is an Antler-Guard in Sentinel
// Stance hit by a melee attack. Calls ApplyDamage on the attacker
// directly; the encounter log carries the structured note.
if (!attack.IsRanged)
FeatureProcessor.OnAntlerGuardHit(enc, attacker, target, attack);
}
var result = new AttackResult
{
AttackerId = attacker.Id,
TargetId = target.Id,
AttackName = attack.Name,
D20Roll = kept,
D20Other = other == -1 ? null : other,
ToHitBonus = attack.ToHitBonus,
AttackTotal = attackTotal,
TargetAc = totalAc,
Hit = hit,
Crit = isCrit && hit,
DamageRolled = damage,
TargetHpAfter = target.CurrentHp,
Situation = situation,
};
enc.AppendLog(CombatLogEntry.Kind.Attack, result.FormatLog(attacker.Name, target.Name));
if (target.IsDown)
enc.AppendLog(CombatLogEntry.Kind.Death, $"{target.Name} falls.");
return result;
}
/// <summary>
/// Roll a saving throw for <paramref name="target"/> against
/// <paramref name="dc"/>. Bonus = ability mod + (proficient ? prof : 0).
/// </summary>
public static SaveResult MakeSave(
Encounter enc,
Combatant target,
SaveId save,
int dc,
bool isProficient = false,
SituationFlags situation = SituationFlags.None)
{
var (kept, _) = enc.RollD20WithMode(situation);
int bonus = AbilityScores.Mod(target.Abilities.Get(save.Ability()))
+ (isProficient ? target.ProficiencyBonus : 0);
int total = kept + bonus;
bool succ = total >= dc;
var result = new SaveResult
{
TargetId = target.Id,
Save = save,
D20Roll = kept,
SaveBonus = bonus,
SaveTotal = total,
Dc = dc,
Succeeded = succ,
};
enc.AppendLog(CombatLogEntry.Kind.Save,
$"{target.Name} {save} save: {total} vs DC {dc} → {(succ ? "succeeds" : "fails")}");
return result;
}
/// <summary>
/// Subtract <paramref name="damage"/> from <paramref name="target"/>'s
/// HP, clamped to 0. Does not log (callers like AttemptAttack handle
/// the structured log entry; this is the raw mutation).
///
/// Phase 5 M6: when a player character drops to 0, install a
/// <see cref="DeathSaveTracker"/> on the combatant; combat HUD reads
/// this and rolls a save at the start of the player's turn until the
/// loop resolves (stabilised, revived, or dead).
/// </summary>
public static void ApplyDamage(Combatant target, int damage)
{
if (damage <= 0) return;
target.CurrentHp = System.Math.Max(0, target.CurrentHp - damage);
if (target.CurrentHp == 0 && target.SourceCharacter is not null)
{
target.Conditions.Add(Condition.Unconscious);
target.DeathSaves ??= new DeathSaveTracker();
}
}
/// <summary>Heal up to MaxHp. Negative amounts are no-ops (use ApplyDamage).</summary>
public static void Heal(Combatant target, int amount)
{
if (amount <= 0) return;
target.CurrentHp = System.Math.Min(target.MaxHp, target.CurrentHp + amount);
// If the heal lifts a downed character above 0, the unconscious
// condition lifts automatically and the death-save loop resets.
if (target.CurrentHp > 0)
{
target.Conditions.Remove(Condition.Unconscious);
target.DeathSaves?.Reset();
}
}
/// <summary>Apply a condition to a target. Logs the change.</summary>
public static void ApplyCondition(Encounter enc, Combatant target, Condition condition)
{
if (target.Conditions.Add(condition))
enc.AppendLog(CombatLogEntry.Kind.ConditionApplied,
$"{target.Name} is now {condition}.");
}
/// <summary>Remove a condition from a target. Logs if it was present.</summary>
public static void RemoveCondition(Encounter enc, Combatant target, Condition condition)
{
if (target.Conditions.Remove(condition))
enc.AppendLog(CombatLogEntry.Kind.ConditionEnded,
$"{target.Name} is no longer {condition}.");
}
}
@@ -0,0 +1,14 @@
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Rules.Combat;
public sealed record SaveResult
{
public required int TargetId { get; init; }
public required SaveId Save { get; init; }
public required int D20Roll { get; init; }
public required int SaveBonus { get; init; }
public required int SaveTotal { get; init; } // D20Roll + bonus
public required int Dc { get; init; }
public required bool Succeeded { get; init; }
}
@@ -0,0 +1,61 @@
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Per-attack situation modifiers. Flags compose: a Sneak Attack with
/// Advantage and Disadvantage (e.g. attacker prone, target shadowed)
/// cancels to a normal roll per d20 rules.
///
/// Phase 5 M4 wires the basic six (Advantage/Disadvantage and the four
/// resolver-time tags); class-feature flags like Reckless Attack come in
/// M6 once the feature engine reads from this enum.
/// </summary>
[System.Flags]
public enum SituationFlags : uint
{
None = 0,
Advantage = 1u << 0,
Disadvantage = 1u << 1,
/// <summary>Attacker is at long range — disadvantage on the roll per d20.</summary>
LongRange = 1u << 2,
/// <summary>Attacker has reach + a melee weapon vs. a target that has cover.</summary>
HalfCover = 1u << 3,
ThreeQuartersCover= 1u << 4,
/// <summary>Attacker meets the Sneak Attack precondition (advantage or ally adjacent).</summary>
SneakAttackEligible = 1u << 5,
/// <summary>Attacker is firing a ranged weapon at a target within 5 ft. — disadvantage.</summary>
RangedInMelee = 1u << 6,
}
public static class SituationFlagsExtensions
{
/// <summary>
/// True when the situation should roll the d20 with advantage. Per
/// d20 rules, advantage and disadvantage cancel exactly (no doubling).
/// </summary>
public static bool RollsAdvantage(this SituationFlags f)
{
bool adv = (f & SituationFlags.Advantage) != 0;
bool dis = (f & SituationFlags.Disadvantage) != 0
|| (f & SituationFlags.LongRange) != 0
|| (f & SituationFlags.RangedInMelee) != 0;
return adv && !dis;
}
/// <summary>True when the situation rolls with disadvantage (and no compensating advantage).</summary>
public static bool RollsDisadvantage(this SituationFlags f)
{
bool adv = (f & SituationFlags.Advantage) != 0;
bool dis = (f & SituationFlags.Disadvantage) != 0
|| (f & SituationFlags.LongRange) != 0
|| (f & SituationFlags.RangedInMelee) != 0;
return dis && !adv;
}
/// <summary>Cover modifier applied to AC: 0 / 2 / 5.</summary>
public static int CoverAcBonus(this SituationFlags f)
{
if ((f & SituationFlags.ThreeQuartersCover) != 0) return 5;
if ((f & SituationFlags.HalfCover) != 0) return 2;
return 0;
}
}
+38
View File
@@ -0,0 +1,38 @@
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Mutable per-turn state for the active combatant: action / bonus action /
/// reaction availability and remaining movement budget. The
/// <see cref="Encounter"/> rebuilds this when each new turn begins; the
/// <see cref="Resolver"/> consumes resources as the combatant uses them.
///
/// Phase 5 M4 tracks the booleans but doesn't enforce them inside Resolver
/// (callers can attack twice in a turn if they want — useful for tests).
/// M5 introduces per-action-cost gating in the live PlayScreen wrapper.
/// </summary>
public struct Turn
{
public int CombatantId;
public bool ActionAvailable;
public bool BonusActionAvailable;
public bool ReactionAvailable;
public int RemainingMovementFt;
public static Turn FreshFor(int combatantId, int speedFt) => new()
{
CombatantId = combatantId,
ActionAvailable = true,
BonusActionAvailable = true,
ReactionAvailable = true,
RemainingMovementFt = speedFt,
};
public void ConsumeAction() => ActionAvailable = false;
public void ConsumeBonusAction() => BonusActionAvailable = false;
public void ConsumeReaction() => ReactionAvailable = false;
public void ConsumeMovement(int feet)
{
RemainingMovementFt -= feet;
if (RemainingMovementFt < 0) RemainingMovementFt = 0;
}
}
@@ -0,0 +1,49 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Reputation;
namespace Theriapolis.Core.Rules.Dialogue;
/// <summary>
/// Phase 6 M3 — read/write window into player + npc state used by
/// <see cref="DialogueRunner"/> to evaluate conditions and apply effects.
/// Keeps the runner free of direct PlayScreen / ActorManager references
/// so the runner can be unit-tested with a synthetic context.
/// </summary>
public sealed class DialogueContext
{
public NpcActor Npc { get; }
public Rules.Character.Character Pc { get; }
public PlayerReputation Reputation { get; }
public Dictionary<string, int> Flags { get; }
public ContentResolver Content { get; }
/// <summary>Player position for tagging RepEvent origins. Optional; defaults to (0, 0) in tests.</summary>
public int PlayerWorldTileX { get; set; }
public int PlayerWorldTileY { get; set; }
public long WorldClockSeconds { get; set; }
/// <summary>Set true by <see cref="DialogueRunner"/> when an option fires the open_shop effect.</summary>
public bool ShopRequested { get; set; }
/// <summary>
/// Phase 6 M3 — quest hook stub. set_flag-only for M3; the real quest
/// engine wires in M4 and consumes <see cref="StartQuestRequests"/>.
/// </summary>
public List<string> StartQuestRequests { get; } = new();
public DialogueContext(NpcActor npc, Rules.Character.Character pc,
PlayerReputation rep, Dictionary<string, int> flags,
ContentResolver content)
{
Npc = npc;
Pc = pc;
Reputation = rep;
Flags = flags;
Content = content;
}
/// <summary>Effective disposition for the current NPC vs the player. Cached per dialogue turn — recomputed on demand.</summary>
public int EffectiveDispositionScore()
=> EffectiveDisposition.For(Npc, Pc, Reputation, Content);
}
@@ -0,0 +1,328 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Dialogue;
/// <summary>
/// Phase 6 M3 — walks a <see cref="DialogueDef"/> graph, evaluates option
/// conditions, branches skill checks against a deterministic dice
/// stream, and applies effects.
///
/// Determinism:
/// <c>dialogueSeed = worldSeed ^ C.RNG_DIALOGUE ^ npcId ^ turnIndex</c>
/// Each skill-check option pulls a fresh d20 keyed by
/// <c>(npcId, turnIndex, optionIndex)</c> — the cache means re-rendering
/// the same node (e.g. tooltip refresh) doesn't re-roll.
///
/// The runner does not own UI. It exposes <see cref="CurrentNode"/> and
/// <see cref="VisibleOptions"/> for the screen to render, plus <see
/// cref="History"/> for scrollback. The screen calls <see cref="ChooseOption"/>
/// when the player picks one; the runner returns a result describing
/// what happened (text to append, skill-check rolled, dialogue ended,
/// shop requested).
/// </summary>
public sealed class DialogueRunner
{
private readonly DialogueDef _tree;
private readonly DialogueContext _ctx;
private readonly ulong _worldSeed;
private readonly ulong _npcId;
private readonly Dictionary<string, DialogueNodeDef> _nodesById;
/// <summary>Cache of (turnIndex, optionIndex) → (rolled, total) so re-renders don't re-roll.</summary>
private readonly Dictionary<(int turn, int option), SkillCheckRoll> _rollCache = new();
public int TurnIndex { get; private set; }
public DialogueNodeDef CurrentNode { get; private set; }
public List<DialogueLogEntry> History { get; } = new();
public bool IsOver { get; private set; }
/// <summary>Direct accessor to the runtime context — exposed so the UI
/// can read <see cref="DialogueContext.ShopRequested"/> after option selection.</summary>
public DialogueContext Context => _ctx;
public DialogueRunner(DialogueDef tree, DialogueContext ctx, ulong worldSeed)
{
_tree = tree ?? throw new System.ArgumentNullException(nameof(tree));
_ctx = ctx ?? throw new System.ArgumentNullException(nameof(ctx));
_worldSeed = worldSeed;
_npcId = (ulong)ctx.Npc.Id;
_nodesById = tree.Nodes.ToDictionary(n => n.Id, System.StringComparer.OrdinalIgnoreCase);
if (!_nodesById.TryGetValue(tree.Root, out var root))
throw new System.InvalidOperationException($"Dialogue '{tree.Id}' root '{tree.Root}' missing");
CurrentNode = root;
AppendNodeText(root);
ApplyEffects(root.OnEnter);
}
/// <summary>Options that pass their visibility predicates at the current turn.</summary>
public IEnumerable<(int Index, DialogueOptionDef Option)> VisibleOptions()
{
for (int i = 0; i < CurrentNode.Options.Length; i++)
{
var opt = CurrentNode.Options[i];
if (AreConditionsMet(opt.Conditions))
yield return (i, opt);
}
}
/// <summary>
/// Pick an option by its index *into the original options array* (not
/// the visible-only list — index stability across re-renders).
/// </summary>
public DialogueChooseResult ChooseOption(int optionIndex)
{
if (IsOver) return DialogueChooseResult.Closed("(dialogue is already over)");
if (optionIndex < 0 || optionIndex >= CurrentNode.Options.Length)
return DialogueChooseResult.Closed("(no such option)");
var opt = CurrentNode.Options[optionIndex];
if (!AreConditionsMet(opt.Conditions))
return DialogueChooseResult.Closed("(option not available)");
// Append the player's choice to history.
History.Add(new DialogueLogEntry(DialogueSpeaker.Pc, opt.Text));
TurnIndex++;
// Skill-check option: roll, branch on success/failure, apply
// appropriate effects/next.
if (opt.SkillCheck is { } check)
{
var roll = ResolveSkillCheck(optionIndex, check);
string log = $" [{check.Skill.ToUpperInvariant()} DC {check.Dc}] roll {roll.D20Raw} + {roll.Bonus} = {roll.Total} → {(roll.Succeeded ? "SUCCESS" : "FAILURE")}";
History.Add(new DialogueLogEntry(DialogueSpeaker.Narration, log));
ApplyEffects(roll.Succeeded ? opt.EffectsOnSuccess : opt.EffectsOnFailure);
string nextId = roll.Succeeded ? opt.NextOnSuccess : opt.NextOnFailure;
return AdvanceTo(nextId, roll);
}
// Plain option.
ApplyEffects(opt.Effects);
return AdvanceTo(opt.Next, default);
}
/// <summary>Force-close the dialogue (player pressed Esc).</summary>
public void End()
{
if (IsOver) return;
IsOver = true;
}
// ── Conditions ───────────────────────────────────────────────────────
private bool AreConditionsMet(DialogueConditionDef[] conditions)
{
foreach (var c in conditions)
if (!Evaluate(c)) return false;
return true;
}
private bool Evaluate(DialogueConditionDef c) => c.Kind.ToLowerInvariant() switch
{
"rep_at_least" => RepFor(c.Faction) >= c.Value,
"rep_below" => RepFor(c.Faction) < c.Value,
"has_item" => HasItem(c.Id),
"not_has_item" => !HasItem(c.Id),
"has_flag" => _ctx.Flags.TryGetValue(c.Flag, out int v) && v != 0,
"not_has_flag" => !_ctx.Flags.TryGetValue(c.Flag, out int v2) || v2 == 0,
"ability_min" => AbilityMod(c.Ability) >= c.Value,
_ => true, // unknown kind → permissive (validated at content-load)
};
private int RepFor(string faction)
{
if (string.IsNullOrEmpty(faction))
return _ctx.EffectiveDispositionScore();
return _ctx.Reputation.Factions.Get(faction);
}
private bool HasItem(string itemId)
{
if (string.IsNullOrEmpty(itemId)) return false;
foreach (var inst in _ctx.Pc.Inventory.Items)
if (string.Equals(inst.Def.Id, itemId, System.StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
private int AbilityMod(string abilityRaw)
{
if (!System.Enum.TryParse<AbilityId>(abilityRaw, true, out var id)) return 0;
return _ctx.Pc.Abilities.ModFor(id);
}
// ── Effects ──────────────────────────────────────────────────────────
private void ApplyEffects(DialogueEffectDef[] effects)
{
foreach (var e in effects) ApplyEffect(e);
}
private void ApplyEffect(DialogueEffectDef e)
{
switch (e.Kind.ToLowerInvariant())
{
case "set_flag":
_ctx.Flags[e.Flag] = e.Value;
break;
case "clear_flag":
_ctx.Flags.Remove(e.Flag);
break;
case "give_item":
if (_ctx.Content.Items.TryGetValue(e.Id, out var giveDef))
_ctx.Pc.Inventory.Add(giveDef, System.Math.Max(1, e.Qty));
break;
case "take_item":
TakeFromInventory(e.Id, System.Math.Max(1, e.Qty));
break;
case "rep_event":
if (e.Event is { } ev)
SubmitRepEvent(ev);
break;
case "open_shop":
_ctx.ShopRequested = true;
break;
case "start_quest":
if (!string.IsNullOrEmpty(e.Quest))
_ctx.StartQuestRequests.Add(e.Quest);
break;
case "give_xp":
_ctx.Pc.Xp = System.Math.Max(0, _ctx.Pc.Xp + e.Xp);
break;
}
}
private void TakeFromInventory(string itemId, int qty)
{
if (string.IsNullOrEmpty(itemId)) return;
for (int i = _ctx.Pc.Inventory.Items.Count - 1; i >= 0 && qty > 0; i--)
{
var inst = _ctx.Pc.Inventory.Items[i];
if (!string.Equals(inst.Def.Id, itemId, System.StringComparison.OrdinalIgnoreCase)) continue;
int take = System.Math.Min(qty, inst.Qty);
inst.Qty -= take;
qty -= take;
if (inst.Qty <= 0) _ctx.Pc.Inventory.Remove(inst);
}
}
private void SubmitRepEvent(DialogueRepEventDef ev)
{
if (!System.Enum.TryParse<RepEventKind>(ev.Kind, true, out var kind)) kind = RepEventKind.Dialogue;
var live = new RepEvent
{
Kind = kind,
FactionId = ev.Faction,
RoleTag = string.IsNullOrEmpty(ev.RoleTag) ? _ctx.Npc.RoleTag : ev.RoleTag,
Magnitude = ev.Magnitude,
Note = ev.Note,
OriginTileX = _ctx.PlayerWorldTileX,
OriginTileY = _ctx.PlayerWorldTileY,
TimestampSeconds = _ctx.WorldClockSeconds,
};
_ctx.Reputation.Submit(live, _ctx.Content.Factions);
}
// ── Skill check ──────────────────────────────────────────────────────
private SkillCheckRoll ResolveSkillCheck(int optionIndex, DialogueSkillCheckDef check)
{
var key = (TurnIndex - 1, optionIndex);
if (_rollCache.TryGetValue(key, out var cached)) return cached;
var skill = SkillIdExtensions.FromJson(check.Skill);
int abilityMod = _ctx.Pc.Abilities.ModFor(skill.Ability());
int profBonus = _ctx.Pc.SkillProficiencies.Contains(skill) ? _ctx.Pc.ProficiencyBonus : 0;
int bonus = abilityMod + profBonus;
ulong seed = _worldSeed
^ C.RNG_DIALOGUE
^ _npcId
^ ((ulong)(uint)key.Item1 << 8)
^ ((ulong)(uint)key.Item2 << 24);
var rng = new SeededRng(seed);
int d20 = (int)(rng.NextUInt64() % 20UL) + 1;
int total = d20 + bonus;
var roll = new SkillCheckRoll(skill, check.Dc, d20, bonus, total, total >= check.Dc);
_rollCache[key] = roll;
return roll;
}
// ── Node transitions ─────────────────────────────────────────────────
private DialogueChooseResult AdvanceTo(string nextId, SkillCheckRoll skillRoll)
{
if (string.IsNullOrEmpty(nextId) || string.Equals(nextId, "<end>", System.StringComparison.OrdinalIgnoreCase))
{
IsOver = true;
return DialogueChooseResult.Closed(skillRoll.Skill == 0 ? "" : "");
}
if (!_nodesById.TryGetValue(nextId, out var next))
{
IsOver = true;
return DialogueChooseResult.Closed($"(missing node '{nextId}' — content bug)");
}
CurrentNode = next;
AppendNodeText(next);
ApplyEffects(next.OnEnter);
return DialogueChooseResult.Advanced(skillRoll);
}
private void AppendNodeText(DialogueNodeDef node)
{
var speaker = node.Speaker.ToLowerInvariant() switch
{
"pc" => DialogueSpeaker.Pc,
"narration" => DialogueSpeaker.Narration,
_ => DialogueSpeaker.Npc,
};
History.Add(new DialogueLogEntry(speaker, ResolvePlaceholders(node.Text)));
}
/// <summary>
/// Substitute placeholders in dialogue text. Phase 6 M3 supports
/// {pc.name}, {npc.role}, {npc.name}, {disposition_label}.
/// </summary>
private string ResolvePlaceholders(string text)
{
if (string.IsNullOrEmpty(text)) return text;
return text
.Replace("{pc.name}", _ctx.Pc is null ? "Wanderer" : "the wanderer", System.StringComparison.OrdinalIgnoreCase)
.Replace("{npc.role}", _ctx.Npc.RoleTag ?? "", System.StringComparison.OrdinalIgnoreCase)
.Replace("{npc.name}", _ctx.Npc.DisplayName, System.StringComparison.OrdinalIgnoreCase)
.Replace("{disposition_label}",
DispositionLabels.DisplayName(DispositionLabels.For(_ctx.EffectiveDispositionScore())),
System.StringComparison.OrdinalIgnoreCase);
}
}
public enum DialogueSpeaker : byte { Npc, Pc, Narration }
public readonly record struct DialogueLogEntry(DialogueSpeaker Speaker, string Text);
public readonly record struct SkillCheckRoll(SkillId Skill, int Dc, int D20Raw, int Bonus, int Total, bool Succeeded);
public readonly struct DialogueChooseResult
{
public bool ClosedAfter { get; }
public string Note { get; }
public SkillCheckRoll? Roll { get; }
private DialogueChooseResult(bool closed, string note, SkillCheckRoll? roll)
{
ClosedAfter = closed;
Note = note;
Roll = roll;
}
public static DialogueChooseResult Closed(string note)
=> new(true, note, null);
public static DialogueChooseResult Advanced(SkillCheckRoll roll)
=> new(false, "", roll.Dc == 0 ? null : roll);
}
@@ -0,0 +1,80 @@
using Theriapolis.Core.Rules.Reputation;
namespace Theriapolis.Core.Rules.Dialogue;
/// <summary>
/// Phase 6 M3 — disposition-driven shop modifiers per the plan §4.6.
///
/// NEMESIS → service refused
/// HOSTILE → service refused
/// ANTAGONISTIC..UNFRIENDLY → +25% prices, +10% sell discount
/// NEUTRAL → base prices
/// FAVORABLE → -10% buy
/// FRIENDLY → -20% buy
/// ALLIED → -30% buy
/// CHAMPION → -40% buy
///
/// Phase 6 M3 ships buy-side adjustment only; sell-side is mirrored at
/// 50% of the buy modifier so a friendly merchant pays a small premium
/// without dual-tunable knobs.
/// </summary>
public static class ShopPricing
{
/// <summary>True if the player can shop at all given the disposition score.</summary>
public static bool ServiceAvailable(int dispositionScore)
{
var label = DispositionLabels.For(dispositionScore);
return label != DispositionLabel.Nemesis && label != DispositionLabel.Hostile;
}
/// <summary>
/// Multiplier applied to a price the player pays. 1.0 = base; &gt;1 =
/// markup; &lt;1 = discount. Caller multiplies the item's listed cost
/// and rounds.
/// </summary>
public static float BuyMultiplier(int dispositionScore)
{
var label = DispositionLabels.For(dispositionScore);
return label switch
{
DispositionLabel.Nemesis => 99f, // shouldn't be called; sentinel
DispositionLabel.Hostile => 99f,
DispositionLabel.Antagonistic => 1.25f,
DispositionLabel.Unfriendly => 1.25f,
DispositionLabel.Neutral => 1.00f,
DispositionLabel.Favorable => 0.90f,
DispositionLabel.Friendly => 0.80f,
DispositionLabel.Allied => 0.70f,
DispositionLabel.Champion => 0.60f,
_ => 1.00f,
};
}
/// <summary>Multiplier applied to a price the merchant pays the player on sell-back.</summary>
public static float SellMultiplier(int dispositionScore)
{
// Mirror buy modifier toward 1.0 by 50%: friendly buy = 0.80 →
// friendly sell = 0.60 (you still take a haircut), antagonistic
// buy = 1.25 → antagonistic sell = 0.30.
var label = DispositionLabels.For(dispositionScore);
return label switch
{
DispositionLabel.Antagonistic => 0.35f,
DispositionLabel.Unfriendly => 0.40f,
DispositionLabel.Neutral => 0.50f,
DispositionLabel.Favorable => 0.55f,
DispositionLabel.Friendly => 0.60f,
DispositionLabel.Allied => 0.65f,
DispositionLabel.Champion => 0.70f,
_ => 0f, // refused at Hostile / Nemesis
};
}
/// <summary>Buy price for one unit (rounded up). Cost is in the item's "cost_fang" or equivalent unit.</summary>
public static int BuyPriceFor(int baseCost, int dispositionScore)
=> System.Math.Max(1, (int)System.Math.Ceiling(baseCost * BuyMultiplier(dispositionScore)));
/// <summary>Sell price for one unit (rounded down).</summary>
public static int SellPriceFor(int baseCost, int dispositionScore)
=> System.Math.Max(0, (int)System.Math.Floor(baseCost * SellMultiplier(dispositionScore)));
}
@@ -0,0 +1,80 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.Core.Time;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Settlements;
namespace Theriapolis.Core.Rules.Quests;
/// <summary>
/// Phase 6 M4 — read/write window the QuestEngine uses to evaluate
/// conditions and apply effects. Deliberately holds references rather
/// than copies so engine ticks see live state.
/// </summary>
public sealed class QuestContext
{
public ContentResolver Content { get; }
public ActorManager Actors { get; }
public PlayerReputation Reputation { get; }
public Dictionary<string, int> Flags { get; }
public AnchorRegistry Anchors { get; }
public WorldClock Clock { get; }
public WorldState World { get; }
public Rules.Character.Character? PlayerCharacter { get; set; }
/// <summary>Most recent dialogue node id reached, surfaced by InteractionScreen for the dialogue_choice condition.</summary>
public string LastDialogueNodeReached { get; set; } = "";
public QuestContext(ContentResolver content, ActorManager actors,
PlayerReputation rep, Dictionary<string, int> flags,
AnchorRegistry anchors, WorldClock clock, WorldState world)
{
Content = content;
Actors = actors;
Reputation = rep;
Flags = flags;
Anchors = anchors;
Clock = clock;
World = world;
}
public bool HasItem(string itemId)
{
if (PlayerCharacter is null) return false;
foreach (var inst in PlayerCharacter.Inventory.Items)
if (string.Equals(inst.Def.Id, itemId, System.StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
/// <summary>
/// Position of the live player actor in tactical-tile (= world-pixel)
/// space, or null if the actor isn't yet spawned.
/// </summary>
public (int x, int y)? PlayerTacticalPos()
{
if (Actors.Player is null) return null;
return ((int)Actors.Player.Position.X, (int)Actors.Player.Position.Y);
}
/// <summary>
/// World-tile Chebyshev distance from the player to the settlement
/// with the given id, or null if the player or settlement isn't
/// resolvable.
/// </summary>
public int? PlayerDistanceToSettlement(int settlementId)
{
if (Actors.Player is null) return null;
Settlement? s = null;
foreach (var sx in World.Settlements)
if (sx.Id == settlementId) { s = sx; break; }
if (s is null) return null;
int playerWX = (int)Actors.Player.Position.X / C.WORLD_TILE_PIXELS;
int playerWY = (int)Actors.Player.Position.Y / C.WORLD_TILE_PIXELS;
int dx = System.Math.Abs(playerWX - s.TileX);
int dy = System.Math.Abs(playerWY - s.TileY);
return System.Math.Max(dx, dy);
}
}
@@ -0,0 +1,343 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Reputation;
namespace Theriapolis.Core.Rules.Quests;
/// <summary>
/// Phase 6 M4 — quest engine. Owns the active + completed quest lists.
/// On each <see cref="Tick"/> it walks active quests, evaluates each
/// step's <see cref="QuestStepDef.TriggerConditions"/>, fires
/// <see cref="QuestStepDef.OnEnter"/>+outcomes when ready, and chains
/// transitions until no further step fires this tick.
///
/// The engine is intentionally not a script interpreter — every trigger
/// and effect is one of a closed set of enum-tagged kinds (plan §8: hard
/// rule). New behaviour goes in via a new kind, never via dynamic code.
/// </summary>
public sealed class QuestEngine
{
private readonly Dictionary<string, QuestState> _active = new(System.StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, QuestState> _completed = new(System.StringComparer.OrdinalIgnoreCase);
public IReadOnlyDictionary<string, QuestState> Active => _active;
public IReadOnlyDictionary<string, QuestState> Completed => _completed;
/// <summary>Append-only player-facing log of "milestones reached" for the journal screen.</summary>
public List<string> Journal { get; } = new();
public QuestStatus StatusOf(string questId)
{
if (_active.TryGetValue(questId, out var a)) return a.Status;
if (_completed.TryGetValue(questId, out var c)) return c.Status;
return QuestStatus.Active; // sentinel for "never started" — caller checks IsKnown
}
public bool IsActive(string questId) => _active.ContainsKey(questId);
public bool IsCompleted(string questId) => _completed.TryGetValue(questId, out var c) && c.Status == QuestStatus.Completed;
public bool IsFailed(string questId) => _completed.TryGetValue(questId, out var c) && c.Status == QuestStatus.Failed;
public QuestState? Get(string questId)
=> _active.TryGetValue(questId, out var a) ? a
: _completed.TryGetValue(questId, out var c) ? c
: null;
/// <summary>
/// Start a quest. Idempotent — re-starting an already-active quest is
/// a no-op; re-starting a completed quest is also a no-op (Phase 6
/// M4 has no "redo" semantics).
/// </summary>
public bool Start(string questId, QuestContext ctx)
{
if (_active.ContainsKey(questId) || _completed.ContainsKey(questId)) return false;
if (!ctx.Content.Quests.TryGetValue(questId, out var def)) return false;
if (_active.Count >= C.QUEST_MAX_ACTIVE) return false;
var state = new QuestState
{
QuestId = questId,
CurrentStep = def.EntryStep,
Status = QuestStatus.Active,
StartedAt = ctx.Clock.InGameSeconds,
StepStartedAt = ctx.Clock.InGameSeconds,
};
_active[questId] = state;
Journal.Add($"Started: {def.Title}");
// Run on_enter for the entry step immediately so the quest can do
// setup (set_flag, give_item, etc.) before its first tick.
if (FindStep(def, def.EntryStep) is { } entry)
ApplyEffects(entry.OnEnter, ctx, state);
// The entry step might itself satisfy its outcomes immediately
// (e.g. trigger conditions all already met). Run a follow-up tick.
TickQuest(def, state, ctx);
return true;
}
/// <summary>End an active quest manually (e.g. dialogue effect or quest-failure step).</summary>
public void End(string questId, QuestStatus status, QuestContext ctx)
{
if (!_active.TryGetValue(questId, out var state)) return;
if (!ctx.Content.Quests.TryGetValue(questId, out var def)) return;
FinishQuest(def, state, status);
}
/// <summary>Per-frame tick. Runs auto-start checks then advances every active quest.</summary>
public void Tick(QuestContext ctx)
{
// Auto-start checks: any quest whose AutoStartWhen conditions all
// pass and which isn't yet active or completed kicks off.
foreach (var def in ctx.Content.Quests.Values)
{
if (def.AutoStartWhen.Length == 0) continue;
if (_active.ContainsKey(def.Id) || _completed.ContainsKey(def.Id)) continue;
if (AreConditionsMet(def.AutoStartWhen, ctx))
Start(def.Id, ctx);
}
// Advance each active quest. Collect first to allow Tick → end-quest
// → modify _active mid-iteration.
var snap = new List<QuestState>(_active.Values);
foreach (var state in snap)
{
if (!ctx.Content.Quests.TryGetValue(state.QuestId, out var def)) continue;
if (state.Status != QuestStatus.Active) continue;
TickQuest(def, state, ctx);
}
}
/// <summary>Walk one quest forward until no step fires this tick.</summary>
private void TickQuest(QuestDef def, QuestState state, QuestContext ctx)
{
// Iterate so an outcome that lands on a step whose triggers also
// fire chains into the next step in the same frame.
int hops = 0;
const int MaxHops = 32; // sanity guard against pathological cycles
while (hops++ < MaxHops)
{
var step = FindStep(def, state.CurrentStep);
if (step is null) break;
// Quest-terminal step?
if (step.CompletesQuest) { FinishQuest(def, state, QuestStatus.Completed); return; }
if (step.FailsQuest) { FinishQuest(def, state, QuestStatus.Failed); return; }
// Triggers gate the step: if not all met, wait for next tick.
if (step.TriggerConditions.Length > 0 && !AreConditionsMet(step.TriggerConditions, ctx))
break;
// Pick first outcome whose `when` clauses are all satisfied.
QuestOutcomeDef? chosen = null;
foreach (var o in step.Outcomes)
{
if (o.When.Length == 0 || AreConditionsMet(o.When, ctx))
{
chosen = o;
break;
}
}
if (chosen is null) break;
ApplyEffects(chosen.Effects, ctx, state);
string? nextId = string.Equals(chosen.Next, "<end>", System.StringComparison.OrdinalIgnoreCase)
? null
: chosen.Next;
if (nextId is null) { FinishQuest(def, state, QuestStatus.Completed); return; }
state.CurrentStep = nextId;
state.StepStartedAt = ctx.Clock.InGameSeconds;
Journal.Add($" → {def.Id}: '{nextId}'");
// Run on_enter for the new step.
if (FindStep(def, nextId) is { } newStep)
ApplyEffects(newStep.OnEnter, ctx, state);
}
}
private void FinishQuest(QuestDef def, QuestState state, QuestStatus status)
{
state.Status = status;
_active.Remove(def.Id);
// Cap completed history to keep save size bounded.
if (_completed.Count >= C.QUEST_LOG_COMPLETED_LIMIT)
{
// Drop the oldest (insertion order via OrderBy on StartedAt).
string? oldest = _completed.Values.OrderBy(s => s.StartedAt).FirstOrDefault()?.QuestId;
if (oldest is not null) _completed.Remove(oldest);
}
_completed[def.Id] = state;
Journal.Add(status switch
{
QuestStatus.Completed => $"Completed: {def.Title}",
QuestStatus.Failed => $"Failed: {def.Title}",
_ => $"Ended: {def.Title}",
});
}
private static QuestStepDef? FindStep(QuestDef def, string stepId)
{
foreach (var s in def.Steps)
if (string.Equals(s.Id, stepId, System.StringComparison.OrdinalIgnoreCase))
return s;
return null;
}
// ── Conditions ───────────────────────────────────────────────────────
private bool AreConditionsMet(QuestConditionDef[] conditions, QuestContext ctx)
{
foreach (var c in conditions)
if (!Evaluate(c, ctx)) return false;
return true;
}
private bool Evaluate(QuestConditionDef c, QuestContext ctx) => c.Kind.ToLowerInvariant() switch
{
"flag_set" => ctx.Flags.TryGetValue(c.Flag, out int v) && v != 0,
"flag_clear" => !ctx.Flags.TryGetValue(c.Flag, out int v2) || v2 == 0,
"flag_at_least" => ctx.Flags.TryGetValue(c.Flag, out int vv) && vv >= c.Value,
"enter_anchor" => CheckEnterAnchor(c, ctx),
"enter_role_proximity" => CheckEnterRoleProximity(c, ctx),
"npc_dead" => CheckNpcAlive(c, ctx) is { } liveDead && !liveDead,
"npc_alive" => CheckNpcAlive(c, ctx) is { } live && live,
"time_elapsed_seconds" => ctx.Clock.InGameSeconds >= c.Seconds,
"rep_at_least" => RepFor(c.Faction, ctx) >= c.Value,
"rep_below" => RepFor(c.Faction, ctx) < c.Value,
"has_item" => ctx.HasItem(c.Id),
"not_has_item" => !ctx.HasItem(c.Id),
"quest_complete" => IsCompleted(c.Quest),
"quest_active" => IsActive(c.Quest),
"dialogue_choice" => string.Equals(ctx.LastDialogueNodeReached, c.Id,
System.StringComparison.OrdinalIgnoreCase),
_ => false,
};
private bool CheckEnterAnchor(QuestConditionDef c, QuestContext ctx)
{
if (string.IsNullOrEmpty(c.Anchor)) return false;
int? sId = ctx.Anchors.ResolveAnchor(c.Anchor.StartsWith("anchor:")
? c.Anchor : $"anchor:{c.Anchor}");
if (sId is null) return false;
var dist = ctx.PlayerDistanceToSettlement(sId.Value);
return dist is not null && dist.Value <= C.QUEST_ENTER_ANCHOR_RADIUS_TILES;
}
private bool CheckEnterRoleProximity(QuestConditionDef c, QuestContext ctx)
{
if (string.IsNullOrEmpty(c.Role)) return false;
int? npcId = ctx.Anchors.ResolveRole(c.Role.StartsWith("role:")
? c.Role : $"role:{c.Role}");
if (npcId is null) return false;
if (ctx.PlayerTacticalPos() is not { } ppos) return false;
foreach (var npc in ctx.Actors.Npcs)
{
if (npc.Id != npcId.Value) continue;
int dx = (int)System.Math.Abs(npc.Position.X - ppos.x);
int dy = (int)System.Math.Abs(npc.Position.Y - ppos.y);
return System.Math.Max(dx, dy) <= C.QUEST_ENTER_ROLE_RADIUS_TILES;
}
return false;
}
private bool? CheckNpcAlive(QuestConditionDef c, QuestContext ctx)
{
string roleTag = string.IsNullOrEmpty(c.Role) ? c.Npc : c.Role;
if (string.IsNullOrEmpty(roleTag)) return null;
int? npcId = ctx.Anchors.ResolveRole(roleTag.StartsWith("role:") ? roleTag : $"role:{roleTag}");
if (npcId is null)
{
// The NPC is not currently spawned; treat as ALIVE if the engine
// hasn't recorded a kill flag (chunks evict NPCs frequently).
return true;
}
foreach (var npc in ctx.Actors.Npcs)
if (npc.Id == npcId.Value) return npc.IsAlive;
return true;
}
private static int RepFor(string faction, QuestContext ctx)
{
if (string.IsNullOrEmpty(faction)) return 0;
return ctx.Reputation.Factions.Get(faction);
}
// ── Effects ──────────────────────────────────────────────────────────
private void ApplyEffects(QuestEffectDef[] effects, QuestContext ctx, QuestState state)
{
foreach (var e in effects) ApplyEffect(e, ctx, state);
}
private void ApplyEffect(QuestEffectDef e, QuestContext ctx, QuestState state)
{
switch (e.Kind.ToLowerInvariant())
{
case "set_flag": ctx.Flags[e.Flag] = e.Value; break;
case "clear_flag": ctx.Flags.Remove(e.Flag); break;
case "give_item":
if (ctx.PlayerCharacter is not null && ctx.Content.Items.TryGetValue(e.Id, out var giveDef))
ctx.PlayerCharacter.Inventory.Add(giveDef, System.Math.Max(1, e.Qty));
break;
case "take_item": TakeItem(ctx, e.Id, System.Math.Max(1, e.Qty)); break;
case "give_xp":
if (ctx.PlayerCharacter is not null)
ctx.PlayerCharacter.Xp = System.Math.Max(0, ctx.PlayerCharacter.Xp + e.Xp);
break;
case "rep_event": if (e.Event is { } ev) SubmitRepEvent(ev, ctx); break;
case "start_quest": if (!string.IsNullOrEmpty(e.Quest)) Start(e.Quest, ctx); break;
case "end_quest": if (_active.ContainsKey(state.QuestId)) FinishQuest(
ctx.Content.Quests[state.QuestId], state, QuestStatus.Completed); break;
case "fail_quest": if (_active.ContainsKey(state.QuestId)) FinishQuest(
ctx.Content.Quests[state.QuestId], state, QuestStatus.Failed); break;
// spawn_npc / despawn_npc are M4 stubs — Phase 6 M5 wires the
// residency manipulation. Recording in the journal so the
// player can see the quest *intends* it.
case "spawn_npc": Journal.Add($"(quest) spawn_npc {e.Role} ← {e.Template}"); break;
case "despawn_npc": Journal.Add($"(quest) despawn_npc {e.Role}"); break;
}
}
private static void TakeItem(QuestContext ctx, string itemId, int qty)
{
if (ctx.PlayerCharacter is null || string.IsNullOrEmpty(itemId)) return;
var inv = ctx.PlayerCharacter.Inventory;
for (int i = inv.Items.Count - 1; i >= 0 && qty > 0; i--)
{
var inst = inv.Items[i];
if (!string.Equals(inst.Def.Id, itemId, System.StringComparison.OrdinalIgnoreCase)) continue;
int take = System.Math.Min(qty, inst.Qty);
inst.Qty -= take;
qty -= take;
if (inst.Qty <= 0) inv.Remove(inst);
}
}
private static void SubmitRepEvent(DialogueRepEventDef ev, QuestContext ctx)
{
if (!System.Enum.TryParse<RepEventKind>(ev.Kind, true, out var kind)) kind = RepEventKind.Quest;
var live = new RepEvent
{
Kind = kind,
FactionId = ev.Faction,
RoleTag = ev.RoleTag,
Magnitude = ev.Magnitude,
Note = ev.Note,
TimestampSeconds = ctx.Clock.InGameSeconds,
};
ctx.Reputation.Submit(live, ctx.Content.Factions);
}
public void Clear()
{
_active.Clear();
_completed.Clear();
Journal.Clear();
}
/// <summary>
/// Save-load helpers — used by <see cref="Persistence.QuestCodec"/> to
/// restore engine state without re-firing on_enter effects (those
/// already applied in the saved game).
/// </summary>
public void AdoptActive(QuestState state) => _active[state.QuestId] = state;
public void AdoptCompleted(QuestState state) => _completed[state.QuestId] = state;
}
@@ -0,0 +1,30 @@
namespace Theriapolis.Core.Rules.Quests;
/// <summary>
/// Phase 6 M4 — runtime per-quest state. One instance per active or
/// completed quest. The <see cref="QuestEngine"/> looks up the parent
/// <see cref="Data.QuestDef"/> by id when ticking, so this struct stays
/// small and serialises cleanly into the save layer.
/// </summary>
public sealed class QuestState
{
public string QuestId { get; init; } = "";
public string CurrentStep { get; set; } = "";
public QuestStatus Status { get; set; } = QuestStatus.Active;
/// <summary>WorldClock seconds when the quest started.</summary>
public long StartedAt { get; set; }
/// <summary>WorldClock seconds when the current step entered.</summary>
public long StepStartedAt { get; set; }
/// <summary>Free-form journal entries the player can browse from QuestLog.</summary>
public List<string> Journal { get; } = new();
}
public enum QuestStatus : byte
{
Active = 0,
Completed = 1,
Failed = 2,
}
@@ -0,0 +1,186 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6.5 M7 — when a player betrays a specific NPC (a
/// <see cref="RepEventKind.Betrayal"/> event with negative magnitude),
/// the betrayal doesn't stay personal. The cascade applies:
///
/// 1. **Personal disposition** drops by the event's magnitude (already
/// handled by <see cref="PersonalDisposition.Apply"/>; this layer
/// doesn't re-apply that delta).
/// 2. **Permanent memory flag** <c>"betrayed_me"</c> on the NPC's
/// personal record (also already handled by
/// <see cref="PersonalDisposition.Apply"/> via the
/// <see cref="PersonalDisposition.Betrayed"/> property — we
/// additionally write the explicit memory tag for dialogue gates that
/// check <c>has_memory_flag: betrayed_me</c>).
/// 3. **Faction propagation** — a tier-mapped negative delta is applied
/// to the betrayed NPC's primary faction; the existing opposition
/// matrix in <see cref="FactionStanding.Apply"/> handles the
/// faction-side cascade.
/// 4. **Permanent aggro** — for guards/patrols, set
/// <see cref="NpcActor.PermanentAggroAfterBetrayal"/>. They attack on
/// sight regardless of faction-standing recovery.
/// 5. **Ledger entry** — a faction-tagged event mirrors the personal
/// event so the reputation screen can show "Betrayed Asha · cost
/// -25 with Hybrid Underground" breadcrumbs.
///
/// Magnitude tier mapping (most-negative wins):
/// ≤ <see cref="C.BETRAYAL_MAGNITUDE_CRITICAL"/> → -50 faction
/// ≤ <see cref="C.BETRAYAL_MAGNITUDE_MAJOR"/> → -30 faction
/// ≤ <see cref="C.BETRAYAL_MAGNITUDE_MODERATE"/> → -15 faction
/// ≤ <see cref="C.BETRAYAL_MAGNITUDE_MINOR"/> → -5 faction
///
/// The cascade is **deterministic** per the input event id — same event,
/// same outcome — so save/load round-trips reproduce identically.
/// </summary>
public static class BetrayalCascade
{
/// <summary>
/// Apply the cascade for an already-applied betrayal event. Caller is
/// responsible for having <see cref="PlayerReputation.Submit"/>'d the
/// underlying <see cref="RepEvent"/> first; this helper layers the
/// cross-cutting consequences on top.
///
/// <paramref name="npcs"/> is the live actor list — guards / patrols
/// belonging to the betrayed NPC's faction get the permanent-aggro
/// flag. Pass an empty enumerable when no live actors are available
/// (Tools / tests).
/// </summary>
public static BetrayalCascadeResult Apply(
RepEvent betrayalEvent,
PlayerReputation rep,
NpcActor? betrayedNpc,
IEnumerable<NpcActor> npcs,
IReadOnlyDictionary<string, FactionDef> factions)
{
if (betrayalEvent is null) throw new System.ArgumentNullException(nameof(betrayalEvent));
if (betrayalEvent.Kind != RepEventKind.Betrayal || betrayalEvent.Magnitude >= 0)
return BetrayalCascadeResult.Empty;
// 1 + 2: PersonalDisposition.Apply already wrote the magnitude AND
// flipped Betrayed=true. The dialogue layer reads
// Memory.Contains("betrayed_me"); ensure the explicit tag is
// present (Apply only writes implicit flags).
if (!string.IsNullOrEmpty(betrayalEvent.RoleTag))
rep.PersonalFor(betrayalEvent.RoleTag).Memory.Add("betrayed_me");
// 3: Faction propagation. Pick the tier from the magnitude; map to
// the faction-side delta; apply via FactionStanding.Apply (which
// cascades through the opposition matrix automatically).
int factionDelta = ResolveFactionDelta(betrayalEvent.Magnitude);
string targetFaction = ResolveFactionForBetrayal(betrayedNpc, betrayalEvent);
var factionDeltas = new List<(string FactionId, int Delta)>();
if (!string.IsNullOrEmpty(targetFaction) && factionDelta != 0)
{
factionDeltas = rep.Factions.Apply(targetFaction, factionDelta, factions);
// Mirror to the ledger as a separate, faction-tagged event so
// the reputation screen can answer "why did Hybrid Underground
// cool to you?" with "you betrayed Asha".
rep.Ledger.Append(new RepEvent
{
Kind = RepEventKind.Betrayal,
FactionId = targetFaction,
RoleTag = betrayalEvent.RoleTag,
Magnitude = factionDelta,
Note = $"betrayal cascade ({betrayalEvent.Note})",
OriginTileX = betrayalEvent.OriginTileX,
OriginTileY = betrayalEvent.OriginTileY,
TimestampSeconds = betrayalEvent.TimestampSeconds,
});
}
// 4: Permanent aggro for guards/patrols belonging to the same faction
// as the betrayed NPC. Read npc.BehaviorId to identify guard-style
// NPCs (brigand / patrol / poi_guard); friendly merchants /
// residents don't go full-aggro on betrayal.
int aggroFlipped = 0;
if (!string.IsNullOrEmpty(targetFaction))
{
foreach (var npc in npcs)
{
if (!npc.IsAlive) continue;
if (npc.PermanentAggroAfterBetrayal) continue; // already flagged
if (string.IsNullOrEmpty(npc.FactionId)) continue;
if (!string.Equals(npc.FactionId, targetFaction,
System.StringComparison.OrdinalIgnoreCase)) continue;
if (!IsAggroEligibleBehavior(npc)) continue;
npc.PermanentAggroAfterBetrayal = true;
aggroFlipped++;
}
}
return new BetrayalCascadeResult(
personalRoleTag: betrayalEvent.RoleTag,
personalMagnitude: betrayalEvent.Magnitude,
factionId: targetFaction,
factionDeltas: factionDeltas,
permanentAggroFlipped: aggroFlipped);
}
/// <summary>
/// Tier the personal-disposition magnitude into the faction-side delta.
/// "Most-negative wins" — the player's worst-case betrayal sets the
/// floor; lighter betrayals get smaller cascades.
/// </summary>
public static int ResolveFactionDelta(int personalMagnitude)
{
if (personalMagnitude >= 0) return 0; // not a betrayal
if (personalMagnitude <= C.BETRAYAL_MAGNITUDE_CRITICAL)
return C.BETRAYAL_FACTION_DELTA_CRITICAL;
if (personalMagnitude <= C.BETRAYAL_MAGNITUDE_MAJOR)
return C.BETRAYAL_FACTION_DELTA_MAJOR;
if (personalMagnitude <= C.BETRAYAL_MAGNITUDE_MODERATE)
return C.BETRAYAL_FACTION_DELTA_MODERATE;
if (personalMagnitude <= C.BETRAYAL_MAGNITUDE_MINOR)
return C.BETRAYAL_FACTION_DELTA_MINOR;
// -1 .. -9 (below the minor threshold) — too small to cascade.
return 0;
}
/// <summary>
/// Resolve which faction takes the cascade hit. Priority:
/// 1. The betrayed NPC's own faction id (most natural attribution).
/// 2. The event's <see cref="RepEvent.FactionId"/> (caller-overridden).
/// 3. Empty (no faction cascade — personal-only event).
/// </summary>
private static string ResolveFactionForBetrayal(NpcActor? betrayedNpc, RepEvent ev)
{
if (betrayedNpc is not null && !string.IsNullOrEmpty(betrayedNpc.FactionId))
return betrayedNpc.FactionId;
return ev.FactionId ?? "";
}
/// <summary>
/// True when an NPC's behavior id makes them a candidate for the
/// permanent-aggro flip — armed/threatening roles only. Civilian
/// merchants / residents stay non-aggro even on betrayal.
/// </summary>
private static bool IsAggroEligibleBehavior(NpcActor npc)
{
string b = npc.BehaviorId ?? "";
return b.Equals("brigand", System.StringComparison.OrdinalIgnoreCase)
|| b.Equals("patrol", System.StringComparison.OrdinalIgnoreCase)
|| b.Equals("poi_guard", System.StringComparison.OrdinalIgnoreCase)
|| b.Equals("wild_animal", System.StringComparison.OrdinalIgnoreCase);
}
}
/// <summary>Componentised result of one cascade application — used by tests + UI surfacing.</summary>
public readonly record struct BetrayalCascadeResult(
string personalRoleTag,
int personalMagnitude,
string factionId,
List<(string FactionId, int Delta)> factionDeltas,
int permanentAggroFlipped)
{
/// <summary>True if the cascade had no effect (e.g. magnitude ≥ 0, or no faction).</summary>
public bool IsEmpty => factionDeltas.Count == 0 && permanentAggroFlipped == 0;
public static BetrayalCascadeResult Empty =>
new("", 0, "", new List<(string, int)>(), 0);
}
@@ -0,0 +1,65 @@
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M2 — coarse-grain banding of an integer disposition score.
/// Per <c>reputation.md §I-2</c> the bands gate dialogue tone, prices,
/// service refusal, and combat-on-sight behaviour.
/// </summary>
public enum DispositionLabel : byte
{
Nemesis = 0, // -100..-76 kill on sight
Hostile = 1, // -75..-51 attacked if recognised
Antagonistic = 2, // -50..-26 refused service
Unfriendly = 3, // -25.. -1 cold reception
Neutral = 4, // 0
Favorable = 5, // +1..+25
Friendly = 6, // +26..+50
Allied = 7, // +51..+75
Champion = 8, // +76..+100
}
/// <summary>
/// Phase 6 M2 — trust ladder accumulated through repeated personal
/// interaction. Distinct from <see cref="DispositionLabel"/> — trust is
/// earned, disposition is felt.
/// </summary>
public enum TrustLevel : byte
{
Stranger = 0,
Acquaintance = 1,
Familiar = 2,
Trusted = 3,
Bonded = 4,
}
public static class DispositionLabels
{
/// <summary>Map an integer disposition score (clamped to ±100) to its label.</summary>
public static DispositionLabel For(int score)
{
if (score >= C.REP_CHAMPION_THRESHOLD) return DispositionLabel.Champion;
if (score >= C.REP_ALLIED_THRESHOLD) return DispositionLabel.Allied;
if (score >= C.REP_FRIENDLY_THRESHOLD) return DispositionLabel.Friendly;
if (score >= C.REP_FAVORABLE_THRESHOLD) return DispositionLabel.Favorable;
if (score == 0) return DispositionLabel.Neutral;
if (score >= C.REP_UNFRIENDLY_THRESHOLD) return DispositionLabel.Unfriendly;
if (score >= C.REP_ANTAGONISTIC_THRESHOLD) return DispositionLabel.Antagonistic;
if (score >= C.REP_HOSTILE_THRESHOLD) return DispositionLabel.Hostile;
return DispositionLabel.Nemesis;
}
/// <summary>Display string ("Nemesis", "Friendly", etc.) for the reputation screen + tooltip.</summary>
public static string DisplayName(DispositionLabel l) => l switch
{
DispositionLabel.Nemesis => "Nemesis",
DispositionLabel.Hostile => "Hostile",
DispositionLabel.Antagonistic => "Antagonistic",
DispositionLabel.Unfriendly => "Unfriendly",
DispositionLabel.Neutral => "Neutral",
DispositionLabel.Favorable => "Favorable",
DispositionLabel.Friendly => "Friendly",
DispositionLabel.Allied => "Allied",
DispositionLabel.Champion => "Champion",
_ => "Unknown",
};
}
@@ -0,0 +1,209 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.World;
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M2 — combines the three reputation layers into a single
/// integer disposition score per <c>reputation.md §I-4</c>:
///
/// EffectiveDisposition(npc, pc) =
/// CladeBiasFor(npc.BiasProfile, pc.Clade, sizeDiff(npc, pc))
/// + FactionWeightedSum(npc.Faction, pc.FactionStandings)
/// + PersonalDisposition(npc.Id)
///
/// Computed lazily — the inputs change too often (faction propagation,
/// time decay) to justify caching. Computation is O(1).
/// </summary>
public static class EffectiveDisposition
{
/// <summary>
/// Final blended score (clamped <c>±C.REP_MAX</c>) for how
/// <paramref name="npc"/> currently feels about
/// <paramref name="pc"/>.
/// </summary>
public static int For(
NpcActor npc,
Rules.Character.Character pc,
PlayerReputation rep,
ContentResolver content,
WorldState? world = null,
ulong worldSeed = 0)
{
return Breakdown(npc, pc, rep, content, world, worldSeed).Total;
}
/// <summary>
/// Componentised view used by the disposition tooltip + reputation
/// screen. Each field carries the contribution of one layer so the
/// UI can answer "why does so-and-so hate me?" without re-deriving.
/// </summary>
public static EffectiveDispositionBreakdown Breakdown(
NpcActor npc,
Rules.Character.Character pc,
PlayerReputation rep,
ContentResolver content,
WorldState? world = null,
ulong worldSeed = 0)
{
int cladeBias = ResolveCladeBias(npc, pc, content);
int sizeBias = SizeDifferentialModifier(npc, pc, content);
int factionMod = ResolveFactionMod(npc, rep, content, world, worldSeed);
int personal = ResolvePersonal(npc, rep);
int total = System.Math.Clamp(cladeBias + sizeBias + factionMod + personal,
C.REP_MIN, C.REP_MAX);
return new EffectiveDispositionBreakdown(
cladeBias, sizeBias, factionMod, personal, total,
DispositionLabels.For(total));
}
// ── Layer 1 — Clade bias ──────────────────────────────────────────────
private static int ResolveCladeBias(NpcActor npc, Rules.Character.Character pc, ContentResolver content)
{
if (string.IsNullOrEmpty(npc.BiasProfileId)) return 0;
if (!content.BiasProfiles.TryGetValue(npc.BiasProfileId, out var profile)) return 0;
// Phase 6.5 M5 — hybrid bias layering. When the PC is hybrid AND
// this NPC has personally detected the hybrid status (memory tag
// "knows_hybrid"), the profile's HybridBias modifier is added to
// the clade-bias. Pre-detection, the PC reads as their presenting
// (dominant) clade and HybridBias is *not* applied.
int bias = profile.CladeBias.TryGetValue(pc.Clade.Id, out int b) ? b : 0;
if (pc.IsHybrid && NpcKnowsPlayerIsHybrid(npc, pc))
bias += profile.HybridBias;
return bias;
}
/// <summary>
/// Phase 6.5 M5 — true if the NPC's <see cref="PersonalDisposition.Memory"/>
/// contains the <c>"knows_hybrid"</c> flag (set by
/// <see cref="Rules.Character.PassingCheck"/> on a successful detection).
/// Falls back to the PC-side <see cref="Rules.Character.HybridState.NpcsWhoKnow"/>
/// list when the NPC has no personal-disposition record yet (which can
/// happen for casual encounters).
/// </summary>
public static bool NpcKnowsPlayerIsHybrid(NpcActor npc, Rules.Character.Character pc)
{
if (pc.Hybrid is null) return false;
if (pc.Hybrid.NpcsWhoKnow.Contains(npc.Id)) return true;
// The NPC's PersonalDisposition lives on the player-rep dictionary;
// this call site doesn't have access. The PC-side NpcsWhoKnow set
// is the authoritative mirror written by PassingCheck after every
// detection — sufficient for the disposition layer to consult.
return false;
}
private static int SizeDifferentialModifier(NpcActor npc, Rules.Character.Character pc, ContentResolver content)
{
// Size index: Small=1, Medium=2, MediumLarge=3, Large=4. Differential is pc.size npc.size.
if (npc.Resident is null) return 0;
if (string.IsNullOrEmpty(npc.Resident.Species)) return 0;
if (!content.Species.TryGetValue(npc.Resident.Species, out var npcSpecies)) return 0;
int npcIdx = SizeIndex(SizeExtensions.FromJson(npcSpecies.Size));
int pcIdx = SizeIndex(pc.Size);
int diff = pcIdx - npcIdx;
// Per reputation.md §I-1 size differential table.
return diff switch
{
0 => 0,
1 => -3,
2 => -8,
3 => -8,
-1 => 2,
-2 => 5,
_ => 5,
};
}
private static int SizeIndex(SizeCategory s) => s switch
{
SizeCategory.Tiny => 0,
SizeCategory.Small => 1,
SizeCategory.Medium => 2,
SizeCategory.MediumLarge => 3,
SizeCategory.Large => 4,
SizeCategory.Huge => 5,
_ => 2,
};
// ── Layer 2 — Faction modifier ────────────────────────────────────────
/// <summary>
/// Derived modifier from the player's faction standings, weighted by
/// how much *this NPC* cares about each faction.
///
/// Phase 6 M5: when the NPC has a <see cref="NpcActor.HomeSettlementId"/>
/// AND a non-null <paramref name="world"/> is supplied, the local
/// (post-propagation, post-decay) standing in their settlement is used
/// instead of the global standing. Otherwise falls back to the M2
/// global lookup.
///
/// Bias-profile <c>faction_affinity</c> hints layer on top — a Covenant
/// Faithful amplifies their Enforcer alignment even if not formally
/// affiliated.
/// </summary>
private static int ResolveFactionMod(
NpcActor npc, PlayerReputation rep, ContentResolver content,
WorldState? world, ulong worldSeed)
{
float total = 0f;
// Resolve the NPC's home settlement (if any) for local-standing lookups.
Settlement? home = null;
if (world is not null && npc.HomeSettlementId is { } hid)
{
foreach (var s in world.Settlements)
if (s.Id == hid) { home = s; break; }
}
// Half-magnitude weight for the NPC's own affiliation.
if (!string.IsNullOrEmpty(npc.FactionId))
{
int standing = home is not null
? RepPropagation.LocalStandingFor(npc.FactionId, home, worldSeed, rep.Ledger, content.Factions)
: rep.Factions.Get(npc.FactionId);
total += standing * 0.5f;
}
// Bias-profile faction-affinity layering: Covenant Faithful npcs
// care about the Enforcers' standing even if not affiliated.
if (!string.IsNullOrEmpty(npc.BiasProfileId)
&& content.BiasProfiles.TryGetValue(npc.BiasProfileId, out var profile))
{
foreach (var (factionId, affinity) in profile.FactionAffinity)
{
int standing = home is not null
? RepPropagation.LocalStandingFor(factionId, home, worldSeed, rep.Ledger, content.Factions)
: rep.Factions.Get(factionId);
// Smaller weight than direct affiliation (×0.25) so the bias
// profile colours rather than dominates.
total += standing * (affinity / 100f) * 0.25f;
}
}
return (int)System.Math.Round(total);
}
// ── Layer 3 — Personal disposition ────────────────────────────────────
private static int ResolvePersonal(NpcActor npc, PlayerReputation rep)
{
if (string.IsNullOrEmpty(npc.RoleTag)) return 0;
return rep.Personal.TryGetValue(npc.RoleTag, out var p) ? p.Score : 0;
}
}
/// <summary>Component view of an <see cref="EffectiveDisposition"/> result.</summary>
public readonly record struct EffectiveDispositionBreakdown(
int CladeBias,
int SizeDifferential,
int FactionModifier,
int Personal,
int Total,
DispositionLabel Label);
@@ -0,0 +1,89 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.World;
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M5 — faction-driven NPC allegiance flips. Per the plan §4.6:
///
/// Patrol aggression: a friendly/neutral NPC with a faction id flips
/// their <see cref="Actor.Allegiance"/> to <see cref="Allegiance.Hostile"/>
/// when the player's local standing with that faction crosses the
/// <see cref="DispositionLabel.Hostile"/> threshold (≤ -51).
///
/// Sticky once Hostile: the flip doesn't bounce back if standing
/// recovers mid-tick — only on chunk re-stream (NPC despawns + reloads
/// fresh from template). This avoids flickering allegiance between
/// frames and matches CRPG convention ("you killed a brigand who saw
/// you stab a guard last week — they remember").
/// </summary>
public static class FactionAggression
{
/// <summary>
/// Walk every faction-affiliated NPC. Flip non-hostile ones to
/// Hostile when the player's local standing with their faction
/// crosses the HOSTILE threshold. Returns the number of NPCs flipped
/// this tick.
///
/// Patrol-aggro reads faction standing directly rather than through
/// the disposition lens — a constable doesn't care about your clade
/// or your personal history with them; they care that their faction
/// says you're wanted.
/// </summary>
public static int UpdateAllegiances(
ActorManager actors,
Rules.Character.Character pc,
PlayerReputation rep,
ContentResolver content,
WorldState world,
ulong worldSeed)
{
if (pc is null) return 0;
int flipped = 0;
foreach (var npc in actors.Npcs)
{
if (!npc.IsAlive) continue;
if (npc.Allegiance == Allegiance.Hostile) continue;
if (npc.Allegiance == Allegiance.Player) continue;
// Phase 6.5 M7 — sticky betrayal aggro fires unconditionally,
// independent of faction id (it could be a betrayed lone wolf).
if (npc.PermanentAggroAfterBetrayal)
{
npc.Allegiance = Allegiance.Hostile;
flipped++;
continue;
}
if (string.IsNullOrEmpty(npc.FactionId)) continue;
int factionStanding = ResolveFactionStanding(npc, rep, content, world, worldSeed);
if (factionStanding <= C.REP_HOSTILE_THRESHOLD)
{
npc.Allegiance = Allegiance.Hostile;
flipped++;
}
}
return flipped;
}
/// <summary>
/// Local faction standing as perceived by this NPC's home settlement
/// (post-propagation), or the global standing if no home is set.
/// </summary>
private static int ResolveFactionStanding(
NpcActor npc, PlayerReputation rep, ContentResolver content,
WorldState world, ulong worldSeed)
{
if (npc.HomeSettlementId is { } hid)
{
foreach (var s in world.Settlements)
if (s.Id == hid)
return RepPropagation.LocalStandingFor(npc.FactionId, s, worldSeed,
rep.Ledger, content.Factions);
}
return rep.Factions.Get(npc.FactionId);
}
}
@@ -0,0 +1,83 @@
using Theriapolis.Core.Data;
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M2 — Layer 2 of the disposition stack. The player's
/// faction-affiliated reputation: <c>Dictionary&lt;FactionId, int&gt;</c>
/// clamped to <c>±C.REP_MAX</c>, with the opposition matrix from
/// <c>reputation.md §I-2</c> applied automatically on every change.
///
/// Phase 6 M2 ships the score-only contract — propagation by distance
/// + time decay arrives in M5. Until then, every <see cref="Apply"/> call
/// fires an event that updates the at-origin standing immediately and
/// cascades through opposition; nothing else happens elsewhere on the map.
/// </summary>
public sealed class FactionStanding
{
private readonly Dictionary<string, int> _standings = new(System.StringComparer.OrdinalIgnoreCase);
/// <summary>Snapshot of every faction's current standing.</summary>
public IReadOnlyDictionary<string, int> Standings => _standings;
/// <summary>
/// Score with <paramref name="factionId"/>. Returns 0 (neutral) when
/// the faction has never been touched.
/// </summary>
public int Get(string factionId)
=> _standings.TryGetValue(factionId, out int v) ? v : 0;
/// <summary>Direct setter, no opposition cascade. Used by save-load and tests.</summary>
public void Set(string factionId, int value)
{
_standings[factionId] = System.Math.Clamp(value, C.REP_MIN, C.REP_MAX);
}
/// <summary>
/// Apply <paramref name="delta"/> to <paramref name="factionId"/>'s
/// standing and cascade the opposition matrix. Returns the list of
/// (factionId, delta) tuples actually applied (caller can log them).
///
/// Cascading is single-hop: <paramref name="factions"/>['inheritors'].opposition
/// is read once. Phase 6 M2 doesn't iterate (no transitive opposition).
/// </summary>
public List<(string FactionId, int Delta)> Apply(
string factionId,
int delta,
IReadOnlyDictionary<string, FactionDef> factions)
{
var applied = new List<(string, int)>();
if (delta == 0) return applied;
// Direct change first — clamped delta accounts for floor/ceiling.
int actualDelta = ApplyClamped(factionId, delta);
applied.Add((factionId, actualDelta));
// Cascade through opposition (use the *requested* delta, not the
// possibly-truncated one, so a clamp at the source doesn't mute
// downstream effects too).
if (factions.TryGetValue(factionId, out var def))
{
foreach (var (otherId, mult) in def.Opposition)
{
if (mult == 0f) continue;
int subDelta = (int)System.Math.Round(delta * mult);
if (subDelta == 0) continue;
int actualSub = ApplyClamped(otherId, subDelta);
if (actualSub != 0) applied.Add((otherId, actualSub));
}
}
return applied;
}
private int ApplyClamped(string factionId, int delta)
{
int current = Get(factionId);
int next = System.Math.Clamp(current + delta, C.REP_MIN, C.REP_MAX);
if (next == current) return 0;
_standings[factionId] = next;
return next - current;
}
public void Clear() => _standings.Clear();
}
@@ -0,0 +1,70 @@
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M2 — Layer 3 of the disposition stack: how a *specific* NPC
/// feels about the player based on direct personal experience. Per
/// <c>reputation.md §I-3</c>, only NPCs the player has actually
/// interacted with accumulate one of these — generic shopkeepers walked
/// past don't bloat state.
///
/// Keyed by role tag (anchor-prefixed for named NPCs:
/// "millhaven.innkeeper"). Generic NPCs that the player talks to register
/// briefly under their generic tag but typically reset on chunk evict —
/// only named NPCs carry a stable id across reloads.
/// </summary>
public sealed class PersonalDisposition
{
/// <summary>Role tag identifying which NPC this record belongs to.</summary>
public string RoleTag { get; init; } = "";
/// <summary>Score relative to neutral, clamped to <c>±C.REP_MAX</c>.</summary>
public int Score { get; set; }
/// <summary>Trust ladder accumulated across the interaction log.</summary>
public TrustLevel Trust { get; set; } = TrustLevel.Stranger;
/// <summary>True after the player betrayed this specific NPC. Sticky — only narrative
/// effects can clear it.</summary>
public bool Betrayed { get; set; }
/// <summary>Last interaction time in WorldClock seconds. 0 = never interacted.</summary>
public long LastInteractionSeconds { get; set; }
/// <summary>Free-form memory tags ("saved-my-kit", "lied-about-rawfang", ...).</summary>
public HashSet<string> Memory { get; } = new();
/// <summary>Last N events affecting this specific NPC. Bounded — see <see cref="MaxLogEntries"/>.</summary>
public List<RepEvent> Log { get; } = new();
public const int MaxLogEntries = 32;
/// <summary>Append a personal event and apply its magnitude to <see cref="Score"/>.</summary>
public void Apply(RepEvent ev)
{
Score = System.Math.Clamp(Score + ev.Magnitude, C.REP_MIN, C.REP_MAX);
if (ev.Kind == RepEventKind.Betrayal && ev.Magnitude < 0) Betrayed = true;
Log.Add(ev);
if (Log.Count > MaxLogEntries) Log.RemoveAt(0);
LastInteractionSeconds = ev.TimestampSeconds;
Trust = ComputeTrust();
}
/// <summary>
/// Trust ladder derived from positive interaction count. Negative events
/// don't promote; betrayal demotes to Stranger regardless of history.
/// </summary>
private TrustLevel ComputeTrust()
{
if (Betrayed) return TrustLevel.Stranger;
int positives = 0;
foreach (var e in Log) if (e.Magnitude > 0) positives++;
return positives switch
{
>= 12 => TrustLevel.Bonded,
>= 7 => TrustLevel.Trusted,
>= 3 => TrustLevel.Familiar,
>= 1 => TrustLevel.Acquaintance,
_ => TrustLevel.Stranger,
};
}
}
@@ -0,0 +1,44 @@
using Theriapolis.Core.Data;
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M2 — top-level aggregate of every reputation track owned by
/// the player. Hangs off PlayScreen as a parallel-to-Character record
/// (deliberate separation: <c>Character</c> is what the player is,
/// <c>PlayerReputation</c> is what the world thinks of them).
///
/// Round-trips through <see cref="Persistence.ReputationSnapshot"/>.
/// </summary>
public sealed class PlayerReputation
{
public FactionStanding Factions { get; } = new();
public Dictionary<string, PersonalDisposition> Personal { get; } = new(System.StringComparer.OrdinalIgnoreCase);
public RepLedger Ledger { get; } = new();
/// <summary>Get-or-create the per-NPC personal disposition record for <paramref name="roleTag"/>.</summary>
public PersonalDisposition PersonalFor(string roleTag)
{
if (!Personal.TryGetValue(roleTag, out var p))
{
p = new PersonalDisposition { RoleTag = roleTag };
Personal[roleTag] = p;
}
return p;
}
/// <summary>
/// Submit a reputation event. Updates faction standing (with opposition
/// cascade), the addressed NPC's personal disposition, and the ledger.
/// </summary>
public void Submit(RepEvent ev, IReadOnlyDictionary<string, FactionDef> factions)
{
if (!string.IsNullOrEmpty(ev.FactionId) && ev.Magnitude != 0)
Factions.Apply(ev.FactionId, ev.Magnitude, factions);
if (!string.IsNullOrEmpty(ev.RoleTag))
PersonalFor(ev.RoleTag).Apply(ev);
Ledger.Append(ev);
}
}
@@ -0,0 +1,66 @@
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M2 — typed, append-only log entry recording a single reputation
/// change. Events are the *cause*; the resulting standing/disposition
/// update is the *effect*. We keep the cause around so the UI can answer
/// "why does so-and-so hate me?" with breadcrumbs.
///
/// Phase 6 M5 layers propagation on top: events written here can fan out
/// to other settlements with distance/time decay.
/// </summary>
public enum RepEventKind : byte
{
Dialogue = 0,
Quest = 1,
Combat = 2,
Rescue = 3,
Betrayal = 4,
Gift = 5,
Trade = 6,
Scent = 7,
Death = 8, // killing a faction-affiliated NPC
Aid = 9, // healing / curing / saving a non-combatant
Crime = 10,
/// <summary>
/// Phase 6.5 M5 — an NPC's scent-detection roll exposed the player
/// as a hybrid. Per-NPC personal-only event (no faction propagation
/// in M5; Phase 8's scent simulation can extend this).
/// </summary>
HybridDetected = 11,
Misc = 255,
}
/// <summary>One immutable reputation event. Time-stamped and tagged with
/// origin coordinates so propagation can apply distance/time decay.</summary>
public sealed record RepEvent
{
/// <summary>
/// Phase 6 M5 — monotonically increasing id assigned by
/// <see cref="RepLedger.Append"/>. Used as the deterministic-RNG
/// seed for frontier-settlement delivery coin-flips. 0 means "not
/// yet appended to a ledger".
/// </summary>
public int SequenceId { get; init; } = 0;
public RepEventKind Kind { get; init; } = RepEventKind.Misc;
/// <summary>Faction id this event affects (empty = personal-only event).</summary>
public string FactionId { get; init; } = "";
/// <summary>NPC role tag this event affects personally (empty = world-only event).</summary>
public string RoleTag { get; init; } = "";
/// <summary>Magnitude before opposition matrix / decay. Sign indicates direction.</summary>
public int Magnitude { get; init; }
/// <summary>Free-form origin context: "saved-her-kit-from-drowning" / "killed-thornfield-guard".</summary>
public string Note { get; init; } = "";
/// <summary>World-tile coordinates where the event occurred (for M5 propagation).</summary>
public int OriginTileX { get; init; }
public int OriginTileY { get; init; }
/// <summary>WorldClock seconds at the time the event was logged.</summary>
public long TimestampSeconds { get; init; }
}
@@ -0,0 +1,74 @@
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M2 — append-only event log surfaced by the reputation screen
/// ("why does so-and-so hate me?"). Bounded to a reasonable tail so the
/// save file stays small even after a 100-hour playthrough.
///
/// Phase 6 M5 layers propagation on top: each entry will be re-walked
/// per game-day to fan out into other settlements with distance/time
/// decay.
/// </summary>
public sealed class RepLedger
{
public const int MaxEntries = 256;
private readonly List<RepEvent> _entries = new();
private int _nextSeq = 1;
public IReadOnlyList<RepEvent> Entries => _entries;
public int Count => _entries.Count;
/// <summary>
/// Append <paramref name="ev"/> to the ledger. If <see cref="RepEvent.SequenceId"/>
/// is 0, a fresh monotone id is assigned; otherwise the supplied id
/// is preserved (used by the save-restore path).
/// </summary>
public RepEvent Append(RepEvent ev)
{
if (ev.SequenceId == 0)
ev = ev with { SequenceId = _nextSeq++ };
else if (ev.SequenceId >= _nextSeq)
_nextSeq = ev.SequenceId + 1;
_entries.Add(ev);
if (_entries.Count > MaxEntries) _entries.RemoveAt(0);
return ev;
}
public void Clear()
{
_entries.Clear();
_nextSeq = 1;
}
/// <summary>Largest <see cref="RepEvent.SequenceId"/> issued so far. 0 = empty ledger.</summary>
public int HighestSequenceId => _nextSeq - 1;
/// <summary>Most recent N events affecting <paramref name="factionId"/>.</summary>
public IEnumerable<RepEvent> ForFaction(string factionId, int count = 8)
{
int yielded = 0;
for (int i = _entries.Count - 1; i >= 0 && yielded < count; i--)
{
if (string.Equals(_entries[i].FactionId, factionId, System.StringComparison.OrdinalIgnoreCase))
{
yielded++;
yield return _entries[i];
}
}
}
/// <summary>Most recent N events affecting <paramref name="roleTag"/>.</summary>
public IEnumerable<RepEvent> ForRole(string roleTag, int count = 8)
{
int yielded = 0;
for (int i = _entries.Count - 1; i >= 0 && yielded < count; i--)
{
if (string.Equals(_entries[i].RoleTag, roleTag, System.StringComparison.OrdinalIgnoreCase))
{
yielded++;
yield return _entries[i];
}
}
}
}
@@ -0,0 +1,172 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Util;
using Theriapolis.Core.World;
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M5 — distance-banded reputation propagation per
/// <c>reputation.md §I-2</c>.
///
/// The model: every <see cref="RepEvent"/> in the
/// <see cref="RepLedger"/> is *visible everywhere* (full magnitude at
/// origin, decayed by Chebyshev tile distance to other settlements,
/// frontier settlements may not receive at all). This module computes
/// per-settlement faction standing on demand by walking the ledger and
/// summing the decayed contributions plus opposition-matrix cascades.
///
/// Determinism: frontier coin-flips are keyed by
/// <c>(worldSeed, eventSequenceId, settlementId)</c> so the same news
/// arrives (or doesn't) the same way across save/load.
///
/// Complexity: O(events × settlements × factions) for a full sweep, but
/// per-NPC-disposition queries hit only the player's home settlement
/// and run in O(events × factions) — bounded ledger size keeps it cheap.
/// </summary>
public static class RepPropagation
{
/// <summary>
/// Faction standing as perceived in <paramref name="settlement"/>.
/// Walks the ledger, applies distance decay + cascade. Clamped to
/// <c>±C.REP_MAX</c>.
/// </summary>
public static int LocalStandingFor(
string factionId,
Settlement settlement,
ulong worldSeed,
RepLedger ledger,
IReadOnlyDictionary<string, FactionDef> factions)
{
if (string.IsNullOrEmpty(factionId)) return 0;
if (settlement is null) return 0;
if (ledger.Count == 0) return 0;
int total = 0;
foreach (var ev in ledger.Entries)
{
int delta = ContributionForFaction(factionId, ev, settlement, worldSeed, factions);
total += delta;
}
return System.Math.Clamp(total, C.REP_MIN, C.REP_MAX);
}
/// <summary>
/// Per-event contribution to a settlement's local standing for one
/// faction. Includes both direct events (event.FactionId == faction)
/// and cascade events (other factions whose opposition matrix names
/// this faction). Returns 0 when the event hasn't propagated to this
/// settlement (frontier coin-flip failure).
/// </summary>
public static int ContributionForFaction(
string factionId,
RepEvent ev,
Settlement settlement,
ulong worldSeed,
IReadOnlyDictionary<string, FactionDef> factions)
{
if (string.IsNullOrEmpty(ev.FactionId)) return 0;
int distTiles = ChebyshevDistance(ev.OriginTileX, ev.OriginTileY,
settlement.TileX, settlement.TileY);
bool isExtreme = System.Math.Abs(ev.Magnitude) >= C.REP_EXTREME_BYPASS_MAGNITUDE;
// Frontier band requires a per-(event, settlement) coin flip.
var band = BandFor(distTiles);
if (!isExtreme && band == DistanceBand.Frontier
&& !FrontierDelivered(worldSeed, ev.SequenceId, settlement.Id))
return 0;
int decayPct = isExtreme ? C.REP_DECAY_AT_ORIGIN_PCT : DecayPctFor(band);
int direct = 0;
if (string.Equals(ev.FactionId, factionId, System.StringComparison.OrdinalIgnoreCase))
direct = (int)System.Math.Round(ev.Magnitude * (decayPct / 100f));
int cascade = 0;
if (factions.TryGetValue(ev.FactionId, out var sourceDef)
&& sourceDef.Opposition.TryGetValue(factionId, out float mult)
&& mult != 0f)
{
cascade = (int)System.Math.Round(ev.Magnitude * mult * (decayPct / 100f));
}
return direct + cascade;
}
/// <summary>
/// Convenience: human-readable breakdown of *why* the local standing
/// looks the way it does. Used by the disposition tooltip and the
/// reputation screen's "recent events" tail.
/// </summary>
public static IEnumerable<(RepEvent Event, int LocalDelta, DistanceBand Band)>
ExplainLocalStanding(
string factionId,
Settlement settlement,
ulong worldSeed,
RepLedger ledger,
IReadOnlyDictionary<string, FactionDef> factions,
int max = 8)
{
if (string.IsNullOrEmpty(factionId) || settlement is null) yield break;
int yielded = 0;
// Most recent first.
for (int i = ledger.Entries.Count - 1; i >= 0 && yielded < max; i--)
{
var ev = ledger.Entries[i];
int delta = ContributionForFaction(factionId, ev, settlement, worldSeed, factions);
if (delta == 0) continue;
int distTiles = ChebyshevDistance(ev.OriginTileX, ev.OriginTileY,
settlement.TileX, settlement.TileY);
yield return (ev, delta, BandFor(distTiles));
yielded++;
}
}
public enum DistanceBand : byte
{
Origin = 0,
Adjacent = 1,
Regional = 2,
Continental = 3,
Frontier = 4,
}
public static DistanceBand BandFor(int chebyshevTiles)
{
if (chebyshevTiles == 0) return DistanceBand.Origin;
if (chebyshevTiles <= C.REP_ADJACENT_DIST_TILES) return DistanceBand.Adjacent;
if (chebyshevTiles <= C.REP_REGIONAL_DIST_TILES) return DistanceBand.Regional;
if (chebyshevTiles <= C.REP_CONTINENTAL_DIST_TILES) return DistanceBand.Continental;
return DistanceBand.Frontier;
}
public static int DecayPctFor(DistanceBand band) => band switch
{
DistanceBand.Origin => C.REP_DECAY_AT_ORIGIN_PCT,
DistanceBand.Adjacent => C.REP_DECAY_ADJACENT_PCT,
DistanceBand.Regional => C.REP_DECAY_REGIONAL_PCT,
DistanceBand.Continental => C.REP_DECAY_CONTINENTAL_PCT,
DistanceBand.Frontier => C.REP_DECAY_FRONTIER_PCT,
_ => 0,
};
/// <summary>
/// Deterministic coin-flip per <c>(worldSeed, eventSequenceId, settlementId)</c>.
/// Returns true if the news of this event reaches the frontier
/// settlement at all.
/// </summary>
public static bool FrontierDelivered(ulong worldSeed, int eventSequenceId, int settlementId)
{
// Mix the keys so seeds collide as rarely as possible.
ulong mix = unchecked(worldSeed
^ C.RNG_REP_PROPAGATION
^ ((ulong)(uint)eventSequenceId << 16)
^ ((ulong)(uint)settlementId << 40));
var rng = new SeededRng(mix);
int roll = (int)(rng.NextUInt64() % 100UL);
return roll < C.REP_FRONTIER_DELIVERY_PROB_PCT;
}
private static int ChebyshevDistance(int x1, int y1, int x2, int y2)
=> System.Math.Max(System.Math.Abs(x1 - x2), System.Math.Abs(y1 - y2));
}
@@ -0,0 +1,88 @@
namespace Theriapolis.Core.Rules.Stats;
/// <summary>
/// The six d20-adjacent ability scores. Score range is 1..30; level-1
/// characters typically end up in 8..18 after clade/species mods.
/// </summary>
public readonly struct AbilityScores : IEquatable<AbilityScores>
{
public readonly byte STR;
public readonly byte DEX;
public readonly byte CON;
public readonly byte INT;
public readonly byte WIS;
public readonly byte CHA;
public AbilityScores(int str, int dex, int con, int @int, int wis, int cha)
{
STR = ClampScore(str);
DEX = ClampScore(dex);
CON = ClampScore(con);
INT = ClampScore(@int);
WIS = ClampScore(wis);
CHA = ClampScore(cha);
}
/// <summary>Standard d20 ability modifier: floor((score - 10) / 2).</summary>
public static int Mod(int score)
{
// C# integer division truncates toward zero; for negatives we need
// floor toward -infinity to match d20 behaviour (score 9 → -1 not 0).
int diff = score - 10;
return diff >= 0 ? diff / 2 : (diff - 1) / 2;
}
public int ModFor(AbilityId id) => Mod(Get(id));
public byte Get(AbilityId id) => id switch
{
AbilityId.STR => STR,
AbilityId.DEX => DEX,
AbilityId.CON => CON,
AbilityId.INT => INT,
AbilityId.WIS => WIS,
AbilityId.CHA => CHA,
_ => throw new ArgumentOutOfRangeException(nameof(id)),
};
/// <summary>Returns a new score block with <paramref name="id"/> replaced.</summary>
public AbilityScores With(AbilityId id, int newScore) => id switch
{
AbilityId.STR => new AbilityScores(newScore, DEX, CON, INT, WIS, CHA),
AbilityId.DEX => new AbilityScores(STR, newScore, CON, INT, WIS, CHA),
AbilityId.CON => new AbilityScores(STR, DEX, newScore, INT, WIS, CHA),
AbilityId.INT => new AbilityScores(STR, DEX, CON, newScore, WIS, CHA),
AbilityId.WIS => new AbilityScores(STR, DEX, CON, INT, newScore, CHA),
AbilityId.CHA => new AbilityScores(STR, DEX, CON, INT, WIS, newScore),
_ => throw new ArgumentOutOfRangeException(nameof(id)),
};
/// <summary>Returns a new block with each ability incremented by the supplied dictionary.</summary>
public AbilityScores Plus(IReadOnlyDictionary<AbilityId, int> mods)
{
var s = this;
foreach (var kv in mods) s = s.With(kv.Key, s.Get(kv.Key) + kv.Value);
return s;
}
/// <summary>The standard array, in descending order: 15, 14, 13, 12, 10, 8.</summary>
public static int[] StandardArray => new[] { 15, 14, 13, 12, 10, 8 };
private static byte ClampScore(int v) => (byte)Math.Clamp(v, 1, 30);
public bool Equals(AbilityScores o) =>
STR == o.STR && DEX == o.DEX && CON == o.CON && INT == o.INT && WIS == o.WIS && CHA == o.CHA;
public override bool Equals(object? o) => o is AbilityScores a && Equals(a);
public override int GetHashCode() => HashCode.Combine(STR, DEX, CON, INT, WIS, CHA);
public override string ToString() => $"STR {STR} DEX {DEX} CON {CON} INT {INT} WIS {WIS} CHA {CHA}";
}
public enum AbilityId : byte
{
STR = 0,
DEX = 1,
CON = 2,
INT = 3,
WIS = 4,
CHA = 5,
}
+51
View File
@@ -0,0 +1,51 @@
namespace Theriapolis.Core.Rules.Stats;
/// <summary>
/// Status effects applied during combat or by environment. Phase 5 ships
/// the most common subset; the enum is open-ended for additions in later
/// phases without renumbering existing values.
/// </summary>
public enum Condition : byte
{
None = 0,
Prone = 1,
Frightened = 2,
Restrained = 3,
Grappled = 4,
Dazed = 5,
Blinded = 6,
Stunned = 7,
Unconscious = 8,
Charmed = 9,
Poisoned = 10,
Deafened = 11,
Invisible = 12,
Petrified = 13,
Incapacitated = 14,
/// <summary>1..6 levels per d20; tracked separately on Character.ExhaustionLevel rather than as a binary flag.</summary>
Exhausted = 15,
}
public static class ConditionExtensions
{
public static Condition FromJson(string raw) => raw.ToLowerInvariant() switch
{
"none" => Condition.None,
"prone" => Condition.Prone,
"frightened" => Condition.Frightened,
"restrained" => Condition.Restrained,
"grappled" => Condition.Grappled,
"dazed" => Condition.Dazed,
"blinded" => Condition.Blinded,
"stunned" => Condition.Stunned,
"unconscious" => Condition.Unconscious,
"charmed" => Condition.Charmed,
"poisoned" => Condition.Poisoned,
"deafened" => Condition.Deafened,
"invisible" => Condition.Invisible,
"petrified" => Condition.Petrified,
"incapacitated" => Condition.Incapacitated,
"exhausted" => Condition.Exhausted,
_ => throw new ArgumentException($"Unknown condition: '{raw}'"),
};
}
@@ -0,0 +1,44 @@
namespace Theriapolis.Core.Rules.Stats;
/// <summary>
/// Damage classifications. Resistance and immunity are checked against
/// these. Theriapolis adds no exotic types beyond standard d20 — the
/// scent/pheromone abilities use <see cref="Condition"/> not damage.
/// </summary>
public enum DamageType : byte
{
Bludgeoning = 0,
Piercing = 1,
Slashing = 2,
Fire = 3,
Cold = 4,
Lightning = 5,
Poison = 6,
Psychic = 7,
Thunder = 8,
Acid = 9,
Necrotic = 10,
Radiant = 11,
Force = 12,
}
public static class DamageTypeExtensions
{
public static DamageType FromJson(string raw) => raw.ToLowerInvariant() switch
{
"bludgeoning" => DamageType.Bludgeoning,
"piercing" => DamageType.Piercing,
"slashing" => DamageType.Slashing,
"fire" => DamageType.Fire,
"cold" => DamageType.Cold,
"lightning" => DamageType.Lightning,
"poison" => DamageType.Poison,
"psychic" => DamageType.Psychic,
"thunder" => DamageType.Thunder,
"acid" => DamageType.Acid,
"necrotic" => DamageType.Necrotic,
"radiant" => DamageType.Radiant,
"force" => DamageType.Force,
_ => throw new ArgumentException($"Unknown damage type: '{raw}'"),
};
}
@@ -0,0 +1,112 @@
using Theriapolis.Core.Items;
namespace Theriapolis.Core.Rules.Stats;
/// <summary>
/// Pure computed values derived from a <see cref="Character"/>'s ability
/// scores, equipped items, conditions, and encumbrance state. Recomputed on
/// demand — nothing here mutates the character. UI panels and the combat
/// resolver call these helpers to surface the current AC, speed, etc.
///
/// Phase 5 M3 ships the AC, Speed, and CarryCap formulas plus the
/// encumbrance band. Class/feature-driven AC bonuses (Bovid Herd Wall +1
/// adjacent ally, Feral Unarmored Defense, etc.) are layered on at combat
/// resolution time, not here — those need positional context.
/// </summary>
public static class DerivedStats
{
public enum EncumbranceBand : byte
{
Light = 0, // ≤ soft threshold — no penalty
Heavy = 1, // > soft threshold — speed -10 ft.
Over = 2, // > hard threshold — speed halved + disadvantage on STR/DEX/CON
}
/// <summary>
/// Armor Class from base 10 (or unarmored-defense pseudo-armor) plus DEX
/// (capped by armor type) plus shield. Out-of-combat baseline; does not
/// include feature/positional bonuses.
/// </summary>
public static int ArmorClass(Theriapolis.Core.Rules.Character.Character c)
{
int dexMod = c.Abilities.ModFor(AbilityId.DEX);
int ac;
var body = c.Inventory.GetEquipped(EquipSlot.Body);
if (body is null)
{
// Unarmored: 10 + DEX. Feral's "Unarmored Defense" (10 + DEX + CON)
// ships at M6 with the rest of class-feature combat effects.
ac = 10 + dexMod;
}
else
{
int dexAllowed = body.Def.AcMaxDex < 0 ? dexMod : Math.Min(dexMod, body.Def.AcMaxDex);
ac = body.Def.AcBase + dexAllowed;
}
var off = c.Inventory.GetEquipped(EquipSlot.OffHand);
if (off is not null && string.Equals(off.Def.Kind, "shield", StringComparison.OrdinalIgnoreCase))
ac += off.Def.AcBase;
// Phase 5 M6: class features may replace the unarmored baseline
// (Feral Unarmored Defense). Per-encounter combat-time bonuses
// (Sentinel Stance) are added at attack-resolution time, not here.
ac = Theriapolis.Core.Rules.Combat.FeatureProcessor.ApplyAcFeatures(c, ac);
return Math.Clamp(ac, C.AC_FLOOR, C.AC_CEILING);
}
/// <summary>Initiative = DEX modifier. Class features that add to it (Feral L7) layered later.</summary>
public static int Initiative(Theriapolis.Core.Rules.Character.Character c) => c.Abilities.ModFor(AbilityId.DEX);
/// <summary>
/// Movement speed in feet per turn. Base from species, modified by
/// encumbrance band and (later) by conditions and class features.
/// </summary>
public static int SpeedFt(Theriapolis.Core.Rules.Character.Character c)
{
int speed = c.Species.BaseSpeedFt;
switch (Encumbrance(c))
{
case EncumbranceBand.Heavy: speed -= 10; break;
case EncumbranceBand.Over: speed /= 2; break;
}
return Math.Max(0, speed);
}
/// <summary>
/// Carrying capacity in pounds. Base = STR × 15, scaled by size category
/// (Small ½×, Large 2×, etc. per equipment.md).
/// </summary>
public static float CarryCapacityLb(Theriapolis.Core.Rules.Character.Character c)
{
return c.Abilities.STR * 15f * c.Size.CarryCapacityMult();
}
/// <summary>Current encumbrance band given inventory weight vs. carry capacity.</summary>
public static EncumbranceBand Encumbrance(Theriapolis.Core.Rules.Character.Character c)
{
float cap = CarryCapacityLb(c);
if (cap <= 0f) return EncumbranceBand.Over;
float w = c.Inventory.TotalWeightLb;
float ratio = w / cap;
if (ratio > C.ENCUMBRANCE_HARD_MULT) return EncumbranceBand.Over;
if (ratio > C.ENCUMBRANCE_SOFT_MULT) return EncumbranceBand.Heavy;
return EncumbranceBand.Light;
}
/// <summary>
/// Speed multiplier applied to <see cref="C.TACTICAL_PLAYER_PX_PER_SEC"/>.
/// 1.0 = normal walking pace; smaller = encumbered drag. Light = 1.0,
/// Heavy ≈ 0.66, Over = 0.5.
/// </summary>
public static float TacticalSpeedMult(Theriapolis.Core.Rules.Character.Character c) => Encumbrance(c) switch
{
EncumbranceBand.Light => 1.0f,
EncumbranceBand.Heavy => 0.66f,
EncumbranceBand.Over => 0.50f,
_ => 1.0f,
};
}
@@ -0,0 +1,32 @@
namespace Theriapolis.Core.Rules.Stats;
/// <summary>
/// Standard d20 proficiency-bonus-by-level table:
/// 1-4 → +2
/// 5-8 → +3
/// 9-12 → +4
/// 13-16 → +5
/// 17-20 → +6
/// Phase 5 only ever evaluates level 1, but the full table ships so
/// future leveling work doesn't have to revisit this file.
/// </summary>
public static class ProficiencyBonus
{
public const int MinLevel = 1;
public const int MaxLevel = 20;
public static int ForLevel(int level)
{
if (level < MinLevel || level > MaxLevel)
throw new ArgumentOutOfRangeException(nameof(level), $"Level must be {MinLevel}..{MaxLevel}, got {level}");
return level switch
{
>= 17 => 6,
>= 13 => 5,
>= 9 => 4,
>= 5 => 3,
_ => 2,
};
}
}
+32
View File
@@ -0,0 +1,32 @@
namespace Theriapolis.Core.Rules.Stats;
/// <summary>
/// Saving-throw categories. There's exactly one per ability; this enum is
/// a thin alias of <see cref="AbilityId"/> kept distinct so callsites read
/// clearly ("MakeSave(SaveId.DEX, dc)" vs "Mod(AbilityId.DEX)").
/// </summary>
public enum SaveId : byte
{
STR = 0,
DEX = 1,
CON = 2,
INT = 3,
WIS = 4,
CHA = 5,
}
public static class SaveIdExtensions
{
public static AbilityId Ability(this SaveId s) => (AbilityId)(byte)s;
public static SaveId FromJson(string raw) => raw.ToUpperInvariant() switch
{
"STR" => SaveId.STR,
"DEX" => SaveId.DEX,
"CON" => SaveId.CON,
"INT" => SaveId.INT,
"WIS" => SaveId.WIS,
"CHA" => SaveId.CHA,
_ => throw new ArgumentException($"Unknown save: '{raw}'"),
};
}
+66
View File
@@ -0,0 +1,66 @@
namespace Theriapolis.Core.Rules.Stats;
/// <summary>
/// Body-size category from clades.md. Determines tactical-tile footprint,
/// reach, equipment fit, and grappling rules.
/// </summary>
public enum SizeCategory : byte
{
Tiny = 0, // reserved; no Phase 5 species uses this
Small = 1, // rabbit-folk, housecat-folk, ferret-folk
Medium = 2, // fox-folk, deer-folk, ram-folk, leopard-folk, badger-folk, hare-folk
MediumLarge = 3, // wolf-folk, elk-folk, lion-folk, wolverine-folk, coyote-folk
Large = 4, // brown bear-folk, polar bear-folk, moose-folk, bull-folk, bison-folk
Huge = 5, // reserved; no Phase 5 species uses this
}
public static class SizeExtensions
{
/// <summary>Tactical-tile footprint per side (1 = 1×1, 2 = 2×2).</summary>
public static int FootprintTiles(this SizeCategory s) => s switch
{
SizeCategory.Tiny => 1,
SizeCategory.Small => 1,
SizeCategory.Medium => 1,
SizeCategory.MediumLarge => 1, // counts as Large for grappling/carrying only
SizeCategory.Large => 2,
SizeCategory.Huge => 3,
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
/// <summary>Default melee reach in tactical tiles (weapon-modifiable).</summary>
public static int DefaultReachTiles(this SizeCategory s) => s switch
{
SizeCategory.Tiny => 1,
SizeCategory.Small => 1,
SizeCategory.Medium => 1,
SizeCategory.MediumLarge => 1,
SizeCategory.Large => 2,
SizeCategory.Huge => 3,
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
/// <summary>Carrying-capacity multiplier per equipment.md (Small ½×, Large 2×).</summary>
public static float CarryCapacityMult(this SizeCategory s) => s switch
{
SizeCategory.Tiny => 0.25f,
SizeCategory.Small => 0.5f,
SizeCategory.Medium => 1.0f,
SizeCategory.MediumLarge => 1.0f, // Medium frame, Large for grappling
SizeCategory.Large => 2.0f,
SizeCategory.Huge => 4.0f,
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
/// <summary>Parses a snake_case JSON value (e.g. "medium_large") into a SizeCategory.</summary>
public static SizeCategory FromJson(string? raw) => raw switch
{
"tiny" => SizeCategory.Tiny,
"small" => SizeCategory.Small,
"medium" => SizeCategory.Medium,
"medium_large" => SizeCategory.MediumLarge,
"large" => SizeCategory.Large,
"huge" => SizeCategory.Huge,
_ => throw new ArgumentException($"Unknown size category: '{raw}'"),
};
}
+77
View File
@@ -0,0 +1,77 @@
namespace Theriapolis.Core.Rules.Stats;
/// <summary>
/// Standard d20-adjacent skill list. Each skill is backed by a single
/// ability — see <see cref="SkillAbility"/>.
/// </summary>
public enum SkillId : byte
{
Acrobatics = 0,
AnimalHandling = 1,
Arcana = 2, // Theriapolis: "Advanced Engineering"
Athletics = 3,
Deception = 4,
History = 5,
Insight = 6,
Intimidation = 7,
Investigation = 8,
Medicine = 9,
Nature = 10,
Perception = 11,
Performance = 12,
Persuasion = 13,
Religion = 14, // Theriapolis: Covenant lore
SleightOfHand = 15,
Stealth = 16,
Survival = 17,
}
public static class SkillIdExtensions
{
public static AbilityId Ability(this SkillId s) => s switch
{
SkillId.Acrobatics => AbilityId.DEX,
SkillId.AnimalHandling => AbilityId.WIS,
SkillId.Arcana => AbilityId.INT,
SkillId.Athletics => AbilityId.STR,
SkillId.Deception => AbilityId.CHA,
SkillId.History => AbilityId.INT,
SkillId.Insight => AbilityId.WIS,
SkillId.Intimidation => AbilityId.CHA,
SkillId.Investigation => AbilityId.INT,
SkillId.Medicine => AbilityId.WIS,
SkillId.Nature => AbilityId.INT,
SkillId.Perception => AbilityId.WIS,
SkillId.Performance => AbilityId.CHA,
SkillId.Persuasion => AbilityId.CHA,
SkillId.Religion => AbilityId.INT,
SkillId.SleightOfHand => AbilityId.DEX,
SkillId.Stealth => AbilityId.DEX,
SkillId.Survival => AbilityId.WIS,
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
/// <summary>Parses a snake_case JSON value (e.g. "animal_handling") into a SkillId.</summary>
public static SkillId FromJson(string raw) => raw.ToLowerInvariant() switch
{
"acrobatics" => SkillId.Acrobatics,
"animal_handling" => SkillId.AnimalHandling,
"arcana" => SkillId.Arcana,
"athletics" => SkillId.Athletics,
"deception" => SkillId.Deception,
"history" => SkillId.History,
"insight" => SkillId.Insight,
"intimidation" => SkillId.Intimidation,
"investigation" => SkillId.Investigation,
"medicine" => SkillId.Medicine,
"nature" => SkillId.Nature,
"perception" => SkillId.Perception,
"performance" => SkillId.Performance,
"persuasion" => SkillId.Persuasion,
"religion" => SkillId.Religion,
"sleight_of_hand" => SkillId.SleightOfHand,
"stealth" => SkillId.Stealth,
"survival" => SkillId.Survival,
_ => throw new ArgumentException($"Unknown skill: '{raw}'"),
};
}
+52
View File
@@ -0,0 +1,52 @@
namespace Theriapolis.Core.Rules.Stats;
/// <summary>
/// Standard d20 XP-to-level table. Phase 5 awards XP and persists it but
/// does not act on level-up — <see cref="LevelForXp"/> is exposed for the
/// HUD to display "next level in N XP" without requiring a level-up
/// flow yet.
/// </summary>
public static class XpTable
{
/// <summary>XP threshold for each level 1..20. <c>Threshold[1] = 0</c> by convention.</summary>
public static readonly int[] Threshold = new[]
{
0, // index 0 unused
0, // level 1
300, // level 2
900,
2_700,
6_500,
14_000,
23_000,
34_000,
48_000,
64_000,
85_000,
100_000,
120_000,
140_000,
165_000,
195_000,
225_000,
265_000,
305_000,
355_000, // level 20
};
public static int LevelForXp(int xp)
{
if (xp < 0) throw new ArgumentOutOfRangeException(nameof(xp));
for (int lv = 20; lv >= 1; lv--)
if (xp >= Threshold[lv]) return lv;
return 1;
}
public static int XpRequiredForNextLevel(int currentLevel)
{
if (currentLevel < 1 || currentLevel > 20)
throw new ArgumentOutOfRangeException(nameof(currentLevel));
if (currentLevel == 20) return int.MaxValue;
return Threshold[currentLevel + 1];
}
}