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