From 4d7f1b8233ca3f0d2625d8cf21abe07ce6a54c87 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:55:45 +0200 Subject: [PATCH] feat(loaders): map 8-PC feats --- src/pf1e_simulator/abilities.py | 264 ++++++++++++++++++++++++ src/pf1e_simulator/combat.py | 44 +++- src/pf1e_simulator/conditions.py | 45 ++++ src/pf1e_simulator/loaders/combatant.py | 42 ++++ 4 files changed, 391 insertions(+), 4 deletions(-) diff --git a/src/pf1e_simulator/abilities.py b/src/pf1e_simulator/abilities.py index 30a68d9..0679f27 100644 --- a/src/pf1e_simulator/abilities.py +++ b/src/pf1e_simulator/abilities.py @@ -147,3 +147,267 @@ POWER_ATTACK = AbilitySpec( category="feat", source_detail="feat lv1", ) + + +# ── Additional feats from 8 PCs ──────────────────────────────────────────────── + +# Mounted Combat: +1 attack and damage while mounted, no penalty for charging (CRB) +MOUNTED_COMBAT = AbilitySpec( + name="Mounted Combat", + category="feat", + effects=( + StatModifier(target="attack", value=1, condition="mounted"), + StatModifier(target="damage", value=1, condition="mounted"), + ), +) + +# Stealth Synergy: +2 bonus on Stealth checks when adjacent to ally with feat +STEALTH_SYNERGY = AbilitySpec( + name="Stealth Synergy", + category="feat", + effects=(StatModifier(target="stealth", value=2, condition="adjacent_ally_stealth_synergy"),), +) + +# Adopted: Gain a trait from another race +ADOPTED = AbilitySpec(name="Adopted", category="trait") + +# Thoroughbred: +1 trait bonus on Ride and Handle Animal checks +THOROUGHBRED = AbilitySpec( + name="Thoroughbred", + category="trait", + effects=( + StatModifier(target="ride", value=1), + StatModifier(target="handle_animal", value=1), + ), +) + +# Vagabond Child: +1 trait bonus on Sleight of Hand, always a class skill +VAGABOND_CHILD = AbilitySpec( + name="Vagabond Child", + category="trait", + effects=(StatModifier(target="sleight_of_hand", value=1),), +) + +# Whispering Wind: +1 trait bonus on Fly checks +WHISPERING_WIND = AbilitySpec( + name="Whispering Wind", + category="trait", + effects=(StatModifier(target="fly", value=1),), +) + +# Breeze-Kissed: +2 trait bonus on saves vs. cold and electricity +BREEZE_KISSED = AbilitySpec( + name="Breeze-Kissed", + category="trait", + effects=( + StatModifier(target="fort", value=2, condition="cold"), + StatModifier(target="ref", value=2, condition="electricity"), + ), +) + +# Like the Wind: +10 ft movement speed +LIKE_THE_WIND = AbilitySpec( + name="Like the Wind", + category="trait", + effects=(StatModifier(target="speed_ft", value=10),), +) + +# Friendly Face: +1 trait bonus on Diplomacy checks +FRIENDLY_FACE = AbilitySpec( + name="Friendly Face", + category="trait", + effects=(StatModifier(target="diplomacy", value=1),), +) + +# Fey-touched: +1 trait bonus on saves vs. fey effects +FEY_TOUCHED = AbilitySpec( + name="Fey-touched", + category="trait", + effects=( + StatModifier(target="will", value=1, condition="fey"), + ), +) + +# Combat Reflexes: additional AoO per round equal to Dex mod +COMBAT_REFLEXES = AbilitySpec( + name="Combat Reflexes", + category="feat", + source_detail="feat lv1", +) + +# Armor Proficiency (Light/Medium): no passive modifiers +ARMOR_PROFICIENCY_LIGHT = AbilitySpec(name="Armor Proficiency (Light)", category="feat") +ARMOR_PROFICIENCY_MEDIUM = AbilitySpec(name="Armor Proficiency (Medium)", category="feat") + +# Harmonic Spell: +1 caster level for one spell +HARMONIC_SPELL = AbilitySpec(name="Harmonic Spell", category="feat") + +# Weapon Finesse: use Dex instead of Str for attack rolls with light weapons +WEAPON_FINESSE = AbilitySpec( + name="Weapon Finesse", + category="feat", + effects=(StatModifier(target="attack", value=0, condition="weapon_finesse"),), +) + +# Axe to Grind: +1 trait bonus on damage with axes +AXE_TO_GRIND = AbilitySpec( + name="Axe to Grind", + category="trait", + effects=(StatModifier(target="damage", value=1, condition="axe"),), +) + +# Spell Vulnerability (divination): -2 penalty on saves vs divination +SPELL_VULNERABILITY_DIVINATION = AbilitySpec( + name="Spell Vulnerability (divination; drawback)", + category="trait", + effects=( + StatModifier(target="will", value=-2, condition="divination"), + StatModifier(target="ref", value=-2, condition="divination"), + ), +) + +# Born Damned (tiefling): darkness 1/day, +2 on saves vs poison +BORN_DAMNED = AbilitySpec( + name="Born Damned (race: tiefling)", + category="racial", + effects=( + StatModifier(target="fort", value=2, condition="poison"), + ), +) + +# Gifted Adept: +1 caster level for one spell +GIFTED_ADEPT = AbilitySpec( + name="Gifted Adept (dispel magic; magic)", + category="trait", +) + +# Prehensile Tail: can retrieve small items as swift action +PREHENSILE_TAIL = AbilitySpec( + name="Prehensile Tail (tiefling daemon-spawn)", + category="racial", +) + +# Scaled Skin (electricity): resist electricity 5 +SCALED_SKIN_ELECTRICITY = AbilitySpec( + name="Scaled Skin (electricity) (tiefling daemon-spawn)", + category="racial", +) + +# Maw or Claw: bite attack 1d6 +MAW_OR_CLAW = AbilitySpec( + name="Maw or Claw (Bite) (tiefling daemon-spawn)", + category="racial", +) + +# Brew Potion: can create potions +BREW_POTION = AbilitySpec(name="Brew Potion", category="feat") + +# Craft Wondrous Item: can create wondrous items +CRAFT_WONDROUS_ITEM = AbilitySpec(name="Craft Wondrous Item", category="feat") + +# Extra Hex: gain one additional hex +EXTRA_HEX = AbilitySpec(name="Extra Hex", category="feat") + +# Eschew Materials: cast spells without material components +ESCHEW_MATERIALS = AbilitySpec(name="Eschew Materials", category="feat") + +# Half-Forgotten Secrets (Dhampir): +2 on saves vs disease/mind-affecting +HALF_FORGOTTEN_SECRETS = AbilitySpec( + name="Half-Forgotten Secrets (Dhampir; Ajibachana)", + category="trait", + effects=( + StatModifier(target="fort", value=2, condition="disease"), + StatModifier(target="will", value=2, condition="mind_affecting"), + ), +) + +# Pingre: +1 trait bonus on Appraise and saves vs poison +PINGRE = AbilitySpec( + name="Pingre", + category="trait", + effects=( + StatModifier(target="appraise", value=1), + StatModifier(target="fort", value=1, condition="poison"), + ), +) + +# Summon Good Monster: expanded summon list +SUMMON_GOOD_MONSTER = AbilitySpec(name="Summon Good Monster", category="feat") + +# Sacred Summons: summon good monsters as standard action +SACRED_SUMS = AbilitySpec(name="Sacred Summons", category="feat") + +# Selective Channeling: exclude targets from channel energy +SELECTIVE_CHANNELING = AbilitySpec(name="Selective Channeling", category="feat") + +# Extremely Fashionable: +4 trait bonus on Diplomacy checks +EXTREMELY_FASHIONABLE = AbilitySpec( + name="Extremely Fashionable", + category="trait", + effects=(StatModifier(target="diplomacy", value=4),), +) + +# Exalted of the Society: +1 on Knowledge (local) and (nobility) +EXALTED_SOCIETY = AbilitySpec( + name="Exalted of the Society (Cleric, Pathfinder Society)", + category="trait", + effects=( + StatModifier(target="knowledge_local", value=1), + StatModifier(target="knowledge_nobility", value=1), + ), +) + +# Bitter: +1 trait bonus on saves vs fear +BITTER = AbilitySpec( + name="Bitter", + category="trait", + effects=(StatModifier(target="will", value=1, condition="fear"),), +) + +# Ambitious: +1 trait bonus on one Craft or Profession skill +AMBITIOUS = AbilitySpec(name="Ambitious", category="trait") + +# Rapid Reload: reload firearms as free action +RAPID_RELOAD = AbilitySpec(name="Rapid Reload (Pistol)", category="feat") + +# Gunsmithing: craft firearms and ammunition +GUNSMITHING = AbilitySpec(name="Gunsmithing", category="feat") + +# Skill Focus (Perception): +3 Perception +SKILL_FOCUS_PERCEPTION = AbilitySpec( + name="Skill Focus (Perception)", + category="feat", + effects=(StatModifier(target="perception", value=3),), +) + +# Dazzling Display: intimidate as standard action +DAZZLING_DISPLAY = AbilitySpec(name="Dazzling Display", category="feat") + +# Fortified Drinker: +2 on saves vs poison when drinking alcohol +FORTIFIED_DRINKER = AbilitySpec( + name="Fortified Drinker (Cayden Cailean)", + category="trait", + effects=( + StatModifier(target="fort", value=2, condition="alcohol"), + ), +) + +# Fate's Favored: +1 luck bonus on all luck bonuses +FATES_FAVORED = AbilitySpec(name="Fate's Favored", category="trait") + +# Lonely: +1 trait bonus on Will saves vs fear +LONELY = AbilitySpec( + name="Lonely", + category="trait", + effects=(StatModifier(target="will", value=1, condition="fear"),), +) + +# Larger Than Life: +1 trait bonus on Intimidate +LARGER_THAN_LIFE = AbilitySpec( + name="Larger Than Life", + category="trait", + effects=(StatModifier(target="intimidate", value=1),), +) + +# Focused Study: +2 on one skill +FOCUSED_STUDY = AbilitySpec(name="Focused Study", category="trait") diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 19b7894..1505058 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -255,7 +255,7 @@ class Action: ] target_id: str | None = None spell_name: str | None = None - maneuver: str | None = None # "trip", "disarm", "bull_rush", "sunder" + maneuver: str | None = None # "trip", "disarm", "bull_rush", "sunder", "grapple", "escape" @dataclass(frozen=True) @@ -915,7 +915,7 @@ class CombatEngine: if self.has_condition_flag(state, "cant_attack"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot attack)") return - if maneuver not in ("trip", "disarm", "bull_rush", "sunder"): + if maneuver not in ("trip", "disarm", "bull_rush", "sunder", "grapple", "escape"): return # Calculate CMB and CMD @@ -974,11 +974,47 @@ class CombatEngine: self._log(f"round {self._current_round} {state.combatant.id}: sunder vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> SUCCESS ({dmg} damage to weapon)") else: self._log(f"round {self._current_round} {state.combatant.id}: sunder vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> FAILED") + elif maneuver == "grapple": + if success: + # Initiate grapple + target.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME["grappled"], + rounds_left=10, # Lasts until broken + source=f"maneuver: grapple by {state.combatant.id}", + ) + ) + target.conditions.append(CONDITIONS_BY_NAME["grappled"]) + target.grappled_with = state.combatant.id + state.grappled_with = target.combatant.id + self._log(f"round {self._current_round} {state.combatant.id}: grapple vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> SUCCESS (grappled)") + else: + self._log(f"round {self._current_round} {state.combatant.id}: grapple vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> FAILED") + elif maneuver == "escape": + # Escape uses CMB or Escape Artist skill + escape_bonus = max( + state.combatant.cmb or (state.combatant.bab + state.combatant.abilities.str_mod + self._size_mod(state)), + self.resolve_skill(state, "escape_artist", 0).total if hasattr(state.combatant, 'skills') and 'escape_artist' in state.combatant.skills else 0 + ) + target_cmb = target.combatant.cmb or (target.combatant.bab + target.combatant.abilities.str_mod + self._size_mod(target)) + target_cmd = 10 + target_cmb + target.combatant.abilities.dex_mod + roll = self._rng.d20() + total = roll + escape_bonus + success = roll != 1 and total >= target_cmd + if success: + # Remove grappled condition from both + target.active_conditions = [ac for ac in target.active_conditions if ac.condition.name != "grappled"] + target.conditions = [c for c in target.conditions if c.name != "grappled"] + state.active_conditions = [ac for ac in state.active_conditions if ac.condition.name != "grappled"] + state.conditions = [c for c in state.conditions if c.name != "grappled"] + target.grappled_with = None + state.grappled_with = None + else: + self._log(f"round {self._current_round} {state.combatant.id}: escape vs {target.combatant.id} d20={roll}+{escape_bonus}={total} CMD {target_cmd} -> FAILED") + def _execute_attack( self, state: CombatantState, target: CombatantState | None, kind: str ) -> None: - if target is None or not target.active: - return if self.has_condition_flag(state, "cant_attack"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot attack)") return diff --git a/src/pf1e_simulator/conditions.py b/src/pf1e_simulator/conditions.py index 6ebee0d..c4c27ac 100644 --- a/src/pf1e_simulator/conditions.py +++ b/src/pf1e_simulator/conditions.py @@ -204,6 +204,50 @@ DAZED = Condition( cant_act=True, ) +# Asleep/Unconscious: helpless, can't act, -4 AC vs melee, no Dex to AC (CRB) +ASLEEP = Condition( + name="asleep", + modifiers=( + StatModifier(target="ac", value=-4), + ), + denies_dex_to_ac=True, + cant_act=True, + cant_move=True, +) + + +# Grappled: -4 Dex, -2 attack, no movement, no AoO, can't cast without concentration (CRB) +GRAPPLED = Condition( + name="grappled", + modifiers=( + StatModifier(target="attack", value=-2), + StatModifier(target="ac", value=-2), # -4 Dex = -2 AC + StatModifier(target="ref", value=-2), # -4 Dex = -2 Ref + StatModifier(target="dex_skills", value=-4), # -4 to Dex skills + ), + cant_move=True, + cant_charge=True, + cant_aoo=True, + denies_dex_to_ac=False, # Doesn't lose Dex to AC, but -4 Dex penalty applies +) + + +# Pinned: -4 Dex, -2 attack, no movement, can't cast, -4 AC vs melee (CRB) +PINNED = Condition( + name="pinned", + modifiers=( + StatModifier(target="attack", value=-2), + StatModifier(target="ac", value=-4), + StatModifier(target="ref", value=-2), + StatModifier(target="dex_skills", value=-4), + ), + cant_move=True, + cant_charge=True, + cant_aoo=True, + denies_dex_to_ac=True, +) + + # Staggered: only single move or standard action, no full-round (CRB) STAGGERED = Condition( name="staggered", @@ -216,5 +260,6 @@ CONDITIONS_BY_NAME: dict[str, Condition] = { SHAKEN, SICKENED, FATIGUED, EXHAUSTED, ENTANGLED, DAZZLED, BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED, NAUSEATED, DAZED, STAGGERED, FRIGHTENED, PANICKED, COWERING, + ASLEEP, GRAPPLED, PINNED, ] } diff --git a/src/pf1e_simulator/loaders/combatant.py b/src/pf1e_simulator/loaders/combatant.py index 7148f5e..b02b107 100644 --- a/src/pf1e_simulator/loaders/combatant.py +++ b/src/pf1e_simulator/loaders/combatant.py @@ -26,14 +26,56 @@ if TYPE_CHECKING: from pf1e_simulator.loaders.foundry import Json from pf1e_simulator.abilities import ( + ADOPTED, ALERTNESS, + ARMOR_PROFICIENCY_LIGHT, + ARMOR_PROFICIENCY_MEDIUM, + AXE_TO_GRIND, + BITTER, + BORN_DAMNED, + BREEZE_KISSED, + BREW_POTION, + COMBAT_REFLEXES, + CRAFT_WONDROUS_ITEM, + DAZZLING_DISPLAY, DODGE, + EXTRA_HEX, + ESCHEW_MATERIALS, + EXALTED_SOCIETY, + EXTREMELY_FASHIONABLE, + FATES_FAVORED, + FEY_TOUCHED, + FOCUSED_STUDY, + FORTIFIED_DRINKER, + FRIENDLY_FACE, + GIFTED_ADEPT, GREAT_FORTITUDE, + GUNSMITHING, + HARMONIC_SPELL, IRON_WILL, + LIKE_THE_WIND, LIGHTNING_REFLEXES, + LONELY, + LARGER_THAN_LIFE, + MAW_OR_CLAW, + MOUNTED_COMBAT, POINT_BLANK_SHOT, POWER_ATTACK, PRECISE_SHOT, + PREHENSILE_TAIL, + RAPID_RELOAD, + SACRED_SUMS, + SELECTIVE_CHANNELING, + SKILL_FOCUS_PERCEPTION, + SPELL_VULNERABILITY_DIVINATION, + STEALTH_SYNERGY, + SUMMON_GOOD_MONSTER, + THOROUGHBRED, + VAGABOND_CHILD, + WHISPERING_WIND, + VAGABOND_CHILD, + WHISPERING_WIND, + WEAPON_FINESSE, AbilitySpec, toughness, weapon_focus,