feat(loaders): map 8-PC feats

This commit is contained in:
2026-09-04 22:55:45 +02:00
parent 934d443d7d
commit 4d7f1b8233
4 changed files with 391 additions and 4 deletions
+264
View File
@@ -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")
+40 -4
View File
@@ -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
+45
View File
@@ -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,
]
}
+42
View File
@@ -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,