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