Initial commit: Theriapolis baseline at port/godot branch point

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -0,0 +1,27 @@
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// One attack a combatant can attempt — a weapon, a natural attack, or an
/// NPC stat-block entry. Built once at combat-start; the resolver rolls
/// against it. Distinct from <see cref="AttackProfile"/>, which is the
/// per-attempt struct that bakes in attacker/defender/situation.
/// </summary>
public sealed record AttackOption
{
public string Name { get; init; } = "";
/// <summary>Total +N to add to the d20 attack roll.</summary>
public int ToHitBonus { get; init; }
public DamageRoll Damage { get; init; } = new(0, 0, 0, DamageType.Bludgeoning);
/// <summary>Reach in tactical tiles. 1 = 5 ft. melee; 2 = 10 ft. polearm or Large reach.</summary>
public int ReachTiles { get; init; } = 1;
/// <summary>Short-range tiles for ranged attacks (0 = melee-only).</summary>
public int RangeShortTiles { get; init; } = 0;
/// <summary>Long-range tiles (disadvantage past short, can't fire past long).</summary>
public int RangeLongTiles { get; init; } = 0;
/// <summary>Crit-range threshold (default 20; razored weapons crit on 19+).</summary>
public int CritOnNatural { get; init; } = 20;
public bool IsRanged => RangeShortTiles > 0;
}
@@ -0,0 +1,30 @@
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Outcome of a single <see cref="Resolver.AttemptAttack"/> call. Captures
/// every dice value for log reconstruction and test assertions.
/// </summary>
public sealed record AttackResult
{
public required int AttackerId { get; init; }
public required int TargetId { get; init; }
public required string AttackName { get; init; }
public required int D20Roll { get; init; } // the kept d20 (post advantage/disadvantage)
public int? D20Other { get; init; } // the other d20 when adv/disadv was rolled
public required int ToHitBonus { get; init; }
public required int AttackTotal { get; init; } // D20Roll + ToHitBonus
public required int TargetAc { get; init; } // includes cover
public required bool Hit { get; init; }
public required bool Crit { get; init; }
public required int DamageRolled { get; init; } // 0 if missed
public required int TargetHpAfter { get; init; }
public required SituationFlags Situation { get; init; }
public string FormatLog(string attackerName, string targetName)
{
if (!Hit)
return $"{attackerName} → {targetName}: miss ({AttackName} {AttackTotal} vs AC {TargetAc})";
string critTag = Crit ? " [CRIT]" : "";
return $"{attackerName} → {targetName}: {DamageRolled} dmg ({AttackName} {AttackTotal} vs AC {TargetAc}){critTag} → HP {TargetHpAfter}";
}
}
@@ -0,0 +1,29 @@
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// One human-readable line in the encounter log. Combat-resolver actions
/// (attacks, saves, conditions, deaths) each emit one of these so test
/// scenarios can assert on the log content as a whole.
/// </summary>
public sealed record CombatLogEntry
{
public enum Kind : byte
{
Note = 0, // generic flavour line ("Round 1 begins.")
Attack = 1,
Save = 2,
Damage = 3, // direct damage that wasn't an attack roll
ConditionApplied = 4,
ConditionEnded = 5,
Death = 6,
Initiative = 7,
TurnStart = 8,
Move = 9,
EncounterEnd = 10,
}
public required int Round { get; init; }
public required int Turn { get; init; }
public required Kind Type { get; init; }
public required string Message { get; init; }
}
+319
View File
@@ -0,0 +1,319 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
// NOTE: deliberately NOT importing Theriapolis.Core.Rules.Character because
// the namespace name collides with the Character class inside it. Fully
// qualify Character; use Allegiance via Rules.Character.Allegiance below.
using Allegiance = Theriapolis.Core.Rules.Character.Allegiance;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Runtime adapter the resolver works with. Wraps either a
/// <see cref="Character"/> (player + future allies) or an
/// <see cref="NpcTemplateDef"/> (NPCs spawned from chunk lists). Carries
/// the mutable per-encounter state — HP, position, conditions — so the
/// source records aren't touched until the encounter ends and results
/// are written back.
/// </summary>
public sealed class Combatant
{
public int Id { get; }
public string Name { get; }
public Allegiance Allegiance { get; }
public SizeCategory Size { get; }
public AbilityScores Abilities { get; }
public int ProficiencyBonus { get; }
public int ArmorClass { get; }
public int MaxHp { get; }
public int SpeedFt { get; }
public int InitiativeBonus { get; }
public IReadOnlyList<AttackOption> AttackOptions { get; }
/// <summary>Source <see cref="Character"/> if built from one (player or ally). Null for NPC-template combatants.</summary>
public Theriapolis.Core.Rules.Character.Character? SourceCharacter { get; }
/// <summary>Source <see cref="NpcTemplateDef"/> if built from one. Null for character combatants.</summary>
public NpcTemplateDef? SourceTemplate { get; }
// ── Mutable per-encounter state ───────────────────────────────────────
public int CurrentHp { get; set; }
public Vec2 Position { get; set; }
public HashSet<Condition> Conditions { get; } = new();
/// <summary>Phase 5 M6: present only on player combatants while at 0 HP. Tracks the death-save loop.</summary>
public DeathSaveTracker? DeathSaves { get; set; }
// ── Phase 5 M6: per-encounter feature flags ──────────────────────────
/// <summary>True while Feral Rage is active. Bonus action toggle.</summary>
public bool RageActive { get; set; }
/// <summary>True while Bulwark Sentinel Stance is active. Halves speed; +2 AC.</summary>
public bool SentinelStanceActive { get; set; }
/// <summary>Set when Sneak Attack damage has fired this turn — once-per-turn limit.</summary>
public bool SneakAttackUsedThisTurn { get; set; }
// ── Phase 6.5 M1: per-encounter feature state ───────────────────────
/// <summary>
/// Pending Vocalization-Dice inspiration die granted by a Muzzle-Speaker.
/// 0 = none. When non-zero, the next attack/check/save this combatant
/// rolls adds 1d&lt;value&gt; to the result; the field then resets to 0.
/// Sides match the Vocalization Dice ladder: 6 / 8 / 10 / 12.
/// </summary>
public int InspirationDieSides { get; set; }
// ── Phase 6.5 M2: subclass-feature per-encounter state ───────────────
/// <summary>
/// Phase 6.5 M2 — Pack-Forged "Packmate's Howl" mark. Set on the target
/// when a Pack-Forged Fangsworn lands a melee hit; the next attack by
/// any *ally* of the Pack-Forged on this target gains advantage. The
/// mark expires when the marker's turn comes around again — tracked
/// here as the round number the mark was placed; resolver checks
/// <c>currentRound == HowlMarkRound + 0</c> (current round) or
/// <c>currentRound == HowlMarkRound + 1</c> (next round, before
/// marker's turn). Cleared on consume.
/// </summary>
public int? HowlMarkRound { get; set; }
/// <summary>The Pack-Forged combatant id that placed the howl mark.</summary>
public int? HowlMarkBy { get; set; }
/// <summary>
/// Phase 6.5 M2 — Blood Memory "Predatory Surge" trigger. Set when this
/// raging Feral kills a creature with a melee attack; consumed by the
/// HUD on the next bonus-action prompt (free extra melee attack).
/// </summary>
public bool PredatorySurgePending { get; set; }
// ── Phase 6.5 M3: Covenant Authority oath mark ───────────────────────
/// <summary>
/// Round number when an oath was placed on this combatant (Covenant-
/// Keeper Covenant's Authority). While the mark is live, the combatant
/// suffers -2 to attack rolls vs. its marker. Expires 10 rounds after
/// placement (= 1 minute in d20 round time).
/// </summary>
public int? OathMarkRound { get; set; }
/// <summary>The Covenant-Keeper combatant id who placed the oath mark.</summary>
public int? OathMarkBy { get; set; }
// ── Phase 7 M0: subclass per-turn / per-encounter flags ──────────────
/// <summary>
/// Phase 7 M0 — Stampede-Heart "Trampling Charge". Set when this turn's
/// first melee attack adds the +1d8 bludgeoning bonus; prevents the
/// bonus from firing twice in one turn. Resets at turn start.
/// </summary>
public bool TramplingChargeUsedThisTurn { get; set; }
/// <summary>
/// Phase 7 M0 — Ambush-Artist "Opening Strike". Set after the
/// first melee attack in this encounter consumes the +2d6 bonus; the
/// bonus only fires once per encounter. Lasts the encounter
/// (no per-turn reset).
/// </summary>
public bool OpeningStrikeUsed { get; set; }
/// <summary>Reset per-turn flags. Called by Encounter.EndTurn for the incoming actor.</summary>
public void OnTurnStart()
{
SneakAttackUsedThisTurn = false;
TramplingChargeUsedThisTurn = false;
}
/// <summary>True once HP ≤ 0. NPC-template combatants are removed from initiative; character combatants enter death-save mode.</summary>
public bool IsDown => CurrentHp <= 0;
/// <summary>True if either alive (HP &gt; 0) or downed-but-not-dead (rolling death saves).</summary>
public bool IsAlive => !IsDown || (DeathSaves is not null && !DeathSaves.Dead);
private Combatant(
int id, string name, Allegiance allegiance,
SizeCategory size, AbilityScores abilities, int profBonus,
int armorClass, int maxHp, int speedFt, int initiativeBonus,
IReadOnlyList<AttackOption> attacks,
Theriapolis.Core.Rules.Character.Character? sourceCharacter, NpcTemplateDef? sourceTemplate,
Vec2 position)
{
Id = id;
Name = name;
Allegiance = allegiance;
Size = size;
Abilities = abilities;
ProficiencyBonus= profBonus;
ArmorClass = armorClass;
MaxHp = maxHp;
SpeedFt = speedFt;
InitiativeBonus = initiativeBonus;
AttackOptions = attacks;
SourceCharacter = sourceCharacter;
SourceTemplate = sourceTemplate;
CurrentHp = maxHp;
Position = position;
}
/// <summary>
/// Build a combatant from a <see cref="Character"/>. Pulls AC, HP, and
/// the primary attack from equipped MainHand (or unarmed strike if none).
/// </summary>
public static Combatant FromCharacter(Theriapolis.Core.Rules.Character.Character c, int id, Vec2 position)
{
int ac = DerivedStats.ArmorClass(c);
int speed = DerivedStats.SpeedFt(c);
int initBonus = DerivedStats.Initiative(c);
var attacks = BuildCharacterAttacks(c);
return new Combatant(
id, c.Background?.Name is { Length: > 0 } ? $"PC-{id}" : $"PC-{id}",
c.SourceCharacterAllegiance(), c.Size, c.Abilities, c.ProficiencyBonus,
ac, c.MaxHp, speed, initBonus, attacks,
sourceCharacter: c, sourceTemplate: null, position: position);
}
/// <summary>
/// Build a combatant from a <see cref="Character"/> with an explicit
/// display name (typically the player's chosen name).
/// </summary>
public static Combatant FromCharacter(Theriapolis.Core.Rules.Character.Character c, int id, string name, Vec2 position, Allegiance allegiance)
{
int ac = DerivedStats.ArmorClass(c);
int speed = DerivedStats.SpeedFt(c);
int initBonus = DerivedStats.Initiative(c);
var attacks = BuildCharacterAttacks(c);
return new Combatant(
id, name, allegiance, c.Size, c.Abilities, c.ProficiencyBonus,
ac, c.MaxHp, speed, initBonus, attacks,
sourceCharacter: c, sourceTemplate: null, position: position);
}
/// <summary>
/// Build a combatant from an NPC template. AC and HP come straight from
/// the template; attacks are mapped 1:1 from <see cref="NpcTemplateDef.Attacks"/>.
/// </summary>
public static Combatant FromNpcTemplate(NpcTemplateDef def, int id, Vec2 position)
{
var size = SizeExtensions.FromJson(def.Size);
var abilities = new AbilityScores(
Score(def.AbilityScores, "STR", 10),
Score(def.AbilityScores, "DEX", 10),
Score(def.AbilityScores, "CON", 10),
Score(def.AbilityScores, "INT", 10),
Score(def.AbilityScores, "WIS", 10),
Score(def.AbilityScores, "CHA", 10));
// NPC profs default to +2 (CR ≤ 4 baseline).
const int npcProf = 2;
int initBonus = AbilityScores.Mod(abilities.DEX);
var attacks = new List<AttackOption>(def.Attacks.Length);
foreach (var atk in def.Attacks) attacks.Add(BuildNpcAttack(atk));
// 5 ft. = 1 tactical tile; convert NPC speed_ft to tiles.
int speedFt = def.SpeedFt;
var allegiance = Theriapolis.Core.Rules.Character.AllegianceExtensions.FromJson(def.DefaultAllegiance);
return new Combatant(
id, def.Name, allegiance, size, abilities, npcProf,
armorClass: def.Ac, maxHp: def.Hp, speedFt: speedFt, initiativeBonus: initBonus,
attacks: attacks,
sourceCharacter: null, sourceTemplate: def, position: position);
}
/// <summary>Distance to another combatant in tactical tiles, edge-to-edge Chebyshev.</summary>
public int DistanceTo(Combatant other) => ReachAndCover.EdgeToEdgeChebyshev(this, other);
private static int Score(IReadOnlyDictionary<string, int> dict, string key, int fallback)
=> dict.TryGetValue(key, out int v) ? v : fallback;
/// <summary>Builds the attack option list for a character: equipped weapon if any, else an unarmed strike.</summary>
private static List<AttackOption> BuildCharacterAttacks(Theriapolis.Core.Rules.Character.Character c)
{
var list = new List<AttackOption>();
var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
if (main is not null && string.Equals(main.Def.Kind, "weapon", System.StringComparison.OrdinalIgnoreCase))
{
list.Add(BuildWeaponAttack(c, main.Def));
}
else
{
list.Add(BuildUnarmedStrike(c));
}
return list;
}
private static AttackOption BuildWeaponAttack(Theriapolis.Core.Rules.Character.Character c, ItemDef weapon)
{
// Finesse weapons use the higher of STR/DEX; ranged weapons use DEX.
bool isFinesse = HasProperty(weapon, "finesse");
bool isRanged = weapon.RangeShortTiles > 0 || HasProperty(weapon, "ammunition") || HasProperty(weapon, "thrown");
AbilityId abil = isRanged
? AbilityId.DEX
: (isFinesse
? (c.Abilities.ModFor(AbilityId.STR) >= c.Abilities.ModFor(AbilityId.DEX)
? AbilityId.STR : AbilityId.DEX)
: AbilityId.STR);
int abilMod = c.Abilities.ModFor(abil);
// Proficiency: assume the character is proficient with all weapons their class lists.
// For Phase 5 M4 we apply proficiency unconditionally (every combat-touching class
// is proficient with their starting weapon). Wrong-proficiency disadvantage lands in M6.
int toHit = c.ProficiencyBonus + abilMod;
var damage = DamageRoll.Parse(
string.IsNullOrEmpty(weapon.Damage) ? "1d4" : weapon.Damage,
string.IsNullOrEmpty(weapon.DamageType)
? DamageType.Bludgeoning
: DamageTypeExtensions.FromJson(weapon.DamageType));
damage = damage with { FlatMod = damage.FlatMod + abilMod };
int reach = weapon.ReachTiles > 0 ? weapon.ReachTiles : c.Size.DefaultReachTiles();
return new AttackOption
{
Name = weapon.Name,
ToHitBonus = toHit,
Damage = damage,
ReachTiles = isRanged ? 0 : reach,
RangeShortTiles = isRanged ? (weapon.RangeShortTiles > 0 ? weapon.RangeShortTiles : 6) : 0,
RangeLongTiles = isRanged ? (weapon.RangeLongTiles > 0 ? weapon.RangeLongTiles : 24) : 0,
CritOnNatural = 20,
};
}
private static AttackOption BuildUnarmedStrike(Theriapolis.Core.Rules.Character.Character c)
{
int strMod = c.Abilities.ModFor(AbilityId.STR);
int toHit = c.ProficiencyBonus + strMod;
return new AttackOption
{
Name = "Unarmed Strike",
ToHitBonus = toHit,
Damage = new DamageRoll(0, 0, System.Math.Max(1, 1 + strMod), DamageType.Bludgeoning),
ReachTiles = c.Size.DefaultReachTiles(),
CritOnNatural = 20,
};
}
private static AttackOption BuildNpcAttack(NpcAttack atk)
{
var dmg = DamageRoll.Parse(
string.IsNullOrEmpty(atk.Damage) ? "1d4" : atk.Damage,
string.IsNullOrEmpty(atk.DamageType)
? DamageType.Bludgeoning
: DamageTypeExtensions.FromJson(atk.DamageType));
return new AttackOption
{
Name = atk.Name,
ToHitBonus = atk.ToHit,
Damage = dmg,
ReachTiles = atk.ReachTiles > 0 ? atk.ReachTiles : 1,
RangeShortTiles = atk.RangeShortTiles,
RangeLongTiles = atk.RangeLongTiles,
CritOnNatural = 20,
};
}
private static bool HasProperty(ItemDef def, string prop)
{
foreach (var p in def.Properties)
if (string.Equals(p, prop, System.StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
}
/// <summary>Convenience extension so callers needn't know whether a Character has Allegiance attached.</summary>
internal static class CharacterCombatExtensions
{
public static Allegiance SourceCharacterAllegiance(this Theriapolis.Core.Rules.Character.Character _)
=> Allegiance.Player;
}
+100
View File
@@ -0,0 +1,100 @@
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Parsed damage expression: <c>NdM+B</c> where N = dice count, M = die
/// sides, B = flat modifier (can be negative). Examples: "1d6", "2d8+2",
/// "1d4-1". <see cref="Roll"/> takes a function that returns 1..M for each
/// dice and aggregates with the flat modifier.
/// </summary>
public sealed record DamageRoll(int DiceCount, int DiceSides, int FlatMod, DamageType DamageType)
{
/// <summary>
/// Roll the damage dice. <paramref name="rollDie"/> takes the die size
/// (e.g. 6) and returns 1..size. On crit, dice double per d20 rules
/// (the flat modifier does NOT double).
/// </summary>
public int Roll(System.Func<int, int> rollDie, bool isCrit = false)
{
int diceToRoll = isCrit ? DiceCount * 2 : DiceCount;
int total = FlatMod;
for (int i = 0; i < diceToRoll; i++)
total += rollDie(DiceSides);
return System.Math.Max(0, total);
}
/// <summary>Theoretical maximum (every die rolls its top face) + flat mod.</summary>
public int Max(bool isCrit = false)
{
int dice = isCrit ? DiceCount * 2 : DiceCount;
return dice * DiceSides + FlatMod;
}
/// <summary>Theoretical minimum (every die rolls 1) + flat mod, clamped to 0.</summary>
public int Min(bool isCrit = false)
{
int dice = isCrit ? DiceCount * 2 : DiceCount;
return System.Math.Max(0, dice * 1 + FlatMod);
}
public override string ToString()
{
string mod = FlatMod == 0 ? "" : (FlatMod > 0 ? $"+{FlatMod}" : $"{FlatMod}");
return $"{DiceCount}d{DiceSides}{mod} {DamageType.ToString().ToLowerInvariant()}";
}
/// <summary>
/// Parses an expression like "1d6", "2d8+2", "1d4-1", "5" (flat 5),
/// or "0" (no damage). Whitespace is allowed. Throws on malformed input.
/// </summary>
public static DamageRoll Parse(string expr, DamageType damageType)
{
if (string.IsNullOrWhiteSpace(expr))
throw new System.ArgumentException("Damage expression is empty", nameof(expr));
string s = expr.Replace(" ", "").ToLowerInvariant();
int dIdx = s.IndexOf('d');
if (dIdx < 0)
{
// No dice — pure flat (e.g. "5" or "-1").
if (!int.TryParse(s, out int flat))
throw new System.FormatException($"Cannot parse damage '{expr}' as flat int.");
return new DamageRoll(0, 0, flat, damageType);
}
// Split into "<count>" "d" "<sides>[modifier]"
string countStr = s.Substring(0, dIdx);
if (countStr.Length == 0) countStr = "1"; // "d6" → 1d6
if (!int.TryParse(countStr, out int diceCount))
throw new System.FormatException($"Bad dice count in '{expr}'");
string rest = s.Substring(dIdx + 1);
int signIdx = -1;
for (int i = 0; i < rest.Length; i++)
{
if (rest[i] == '+' || rest[i] == '-') { signIdx = i; break; }
}
int sides;
int flatMod;
if (signIdx < 0)
{
if (!int.TryParse(rest, out sides))
throw new System.FormatException($"Bad dice sides in '{expr}'");
flatMod = 0;
}
else
{
if (!int.TryParse(rest.Substring(0, signIdx), out sides))
throw new System.FormatException($"Bad dice sides in '{expr}'");
if (!int.TryParse(rest.Substring(signIdx), out flatMod))
throw new System.FormatException($"Bad flat mod in '{expr}'");
}
if (diceCount < 0 || sides < 0)
throw new System.FormatException($"Negative dice count or sides in '{expr}'");
return new DamageRoll(diceCount, sides, flatMod, damageType);
}
}
@@ -0,0 +1,79 @@
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Phase 5 M6 player death-save loop. d20 every turn while at 0 HP:
/// - 1 → 2 failures
/// - 2..9 → 1 failure
/// - 10..19 → 1 success
/// - 20 → revive at 1 HP (zero out failures + successes)
///
/// 3 cumulative successes (≥10) → stabilised at 0 HP (cleared on heal).
/// 3 cumulative failures (<10) → dead. CombatHUDScreen pushes
/// <see cref="Game.Screens.DefeatedScreen"/> when this fires.
///
/// Tracker lives on <see cref="Combatant"/> only for the player; NPC
/// combatants skip death saves and are removed at 0 HP.
/// </summary>
public sealed class DeathSaveTracker
{
public int Successes { get; private set; }
public int Failures { get; private set; }
public bool Stabilised { get; private set; }
public bool Dead { get; private set; }
/// <summary>Roll a death save and update counters. Returns the outcome.</summary>
public DeathSaveOutcome Roll(Encounter enc, Combatant target)
{
if (Dead || Stabilised) return DeathSaveOutcome.NoOp;
int d20 = enc.RollD20();
DeathSaveOutcome outcome;
if (d20 == 20)
{
// Critical success — revive at 1 HP.
target.CurrentHp = 1;
target.Conditions.Remove(Condition.Unconscious);
Successes = 0;
Failures = 0;
outcome = DeathSaveOutcome.CriticalRevive;
}
else if (d20 >= 10)
{
Successes++;
outcome = Successes >= 3 ? DeathSaveOutcome.Stabilised : DeathSaveOutcome.Success;
if (Successes >= 3) Stabilised = true;
}
else
{
int failsThisRoll = d20 == 1 ? 2 : 1;
Failures += failsThisRoll;
outcome = Failures >= 3 ? DeathSaveOutcome.Dead : DeathSaveOutcome.Failure;
if (Failures >= 3) Dead = true;
}
enc.AppendLog(CombatLogEntry.Kind.Save,
$"{target.Name} death save: {d20} → {outcome} ({Successes}S/{Failures}F)");
return outcome;
}
/// <summary>Called when the character is healed above 0 HP — cancels the loop.</summary>
public void Reset()
{
Successes = 0;
Failures = 0;
Stabilised = false;
// Don't reset Dead — once dead, stays dead.
}
}
public enum DeathSaveOutcome
{
NoOp = 0,
Success = 1,
Failure = 2,
Stabilised = 3,
Dead = 4,
CriticalRevive = 5,
}
+217
View File
@@ -0,0 +1,217 @@
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// One combat encounter. Owns the participants, initiative order, current
/// turn pointer, log, and a per-encounter <see cref="SeededRng"/> seeded
/// from <c>worldSeed ^ C.RNG_COMBAT ^ encounterId</c>. Save/load can resume
/// mid-combat by capturing <see cref="EncounterSeed"/> +
/// <see cref="RollCount"/> and replaying the dice stream from the same
/// sequence point — see <see cref="ResumeRolls"/>.
/// </summary>
public sealed class Encounter
{
public ulong EncounterId { get; }
public ulong EncounterSeed { get; }
public IReadOnlyList<Combatant> Participants => _participants;
public IReadOnlyList<int> InitiativeOrder => _initiativeOrder;
public int CurrentTurnIndex { get; private set; }
public int RoundNumber { get; private set; } = 1;
public Turn CurrentTurn { get; private set; }
public IReadOnlyList<CombatLogEntry> Log => _log;
public bool IsOver => _isOver;
/// <summary>How many dice rolls have been drawn from this encounter's RNG.</summary>
public int RollCount { get; private set; }
private readonly List<Combatant> _participants;
private readonly List<int> _initiativeOrder;
private readonly List<CombatLogEntry> _log = new();
private SeededRng _rng;
private bool _isOver;
public Encounter(ulong worldSeed, ulong encounterId, IEnumerable<Combatant> combatants)
{
EncounterId = encounterId;
EncounterSeed = worldSeed ^ C.RNG_COMBAT ^ encounterId;
_rng = new SeededRng(EncounterSeed);
_participants = new List<Combatant>(combatants);
if (_participants.Count == 0)
throw new System.ArgumentException("Encounter requires at least one combatant.", nameof(combatants));
_initiativeOrder = RollInitiative();
CurrentTurnIndex = 0;
CurrentTurn = Turn.FreshFor(CurrentActor.Id, CurrentActor.SpeedFt);
AppendLog(CombatLogEntry.Kind.Initiative, FormatInitiativeOrder());
AppendLog(CombatLogEntry.Kind.TurnStart, $"Round 1 — {CurrentActor.Name}'s turn.");
}
public Combatant CurrentActor => _participants[_initiativeOrder[CurrentTurnIndex]];
public Combatant? GetById(int id)
{
foreach (var c in _participants) if (c.Id == id) return c;
return null;
}
/// <summary>
/// Advances to the next living combatant. Wraps the round counter when
/// we cycle past the last initiative slot. Marks the encounter over if
/// only one allegiance has living combatants.
/// </summary>
public void EndTurn()
{
if (_isOver) return;
int n = _initiativeOrder.Count;
for (int step = 0; step < n; step++)
{
CurrentTurnIndex++;
if (CurrentTurnIndex >= n)
{
CurrentTurnIndex = 0;
RoundNumber++;
}
var next = CurrentActor;
if (next.IsAlive)
{
CurrentTurn = Turn.FreshFor(next.Id, next.SpeedFt);
next.OnTurnStart(); // Phase 5 M6: reset per-turn feature flags (Sneak Attack)
AppendLog(CombatLogEntry.Kind.TurnStart, $"Round {RoundNumber} — {next.Name}'s turn.");
CheckForVictory();
return;
}
}
// No one is alive.
EndEncounter("No combatants remain.");
}
/// <summary>
/// Returns true and ends the encounter if only one allegiance has
/// living combatants left. Called automatically at end-of-turn.
/// </summary>
public bool CheckForVictory()
{
var living = new HashSet<Rules.Character.Allegiance>();
foreach (var c in _participants)
if (c.IsAlive && !c.IsDown) living.Add(c.Allegiance);
// Allies and Players count as the same side for victory purposes.
bool playerSide = living.Contains(Rules.Character.Allegiance.Player) || living.Contains(Rules.Character.Allegiance.Allied);
bool hostileSide = living.Contains(Rules.Character.Allegiance.Hostile);
if (!playerSide || !hostileSide)
{
string verdict = playerSide ? "Player side wins." : (hostileSide ? "Hostile side wins." : "Mutual annihilation.");
EndEncounter(verdict);
return true;
}
return false;
}
private void EndEncounter(string verdict)
{
_isOver = true;
AppendLog(CombatLogEntry.Kind.EncounterEnd, $"Encounter ends after {RoundNumber} round(s). {verdict}");
}
// ── Dice ──────────────────────────────────────────────────────────────
/// <summary>
/// Draw a uniform integer in [1, sides]. Increments
/// <see cref="RollCount"/>; save/load uses that count to resume.
/// </summary>
public int RollDie(int sides)
{
if (sides < 1) return 0;
RollCount++;
return (int)(_rng.NextUInt64() % (ulong)sides) + 1;
}
public int RollD20() => RollDie(20);
/// <summary>
/// Roll d20 with advantage (best of two) or disadvantage (worst of two).
/// Returns (kept, other) so the caller can log both.
/// </summary>
public (int kept, int other) RollD20WithMode(SituationFlags flags)
{
if (flags.RollsAdvantage())
{
int a = RollD20();
int b = RollD20();
return a >= b ? (a, b) : (b, a);
}
if (flags.RollsDisadvantage())
{
int a = RollD20();
int b = RollD20();
return a <= b ? (a, b) : (b, a);
}
return (RollD20(), -1);
}
/// <summary>
/// Re-create the RNG and skip <paramref name="rollCount"/> rolls.
/// Used by the save layer to resume mid-combat encounters: capture
/// (encounterId, rollCount) on save; recreate Encounter with same
/// participants and call ResumeRolls(savedRollCount) on load.
/// </summary>
public void ResumeRolls(int rollCount)
{
_rng = new SeededRng(EncounterSeed);
for (int i = 0; i < rollCount; i++) _rng.NextUInt64();
RollCount = rollCount;
}
// ── Logging ───────────────────────────────────────────────────────────
public void AppendLog(CombatLogEntry.Kind kind, string message)
{
_log.Add(new CombatLogEntry
{
Round = RoundNumber,
Turn = CurrentTurnIndex,
Type = kind,
Message = message,
});
}
// ── Initiative ────────────────────────────────────────────────────────
private List<int> RollInitiative()
{
var rolls = new (int idx, int total, int initBonus, int dexMod)[_participants.Count];
for (int i = 0; i < _participants.Count; i++)
{
var c = _participants[i];
int d20 = RollD20();
rolls[i] = (i, d20 + c.InitiativeBonus, c.InitiativeBonus,
Stats.AbilityScores.Mod(c.Abilities.DEX));
}
// Sort descending by total; ties broken by DEX mod descending; final tiebreaker by id ascending.
System.Array.Sort(rolls, (a, b) =>
{
int byTotal = b.total.CompareTo(a.total);
if (byTotal != 0) return byTotal;
int byDex = b.dexMod.CompareTo(a.dexMod);
if (byDex != 0) return byDex;
return _participants[a.idx].Id.CompareTo(_participants[b.idx].Id);
});
var order = new List<int>(rolls.Length);
foreach (var r in rolls) order.Add(r.idx);
return order;
}
private string FormatInitiativeOrder()
{
var parts = new List<string>(_initiativeOrder.Count);
foreach (int idx in _initiativeOrder)
{
var c = _participants[idx];
parts.Add($"{c.Name} (init+{c.InitiativeBonus})");
}
return "Initiative: " + string.Join(", ", parts);
}
}
@@ -0,0 +1,82 @@
using Theriapolis.Core.Entities;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Per-tick check used by <see cref="Game.Screens.PlayScreen"/>:
/// "is there a hostile NPC within encounter trigger range that has line of
/// sight?" Returns the closest qualifying actor (or null) so the caller can
/// kick off an encounter.
///
/// Friendly / Neutral proximity is the same shape but uses a tighter radius
/// — see <see cref="FindInteractCandidate"/>.
/// </summary>
public static class EncounterTrigger
{
/// <summary>
/// Find the closest live <em>hostile</em> NPC within
/// <see cref="C.ENCOUNTER_TRIGGER_TILES"/> of the player that the
/// <paramref name="losBlocked"/> predicate can see (no blocking tile
/// between). Returns null if none found.
/// </summary>
public static NpcActor? FindHostileTrigger(
ActorManager actors,
System.Func<int, int, bool>? losBlocked = null)
{
var player = actors.Player;
if (player is null) return null;
var blocked = losBlocked ?? LineOfSight.AlwaysClear;
NpcActor? best = null;
int bestDistSq = C.ENCOUNTER_TRIGGER_TILES * C.ENCOUNTER_TRIGGER_TILES;
foreach (var npc in actors.Npcs)
{
if (!npc.IsAlive) continue;
if (npc.Allegiance != Rules.Character.Allegiance.Hostile) continue;
int distSq = ChebyshevDistSq(player.Position, npc.Position);
if (distSq > bestDistSq) continue;
if (!LineOfSight.HasLine(player.Position, npc.Position, blocked)) continue;
if (best is null || distSq < bestDistSq) { best = npc; bestDistSq = distSq; }
}
return best;
}
/// <summary>
/// Friendly / Neutral NPCs within
/// <see cref="C.INTERACT_PROMPT_TILES"/> of the player. The HUD shows
/// "[F] Talk to ..." for the closest match.
/// </summary>
public static NpcActor? FindInteractCandidate(
ActorManager actors,
System.Func<int, int, bool>? losBlocked = null)
{
var player = actors.Player;
if (player is null) return null;
var blocked = losBlocked ?? LineOfSight.AlwaysClear;
NpcActor? best = null;
int bestDistSq = C.INTERACT_PROMPT_TILES * C.INTERACT_PROMPT_TILES;
foreach (var npc in actors.Npcs)
{
if (!npc.IsAlive) continue;
if (npc.Allegiance != Rules.Character.Allegiance.Friendly &&
npc.Allegiance != Rules.Character.Allegiance.Neutral) continue;
int distSq = ChebyshevDistSq(player.Position, npc.Position);
if (distSq > bestDistSq) continue;
if (!LineOfSight.HasLine(player.Position, npc.Position, blocked)) continue;
if (best is null || distSq < bestDistSq) { best = npc; bestDistSq = distSq; }
}
return best;
}
private static int ChebyshevDistSq(Vec2 a, Vec2 b)
{
int dx = (int)System.Math.Abs(a.X - b.X);
int dy = (int)System.Math.Abs(a.Y - b.Y);
int d = System.Math.Max(dx, dy);
return d * d;
}
}
@@ -0,0 +1,781 @@
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Phase 5 M6: dispatches class-feature combat effects at hook points the
/// resolver and DerivedStats call into. Hand-coded switch-on-class-id; the
/// alternative would be a feature-registration system — overkill for the
/// half-dozen combat-touching level-1 features we actually ship.
///
/// Implemented features:
/// - Fangsworn fighting styles: Duelist (+2 dmg one-handed), Great Weapon (re-roll 1s/2s on dmg)
/// - Feral: Unarmored Defense (10 + DEX + CON when no body armor), Feral Rage (+2 dmg, resistance)
/// - Bulwark: Sentinel Stance (+2 AC), Guardian's Mark (UI hook only — full effect M6.5)
/// - Shadow-Pelt: Sneak Attack (+1d6 first hit per turn with finesse/ranged weapon)
///
/// Stubs (no combat effect at M6 — flagged for later wiring):
/// - Scent-Broker, Covenant-Keeper, Muzzle-Speaker, Claw-Wright level-1
/// features. They appear in level_table but don't alter dice yet.
/// </summary>
public static class FeatureProcessor
{
/// <summary>
/// Returns the raw AC for a character, factoring in class features.
/// Called by <see cref="DerivedStats.ArmorClass"/> *after* the standard
/// armor/shield/DEX computation so this layer can either replace
/// (Unarmored Defense) or add (Sentinel Stance) to the base.
///
/// Returns the *new* AC value to use; pass back <paramref name="baseAc"/>
/// when no feature applies.
/// </summary>
public static int ApplyAcFeatures(Theriapolis.Core.Rules.Character.Character c, int baseAc)
{
int ac = baseAc;
// Feral Unarmored Defense replaces base if no body armor.
if (c.ClassDef.Id == "feral" && c.Inventory.GetEquipped(EquipSlot.Body) is null)
{
int dex = c.Abilities.ModFor(AbilityId.DEX);
int con = c.Abilities.ModFor(AbilityId.CON);
int unarmoredAc = 10 + dex + con;
// Take whichever is higher — Feral may pick up a buckler offhand etc. that pushes baseAc higher.
if (unarmoredAc > ac) ac = unarmoredAc;
}
return ac;
}
/// <summary>
/// AC bonus from per-encounter combat-time features (Sentinel Stance, etc).
/// Combat resolver adds this to the combatant's base AC at attack-resolution time.
///
/// Phase 6.5 M2 layers in subclass passive AC bonuses — caller passes
/// the encounter so the resolver can consult positional state for
/// adjacency-driven features (Herd-Wall Interlock Shields, Lone Fang
/// Isolation Bonus).
/// </summary>
public static int ApplyAcBonus(Combatant target, Encounter? enc = null)
{
int bonus = 0;
if (target.SentinelStanceActive) bonus += 2;
// Phase 6.5 M2 subclass passives.
var c = target.SourceCharacter;
if (c is not null && enc is not null && !string.IsNullOrEmpty(c.SubclassId))
{
switch (c.SubclassId)
{
case "lone_fang":
if (HasLoneFangIsolation(target, enc)) bonus += 1;
break;
case "herd_wall":
if (HasHerdWallAdjacentAlly(target, enc)) bonus += 1;
break;
}
}
return bonus;
}
/// <summary>
/// Phase 6.5 M2 — to-hit bonus from subclass features that boost
/// attack rolls (e.g. Lone Fang Isolation Bonus). Resolver adds this
/// to <c>attackTotal</c> alongside the base attack bonus.
/// </summary>
public static int ApplyToHitBonus(Combatant attacker, Encounter enc)
{
int bonus = 0;
var c = attacker.SourceCharacter;
if (c is null || string.IsNullOrEmpty(c.SubclassId)) return 0;
switch (c.SubclassId)
{
case "lone_fang":
if (HasLoneFangIsolation(attacker, enc)) bonus += 2;
break;
}
return bonus;
}
/// <summary>
/// True when the Lone Fang's "Isolation Bonus" applies — no allied
/// combatant within 10 ft. <see cref="ReachAndCover.EdgeToEdgeChebyshev"/>
/// returns the number of *empty tiles between* two footprints, so:
/// 0 = touching (5 ft. away), 1 = one empty tile (10 ft.), etc.
/// "Within 10 ft" means edge-to-edge ≤ 1.
/// </summary>
private static bool HasLoneFangIsolation(Combatant self, Encounter enc)
{
foreach (var c in enc.Participants)
{
if (c.Id == self.Id) continue;
if (c.IsDown) continue;
bool sameSide = (self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|| self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
&& (c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|| c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied);
if (!sameSide) continue;
if (ReachAndCover.EdgeToEdgeChebyshev(self, c) <= 1) return false;
}
return true;
}
/// <summary>
/// True when the Herd-Wall has at least one allied combatant adjacent.
/// "Adjacent" in the d20 sense = sharing an edge or corner; with the
/// edge-to-edge "empty tiles between" metric that's distance 0.
/// </summary>
private static bool HasHerdWallAdjacentAlly(Combatant self, Encounter enc)
{
foreach (var c in enc.Participants)
{
if (c.Id == self.Id) continue;
if (c.IsDown) continue;
bool sameSide = (self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|| self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
&& (c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|| c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied);
if (!sameSide) continue;
if (ReachAndCover.EdgeToEdgeChebyshev(self, c) == 0) return true;
}
return false;
}
/// <summary>
/// Phase 6.5 M2 — Pack-Forged "Packmate's Howl". Called from the
/// resolver when a Pack-Forged hits a target with a melee attack:
/// marks the target so the next *ally* attack against it gains
/// advantage (until the marker's next turn).
/// </summary>
public static void OnPackForgedHit(Encounter enc, Combatant attacker, Combatant target, AttackOption attack)
{
var c = attacker.SourceCharacter;
if (c is null || c.SubclassId != "pack_forged") return;
if (attack.IsRanged) return; // melee only per the description
target.HowlMarkRound = enc.RoundNumber;
target.HowlMarkBy = attacker.Id;
enc.AppendLog(CombatLogEntry.Kind.Note,
$" Packmate's Howl: {target.Name} marked — next ally attack has advantage.");
}
/// <summary>
/// Phase 6.5 M2 — Pack-Forged consumption hook. If the target carries
/// a Howl mark from one of the attacker's *allies* (not self), and the
/// mark hasn't expired, returns true (advantage on this attack) and
/// clears the mark. Resolver calls this before rolling the d20.
/// </summary>
public static bool ConsumeHowlAdvantage(Encounter enc, Combatant attacker, Combatant target)
{
if (target.HowlMarkRound is not int markRound) return false;
if (target.HowlMarkBy is not int markBy) return false;
if (markBy == attacker.Id) return false; // can't consume your own mark
// Mark expires once the marker's next turn begins. Approximation: a
// mark placed on round N consumed on round N or N+1 (before marker
// gets to act) is valid; round > markRound + 1 = expired.
if (enc.RoundNumber > markRound + 1) return false;
// Allies only: the marker must be on the same side as the attacker.
var marker = enc.GetById(markBy);
if (marker is null) return false;
bool sameSide = (attacker.Allegiance == marker.Allegiance)
|| (attacker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
&& marker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
|| (attacker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied
&& marker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player);
if (!sameSide) return false;
// Consume.
target.HowlMarkRound = null;
target.HowlMarkBy = null;
enc.AppendLog(CombatLogEntry.Kind.Note,
$" {attacker.Name} consumes Packmate's Howl — advantage on this attack.");
return true;
}
/// <summary>
/// Phase 6.5 M2 — Blood Memory "Predatory Surge". Called by the
/// resolver when a raging Feral with this subclass reduces a target
/// to 0 HP with a melee attack. Sets the surge-pending flag; the HUD
/// can offer the player a free bonus melee attack (M2 wires the flag;
/// the bonus-action consumption is the player's job via the existing
/// attack input).
/// </summary>
public static void OnBloodMemoryKill(Encounter enc, Combatant attacker, AttackOption attack)
{
var c = attacker.SourceCharacter;
if (c is null || c.SubclassId != "blood_memory") return;
if (!attacker.RageActive) return;
if (attack.IsRanged) return;
attacker.PredatorySurgePending = true;
enc.AppendLog(CombatLogEntry.Kind.Note,
$" Predatory Surge: {attacker.Name} can take a free melee attack.");
}
/// <summary>
/// Damage bonus from feature effects (Fighting Style, Rage, Sneak Attack).
/// Returns extra damage to add to the rolled total. Side effects: marks
/// <see cref="Combatant.SneakAttackUsedThisTurn"/> when sneak attack fires.
/// </summary>
public static int ApplyDamageBonus(
Encounter enc,
Combatant attacker,
Combatant target,
AttackOption attack,
bool isCrit)
{
int bonus = 0;
var c = attacker.SourceCharacter;
// Feral Rage — +2 damage on melee attacks while raging.
if (attacker.RageActive && !attack.IsRanged) bonus += 2;
// Fangsworn fighting styles.
if (c is not null && c.ClassDef.Id == "fangsworn")
{
if (c.FightingStyle == "duelist" && IsOneHanded(c))
bonus += 2;
// Great Weapon and Natural Predator handled elsewhere (re-roll
// and to-hit respectively).
}
// Shadow-Pelt Sneak Attack — once per turn, +1d6 with finesse/ranged.
if (c is not null && c.ClassDef.Id == "shadow_pelt"
&& !attacker.SneakAttackUsedThisTurn
&& IsFinesseOrRanged(attacker, attack))
{
int d6 = enc.RollDie(6);
if (isCrit) d6 += enc.RollDie(6); // crit doubles the sneak attack die
bonus += d6;
attacker.SneakAttackUsedThisTurn = true;
enc.AppendLog(CombatLogEntry.Kind.Note, $" Sneak Attack: +{d6}");
}
// Phase 7 M0 — Ambush-Artist "Opening Strike". First melee attack of
// round 1 in the encounter (the "ambush" round) deals +2d6 sneak
// damage. Stacks with base Sneak Attack — opening strike represents
// a different surprise mechanism.
if (c is not null && c.SubclassId == "ambush_artist"
&& !attacker.OpeningStrikeUsed
&& enc.RoundNumber == 1
&& !attack.IsRanged)
{
int d6a = enc.RollDie(6);
int d6b = enc.RollDie(6);
int extra = d6a + d6b;
if (isCrit) extra += enc.RollDie(6) + enc.RollDie(6);
bonus += extra;
attacker.OpeningStrikeUsed = true;
enc.AppendLog(CombatLogEntry.Kind.Note, $" Opening Strike: +{extra}");
}
// Phase 7 M0 — Stampede-Heart "Trampling Charge". First melee attack
// each turn while raging deals +1d8 bludgeoning. Phase 7 simplifies
// the JSON's "moved 20+ ft. straight" geometry constraint to "first
// melee attack while raging" — captures the spirit of the charge
// without requiring a movement-vector tracker the tactical layer
// doesn't yet expose. Phase 8 / 9 polish can refine.
if (c is not null && c.SubclassId == "stampede_heart"
&& attacker.RageActive
&& !attacker.TramplingChargeUsedThisTurn
&& !attack.IsRanged)
{
int d8 = enc.RollDie(8);
if (isCrit) d8 += enc.RollDie(8);
bonus += d8;
attacker.TramplingChargeUsedThisTurn = true;
enc.AppendLog(CombatLogEntry.Kind.Note, $" Trampling Charge: +{d8}");
}
return bonus;
}
/// <summary>
/// Phase 7 M0 — Antler-Guard "Retaliatory Strike". Called from
/// <see cref="Resolver.AttemptAttack"/> after damage applies on a melee
/// hit. If the target is an Antler-Guard Bulwark in Sentinel Stance,
/// the attacker takes 1d8 + CON (the target's CON) automatic damage.
/// Phase 7 contract: deterrence-style return-damage, no save, no roll —
/// the attack itself is the trigger. Doesn't fire on ranged attacks
/// (the JSON specifies "from a melee attack").
/// </summary>
public static int OnAntlerGuardHit(Encounter enc, Combatant attacker, Combatant target, AttackOption attack)
{
var c = target.SourceCharacter;
if (c is null || c.SubclassId != "antler_guard") return 0;
if (!target.SentinelStanceActive) return 0;
if (attack.IsRanged) return 0;
// 1d8 + CON-mod return damage; min 1.
int d8 = enc.RollDie(8);
int con = AbilityScores.Mod(target.Abilities.Get(AbilityId.CON));
int retaliation = System.Math.Max(1, d8 + con);
Resolver.ApplyDamage(attacker, retaliation);
enc.AppendLog(CombatLogEntry.Kind.Note,
$" Retaliatory Strike: {target.Name} returns {retaliation} ({d8}+{con}) to {attacker.Name}.");
return retaliation;
}
/// <summary>
/// Re-roll 1s and 2s on damage dice for Fangsworn Great Weapon style.
/// Called by DamageRoll.Roll only if the attacker has the style + a
/// two-handed weapon. Returns the (possibly adjusted) dice value.
/// </summary>
public static int GreatWeaponReroll(Encounter enc, Combatant attacker, AttackOption attack, int rolledDie, int sides)
{
var c = attacker.SourceCharacter;
if (c is null || c.ClassDef.Id != "fangsworn" || c.FightingStyle != "great_weapon") return rolledDie;
if (!IsTwoHanded(c)) return rolledDie;
if (rolledDie > 2) return rolledDie;
// Re-roll once and take the new value (even if also 1 or 2).
int rerolled = enc.RollDie(sides);
return rerolled;
}
/// <summary>
/// True if the damage type is fully resisted (half-damage). Phase 5 M6:
/// Feral Rage gives resistance to bludgeoning/piercing/slashing while active.
/// </summary>
public static bool IsResisted(Combatant target, DamageType damageType)
{
if (target.RageActive)
{
return damageType == DamageType.Bludgeoning
|| damageType == DamageType.Piercing
|| damageType == DamageType.Slashing;
}
return false;
}
/// <summary>
/// Activate Feral Rage. Returns true if the rage started (had uses
/// remaining); false if the character has no uses left.
/// </summary>
public static bool TryActivateRage(Encounter enc, Combatant attacker)
{
var c = attacker.SourceCharacter;
if (c is null || c.ClassDef.Id != "feral") return false;
if (attacker.RageActive) return false;
if (c.RageUsesRemaining <= 0) return false;
attacker.RageActive = true;
c.RageUsesRemaining--;
enc.AppendLog(CombatLogEntry.Kind.Note, $"{attacker.Name} enters a rage. ({c.RageUsesRemaining} use(s) left)");
return true;
}
/// <summary>Toggle Bulwark Sentinel Stance.</summary>
public static bool ToggleSentinelStance(Encounter enc, Combatant attacker)
{
var c = attacker.SourceCharacter;
if (c is null || c.ClassDef.Id != "bulwark") return false;
attacker.SentinelStanceActive = !attacker.SentinelStanceActive;
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{attacker.Name} {(attacker.SentinelStanceActive ? "enters" : "leaves")} Sentinel Stance.");
return true;
}
// ── Phase 6.5 M1: level-1 active class features ──────────────────────
/// <summary>
/// Claw-Wright <c>field_repair</c>. Action; heals <c>1d8 + INT mod</c>
/// HP to the target. Hybrid heal-target effectiveness (75%) applies if
/// the target is a hybrid PC (Phase 6.5 M5 schema-stub for now — no
/// hybrids exist yet, so the multiplier is gated by future data).
/// </summary>
public static bool TryFieldRepair(Encounter enc, Combatant healer, Combatant target)
{
var c = healer.SourceCharacter;
if (c is null || c.ClassDef.Id != "claw_wright") return false;
if (c.FieldRepairUsesRemaining <= 0)
{
enc.AppendLog(CombatLogEntry.Kind.Note, $"{healer.Name}: Field Repair exhausted (rest to recover).");
return false;
}
if (target.IsDown) return false;
int intMod = c.Abilities.ModFor(AbilityId.INT);
// Phase 7 M0 — Body-Wright "Combat Medic" rolls 2d8 + INT instead of
// the base 1d8 + INT. The bonus-action treatment described in the
// JSON is a HUD-side concern (the resource economy is unchanged);
// this hook adjusts only the dice.
int rolled;
if (c.SubclassId == "body_wright")
{
rolled = enc.RollDie(8) + enc.RollDie(8);
}
else
{
rolled = enc.RollDie(8);
}
int healed = Math.Max(1, rolled + intMod);
// Phase 6.5 M4 — Medical Incompatibility: hybrid recipients heal at
// 75% effectiveness (round down, min 1). Non-hybrids pass through.
int delivered = healed;
if (target.SourceCharacter is { } targetCharacter)
delivered = Theriapolis.Core.Rules.Character.HybridDetriments.ScaleHealForHybrid(
targetCharacter, healed);
target.CurrentHp = Math.Min(target.MaxHp, target.CurrentHp + delivered);
c.FieldRepairUsesRemaining--;
string scaledNote = (target.SourceCharacter?.IsHybrid ?? false) && delivered != healed
? $" (hybrid → {delivered})"
: "";
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{healer.Name} Field Repair on {target.Name}: rolled {rolled} + INT {intMod:+#;-#;0} = {healed} HP{scaledNote}. ({target.CurrentHp}/{target.MaxHp})");
return true;
}
/// <summary>
/// Covenant-Keeper <c>lay_on_paws</c>. Action; spend up to a fixed
/// amount from a pool of <c>5 × CHA</c> HP per long rest (per-encounter
/// at M1) to heal a target. Pool tops up via
/// <see cref="EnsureLayOnPawsPoolReady"/>; spending one point cures
/// disease — not modelled here yet (no disease subsystem).
/// </summary>
public static bool TryLayOnPaws(Encounter enc, Combatant healer, Combatant target, int requestHp)
{
var c = healer.SourceCharacter;
if (c is null || c.ClassDef.Id != "covenant_keeper") return false;
if (target.IsDown) return false;
if (requestHp <= 0) return false;
int spend = Math.Min(requestHp, c.LayOnPawsPoolRemaining);
if (spend <= 0)
{
enc.AppendLog(CombatLogEntry.Kind.Note, $"{healer.Name}: Lay on Paws pool empty (rest to refill).");
return false;
}
// Phase 6.5 M4 — Medical Incompatibility scales hybrid heal received,
// but the *cost* to the pool is the requested amount. (Hybrid pays
// the same cost; the inefficiency models the body resisting the
// calibration, not the healer wasting effort.)
int delivered = spend;
if (target.SourceCharacter is { } targetCharacter)
delivered = Theriapolis.Core.Rules.Character.HybridDetriments.ScaleHealForHybrid(
targetCharacter, spend);
target.CurrentHp = Math.Min(target.MaxHp, target.CurrentHp + delivered);
c.LayOnPawsPoolRemaining -= spend;
string scaledNote = (target.SourceCharacter?.IsHybrid ?? false) && delivered != spend
? $" (hybrid → {delivered})"
: "";
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{healer.Name} channels Lay on Paws → {target.Name} +{spend} HP{scaledNote}. ({target.CurrentHp}/{target.MaxHp}, pool {c.LayOnPawsPoolRemaining})");
return true;
}
/// <summary>
/// Initialise / refresh the Lay on Paws pool to <c>5 × CHA mod</c> if
/// the character has the <c>lay_on_paws</c> feature. Called at
/// encounter start so M1 (no rest model) treats every encounter as
/// fully rested. CHA mod ≤ 0 yields a 1-point minimum so a low-CHA
/// Covenant-Keeper still has a token pool.
/// </summary>
public static void EnsureLayOnPawsPoolReady(Theriapolis.Core.Rules.Character.Character c)
{
if (c.ClassDef.Id != "covenant_keeper") return;
int chaMod = c.Abilities.ModFor(AbilityId.CHA);
int target = Math.Max(1, 5 * Math.Max(1, chaMod));
if (c.LayOnPawsPoolRemaining < target)
c.LayOnPawsPoolRemaining = target;
}
/// <summary>
/// Refresh the Field Repair use to 1 if it's been spent. Encounter-rest
/// equivalence per the Phase 5 contract.
/// </summary>
public static void EnsureFieldRepairReady(Theriapolis.Core.Rules.Character.Character c)
{
if (c.ClassDef.Id != "claw_wright") return;
if (c.FieldRepairUsesRemaining < 1) c.FieldRepairUsesRemaining = 1;
}
/// <summary>
/// Refresh Vocalization Dice to 4 if any have been spent. Encounter-rest
/// equivalence per the Phase 5 contract.
/// </summary>
public static void EnsureVocalizationDiceReady(Theriapolis.Core.Rules.Character.Character c)
{
if (c.ClassDef.Id != "muzzle_speaker") return;
if (c.VocalizationDiceRemaining < 4) c.VocalizationDiceRemaining = 4;
}
// ── Phase 6.5 M3: Pheromone Craft (Scent-Broker) ─────────────────────
/// <summary>
/// Pheromone Craft uses-per-encounter cap based on character level. The
/// JSON ladder unlocks more uses at higher levels:
/// L14 → 0 (feature not unlocked yet),
/// L58 → 2 (pheromone_craft_2 from L2 entry retains here, but the L5
/// entry brings pheromone_craft_3),
/// L912 → 4, L13+ → 5. The granted-at-each-level structure in
/// <c>classes.json</c> uses the highest-tier feature unlocked.
/// </summary>
public static int PheromoneUsesAtLevel(int level) => level switch
{
>= 13 => 5,
>= 9 => 4,
>= 5 => 3,
>= 2 => 2,
_ => 0,
};
/// <summary>
/// Refill the Scent-Broker's Pheromone Craft pool to the per-level cap.
/// Encounter-rest equivalence; Phase 8 replaces with real short-rest.
/// </summary>
public static void EnsurePheromoneUsesReady(Theriapolis.Core.Rules.Character.Character c)
{
if (c.ClassDef.Id != "scent_broker") return;
int cap = PheromoneUsesAtLevel(c.Level);
if (c.PheromoneUsesRemaining < cap) c.PheromoneUsesRemaining = cap;
}
/// <summary>
/// Scent-Broker <c>pheromone_craft_*</c>. Bonus action; emits a 10-ft
/// (= 2 tactical tile) cloud centred on the caster. Every creature in
/// range that the caster considers hostile must make a CON save vs.
/// <c>DC = 8 + prof + WIS mod</c>; on failure, the pheromone-mapped
/// <see cref="Theriapolis.Core.Rules.Stats.Condition"/> is applied
/// (<see cref="PheromoneTypeExtensions.AppliedCondition"/>).
/// Consumes one Pheromone Use.
/// </summary>
public static bool TryEmitPheromone(Encounter enc, Combatant caster, PheromoneType type)
{
var c = caster.SourceCharacter;
if (c is null || c.ClassDef.Id != "scent_broker") return false;
if (c.Level < 2)
{
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name}: Pheromone Craft unlocks at level 2.");
return false;
}
if (c.PheromoneUsesRemaining <= 0)
{
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name}: Pheromone Craft exhausted (rest to recover).");
return false;
}
int wisMod = c.Abilities.ModFor(Theriapolis.Core.Rules.Stats.AbilityId.WIS);
int dc = 8 + c.ProficiencyBonus + wisMod;
var applied = type.AppliedCondition();
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name} emits {type.DisplayName()} pheromone (DC {dc}).");
int affected = 0;
foreach (var t in enc.Participants)
{
if (t.Id == caster.Id) continue;
if (t.IsDown) continue;
// 10 ft. cloud = within 1 empty tile (≤ 1 edge-to-edge).
if (ReachAndCover.EdgeToEdgeChebyshev(caster, t) > 1) continue;
// Only target hostiles for offensive pheromones; calm targets
// hostiles too (charmed-toward-source is the desired effect).
bool sameSide = (caster.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|| caster.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
&& (t.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|| t.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied);
if (sameSide) continue;
// Roll CON save: 1d20 + CON mod.
int conMod = Theriapolis.Core.Rules.Stats.AbilityScores.Mod(t.Abilities.CON);
int saveRoll = enc.RollD20();
int saveTotal = saveRoll + conMod;
bool saved = saveTotal >= dc;
enc.AppendLog(CombatLogEntry.Kind.Save,
$" {t.Name} CON save: {saveRoll}{conMod:+#;-#;0} = {saveTotal} vs DC {dc} → {(saved ? "saved" : "FAILED")}");
if (!saved && applied != Theriapolis.Core.Rules.Stats.Condition.None)
{
Resolver.ApplyCondition(enc, t, applied);
affected++;
}
}
c.PheromoneUsesRemaining--;
if (affected == 0)
enc.AppendLog(CombatLogEntry.Kind.Note,
$" No hostiles affected by the pheromone.");
return true;
}
// ── Phase 6.5 M3: Covenant Authority (Covenant-Keeper) ───────────────
/// <summary>
/// Covenant Authority uses-per-encounter cap based on level. JSON
/// ladder: <c>covenants_authority_2/3/4/5</c> at L2/L9/L13/L17 →
/// 2 / 3 / 4 / 5.
/// </summary>
public static int CovenantAuthorityUsesAtLevel(int level) => level switch
{
>= 17 => 5,
>= 13 => 4,
>= 9 => 3,
>= 2 => 2,
_ => 0,
};
/// <summary>
/// Refill the Covenant-Keeper's Authority pool to the per-level cap.
/// </summary>
public static void EnsureCovenantAuthorityReady(Theriapolis.Core.Rules.Character.Character c)
{
if (c.ClassDef.Id != "covenant_keeper") return;
int cap = CovenantAuthorityUsesAtLevel(c.Level);
if (c.CovenantAuthorityUsesRemaining < cap) c.CovenantAuthorityUsesRemaining = cap;
}
/// <summary>
/// Covenant-Keeper <c>covenants_authority_*</c>. Bonus action; declares
/// an oath against a target hostile; for 10 rounds (1 minute), the
/// oath-marked creature suffers -2 to attack rolls against the
/// Covenant-Keeper. Consumes one use. The full three-option
/// description (Compel Truth / Rebuke Predation / Shield the Innocent)
/// is plan-deferred to Phase 8/9 dialogue + AoE polish; M3 ships the
/// simple combat-marker mechanic per the Phase 6.5 plan §4.4.
/// </summary>
public static bool TryDeclareOath(Encounter enc, Combatant caster, Combatant target)
{
var c = caster.SourceCharacter;
if (c is null || c.ClassDef.Id != "covenant_keeper") return false;
if (c.Level < 2)
{
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name}: Covenant's Authority unlocks at level 2.");
return false;
}
if (c.CovenantAuthorityUsesRemaining <= 0)
{
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name}: Covenant's Authority exhausted (rest to recover).");
return false;
}
if (target.IsDown) return false;
if (target.Id == caster.Id) return false;
target.OathMarkRound = enc.RoundNumber;
target.OathMarkBy = caster.Id;
c.CovenantAuthorityUsesRemaining--;
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name} pronounces an oath against {target.Name} — -2 attack vs caster for 1 minute.");
return true;
}
/// <summary>
/// Phase 6.5 M3 — to-hit penalty applied to a marked attacker rolling
/// against the Covenant-Keeper who marked them. Returns 0 when no
/// active oath, -2 when the marked attacker targets the marker, and 0
/// for any other target (the oath is target-specific).
/// </summary>
public static int OathAttackPenalty(Encounter enc, Combatant attacker, Combatant defender)
{
if (attacker.OathMarkRound is not int markRound) return 0;
if (attacker.OathMarkBy is not int markBy) return 0;
// Expire after 10 rounds.
if (enc.RoundNumber > markRound + 9)
{
attacker.OathMarkRound = null;
attacker.OathMarkBy = null;
return 0;
}
if (markBy != defender.Id) return 0; // penalty only when attacking the marker
return -2;
}
/// <summary>
/// Muzzle-Speaker Vocalization Dice (level-1 d6, scaling to d8/d10/d12
/// at L5/L9/L15). Bonus action; consumes one die. The target combatant
/// gains <see cref="Combatant.InspirationDieSides"/> = the current die
/// size; the next attack/check/save they make rolls that bonus.
/// </summary>
public static bool TryGrantVocalizationDie(Encounter enc, Combatant caster, Combatant ally)
{
var c = caster.SourceCharacter;
if (c is null || c.ClassDef.Id != "muzzle_speaker") return false;
if (caster.Id == ally.Id) return false; // can't inspire yourself
if (c.VocalizationDiceRemaining <= 0)
{
enc.AppendLog(CombatLogEntry.Kind.Note, $"{caster.Name}: Vocalization Dice spent (rest to recover).");
return false;
}
if (ally.InspirationDieSides > 0)
{
// Already inspired — overlapping inspirations don't stack at L1.
enc.AppendLog(CombatLogEntry.Kind.Note, $"{caster.Name}: {ally.Name} already inspired.");
return false;
}
// Range gate: 60 ft. = 12 tactical tiles per the standard 5-ft tile.
int dist = caster.DistanceTo(ally);
if (dist > 12)
{
enc.AppendLog(CombatLogEntry.Kind.Note, $"{caster.Name}: {ally.Name} too far for Vocalization Dice ({dist}/12).");
return false;
}
int sides = VocalizationDieSidesFor(c.Level);
ally.InspirationDieSides = sides;
c.VocalizationDiceRemaining--;
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name} grants {ally.Name} a Vocalization Die (1d{sides}). ({c.VocalizationDiceRemaining} left)");
return true;
}
/// <summary>
/// Standard d20 Bardic Inspiration die ladder, mapped to Vocalization
/// Dice per <c>classes.json</c> level table:
/// 14 → d6; 58 → d8; 914 → d10; 15+ → d12.
/// </summary>
public static int VocalizationDieSidesFor(int level) => level switch
{
>= 15 => 12,
>= 9 => 10,
>= 5 => 8,
_ => 6,
};
/// <summary>
/// Consume an inspiration die (if any) on a d20 roll. Adds 1d&lt;sides&gt;
/// to the d20 result and clears the field. Returns the bonus added (0 if
/// no inspiration was active).
/// </summary>
public static int ConsumeInspirationDie(Encounter enc, Combatant roller)
{
if (roller.InspirationDieSides <= 0) return 0;
int sides = roller.InspirationDieSides;
int rolled = enc.RollDie(sides);
roller.InspirationDieSides = 0;
enc.AppendLog(CombatLogEntry.Kind.Note,
$" {roller.Name} adds Vocalization Die (1d{sides} = {rolled}).");
return rolled;
}
// ── Helpers ──────────────────────────────────────────────────────────
private static bool IsOneHanded(Theriapolis.Core.Rules.Character.Character c)
{
var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
if (main is null) return false;
if (HasProp(main.Def, "two_handed")) return false;
var off = c.Inventory.GetEquipped(EquipSlot.OffHand);
// Duelist requires the off hand to be empty (shields don't count as another weapon, but the d20 spec says "no other weapon" — for M6 we treat shields as OK).
if (off is null) return true;
return string.Equals(off.Def.Kind, "shield", System.StringComparison.OrdinalIgnoreCase);
}
private static bool IsTwoHanded(Theriapolis.Core.Rules.Character.Character c)
{
var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
return main is not null && HasProp(main.Def, "two_handed");
}
private static bool IsFinesseOrRanged(Combatant attacker, AttackOption attack)
{
if (attack.IsRanged) return true;
var c = attacker.SourceCharacter;
if (c is null) return false;
var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
if (main is null) return false;
return HasProp(main.Def, "finesse");
}
private static bool HasProp(Theriapolis.Core.Data.ItemDef def, string prop)
{
foreach (var p in def.Properties)
if (string.Equals(p, prop, System.StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
}
@@ -0,0 +1,48 @@
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Tactical-tile line-of-sight via Bresenham. The caller supplies a
/// "blocked at (x, y)?" predicate so this helper stays free of a hard
/// dependency on TacticalChunk / WorldState — Phase 5 M4 tests use a flat
/// arena (always-clear); M5 plugs in the live tactical-tile sampler.
/// </summary>
public static class LineOfSight
{
/// <summary>
/// True if a straight line from <paramref name="from"/> to
/// <paramref name="to"/> traverses only un-blocked tiles. Endpoints
/// themselves are NOT consulted — only the intermediate tiles.
/// </summary>
public static bool HasLine(Vec2 from, Vec2 to, System.Func<int, int, bool> isBlockedAt)
{
int x0 = (int)System.Math.Floor(from.X);
int y0 = (int)System.Math.Floor(from.Y);
int x1 = (int)System.Math.Floor(to.X);
int y1 = (int)System.Math.Floor(to.Y);
int dx = System.Math.Abs(x1 - x0);
int dy = System.Math.Abs(y1 - y0);
int sx = x0 < x1 ? 1 : -1;
int sy = y0 < y1 ? 1 : -1;
int err = dx - dy;
int x = x0, y = y0;
while (true)
{
// Skip the endpoint itself
if (!(x == x0 && y == y0) && !(x == x1 && y == y1))
{
if (isBlockedAt(x, y)) return false;
}
if (x == x1 && y == y1) return true;
int e2 = 2 * err;
if (e2 > -dy) { err -= dy; x += sx; }
if (e2 < dx) { err += dx; y += sy; }
}
}
/// <summary>Convenience: always-clear arena. Used by combat-duel and most M4 tests.</summary>
public static readonly System.Func<int, int, bool> AlwaysClear = (_, _) => false;
}
@@ -0,0 +1,36 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Tactical;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Maps a <see cref="TacticalSpawn"/> + chunk's <see cref="TacticalChunk.DangerZone"/>
/// to the actual <see cref="NpcTemplateDef"/> that should spawn there.
/// Lookup table lives in <c>npc_templates.json</c>'s
/// <c>spawn_kind_to_template_by_zone</c> map (loaded into
/// <see cref="NpcTemplateContent.SpawnKindToTemplateByZone"/>).
///
/// Returns null when no template is configured for the spawn kind/zone (the
/// caller should skip that spawn — chunk is silently denser, that's OK).
/// </summary>
public static class NpcInstantiator
{
public static NpcTemplateDef? PickTemplate(
SpawnKind kind,
int dangerZone,
NpcTemplateContent content)
{
if (kind == SpawnKind.None) return null;
string kindKey = kind.ToString();
if (!content.SpawnKindToTemplateByZone.TryGetValue(kindKey, out var byZone))
return null;
if (byZone.Length == 0) return null;
// Clamp the zone index to the table's length.
int zoneIdx = System.Math.Clamp(dangerZone, 0, byZone.Length - 1);
string templateId = byZone[zoneIdx];
foreach (var t in content.Templates)
if (string.Equals(t.Id, templateId, System.StringComparison.OrdinalIgnoreCase))
return t;
return null;
}
}
@@ -0,0 +1,45 @@
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Phase 6.5 M3 — pheromone compounds a Scent-Broker can deploy via
/// Pheromone Craft. Each maps to a <see cref="Theriapolis.Core.Rules.Stats.Condition"/>
/// applied to creatures in the radius that fail their CON save.
///
/// The four compounds match <c>theriapolis-rpg-equipment.md</c>'s pheromone
/// vials, but here they're emitted directly via the class feature without
/// the consumable.
/// </summary>
public enum PheromoneType : byte
{
/// <summary>Failed save → <see cref="Theriapolis.Core.Rules.Stats.Condition.Frightened"/>.</summary>
Fear = 0,
/// <summary>Failed save → <see cref="Theriapolis.Core.Rules.Stats.Condition.Charmed"/> (won't attack source).</summary>
Calm = 1,
/// <summary>Failed save → <see cref="Theriapolis.Core.Rules.Stats.Condition.Dazed"/> (loss of focus).</summary>
Arousal = 2,
/// <summary>Failed save → <see cref="Theriapolis.Core.Rules.Stats.Condition.Poisoned"/> (debuff).</summary>
Nausea = 3,
}
public static class PheromoneTypeExtensions
{
/// <summary>Maps a <see cref="PheromoneType"/> to the condition it applies on a failed save.</summary>
public static Theriapolis.Core.Rules.Stats.Condition AppliedCondition(this PheromoneType t) => t switch
{
PheromoneType.Fear => Theriapolis.Core.Rules.Stats.Condition.Frightened,
PheromoneType.Calm => Theriapolis.Core.Rules.Stats.Condition.Charmed,
PheromoneType.Arousal => Theriapolis.Core.Rules.Stats.Condition.Dazed,
PheromoneType.Nausea => Theriapolis.Core.Rules.Stats.Condition.Poisoned,
_ => Theriapolis.Core.Rules.Stats.Condition.None,
};
/// <summary>Human-readable display name for combat log entries.</summary>
public static string DisplayName(this PheromoneType t) => t switch
{
PheromoneType.Fear => "Fear",
PheromoneType.Calm => "Calm",
PheromoneType.Arousal => "Arousal",
PheromoneType.Nausea => "Nausea",
_ => "Unknown",
};
}
@@ -0,0 +1,67 @@
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Size-aware spatial helpers for combat. Combatants occupy
/// <see cref="Stats.SizeExtensions.FootprintTiles"/>² tactical tiles
/// anchored at their integer <see cref="Combatant.Position"/>; this helper
/// computes edge-to-edge Chebyshev distance and reach predicates.
/// </summary>
public static class ReachAndCover
{
/// <summary>
/// Edge-to-edge Chebyshev distance — number of empty tiles between two
/// footprints. Adjacent (sharing an edge or corner) returns 0; one
/// empty tile between returns 1; overlapping returns 0.
/// </summary>
public static int EdgeToEdgeChebyshev(Combatant a, Combatant b)
{
int aSize = a.Size.FootprintTiles();
int bSize = b.Size.FootprintTiles();
int aMinX = (int)System.Math.Floor(a.Position.X);
int aMinY = (int)System.Math.Floor(a.Position.Y);
int aMaxX = aMinX + aSize - 1;
int aMaxY = aMinY + aSize - 1;
int bMinX = (int)System.Math.Floor(b.Position.X);
int bMinY = (int)System.Math.Floor(b.Position.Y);
int bMaxX = bMinX + bSize - 1;
int bMaxY = bMinY + bSize - 1;
// Per-axis gap: positive = number of tile-steps to bring edges to
// touching (then -1 because touching = 0 empty tiles between).
int dx = System.Math.Max(0, System.Math.Max(aMinX - bMaxX, bMinX - aMaxX) - 1);
int dy = System.Math.Max(0, System.Math.Max(aMinY - bMaxY, bMinY - aMaxY) - 1);
return System.Math.Max(dx, dy);
}
/// <summary>True if <paramref name="defender"/> is within the attack's reach (melee) or short range (ranged).</summary>
public static bool IsInReach(Combatant attacker, Combatant defender, AttackOption attack)
{
int dist = EdgeToEdgeChebyshev(attacker, defender);
if (attack.IsRanged)
return dist <= attack.RangeLongTiles;
return dist <= attack.ReachTiles;
}
/// <summary>True if the defender sits past short range (disadvantage on the attack).</summary>
public static bool IsLongRange(Combatant attacker, Combatant defender, AttackOption attack)
{
if (!attack.IsRanged) return false;
int dist = EdgeToEdgeChebyshev(attacker, defender);
return dist > attack.RangeShortTiles && dist <= attack.RangeLongTiles;
}
/// <summary>
/// One step of greedy movement toward <paramref name="goal"/>. Returns
/// the new position one tile closer in 8-connected (Chebyshev) space.
/// Movement budget is ignored — the caller is responsible for charging it.
/// </summary>
public static Vec2 StepToward(Vec2 from, Vec2 goal)
{
int dx = System.Math.Sign(goal.X - from.X);
int dy = System.Math.Sign(goal.Y - from.Y);
return new Vec2((int)from.X + dx, (int)from.Y + dy);
}
}
@@ -0,0 +1,171 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Settlements;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Phase 6 M1 — turns a chunk's <see cref="SpawnKind.Resident"/> records
/// into live <see cref="NpcActor"/>s.
///
/// Each <see cref="TacticalSpawn"/> with kind Resident sits at a
/// world-pixel position that <see cref="SettlementStamper"/> emitted from a
/// <see cref="BuildingResidentSlot"/>. Resolution:
///
/// 1. Walk the world's settlements. Find the one whose
/// <see cref="Settlement.Buildings"/> contains a building footprint
/// that contains this spawn point. Within that building, find the
/// slot whose <c>SpawnX/SpawnY</c> match — that's the role tag.
/// 2. Look up the resident template. Named (anchor-prefixed) tags hit
/// <see cref="ContentResolver.ResidentsByRoleTag"/> directly. Generic
/// tags hit <see cref="ContentResolver.Residents"/> filtered by
/// <see cref="ResidentTemplateDef.RoleTag"/> equality, weighted by
/// <see cref="ResidentTemplateDef.Weight"/>.
/// 3. Build an <see cref="NpcActor"/> from the chosen template. Register
/// named-role NPCs in the <see cref="AnchorRegistry"/> so quest
/// scripts can resolve them by symbolic id.
///
/// The lookup is a linear walk over settlements (small N — &lt; 100) but is
/// deterministic for a given (worldSeed, chunk, spawnIndex).
/// </summary>
public static class ResidentInstantiator
{
/// <summary>
/// Resolve and spawn an NpcActor for a single Resident spawn record.
/// Returns null when the world has no resident template configured for
/// this slot's role tag (the spawn is silently dropped — the building
/// just stays empty, which is fine).
/// </summary>
public static NpcActor? Spawn(
ulong worldSeed,
TacticalChunk chunk,
int spawnIndex,
TacticalSpawn spawn,
WorldState world,
ContentResolver content,
ActorManager actors,
AnchorRegistry? registry = null)
{
if (spawn.Kind != SpawnKind.Resident) return null;
int worldPxX = chunk.OriginX + spawn.LocalX;
int worldPxY = chunk.OriginY + spawn.LocalY;
if (!TryFindSlot(world, worldPxX, worldPxY, out var settlement, out var building, out var slot))
return null;
var template = ResolveTemplate(slot.RoleTag, content, worldSeed, settlement!.Id, building!.Id, spawnIndex);
if (template is null) return null;
var npc = new NpcActor(template)
{
Id = -1, // ActorManager assigns
Position = new Vec2(worldPxX, worldPxY),
SourceChunk = chunk.Coord,
SourceSpawnIndex = spawnIndex,
// The named role tag wins over the generic one declared on the
// template — preserves "millhaven.innkeeper" identity even when
// the generic "innkeeper" template is what spawned.
RoleTag = string.IsNullOrEmpty(slot.RoleTag) ? template.RoleTag : slot.RoleTag,
// Phase 6 M5 — anchor the resident to its host settlement so
// RepPropagation can compute their local faction standing.
HomeSettlementId = settlement.Id,
};
var spawned = actors.SpawnNpc(npc);
if (registry is not null)
{
if (settlement.Anchor is not null)
registry.RegisterAnchor(settlement.Anchor.Value, settlement.Id);
registry.RegisterRole(spawned.RoleTag, spawned.Id);
}
return spawned;
}
/// <summary>
/// Pick the resident template for a given role tag. Named anchor-
/// prefixed tags ("millhaven.innkeeper") prefer named templates;
/// generic tags ("innkeeper") roll among matching generics by weight.
/// </summary>
public static ResidentTemplateDef? ResolveTemplate(
string roleTag,
ContentResolver content,
ulong worldSeed,
int settlementId,
int buildingId,
int spawnIndex)
{
// Named, anchor-prefixed: prefer the exact match.
if (content.ResidentsByRoleTag.TryGetValue(roleTag, out var named))
return named;
// Generic: collect all unnamed templates whose RoleTag equals the
// suffix-stripped tag (e.g. "millhaven.innkeeper" → "innkeeper").
string suffix = roleTag;
int dot = roleTag.LastIndexOf('.');
if (dot >= 0) suffix = roleTag[(dot + 1)..];
var pool = new List<ResidentTemplateDef>();
foreach (var r in content.Residents.Values)
if (!r.Named && string.Equals(r.RoleTag, suffix, System.StringComparison.OrdinalIgnoreCase))
pool.Add(r);
if (pool.Count == 0) return null;
if (pool.Count == 1) return pool[0];
// Weighted roll, deterministic per (worldSeed, settlementId, buildingId, spawnIndex).
var rng = SeededRng.ForSubsystem(worldSeed,
unchecked(C.RNG_NPC_SPAWN ^ (ulong)settlementId
^ ((ulong)buildingId << 16)
^ ((ulong)spawnIndex << 32)));
// Sort for stable iteration before the RNG roll.
pool.Sort(static (a, b) => string.Compare(a.Id, b.Id, System.StringComparison.Ordinal));
float total = 0f;
foreach (var t in pool) total += System.Math.Max(0f, t.Weight);
if (total <= 0f) return pool[0];
float roll = rng.NextFloat() * total;
float acc = 0f;
foreach (var t in pool)
{
acc += System.Math.Max(0f, t.Weight);
if (roll <= acc) return t;
}
return pool[^1];
}
/// <summary>
/// Walk the world's settlements to find the one whose building footprint
/// contains <paramref name="worldPxX"/>/<paramref name="worldPxY"/> AND
/// whose resident slot sits exactly on that point.
/// </summary>
public static bool TryFindSlot(
WorldState world, int worldPxX, int worldPxY,
out Settlement? settlement, out BuildingFootprint? building, out BuildingResidentSlot slot)
{
settlement = null;
building = null;
slot = default;
foreach (var s in world.Settlements)
{
if (!s.BuildingsResolved) continue;
foreach (var b in s.Buildings)
{
if (!b.ContainsTile(worldPxX, worldPxY)) continue;
foreach (var r in b.Residents)
{
if (r.SpawnX == worldPxX && r.SpawnY == worldPxY)
{
settlement = s;
building = b;
slot = r;
return true;
}
}
}
}
return false;
}
}
+208
View File
@@ -0,0 +1,208 @@
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Pure d20-adjacent rule evaluator. Static — no per-encounter state lives
/// here; everything flows through <see cref="Encounter"/> (dice + log) and
/// the supplied <see cref="Combatant"/> instances (HP + conditions).
///
/// Phase 5 M4 ships AttemptAttack, MakeSave, ApplyDamage, ApplyCondition.
/// Class-feature combat effects (Sneak Attack damage, Rage damage bonus,
/// fighting-style modifiers, etc.) are layered on at M6 by inspecting the
/// attacker's <see cref="Character"/> features in <see cref="AttemptAttack"/>.
/// </summary>
public static class Resolver
{
/// <summary>
/// Roll an attack from <paramref name="attacker"/> against
/// <paramref name="target"/>. Logs the outcome on
/// <paramref name="enc"/>'s log. Mutates target HP if the attack hits.
/// </summary>
public static AttackResult AttemptAttack(
Encounter enc,
Combatant attacker,
Combatant target,
AttackOption attack,
SituationFlags situation = SituationFlags.None)
{
// Range/long-range disadvantage decoration: if the attack is ranged
// and the target is past short range, OR the calling code is firing
// a ranged attack into melee, fold those in.
if (attack.IsRanged && ReachAndCover.IsLongRange(attacker, target, attack))
situation |= SituationFlags.LongRange;
// Phase 6.5 M2 — Pack-Forged "Packmate's Howl" consumption: if the
// target is howl-marked by an ally of this attacker, force advantage
// on this attack roll.
if (FeatureProcessor.ConsumeHowlAdvantage(enc, attacker, target))
situation |= SituationFlags.Advantage;
// Phase 6.5 M3 — Frightened attackers roll at disadvantage.
if (attacker.Conditions.Contains(Condition.Frightened))
situation |= SituationFlags.Disadvantage;
var (kept, other) = enc.RollD20WithMode(situation);
// Phase 5 M6: stack Sentinel Stance and other per-combatant AC bonuses.
// Phase 6.5 M2 — pass the encounter so passive subclass AC features
// (Herd-Wall Interlock Shields, Lone Fang Isolation Bonus) can read
// positional state.
int totalAc = target.ArmorClass + situation.CoverAcBonus()
+ FeatureProcessor.ApplyAcBonus(target, enc);
// Phase 6.5 M1: consume an inspiration die (Vocalization Dice) on
// attack rolls. The bonus applies to the d20 total *before* compare;
// crits/fumbles still trigger off the natural d20.
int inspirationBonus = FeatureProcessor.ConsumeInspirationDie(enc, attacker);
// Phase 6.5 M2 — subclass to-hit bonuses (Lone Fang Isolation Bonus).
int subclassToHit = FeatureProcessor.ApplyToHitBonus(attacker, enc);
// Phase 6.5 M3 — Covenant's Authority oath mark: -2 attack vs. marker.
int oathPenalty = FeatureProcessor.OathAttackPenalty(enc, attacker, target);
int attackTotal = kept + attack.ToHitBonus + inspirationBonus + subclassToHit + oathPenalty;
bool natural1 = kept == 1;
bool natural20 = kept >= attack.CritOnNatural;
bool isCrit = natural20;
bool hit = !natural1 && (natural20 || attackTotal >= totalAc);
int damage = 0;
if (hit)
{
// Damage roll wraps the per-die source so Great Weapon style
// can re-roll 1s/2s on damage dice without changing the resolver
// contract. The per-die delegate consumes RNG via the encounter.
damage = attack.Damage.Roll(
sides => FeatureProcessor.GreatWeaponReroll(enc, attacker, attack, enc.RollDie(sides), sides),
isCrit);
// Per-feature damage bonuses (Duelist, Rage, Sneak Attack).
damage += FeatureProcessor.ApplyDamageBonus(enc, attacker, target, attack, isCrit);
// Resistance halves damage (Rage vs phys).
if (FeatureProcessor.IsResisted(target, attack.Damage.DamageType))
damage = damage / 2;
ApplyDamage(target, damage);
// Phase 6.5 M2 — subclass on-hit triggers.
// Pack-Forged: melee hit marks the target so allies' next attack
// gets advantage.
if (!attack.IsRanged)
FeatureProcessor.OnPackForgedHit(enc, attacker, target, attack);
// Blood Memory: melee kill while raging triggers Predatory Surge.
if (target.IsDown && !attack.IsRanged && attacker.RageActive)
FeatureProcessor.OnBloodMemoryKill(enc, attacker, attack);
// Phase 7 M0 — Antler-Guard Retaliatory Strike. Returns 1d8+CON
// to the attacker when the target is an Antler-Guard in Sentinel
// Stance hit by a melee attack. Calls ApplyDamage on the attacker
// directly; the encounter log carries the structured note.
if (!attack.IsRanged)
FeatureProcessor.OnAntlerGuardHit(enc, attacker, target, attack);
}
var result = new AttackResult
{
AttackerId = attacker.Id,
TargetId = target.Id,
AttackName = attack.Name,
D20Roll = kept,
D20Other = other == -1 ? null : other,
ToHitBonus = attack.ToHitBonus,
AttackTotal = attackTotal,
TargetAc = totalAc,
Hit = hit,
Crit = isCrit && hit,
DamageRolled = damage,
TargetHpAfter = target.CurrentHp,
Situation = situation,
};
enc.AppendLog(CombatLogEntry.Kind.Attack, result.FormatLog(attacker.Name, target.Name));
if (target.IsDown)
enc.AppendLog(CombatLogEntry.Kind.Death, $"{target.Name} falls.");
return result;
}
/// <summary>
/// Roll a saving throw for <paramref name="target"/> against
/// <paramref name="dc"/>. Bonus = ability mod + (proficient ? prof : 0).
/// </summary>
public static SaveResult MakeSave(
Encounter enc,
Combatant target,
SaveId save,
int dc,
bool isProficient = false,
SituationFlags situation = SituationFlags.None)
{
var (kept, _) = enc.RollD20WithMode(situation);
int bonus = AbilityScores.Mod(target.Abilities.Get(save.Ability()))
+ (isProficient ? target.ProficiencyBonus : 0);
int total = kept + bonus;
bool succ = total >= dc;
var result = new SaveResult
{
TargetId = target.Id,
Save = save,
D20Roll = kept,
SaveBonus = bonus,
SaveTotal = total,
Dc = dc,
Succeeded = succ,
};
enc.AppendLog(CombatLogEntry.Kind.Save,
$"{target.Name} {save} save: {total} vs DC {dc} → {(succ ? "succeeds" : "fails")}");
return result;
}
/// <summary>
/// Subtract <paramref name="damage"/> from <paramref name="target"/>'s
/// HP, clamped to 0. Does not log (callers like AttemptAttack handle
/// the structured log entry; this is the raw mutation).
///
/// Phase 5 M6: when a player character drops to 0, install a
/// <see cref="DeathSaveTracker"/> on the combatant; combat HUD reads
/// this and rolls a save at the start of the player's turn until the
/// loop resolves (stabilised, revived, or dead).
/// </summary>
public static void ApplyDamage(Combatant target, int damage)
{
if (damage <= 0) return;
target.CurrentHp = System.Math.Max(0, target.CurrentHp - damage);
if (target.CurrentHp == 0 && target.SourceCharacter is not null)
{
target.Conditions.Add(Condition.Unconscious);
target.DeathSaves ??= new DeathSaveTracker();
}
}
/// <summary>Heal up to MaxHp. Negative amounts are no-ops (use ApplyDamage).</summary>
public static void Heal(Combatant target, int amount)
{
if (amount <= 0) return;
target.CurrentHp = System.Math.Min(target.MaxHp, target.CurrentHp + amount);
// If the heal lifts a downed character above 0, the unconscious
// condition lifts automatically and the death-save loop resets.
if (target.CurrentHp > 0)
{
target.Conditions.Remove(Condition.Unconscious);
target.DeathSaves?.Reset();
}
}
/// <summary>Apply a condition to a target. Logs the change.</summary>
public static void ApplyCondition(Encounter enc, Combatant target, Condition condition)
{
if (target.Conditions.Add(condition))
enc.AppendLog(CombatLogEntry.Kind.ConditionApplied,
$"{target.Name} is now {condition}.");
}
/// <summary>Remove a condition from a target. Logs if it was present.</summary>
public static void RemoveCondition(Encounter enc, Combatant target, Condition condition)
{
if (target.Conditions.Remove(condition))
enc.AppendLog(CombatLogEntry.Kind.ConditionEnded,
$"{target.Name} is no longer {condition}.");
}
}
@@ -0,0 +1,14 @@
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Rules.Combat;
public sealed record SaveResult
{
public required int TargetId { get; init; }
public required SaveId Save { get; init; }
public required int D20Roll { get; init; }
public required int SaveBonus { get; init; }
public required int SaveTotal { get; init; } // D20Roll + bonus
public required int Dc { get; init; }
public required bool Succeeded { get; init; }
}
@@ -0,0 +1,61 @@
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Per-attack situation modifiers. Flags compose: a Sneak Attack with
/// Advantage and Disadvantage (e.g. attacker prone, target shadowed)
/// cancels to a normal roll per d20 rules.
///
/// Phase 5 M4 wires the basic six (Advantage/Disadvantage and the four
/// resolver-time tags); class-feature flags like Reckless Attack come in
/// M6 once the feature engine reads from this enum.
/// </summary>
[System.Flags]
public enum SituationFlags : uint
{
None = 0,
Advantage = 1u << 0,
Disadvantage = 1u << 1,
/// <summary>Attacker is at long range — disadvantage on the roll per d20.</summary>
LongRange = 1u << 2,
/// <summary>Attacker has reach + a melee weapon vs. a target that has cover.</summary>
HalfCover = 1u << 3,
ThreeQuartersCover= 1u << 4,
/// <summary>Attacker meets the Sneak Attack precondition (advantage or ally adjacent).</summary>
SneakAttackEligible = 1u << 5,
/// <summary>Attacker is firing a ranged weapon at a target within 5 ft. — disadvantage.</summary>
RangedInMelee = 1u << 6,
}
public static class SituationFlagsExtensions
{
/// <summary>
/// True when the situation should roll the d20 with advantage. Per
/// d20 rules, advantage and disadvantage cancel exactly (no doubling).
/// </summary>
public static bool RollsAdvantage(this SituationFlags f)
{
bool adv = (f & SituationFlags.Advantage) != 0;
bool dis = (f & SituationFlags.Disadvantage) != 0
|| (f & SituationFlags.LongRange) != 0
|| (f & SituationFlags.RangedInMelee) != 0;
return adv && !dis;
}
/// <summary>True when the situation rolls with disadvantage (and no compensating advantage).</summary>
public static bool RollsDisadvantage(this SituationFlags f)
{
bool adv = (f & SituationFlags.Advantage) != 0;
bool dis = (f & SituationFlags.Disadvantage) != 0
|| (f & SituationFlags.LongRange) != 0
|| (f & SituationFlags.RangedInMelee) != 0;
return dis && !adv;
}
/// <summary>Cover modifier applied to AC: 0 / 2 / 5.</summary>
public static int CoverAcBonus(this SituationFlags f)
{
if ((f & SituationFlags.ThreeQuartersCover) != 0) return 5;
if ((f & SituationFlags.HalfCover) != 0) return 2;
return 0;
}
}
+38
View File
@@ -0,0 +1,38 @@
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Mutable per-turn state for the active combatant: action / bonus action /
/// reaction availability and remaining movement budget. The
/// <see cref="Encounter"/> rebuilds this when each new turn begins; the
/// <see cref="Resolver"/> consumes resources as the combatant uses them.
///
/// Phase 5 M4 tracks the booleans but doesn't enforce them inside Resolver
/// (callers can attack twice in a turn if they want — useful for tests).
/// M5 introduces per-action-cost gating in the live PlayScreen wrapper.
/// </summary>
public struct Turn
{
public int CombatantId;
public bool ActionAvailable;
public bool BonusActionAvailable;
public bool ReactionAvailable;
public int RemainingMovementFt;
public static Turn FreshFor(int combatantId, int speedFt) => new()
{
CombatantId = combatantId,
ActionAvailable = true,
BonusActionAvailable = true,
ReactionAvailable = true,
RemainingMovementFt = speedFt,
};
public void ConsumeAction() => ActionAvailable = false;
public void ConsumeBonusAction() => BonusActionAvailable = false;
public void ConsumeReaction() => ReactionAvailable = false;
public void ConsumeMovement(int feet)
{
RemainingMovementFt -= feet;
if (RemainingMovementFt < 0) RemainingMovementFt = 0;
}
}