From 0c141414a92a09dc6541eca1ffc8706e4a4e344e Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 13 Sep 2026 17:39:52 +0200 Subject: [PATCH] feat(rules): conform bulls strength, bears endurance and shield to the SRD Bull's Strength now grants the SRD +4 enhancement bonus to Strength as +2 melee attack, +2 melee damage and +2 CMB (three modifiers instead of the previous +1/+1 approximation). Shield now uses a real shield bonus type (+4 AC) instead of approximating it as deflection: the engine's BonusType literal and stacking docs gain the shield type, which stays out of the CMD bonus list (SRD Combat Maneuver Defense). Bear's Endurance keeps its +2 Fortitude / +2 hp per HD and its description now cites the SRD and flags the hp modifier as informational. Golden transcripts updated: the buff log reports 3 mods and Tammara's pistol attacks gain the extra +1 enhancement (d20=10+9-6=15). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- data/spells/bears_endurance.json | 2 +- data/spells/bulls_strength.json | 7 ++++--- data/spells/shield.json | 4 ++-- src/pf1e_simulator/effects.py | 4 +++- tests/test_pc_combat_golden.py | 22 +++++++++++----------- tests/test_spells.py | 11 ++++++++--- 6 files changed, 29 insertions(+), 21 deletions(-) diff --git a/data/spells/bears_endurance.json b/data/spells/bears_endurance.json index c5c8c12..a060d85 100644 --- a/data/spells/bears_endurance.json +++ b/data/spells/bears_endurance.json @@ -16,5 +16,5 @@ "duration_rounds": 60 } ], - "description": "The subject gains a +4 enhancement bonus to Constitution, granting +2 Fortitude and +2 hit points (approximated as +2/+2). Fortitude negates (harmless). Duration 10 min/level approximated as 60 rounds." + "description": "The subject gains a +4 enhancement bonus to Constitution, granting +2 to Fortitude saves and +2 hit points per Hit Die (SRD bear's endurance; the hp modifier is informational — the engine has no per-HD hp pool). Will negates (harmless). Duration 10 min/level approximated as 60 rounds." } diff --git a/data/spells/bulls_strength.json b/data/spells/bulls_strength.json index 0367b77..0b6c161 100644 --- a/data/spells/bulls_strength.json +++ b/data/spells/bulls_strength.json @@ -10,11 +10,12 @@ { "type": "buff", "modifiers": [ - { "target": "attack", "value": 1, "bonus_type": "enhancement" }, - { "target": "damage", "value": 1, "bonus_type": "enhancement" } + { "target": "attack", "value": 2, "bonus_type": "enhancement" }, + { "target": "damage", "value": 2, "bonus_type": "enhancement" }, + { "target": "cmb", "value": 2, "bonus_type": "enhancement" } ], "duration_rounds": 60 } ], - "description": "The subject gains a +4 enhancement bonus to Strength, granting +2 attack and +2 damage (approximated as +1/+1). Fortitude negates (harmless)." + "description": "The subject gains a +4 enhancement bonus to Strength, granting +2 to melee attack rolls, melee damage rolls, and combat maneuver checks (SRD bull's strength). Will negates (harmless)." } diff --git a/data/spells/shield.json b/data/spells/shield.json index fe8e1cd..597634e 100644 --- a/data/spells/shield.json +++ b/data/spells/shield.json @@ -10,10 +10,10 @@ { "type": "buff", "modifiers": [ - { "target": "ac", "value": 4, "bonus_type": "deflection" } + { "target": "ac", "value": 4, "bonus_type": "shield" } ], "duration_rounds": 10 } ], - "description": "An invisible shield of force hovers in front of you, granting a +4 shield bonus to AC for 1 min./level (modeled as 10 rounds; the shield bonus type is approximated as deflection since the engine has no shield bonus type). Negating magic missile and the ranged-attack deflection are not modeled." + "description": "An invisible shield of force hovers in front of you, granting a +4 shield bonus to AC for 1 min./level (modeled as 10 rounds). Negating magic missile and the ranged-attack deflection are not modeled." } diff --git a/src/pf1e_simulator/effects.py b/src/pf1e_simulator/effects.py index 992e7ea..c28fd57 100644 --- a/src/pf1e_simulator/effects.py +++ b/src/pf1e_simulator/effects.py @@ -3,7 +3,8 @@ PF1e bonus types determine whether multiple bonuses to the same stat stack: - Stacking types (dodge, racial, trait, untyped/"") all sum together. - Non-stacking types (morale, sacred, profane, competence, insight, luck, - enhancement, deflection, natural) keep only the highest value per type. + enhancement, deflection, natural, shield) keep only the highest value per + type. - Penalties follow the same rules: same-type penalties don't stack (the least severe — i.e. the max value — is kept). @@ -36,6 +37,7 @@ BonusType = Literal[ "enhancement", # no stack "deflection", # no stack "natural", # no stack + "shield", # no stack "circumstance", # no stack (simulator can't distinguish circumstances) ] diff --git a/tests/test_pc_combat_golden.py b/tests/test_pc_combat_golden.py index a2367fb..c2d2770 100644 --- a/tests/test_pc_combat_golden.py +++ b/tests/test_pc_combat_golden.py @@ -64,28 +64,28 @@ class TestGoldenCombats: assert result.transcript == ( "initiative: tammara d20=15+7=22", "initiative: goblin d20=10+6=16", - ("round 1 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) " + ("round 1 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 3 mods (60r) " "(36->36)"), "round 1 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - ("round 2 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) " + ("round 2 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 3 mods (60r) " "(36->36)"), "round 2 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - ("round 3 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) " + ("round 3 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 3 mods (60r) " "(36->36)"), "round 3 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - "round 4 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=14 AC 16 -> MISS", + "round 4 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=15 AC 16 -> MISS", "round 4 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - "round 5 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=14 AC 16 -> MISS", + "round 5 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=15 AC 16 -> MISS", "round 5 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - "round 6 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=14 AC 16 -> MISS", + "round 6 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=15 AC 16 -> MISS", "round 6 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - "round 7 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=14 AC 16 -> MISS", + "round 7 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=15 AC 16 -> MISS", "round 7 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - "round 8 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=14 AC 16 -> MISS", + "round 8 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=15 AC 16 -> MISS", "round 8 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - "round 9 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=14 AC 16 -> MISS", + "round 9 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=15 AC 16 -> MISS", "round 9 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - ("round 10 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=14 AC 16 " + ("round 10 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=15 AC 16 " "-> MISS"), "round 10 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", "battle over: draw after 10 rounds", @@ -106,7 +106,7 @@ class TestGoldenCombats: assert result.transcript == ( "initiative: tammara d20=15+7=22", "initiative: lich d20=10+6=16", - ("round 1 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) " + ("round 1 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 3 mods (60r) " "(36->36)"), "round 1 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 43 damage (36->-7)", "battle over: monsters win in 1 rounds", diff --git a/tests/test_spells.py b/tests/test_spells.py index ae18a79..209611c 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -193,7 +193,9 @@ class TestSpellLoading: spell = load_spell(_SPELLS_DIR / "bulls_strength.json") eff = spell.effects[0] assert isinstance(eff, BuffEffect) - assert len(eff.modifiers) == 2 + # SRD bull's strength: +4 enhancement Str -> +2 melee attack, +2 melee + # damage, +2 CMB (three modifiers). + assert len(eff.modifiers) == 3 def test_summon_monster_casts_for_one_round(self) -> None: # PF1e: Summon Monster I/II have a 1-round casting time. @@ -616,10 +618,13 @@ class TestBullsStrength: buff_mods = [m for b in t.active_buffs for m in b.modifiers] atk_mods = [e for e in buff_mods if e.target == "attack"] dmg_mods = [e for e in buff_mods if e.target == "damage"] + cmb_mods = [e for e in buff_mods if e.target == "cmb"] assert len(atk_mods) == 1 assert len(dmg_mods) == 1 - assert atk_mods[0].value == 1 - assert dmg_mods[0].value == 1 + assert len(cmb_mods) == 1 + assert atk_mods[0].value == 2 + assert dmg_mods[0].value == 2 + assert cmb_mods[0].value == 2 def test_transcript_shows_buff(self) -> None: caster = _make_caster()