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