Add injury to insult#
A case study of murdering someone with the right choice of words.
# Install in JupyterLite
%pip install -q pathfinder2e-stats
import matplotlib as mpl # noqa: F401 # Needed by JupyterLite
import numpy as np
import xarray
import pathfinder2e_stats as pf2
Note: you may need to restart the kernel to use updated packages.
Attacker#
Nyah, level 5 witch (The Resentment) Skills Diplomacy +14 (Bon Mot)
Occult Spells DC 21; 3rd Paralyze, Biting Words; 2nd Blistering Invective; 1st Sure Strike; Cantrips (3rd) Evil Eye, Guidance
Attack routine#
Bon Mot ➡ Blistering Invective
Paralyze ➡ Evil Eye
Evil Eye ➡ Biting Words
Evil Eye ➡ Sure Strike ➡ Biting Words attack
Evil Eye ➡ Guidance ➡ Biting Words attack
Assumptions#
The target attempts to clear neither Bon Mot nor Sickened
No movement is needed; the target remains within 30ft at all times
Spellcasting is not disrupted or obstructed in any way
Ignoring damage dealt to other creatures by casting heightened blistering invective
level = 5
diplomacy = (
(
pf2.tables.PC.level
+ pf2.tables.PC.skill_proficiency.others.sel(priority=1)
+ pf2.tables.PC.ability_bonus.boosts.sel(initial=3)
+ pf2.tables.PC.skill_item_bonus.diplomacy
)
.sel(level=level)
.item()
)
spell_DC = pf2.tables.SIMPLE_PC.spell_DC.witch.sum("component").sel(level=level).item()
print(f"{diplomacy=} {spell_DC=}")
diplomacy=14 spell_DC=21
# You can change any of these to upcast or downcast them;
# damage and incapacitation trait are adjusted automatically
blistering_invective_rank = 2
paralyze_rank = 3
biting_words_rank = 3
Targets#
targets = xarray.Dataset(
{
"target": [
"The Stag Lord",
"Ettin",
"Vampire Count",
"Hill Giant",
"Dweomercat",
"Sphinx",
],
"level": ("target", [6, 6, 6, 7, 7, 8]),
"HP": ("target", [110, 110, 65, 140, 100, 135]),
"AC": ("target", [23, 21, 24, 24, 25, 27]),
"Will": ("target", [9, 12, 17, 13, 17, 19]),
"bonus_save_vs_magic": ("target", [0, 0, 0, 0, 1, 0]),
"sickened": ("target", [1, 0, 0, 0, 0, 0]),
}
)
targets["rank"] = pf2.level2rank(targets.level)
targets.to_pandas()
| level | HP | AC | Will | bonus_save_vs_magic | sickened | rank | |
|---|---|---|---|---|---|---|---|
| target | |||||||
| The Stag Lord | 6 | 110 | 23 | 9 | 0 | 1 | 3 |
| Ettin | 6 | 110 | 21 | 12 | 0 | 0 | 3 |
| Vampire Count | 6 | 65 | 24 | 17 | 0 | 0 | 3 |
| Hill Giant | 7 | 140 | 24 | 13 | 0 | 0 | 4 |
| Dweomercat | 7 | 100 | 25 | 17 | 1 | 0 | 4 |
| Sphinx | 8 | 135 | 27 | 19 | 0 | 0 | 4 |
As this is a what-if analysis, roll a single d20 and compare it against different targets.
We don’t want to repeat dependent_dims=["target"] for every call of check() and damage(); so we’re going to set it as thread-wide configuration. As we’re going to analyse multiple rounds and we’re going to use a round independent dimension later, let’s take the opportunity to configure it now too.
pf2.set_config(
check_independent_dims=["round"],
check_dependent_dims=["target"],
damage_independent_dims=["round"],
damage_dependent_dims=["target"],
)
Attack routine#
Round 1: Bon Mot ➡ Blistering Invective#
bon_mot = pf2.check(
diplomacy,
DC=targets.Will + 10 - targets.sickened,
)
bon_mot["Will_penalty"] = pf2.map_outcome(
bon_mot.outcome,
{pf2.DoS.success: 2, pf2.DoS.critical_success: 3},
)
sickened = [targets.sickened]
will = [
pf2.sum_bonuses(
("untyped", targets.Will),
("status", targets.bonus_save_vs_magic),
("status", -targets.sickened),
("status", -bon_mot.Will_penalty),
)
]
blistering_invective_spec = pf2.armory.spells.blistering_invective(
blistering_invective_rank
)
blistering_invective_spec
blistering_invective = pf2.damage(
pf2.check(will[0], DC=spell_DC),
blistering_invective_spec,
persistent_damage_rounds=5,
).rename({"persistent_round": "round"})
blistering_invective_damage = (
blistering_invective["persistent_damage"]
.where(blistering_invective["apply_persistent_damage"], 0)
.sum("damage_type")
)
Probability of being on fire, by target by round
(blistering_invective_damage > 0).mean("roll").round(2).to_pandas()
| round | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| target | |||||
| The Stag Lord | 0.95 | 0.66 | 0.46 | 0.32 | 0.23 |
| Ettin | 0.93 | 0.65 | 0.46 | 0.32 | 0.22 |
| Vampire Count | 0.69 | 0.48 | 0.34 | 0.24 | 0.17 |
| Hill Giant | 0.91 | 0.64 | 0.45 | 0.31 | 0.22 |
| Dweomercat | 0.64 | 0.45 | 0.31 | 0.22 | 0.15 |
| Sphinx | 0.58 | 0.41 | 0.28 | 0.20 | 0.14 |
Mean damage of Blistering Invective every round (assuming no actions are spent putting the fire out)
blistering_invective_damage.mean("roll").round(2).to_pandas()
| round | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| target | |||||
| The Stag Lord | 7.03 | 4.93 | 3.42 | 2.40 | 1.68 |
| Ettin | 5.20 | 3.63 | 2.56 | 1.79 | 1.25 |
| Vampire Count | 3.34 | 2.32 | 1.63 | 1.14 | 0.80 |
| Hill Giant | 4.87 | 3.40 | 2.38 | 1.66 | 1.16 |
| Dweomercat | 2.98 | 2.08 | 1.46 | 1.01 | 0.71 |
| Sphinx | 2.55 | 1.78 | 1.25 | 0.87 | 0.61 |
frightened = pf2.map_outcome(
blistering_invective["outcome"],
{pf2.DoS.failure: 1, pf2.DoS.critical_failure: 2},
)
# The frightened condition decays with every round that passes
frightened = np.maximum(0, frightened - blistering_invective["round"])
roll_with_high_frightened = np.unique(
frightened,
return_index=True,
axis=frightened.dims.index("roll"),
)[1][-2]
frightened.isel(roll=roll_with_high_frightened).to_pandas()
| round | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| target | |||||
| The Stag Lord | 2 | 1 | 0 | 0 | 0 |
| Ettin | 2 | 1 | 0 | 0 | 0 |
| Vampire Count | 1 | 0 | 0 | 0 | 0 |
| Hill Giant | 1 | 0 | 0 | 0 | 0 |
| Dweomercat | 1 | 0 | 0 | 0 | 0 |
| Sphinx | 1 | 0 | 0 | 0 | 0 |
Round 2: Paralyze ➡ Evil Eye#
will.append(
pf2.sum_bonuses(
("untyped", targets.Will),
("status", targets.bonus_save_vs_magic),
("status", -sickened[-1]),
("status", -bon_mot.Will_penalty),
("status", -frightened.isel(round=1, drop=True)),
)
)
paralyze = pf2.check(
bonus=will[-1],
DC=spell_DC,
incapacitation=targets["rank"] > paralyze_rank,
)
# In case of failure, we use Evil Eye to extend the paralysis for the whole combat
paralyze["need_evil_eye"] = paralyze.outcome <= pf2.DoS.failure
paralyze["off_guard"] = paralyze.outcome <= pf2.DoS.failure
Probability of the target being paralyzed, as well as of needing to cast Evil Eye every round in order to maintain the condition for the whole fight.
FIXME: a critical failure on the initial saving throw followed by a critical success on any of the consecutive rounds will cause the target to snap out of paralysis early. This is not modelled here yet.
paralyze["need_evil_eye"].mean("roll").round(2).to_pandas().to_frame("% paralyzed")
| % paralyzed | |
|---|---|
| target | |
| The Stag Lord | 0.66 |
| Ettin | 0.47 |
| Vampire Count | 0.20 |
| Hill Giant | 0.05 |
| Dweomercat | 0.05 |
| Sphinx | 0.05 |
def evil_eye(will_bonus, spell_DC):
c = pf2.check(will_bonus, DC=spell_DC).outcome
return pf2.map_outcome(c, {pf2.DoS.critical_failure: 2, pf2.DoS.failure: 1})
sickened.append(np.maximum(sickened[-1], evil_eye(will[-1], spell_DC)))
Round 3: Evil Eye ➡ Biting Words#
If the target scored a simple failure vs. Paralyze in round 2, extend its duration with Evil Eye. Then, cast Biting Words.
Round 4: Evil Eye ➡ Sure Strike ➡ Biting Words attack#
Round 5: Evil Eye ➡ Guidance ➡ Biting Words attack#
for _round in range(2, 5):
will.append(
pf2.sum_bonuses(
("untyped", targets.Will),
("status", targets.bonus_save_vs_magic),
("status", -sickened[-1]),
("status", -bon_mot.Will_penalty),
)
)
sickened.append(np.maximum(sickened[-1], evil_eye(will[-1], spell_DC)))
assert len(will) == 5
assert len(sickened) == 5
will = xarray.concat(will, dim="round")
sickened = xarray.concat(sickened, dim="round")
sure_strike = xarray.DataArray([False, False, False, True, False], dims=["round"])
guidance = xarray.DataArray([False, False, False, False, True], dims=["round"])
Mean Will saves debuff by target and round
(will - targets.Will).mean("roll").round(2).T.to_pandas()
| round | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| target | |||||
| The Stag Lord | -2.20 | -2.20 | -2.22 | -2.24 | -2.26 |
| Ettin | -1.45 | -1.47 | -1.61 | -1.71 | -1.77 |
| Vampire Count | -0.85 | -0.88 | -0.97 | -1.08 | -1.17 |
| Hill Giant | -1.30 | -1.32 | -1.46 | -1.57 | -1.65 |
| Dweomercat | 0.15 | 0.12 | 0.06 | -0.02 | -0.10 |
| Sphinx | -0.65 | -0.68 | -0.72 | -0.79 | -0.85 |
off_guard = xarray.concat(
[
xarray.DataArray(False),
paralyze.off_guard.expand_dims(round=4),
],
dim="round",
)
AC = pf2.sum_bonuses(
("untyped", targets.AC),
("status", -frightened),
("status", -sickened),
("circumstance", off_guard.astype(int) * -2),
)
Mean Armor Class debuff by target and round
(AC - targets.AC).mean("roll").round(2).sel(drop=True).to_pandas()
| round | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| target | |||||
| The Stag Lord | -1.21 | -2.53 | -2.70 | -2.83 | -2.93 |
| Ettin | -0.53 | -1.51 | -1.78 | -1.96 | -2.08 |
| Vampire Count | -0.24 | -0.67 | -0.84 | -1.00 | -1.14 |
| Hill Giant | -0.46 | -0.59 | -0.85 | -1.04 | -1.16 |
| Dweomercat | -0.19 | -0.34 | -0.46 | -0.61 | -0.74 |
| Sphinx | -0.13 | -0.28 | -0.36 | -0.47 | -0.57 |
In round 3, we use Sure Strike only if we don’t need to extend the duration of paralyze. In round 4 and 5, we always use Sure Strike.
biting_words_check = pf2.check(
spell_DC - 10 + guidance,
DC=AC,
fortune=sure_strike,
)
biting_words_check["outcome"] = biting_words_check["outcome"].where(
AC["round"] >= 2, pf2.DoS.no_roll
)
biting_words_damage = pf2.damage(
biting_words_check,
pf2.armory.spells.biting_words(biting_words_rank),
).total_damage
Mean damage of Biting Words by target and round
(biting_words_damage.mean("roll").round(2).to_pandas())
| target | The Stag Lord | Ettin | Vampire Count | Hill Giant | Dweomercat | Sphinx |
|---|---|---|---|---|---|---|
| round | ||||||
| 0 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 1 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 2 | 14.32 | 16.36 | 10.37 | 10.36 | 8.91 | 6.70 |
| 3 | 21.42 | 24.05 | 16.74 | 16.78 | 14.99 | 11.93 |
| 4 | 16.67 | 19.03 | 11.90 | 11.83 | 10.29 | 7.97 |
Put it all together#
final = xarray.Dataset(
{
"AC": AC,
"Will": will,
"off_guard": paralyze.off_guard,
"need_evil_eye": paralyze.need_evil_eye,
"blistering_invective": blistering_invective_damage,
"biting_words": biting_words_damage,
"total_damage": blistering_invective_damage + biting_words_damage,
}
).transpose("target", "roll", "round")
final["harmed"] = final.total_damage.sum("round") > 0
final["bloodied"] = final.total_damage.sum("round") > targets.HP // 2
final["killed"] = final.total_damage.sum("round") >= targets.HP
final
<xarray.Dataset> Size: 123MB
Dimensions: (target: 6, roll: 100000, round: 5)
Coordinates:
* target (target) <U13 312B 'The Stag Lord' ... 'Sphinx'
Dimensions without coordinates: roll, round
Data variables:
AC (target, roll, round) int64 24MB 22 20 20 ... 27 27 27
Will (target, roll, round) int64 24MB 6 6 6 6 ... 19 19 19
off_guard (target, roll) bool 600kB True False ... False False
need_evil_eye (target, roll) bool 600kB True False ... False False
blistering_invective (target, roll, round) int64 24MB 3 7 5 7 ... 4 2 3 2
biting_words (target, roll, round) int64 24MB 0 0 0 32 ... 0 18 27
total_damage (target, roll, round) int64 24MB 3 7 5 39 ... 2 21 29
harmed (target, roll) bool 600kB True True True ... True True
bloodied (target, roll) bool 600kB True False ... False False
killed (target, roll) bool 600kB False False ... False FalseLet’s analyse our results!#
Mean cumulative damage by the end of the attack routine#
(
final[["blistering_invective", "biting_words", "total_damage"]]
.mean("roll")
.sum("round")
.round(2)
.to_pandas()
)
| blistering_invective | biting_words | total_damage | |
|---|---|---|---|
| target | |||
| The Stag Lord | 19.48 | 52.41 | 71.89 |
| Ettin | 14.43 | 59.43 | 73.87 |
| Vampire Count | 9.22 | 39.02 | 48.24 |
| Hill Giant | 13.47 | 38.97 | 52.44 |
| Dweomercat | 8.23 | 34.19 | 42.42 |
| Sphinx | 7.06 | 26.60 | 33.66 |
Various probabilities#
Probability of dealing any HP damage at all
Probability of dealing more than 50% HP damage
Probability of solo killing the target
Probability of paralyzing the target in round 2
Probability of needing to spam evil eye every round to keep the target paralyzed
(
final[["harmed", "bloodied", "killed", "off_guard", "need_evil_eye"]]
.mean("roll")
.round(2)
.to_pandas()
)
| harmed | bloodied | killed | off_guard | need_evil_eye | |
|---|---|---|---|---|---|
| target | |||||
| The Stag Lord | 1.00 | 0.68 | 0.12 | 0.66 | 0.66 |
| Ettin | 1.00 | 0.72 | 0.12 | 0.47 | 0.47 |
| Vampire Count | 0.97 | 0.71 | 0.25 | 0.20 | 0.20 |
| Hill Giant | 0.99 | 0.23 | 0.00 | 0.05 | 0.05 |
| Dweomercat | 0.95 | 0.34 | 0.03 | 0.05 | 0.05 |
| Sphinx | 0.89 | 0.10 | 0.00 | 0.05 | 0.05 |
Damage distribution, normalized by target’s Hit Points total#
_ = (
(final["total_damage"].sum("round") / targets.HP)
.to_pandas()
.T.describe()
.T.plot(
kind="barh",
y="mean",
xerr="std",
legend=False,
title="Damage as % of total HP after round 5; mean+stddev",
)
)