Exacting Strike#
The question we want to answer#
“I’m a level 5 fighter with a +1 Striking Maul. What’s my average damage? Is Exacting Strike a good feat?”
Let’s simulate striking 3 times in a round, against a target that is not off-guard. On your second strike, you may use Exacting Strike. If you do, the outcome changes the MAP on your third strike. Every time you roll a critical hit, the target needs to pass a fortitude save or be knocked prone. If they’re knocked prone, they become flat-footed to the following attacks, and they will have to stand on the next round, provoking a Reactive Strike.
# Install in JupyterLite
%pip install -q pathfinder2e-stats
import matplotlib as mpl # noqa: F401 # Needed by JupyterLite
import xarray
import pathfinder2e_stats as pf2
Note: you may need to restart the kernel to use updated packages.
Targets#
Let’s define three targets using Building Creatures from the GM Core:
an extra that’s 2 levels below us, low AC, and low fortitude
a monster at our same level, medium AC, and medium fortitude
a boss that’s 2 levels above us, high AC, and high fortitude
targets = (
pf2.tables.SIMPLE_NPC[["AC", "saving_throws"]]
.rename({"saving_throws": "fortitude"})
.sel(level=5, drop=True)
)
targets.to_pandas()
| AC | fortitude | |
|---|---|---|
| challenge | ||
| Weak | 16 | 6 |
| Matched | 21 | 12 |
| Boss | 25 | 18 |
As this is a what-if analysis, roll a single d20 and compare it against different targets, with and without exacting strike.
We don’t want to repeat dependent_dims=["challenge"] for every call of check() and damage(); so we’re going to set it as thread-wide configuration. Later on we’ll add dimension exacting_strike which needs the same treatment.
pf2.set_config(
check_dependent_dims=["challenge", "exacting_strike"],
damage_dependent_dims=["challenge", "exacting_strike"],
)
Attacker#
Then we define our own stats.
Note: the same way we defined multiple targets using an xarray.Dataset, we could have multiple attackers, for example a fighter vs. a barbarian.
level = 5
attack_bonus = (
pf2.tables.SIMPLE_PC.weapon_attack_bonus.fighter.sum("component")
.sel(mastery=True, category="martial", level=level, drop=True)
.item()
)
class_DC = (
pf2.tables.SIMPLE_PC.class_DC.fighter.sum("component").sel(level=level).item()
)
print(f"{attack_bonus=}\n{class_DC=}")
attack_bonus=16
class_DC=21
damage_dice = pf2.tables.PC.weapon_dice.striking_rune.sel(level=level).item()
damage_bonus = (
(
pf2.tables.PC.ability_bonus.boosts.sel(initial=4)
+ pf2.tables.PC.weapon_specialization.fighter.sel(mastery=True)
)
.sel(level=level)
.item()
)
damage_spec = pf2.armory.pathfinder.melee.maul(
damage_dice, damage_bonus
) + pf2.armory.runes.auto(level=level)
damage_spec
First strike#
strike1 = pf2.damage(
# This is a what-if analysis of the same attack against multiple targets,
# so we'll roll attack and damage only once and compare it against the
# different ACs.
pf2.check(attack_bonus, DC=targets.AC),
damage_spec,
)
strike1
<xarray.Dataset> Size: 8MB
Dimensions: (challenge: 3, roll: 100000, damage_type: 1)
Coordinates:
* challenge (challenge) object 24B 'Weak' 'Matched' 'Boss'
* damage_type (damage_type) <U11 44B 'bludgeoning'
Dimensions without coordinates: roll
Data variables:
bonus int64 8B 16
DC (challenge) int64 24B 16 21 25
natural (roll) int64 800kB 18 13 11 6 7 1 2 1 ... 4 15 3 14 13 1 1 4
outcome (roll, challenge) int64 2MB 2 2 1 2 1 1 2 ... 0 -1 -1 1 0 0
direct_damage (roll, challenge, damage_type) int64 2MB 34 34 17 ... 18 0 0
total_damage (roll, challenge) int64 2MB 34 34 17 36 18 18 ... 0 0 18 0 0
Attributes:
legend: {-2: 'No roll', -1: 'Critical failure', 0: 'Failure', 1: 'S...
damage_spec: {'Critical success': '(2d12+4)x2 bludgeoning', 'Success': '...The target must roll a Fortitude save after every critical hit or be knocked prone. Let’s pre-roll them in advance as the bonus and DC don’t change.
fort_saves = pf2.check(
bonus=targets.fortitude,
DC=class_DC,
allow_critical_success=False,
allow_critical_failure=False,
# Rerun the random number generator 4 times for 4 strikes
# (but all targets use the same roll; see config setting for dependent_dims above)
independent_dims={"strike": 4},
)
fort_saves.coords["strike"] = [1, 2, 3, "reactive"]
knocked_prone = fort_saves.outcome == pf2.DoS.failure
Visualize the outcome of failing the fortitude save, and consequently getting knocked prone, on a critical hit. Most of these saving throws won’t actually be rolled as we haven’t filtered by attack outcome yet. Note how they’re rolled once for the three challenges (with progressively improving outcomes depending on the different Fortitude bonuses of the three targets), but are rolled independently for each of the 4 strikes and each of the 100,000 rolls.
knocked_prone.sel(roll=slice(10)).stack(col=["strike", "challenge"]).to_pandas()
| strike | 1 | 2 | 3 | reactive | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| challenge | Weak | Matched | Boss | Weak | Matched | Boss | Weak | Matched | Boss | Weak | Matched | Boss |
| roll | ||||||||||||
| 0 | True | False | False | True | True | False | True | True | False | True | True | True |
| 1 | True | False | False | False | False | False | False | False | False | True | True | False |
| 2 | True | True | False | True | True | False | False | False | False | False | False | False |
| 3 | False | False | False | True | True | False | True | True | False | True | False | False |
| 4 | False | False | False | False | False | False | True | False | False | True | False | False |
| 5 | False | False | False | True | True | False | True | False | False | True | False | False |
| 6 | True | True | True | True | False | False | True | True | False | False | False | False |
| 7 | True | True | False | False | False | False | True | False | False | True | True | False |
| 8 | False | False | False | False | False | False | True | True | False | False | False | False |
| 9 | True | True | False | True | True | False | False | False | False | True | False | False |
Calculate the chance of being knocked prone by the first strike
knocked_prone_1 = knocked_prone.sel(strike="1", drop=True).where(
strike1.outcome == pf2.DoS.critical_success, False
)
knocked_prone_1.mean("roll").round(2).to_pandas()
challenge
Weak 0.38
Matched 0.12
Boss 0.01
dtype: float64
Second strike#
The AC changes depending if the target has been knocked prone by the first strike or not.
strike2 = pf2.damage(
pf2.check(attack_bonus - 5, DC=targets.AC - knocked_prone_1 * 2),
damage_spec,
)
What is the chance of being knocked prone by the first or the second strike?
knocked_prone_2 = (
knocked_prone.sel(strike="2", drop=True).where(
strike2.outcome == pf2.DoS.critical_success, False
)
) | knocked_prone_1
knocked_prone_2.mean("roll").to_pandas().to_frame("%") * 100.0
| % | |
|---|---|
| challenge | |
| Weak | 51.316 |
| Matched | 13.754 |
| Boss | 1.499 |
Third strike#
We want to investigate the benefit of Exacting Strike. So, from now on we’re going to calculate everything twice, with and without the feat. But we’re only going to roll once.
MAP3 = xarray.concat(
[xarray.DataArray(10), xarray.where(strike2.outcome == pf2.DoS.failure, 5, 10)],
dim="exacting_strike",
)
MAP3.coords["exacting_strike"] = [False, True]
strike3 = pf2.damage(
pf2.check(
attack_bonus - MAP3,
DC=targets.AC - knocked_prone_2 * 2,
),
damage_spec,
)
The overall attack bonus of the third strike is 16-5=11 or 16-10=6 as a function of the outcome of the second strike, so it has dims=(roll, challenge, exacting_strike) instead of being a scalar 16-5=11.
strike3.bonus.isel(roll=slice(10)).stack(
col=["exacting_strike", "challenge"]
).to_pandas()
| exacting_strike | False | True | ||||
|---|---|---|---|---|---|---|
| challenge | Weak | Matched | Boss | Weak | Matched | Boss |
| roll | ||||||
| 0 | 6 | 6 | 6 | 6 | 6 | 11 |
| 1 | 6 | 6 | 6 | 6 | 11 | 6 |
| 2 | 6 | 6 | 6 | 6 | 11 | 11 |
| 3 | 6 | 6 | 6 | 6 | 6 | 11 |
| 4 | 6 | 6 | 6 | 6 | 6 | 11 |
| 5 | 6 | 6 | 6 | 6 | 11 | 11 |
| 6 | 6 | 6 | 6 | 6 | 11 | 11 |
| 7 | 6 | 6 | 6 | 6 | 6 | 6 |
| 8 | 6 | 6 | 6 | 6 | 6 | 6 |
| 9 | 6 | 6 | 6 | 6 | 6 | 11 |
What is the chance that the target will be prone by the end of the round?
knocked_prone_3 = (
knocked_prone.sel(strike="3", drop=True).where(
strike3.outcome == pf2.DoS.critical_success, False
)
) | knocked_prone_2
knocked_prone_3.mean("roll").round(2).to_pandas()
| exacting_strike | False | True |
|---|---|---|
| challenge | ||
| Weak | 0.53 | 0.55 |
| Matched | 0.15 | 0.15 |
| Boss | 0.02 | 0.02 |
And finally the reactive strike, which happens only if the target is prone by the end of the round.
# Reactive Strike does not benefit from prone
reactive_strike_check = pf2.check(attack_bonus, DC=targets.AC)
# Don't roll reactive strike unless the target is prone
reactive_strike_check["outcome"] = reactive_strike_check.outcome.where(
knocked_prone_3, pf2.DoS.no_roll
)
reactive_strike = pf2.damage(reactive_strike_check, damage_spec)
knocked_prone_by_reactive_strike = knocked_prone.sel(
strike="reactive", drop=True
).where(reactive_strike.outcome == pf2.DoS.critical_success, False)
What is the chance that the enemy will be knocked prone by one of the three Strikes, stand up, and then get knocked prone again by Reactive Strike?
knocked_prone_by_reactive_strike.mean("roll").to_pandas() * 100.0
| exacting_strike | False | True |
|---|---|---|
| challenge | ||
| Weak | 20.415 | 21.038 |
| Matched | 1.856 | 1.856 |
| Boss | 0.019 | 0.019 |
We’re done! Let’s assemble our aggregated object.
all_strikes = xarray.concat([strike1, strike2, strike3, reactive_strike], dim="strike")
all_strikes.coords["strike"] = fort_saves.strike
all_strikes["prone_at_end_of_round"] = knocked_prone_3
all_strikes["prone_on_reactive_strike"] = knocked_prone_by_reactive_strike
all_strikes
<xarray.Dataset> Size: 91MB
Dimensions: (strike: 4, exacting_strike: 2, roll: 100000,
challenge: 3, damage_type: 1)
Coordinates:
* strike (strike) <U21 336B '1' '2' '3' 'reactive'
* exacting_strike (exacting_strike) bool 2B False True
* challenge (challenge) object 24B 'Weak' 'Matched' 'Boss'
* damage_type (damage_type) <U11 44B 'bludgeoning'
Dimensions without coordinates: roll
Data variables:
bonus (strike, exacting_strike, roll, challenge) int64 19MB ...
DC (strike, challenge, roll) int64 10MB 16 16 ... 25
natural (strike, roll) int64 3MB 18 13 11 6 ... 18 19 18
outcome (strike, roll, challenge, exacting_strike) int64 19MB ...
direct_damage (strike, roll, challenge, damage_type, exacting_strike) int64 19MB ...
total_damage (strike, roll, challenge, exacting_strike) int64 19MB ...
prone_at_end_of_round (roll, challenge, exacting_strike) bool 600kB T...
prone_on_reactive_strike (roll, challenge, exacting_strike) bool 600kB F...
Attributes:
legend: {-2: 'No roll', -1: 'Critical failure', 0: 'Failure', 1: 'S...
damage_spec: {'Critical success': '(2d12+4)x2 bludgeoning', 'Success': '...We can finally aggregate our measures to gather insights.
agg_measures = all_strikes.sum("strike").mean("roll")
agg_measures["any_damage"] = (all_strikes["total_damage"].sum("strike") > 0).mean(
"roll"
)
agg_measures = agg_measures[
["total_damage", "any_damage", "prone_at_end_of_round", "prone_on_reactive_strike"]
]
agg_measures.stack(idx=["challenge", "exacting_strike"]).to_array("measure").round(
2
).T.to_pandas()
| measure | total_damage | any_damage | prone_at_end_of_round | prone_on_reactive_strike | |
|---|---|---|---|---|---|
| challenge | exacting_strike | ||||
| Weak | False | 70.99 | 1.00 | 0.53 | 0.20 |
| True | 72.32 | 1.00 | 0.55 | 0.21 | |
| Matched | False | 38.34 | 0.94 | 0.15 | 0.02 |
| True | 40.06 | 0.96 | 0.15 | 0.02 | |
| Boss | False | 21.57 | 0.76 | 0.02 | 0.00 |
| True | 23.45 | 0.81 | 0.02 | 0.00 |
What’s the % benefit of exacting strike compared to three regular strikes?
(
agg_measures.sel(exacting_strike=True) / agg_measures.sel(exacting_strike=False)
).to_pandas()
| total_damage | any_damage | prone_at_end_of_round | prone_on_reactive_strike | |
|---|---|---|---|---|
| challenge | ||||
| Weak | 1.018750 | 1.002039 | 1.028557 | 1.030517 |
| Matched | 1.044832 | 1.021580 | 1.000000 | 1.000000 |
| Boss | 1.087485 | 1.058450 | 1.000000 | 1.000000 |
Conclusions#
The answer to the original question, is Exacting Strike a good feat? is that it’s quite inconsequential against weak enemies but, if you start your round in striking range of a boss and you’ve got nothing better to do with your third action, it will yield a solid 9% damage boost on average and will let you deal some damage 6% more frequently.
For all but the weakest enemies it’s inconsequential for the purpose of triggering special abilities that go off on critical hits, like the hammers critical specialization: you’d need to crit on a 19 on the die or less, while at MAP-5, for it to matter.
What’s the damage distribution?
total_damage = all_strikes["total_damage"].sum("strike")
means = total_damage.mean("roll").to_pandas()
stds = total_damage.std("roll").to_pandas()
_ = means.plot.barh(
xerr=stds,
title="Damage over 3 strikes, with and without Exacting Strike: mean+stddev",
)
Homework#
How does Exacting Strike perform compared to Vicious Swing?
What’s better, a sword (off-guard on a crit, no save) or a hammer (prone on a crit and trigger Reactive Strike, but with save)?
How does a two-hander (e.g. maul) perform compared to two one-handers (e.g. warhammer and light hammer) with Double Slice?
What’s the damage distribution of a barbarian vs. that of a fighter?
How much extra damage, on average, does a +1 to hit (or a -1 to AC) yield?
Last words#
In real play, circumstance is everything. For example, Exacting Strike is worthless when you have to spend one action moving into position (at least until you start getting Quickened with some consistency). Knocking a target prone is much more valuable if there are multiple martials with Reactive Strike, Stand Still, or similar feats in the party. Making a target off-guard is a lot more valuable if there’s a rogue in party; etc. etc.