Getting Started#
In this tutorial, we’ll learn how to simulate probabilities with pathfinder2e_stats.
To follow this tutorial, you’ll need to have at least a basic understanding of
The Pathfinder rules, and
Data science workflows, e.g. based on Python + pandas + Jupyter notebooks. See Intended audience.
If you don’t have your Jupyter Notebook development environment ready yet, go back to Installation.
Rolling some dice#
Let’s start simple - let’s import the module and roll a d6.
We’re going to roll it one hundred thousand times.
import pathfinder2e_stats as pf2
oned6 = pf2.roll(1, 6)
oned6
<xarray.DataArray (roll: 100000)> Size: 800kB array([6, 4, 4, ..., 1, 1, 2], shape=(100000,)) Dimensions without coordinates: roll
pathfinder2e_stats functions return standard xarray.DataArray and xarray.Dataset objects, which can be analyzed with standard data science techniques. We can start immediately answering some questions - for example, what is the mean roll?
oned6.mean()
<xarray.DataArray ()> Size: 8B array(3.49798)
Note that the result above is a numerical approximation: the mean of rolling 1d6 an infinite amount of times is exactly 3.5. If we roll it less times than that, however, there’s going to be some error.
If we roll it again, we are going to get a different sequence. This is because pathfinder2e_stats uses a global random number generator, which by default is reset to a fixed seed every time you restart your notebook. See seed().
pf2.roll(1, 6)
<xarray.DataArray (roll: 100000)> Size: 800kB array([3, 4, 1, ..., 4, 3, 4], shape=(100000,)) Dimensions without coordinates: roll
For the sake of convenience, we can quickly visualize arbitrary DataArrays and Dataset with the custom accessor .display(), available for all xarray objects after importing pathfinder2e_stats:
pf2.roll(1, 6).display("1d6")
| variable | 1d6 |
|---|---|
| count | 100000.000000 |
| mean | 3.500710 |
| std | 1.703475 |
| min | 1.000000 |
| 25% | 2.000000 |
| 50% | 3.000000 |
| 75% | 5.000000 |
| max | 6.000000 |
Well, that was easy, but we could have figured out the answer by doing the maths on the back of an envelope! Let’s move on to something that is more complicated. A timeless classic: a 6d6 Fireball!
fireball = pf2.roll(6, 6)
fireball
<xarray.DataArray (roll: 100000)> Size: 800kB array([17, 22, 24, ..., 24, 17, 21], shape=(100000,)) Dimensions without coordinates: roll
What is the damage distribution? First we’re going to calculate it numerically; then we’ll visualize it with matplotlib (but we could use any other library, like plotly or hvplot).
fireball.value_counts("roll").to_pandas().to_frame("count")
| count | |
|---|---|
| unique_value | |
| 6 | 4 |
| 7 | 13 |
| 8 | 48 |
| 9 | 121 |
| 10 | 264 |
| 11 | 521 |
| 12 | 1036 |
| 13 | 1615 |
| 14 | 2400 |
| 15 | 3476 |
| 16 | 4853 |
| 17 | 6230 |
| 18 | 7440 |
| 19 | 8258 |
| 20 | 8969 |
| 21 | 9282 |
| 22 | 9135 |
| 23 | 8317 |
| 24 | 7272 |
| 25 | 6132 |
| 26 | 4859 |
| 27 | 3578 |
| 28 | 2477 |
| 29 | 1670 |
| 30 | 1005 |
| 31 | 548 |
| 32 | 292 |
| 33 | 123 |
| 34 | 46 |
| 35 | 16 |
_ = fireball.to_pandas().hist(bins=30)
But wait - that’s just the base damage! The actual damage of a fireball depends on the target’s reflex saving throw, as well as their resistances, immunities and weaknesses. pathfinder2e_stats makes dealing with all this very easy.
Rolling checks#
In Pathfinder, a check is whenever one rolls a d20+bonus against a DC; this includes attack rolls against AC.
For example, a paladin with +8 Diplomacy tries to convince a guard to let them pass. The DC is 15.
To simulate that, we call check().
request = pf2.check(8, DC=15)
request
<xarray.Dataset> Size: 2MB
Dimensions: (roll: 100000)
Dimensions without coordinates: roll
Data variables:
bonus int64 8B 8
DC int64 8B 15
natural (roll) int64 800kB 4 2 12 19 3 9 7 3 10 8 ... 19 2 12 2 3 7 11 4 1
outcome (roll) int64 800kB 0 0 1 2 0 1 1 0 1 1 1 ... 2 0 2 0 1 0 0 1 1 0 -1
Attributes:
legend: {-2: 'No roll', -1: 'Critical failure', 0: 'Failure', 1: 'Succe...The output of check() is a Dataset, which contains several variables. We normally only care about the last one, outcome. However, there are several other variables before it that explain how we reached that outcome, allowing us to fully trace its logic:
natural is the bare d20 roll, 100,000 times
outcome is the roll’s degree of success, taking into account critical success/failure rules, natural 1s and 20s.
outcome is an integer (sadly there are no categorical dtypes in xarray yet), whose meaning is mapped in the legend attribute of the dataset, as shown above. It is also available in the DoS enum. For the sake of robustness and readability, when you express an outcome (we’ll see later when and how) you should always use DoS and never its numerical value.
Have a look at the API documentation for additional parameters, such as fortune/misfortune effects to roll twice and take highest/lowest, conditionally using hero points depending on initial outcome and special rules like the Keen rune.
You can aggregate the result by using handy helpers such as outcome_counts():
# Probability to get each outcome
pf2.outcome_counts(request).to_pandas().to_frame("%") * 100.0
| % | |
|---|---|
| outcome | |
| Critical success | 20.106 |
| Success | 49.969 |
| Failure | 24.981 |
| Critical failure | 4.944 |
It’s also common to compare against DoS. Operators >, >=, <, and <= are supported:
# Probability to get at least a success
(request.outcome >= pf2.DoS.success).mean().item()
0.70075
# Probability to get a critical success
(request.outcome == pf2.DoS.critical_success).mean().item()
0.20106
Attack rolls, saving throws, counteract checks, flat checks, etc. work exactly in the same way as skill checks. For example, the party rogue can Strike a bandit (AC22) with his +14 rapier:
strike = pf2.check(14, DC=22)
pf2.outcome_counts(strike).to_pandas().to_frame("%") * 100.0
| % | |
|---|---|
| outcome | |
| Critical success | 14.846 |
| Success | 50.079 |
| Failure | 29.981 |
| Critical failure | 5.094 |
Or a wizard can blast the bandit, who has +10 reflex, with his DC21 fireball:
reflex_save = pf2.check(10, DC=21)
pf2.outcome_counts(reflex_save).to_pandas().to_frame("%") * 100.0
| % | |
|---|---|
| outcome | |
| Critical success | 5.083 |
| Success | 44.984 |
| Failure | 44.972 |
| Critical failure | 4.961 |
Finally, with map_outcome() you can post-process the check outcome, for example to define the Evasion class feature or similar (if you roll a success, you get a critical success instead):
save_with_evasion = pf2.map_outcome(reflex_save, evasion=True)
pf2.outcome_counts(save_with_evasion).to_pandas().to_frame("%") * 100.0
| % | |
|---|---|
| outcome | |
| Critical success | 50.067 |
| Failure | 44.972 |
| Critical failure | 4.961 |
Damage profiles#
Previously, we saw how to roll raw 6d6. However, let’s refine that - let’s define the damage profile of a fireball, which we’re going to roll in the next section.
fireball = pf2.Damage("fire", 6, 6, basic_save=True)
fireball
Damage offers many keyword arguments and supports addition. Let’s have a rogue’s 2d8+3 deadly d8 rapier, with 1d6 sneak attack:
rapier = pf2.Damage("piercing", 2, 6, 3, deadly=8)
sneak_attack = pf2.Damage("precision", 1, 6)
rapier + sneak_attack
When rolling damage in the next chapter, we’ll see that pathfinder2e_stats automatically manages the deadly, fatal, etc. traits.
To preview the breakdown of what’s going to be rolled for each degree of success, we can call the expand() method:
(rapier + sneak_attack).expand()
Success 2d6+3 piercing plus 1d6 precision
Note how the basic_save=True flag on the fireball damage profile means it expands differently from a weapon:
fireball.expand()
Failure 6d6 fire
Critical failure (6d6)x2 fire
If basic save/basic attack damage rules, deadly, fatal, etc. are not enough, it’s possible to hand-craft more sophisticated damage profiles with ExpandedDamage - which is what you get when you call expand(). You can define an ExpandedDamage by initialising the class directly or by adding it to Damage. For example, let’s define a Flaming rune:
flaming_rune = pf2.Damage("fire", 1, 6) + {
pf2.DoS.critical_success: [pf2.Damage("fire", 1, 10, persistent=True)]
}
flaming_rune
Success 1d6 fire
Our rogue is getting an upgrade! Note that adding Damage + ExpandedDamage always expands the Damage first, so you’ll no longer read deadly d8 but the full success/critical success outcome for it.
flaming_rapier = rapier + sneak_attack + flaming_rune
flaming_rapier
Success 2d6+3 piercing plus 1d6 precision plus 1d6 fire
An ExpandedDamage is just a fancy mapping of lists of Damage, so usual mapping conversion techniques work:
dict(flaming_rapier)
{<DoS.critical_success: 2>: [**Damage** (2d6+3)x2 piercing,
**Damage** 1d8 piercing,
**Damage** (1d6)x2 precision,
**Damage** (1d6)x2 fire,
**Damage** 1d10 persistent fire],
<DoS.success: 1>: [**Damage** 2d6+3 piercing,
**Damage** 1d6 precision,
**Damage** 1d6 fire]}
Rolling damage#
Now that we have the output of a check(), like an attack roll or a saving throw, and the Damage profile, we can finally roll some damage().
Let’s reuse the strike outcome from above to roll damage for the flaming rapier:
flaming_rapier_damage = pf2.damage(strike, flaming_rapier)
flaming_rapier_damage
<xarray.Dataset> Size: 20MB
Dimensions: (roll: 100000, damage_type: 3, persistent_round: 3)
Coordinates:
* damage_type (damage_type) <U9 108B 'fire' ... 'precision'
Dimensions without coordinates: roll, persistent_round
Data variables:
bonus int64 8B 14
DC int64 8B 22
natural (roll) int64 800kB 18 16 19 15 19 ... 10 16 4 18 10
outcome (roll) int64 800kB 2 1 2 1 2 0 1 ... 0 0 1 1 0 2 1
direct_damage (roll, damage_type) int64 2MB 10 21 2 1 ... 1 15 2
persistent_damage (roll, damage_type, persistent_round) int64 7MB ...
persistent_damage_DC (damage_type) int64 24B 15 15 15
persistent_damage_check (roll, damage_type, persistent_round) int64 7MB ...
apply_persistent_damage (roll, damage_type, persistent_round) bool 900kB ...
total_damage (roll) int64 800kB 43 20 59 20 36 ... 12 21 0 46 18
Attributes:
legend: {-2: 'No roll', -1: 'Critical failure', 0: 'Failure', 1: 'S...
damage_spec: {'Critical success': '(2d6+3)x2 piercing plus 1d8 piercing ...damage() makes a copy of the dataset from the check outcome and adds variables to it. Again, most times we’re going to care only about total_damage, but it can be interesting to understand how we got there:
direct_damage how much immediate, simple damage we got on each of the 100,000 attacks. This is broken down by
damage_typebetween piercing, precision and fire.persistent_damage persistent fire damage caused by the Flaming rune on critical hits. This is rolled by default for 3 rounds, after which we assume either that the target expired, the combat ended, or the persistent damage ended on its own.
persistent_damage_DC DC for persistent damage to end on its own each round.
persistent_damage_check the outcome of the flat check at the end of each of the 3 rounds to end the persistent damage from continuing into the next round.
apply_persistent_damage whether the persistent_damage is still ongoing in this round or it already ended thanks to a successful save on a previous round.
total_damage the sum of direct damage, persistent damage over all the rounds, and splash damage over multiple targets, with the damage type squashed.
damage() also supports defining weaknesses, resistances, and immunities.
We can invoke .display here too for a quick overview:
flaming_rapier_damage.display(transpose=True)
| variable | |
|---|---|
| bonus | 14 |
| DC | 22 |
| count | mean | std | min | 25% | 50% | 75% | max | |||
|---|---|---|---|---|---|---|---|---|---|---|
| variable | damage_type | persistent_round | ||||||||
| natural | 100000.0 | 10.46946 | 5.765175 | 1.0 | 5.0 | 10.0 | 15.0 | 20.0 | ||
| outcome | 100000.0 | 0.74677 | 0.766753 | -1.0 | 0.0 | 1.0 | 1.0 | 2.0 | ||
| direct_damage | fire | 100000.0 | 2.78861 | 2.971520 | 0.0 | 0.0 | 2.0 | 5.0 | 12.0 | |
| piercing | 100000.0 | 8.66395 | 8.483261 | 0.0 | 0.0 | 9.0 | 12.0 | 38.0 | ||
| precision | 100000.0 | 2.78481 | 2.965267 | 0.0 | 0.0 | 2.0 | 5.0 | 12.0 | ||
| persistent_damage | fire | 0 | 100000.0 | 0.81526 | 2.244661 | 0.0 | 0.0 | 0.0 | 0.0 | 10.0 |
| 1 | 100000.0 | 0.81188 | 2.240255 | 0.0 | 0.0 | 0.0 | 0.0 | 10.0 | ||
| 2 | 100000.0 | 0.81176 | 2.236411 | 0.0 | 0.0 | 0.0 | 0.0 | 10.0 | ||
| piercing | 0 | 100000.0 | 0.00000 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | |
| 1 | 100000.0 | 0.00000 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | ||
| 2 | 100000.0 | 0.00000 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | ||
| precision | 0 | 100000.0 | 0.00000 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | |
| 1 | 100000.0 | 0.00000 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | ||
| 2 | 100000.0 | 0.00000 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | ||
| persistent_damage_check | fire | 0 | 100000.0 | 0.30043 | 0.458447 | 0.0 | 0.0 | 0.0 | 1.0 | 1.0 |
| 1 | 100000.0 | 0.29883 | 0.457747 | 0.0 | 0.0 | 0.0 | 1.0 | 1.0 | ||
| 2 | 100000.0 | 0.30197 | 0.459115 | 0.0 | 0.0 | 0.0 | 1.0 | 1.0 | ||
| piercing | 0 | 100000.0 | 0.29923 | 0.457923 | 0.0 | 0.0 | 0.0 | 1.0 | 1.0 | |
| 1 | 100000.0 | 0.30108 | 0.458730 | 0.0 | 0.0 | 0.0 | 1.0 | 1.0 | ||
| 2 | 100000.0 | 0.30115 | 0.458760 | 0.0 | 0.0 | 0.0 | 1.0 | 1.0 | ||
| precision | 0 | 100000.0 | 0.29979 | 0.458168 | 0.0 | 0.0 | 0.0 | 1.0 | 1.0 | |
| 1 | 100000.0 | 0.30074 | 0.458582 | 0.0 | 0.0 | 0.0 | 1.0 | 1.0 | ||
| 2 | 100000.0 | 0.29799 | 0.457377 | 0.0 | 0.0 | 0.0 | 1.0 | 1.0 | ||
| apply_persistent_damage | fire | 0 | 100000.0 | 1.00000 | 0.000000 | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 |
| 1 | 100000.0 | 0.69957 | 0.458447 | 0.0 | 0.0 | 1.0 | 1.0 | 1.0 | ||
| 2 | 100000.0 | 0.49059 | 0.499914 | 0.0 | 0.0 | 0.0 | 1.0 | 1.0 | ||
| piercing | 0 | 100000.0 | 1.00000 | 0.000000 | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | |
| 1 | 100000.0 | 0.70077 | 0.457923 | 0.0 | 0.0 | 1.0 | 1.0 | 1.0 | ||
| 2 | 100000.0 | 0.48907 | 0.499883 | 0.0 | 0.0 | 0.0 | 1.0 | 1.0 | ||
| precision | 0 | 100000.0 | 1.00000 | 0.000000 | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | |
| 1 | 100000.0 | 0.70021 | 0.458168 | 0.0 | 0.0 | 1.0 | 1.0 | 1.0 | ||
| 2 | 100000.0 | 0.48940 | 0.499890 | 0.0 | 0.0 | 0.0 | 1.0 | 1.0 | ||
| total_damage | 100000.0 | 16.02024 | 16.954767 | 0.0 | 0.0 | 15.0 | 20.0 | 84.0 |
| damage_type | fire | piercing | precision |
|---|---|---|---|
| variable | |||
| persistent_damage_DC | 15 | 15 | 15 |
From here we can start dicing and slicing with standard data science techniques. For example, let’s plot the damage distribution:
flaming_rapier_damage.total_damage.to_pandas().hist(
bins=flaming_rapier_damage.total_damage.max().item() + 1
)
<Axes: >
The above clearly shows the three distributions depending on the outcome of the attack roll:
Miss and Critical Miss no damage
Hit 4d6+3
Critical Hit (4d6+3)x2 + 1d8 + 1d10 persistent over up to 3 rounds
Let’s exclude misses:
rapier_hit_dmg = flaming_rapier_damage.total_damage[
flaming_rapier_damage.outcome >= pf2.DoS.success
]
rapier_hit_dmg.min().item()
7
rapier_hit_dmg.to_pandas().hist(bins=rapier_hit_dmg.max().item())
<Axes: >
Let’s do the same for the fireball and let’s observe the 4 intersecting distributions for the different saving throw outcomes:
Critical Success no damage
Success (6d6)/2
Failure 6d6
Critical Failure (6d6)x2
fireball_damage = pf2.damage(reflex_save, fireball)
fireball_damage.total_damage.to_pandas().hist(
bins=fireball_damage.total_damage.max().item() + 1
)
<Axes: >
Multiple targets and what-if analysis#
You may want to hit multiple targets with the same fireball, each rolling a separate saving throw.
In almost all functions of pathfinder2e_stats, instead of scalars you can pass as inputs DataArray objects with arbitrary dimensions and coordinates.
Earlier, we had a bandit with +10 Reflex roll against a DC21 fireball:
reflex_save = pf2.check(10, DC=21)
Let’s have the fireball hit three targets instead:
import xarray
reflex_bonus = xarray.DataArray(
[10, 13, 11],
dims=["target"],
coords={"target": ["Alice", "Bob", "Charlie"]},
)
reflex_bonus
<xarray.DataArray (target: 3)> Size: 24B array([10, 13, 11]) Coordinates: * target (target) <U7 84B 'Alice' 'Bob' 'Charlie'
In the above example, the coords parameter is optional, but helps track what’s what in the next steps.
We’re going to roll the saving throw and the damage in a moment. Before we do that, we need to first go through the topic, in statistics, of dependent and independent variables. Two dependent variables are those that can be obtained by transforming the same original random variable through deterministic functions. Independent variables are those that don’t; in other words they’re not perfectly (anti)correlated.
In pathfinder2e_stats, every time you add extra dimensions to the inputs of check() or damage(), you need to declare whether the points along the new dimension should be rolled only once or separately, by passing parameters independent_dims and dependent_dims.
In the case of a fireball, each target rolls a separate check to save; the damage is rolled only once and then halved/doubled depending on the outcome:
reflex_save = pf2.check(reflex_bonus, DC=21, independent_dims=["target"])
fireball_damage = pf2.damage(reflex_save, fireball, dependent_dims=["target"])
fireball_damage
<xarray.Dataset> Size: 10MB
Dimensions: (target: 3, roll: 100000, damage_type: 1)
Coordinates:
* target (target) <U7 84B 'Alice' 'Bob' 'Charlie'
* damage_type (damage_type) <U4 16B 'fire'
Dimensions without coordinates: roll
Data variables:
bonus (target) int64 24B 10 13 11
DC int64 8B 21
natural (roll, target) int64 2MB 13 19 14 12 4 6 ... 7 15 19 17 2 20
outcome (roll, target) int64 2MB 1 2 1 1 0 0 0 1 ... 0 1 0 1 1 1 0 2
direct_damage (roll, target, damage_type) int64 2MB 6 0 6 10 ... 9 7 15 0
total_damage (roll, target) int64 2MB 6 0 6 10 20 20 24 ... 19 9 9 7 15 0
Attributes:
legend: {-2: 'No roll', -1: 'Critical failure', 0: 'Failure', 1: 'S...
damage_spec: {'Success': '(6d6)/2 fire', 'Failure': '6d6 fire', 'Critica...Note how variables natural, outcome, direct_damage and total_damage have gained the extra dimension target, and how the target coordinate was propagated from the input. Let’s have a look at some damage outputs to observe that saving throws were indeed independent but the damage is not:
fireball_damage.total_damage.isel(roll=slice(10)).to_pandas()
| target | Alice | Bob | Charlie |
|---|---|---|---|
| roll | |||
| 0 | 6 | 0 | 6 |
| 1 | 10 | 20 | 20 |
| 2 | 24 | 12 | 12 |
| 3 | 17 | 8 | 8 |
| 4 | 26 | 13 | 13 |
| 5 | 20 | 0 | 40 |
| 6 | 11 | 23 | 11 |
| 7 | 11 | 11 | 11 |
| 8 | 38 | 19 | 19 |
| 9 | 7 | 7 | 7 |
Which dimensions are dependent and which are independent depends on the situation. Consider:
You want to hit 3 times with MAP. All attack and damage rolls are independent:
MAP = xarray.DataArray([0, -5, -10], dims=["strike"])
strike = pf2.check(14 + MAP, DC=22, independent_dims=["strike"])
damage = pf2.damage(strike, flaming_rapier, independent_dims=["strike"])
You want to hit two targets with Swipe. You roll attack and damage only once; your attack may miss on one target and hit or critically hit on the other depending on their ACs:
AC = xarray.DataArray([22, 15], dims=["target"])
strike = pf2.check(14, DC=AC, dependent_dims=["target"])
damage = pf2.damage(strike, flaming_rapier, dependent_dims=["target"])
You want to know if it’s worthwhile to spend an action to move into flanking position, given the same target. Roll attack and damage only once and compare it against the AC with and without flanking. This type hypothetical study is commonly called a what-if analysis:
off_guard = xarray.DataArray(
[0, -2], dims=["off-guard"], coords={"off-guard": [False, True]}
)
strike = pf2.check(14, DC=22 + off_guard, dependent_dims=["off-guard"])
damage = pf2.damage(strike, flaming_rapier, dependent_dims=["off-guard"])
What’s the impact on the damage distribution of the off-guard condition, given this specific attack bonus, base AC, and weapon?
damage.total_damage.display()
| variable | total_damage | |
|---|---|---|
| off-guard | False | True |
| count | 100000.000000 | 100000.000000 |
| mean | 16.054840 | 21.120090 |
| std | 16.993548 | 19.096843 |
| min | 0.000000 | 0.000000 |
| 25% | 0.000000 | 7.000000 |
| 50% | 15.000000 | 17.000000 |
| 75% | 20.000000 | 26.000000 |
| max | 82.000000 | 86.000000 |
You can combine extra dimensions arbitrarily. For example, let’s strike the same target 3 times with increasing MAP, and observe how much getting them off-guard matters:
strike = pf2.check(
14 + MAP,
DC=22 + off_guard,
independent_dims=["strike"],
dependent_dims=["off-guard"],
)
damage = pf2.damage(
strike,
flaming_rapier,
independent_dims=["strike"],
dependent_dims=["off-guard"],
)
damage
<xarray.Dataset> Size: 118MB
Dimensions: (strike: 3, off-guard: 2, roll: 100000,
damage_type: 3, persistent_round: 3)
Coordinates:
* off-guard (off-guard) bool 2B False True
* damage_type (damage_type) <U9 108B 'fire' ... 'precision'
Dimensions without coordinates: strike, roll, persistent_round
Data variables:
bonus (strike) int64 24B 14 9 4
DC (off-guard) int64 16B 22 20
natural (roll, strike) int64 2MB 5 20 3 8 14 ... 15 6 3 11
outcome (roll, strike, off-guard) int64 5MB 0 0 2 ... 0 0 0
direct_damage (roll, strike, off-guard, damage_type) int64 14MB ...
persistent_damage (roll, strike, off-guard, damage_type, persistent_round) int64 43MB ...
persistent_damage_DC (damage_type) int64 24B 15 15 15
persistent_damage_check (roll, damage_type, strike, off-guard, persistent_round) int64 43MB ...
apply_persistent_damage (roll, damage_type, strike, off-guard, persistent_round) bool 5MB ...
total_damage (roll, strike, off-guard) int64 5MB 0 0 42 ... 0 0
Attributes:
legend: {-2: 'No roll', -1: 'Critical failure', 0: 'Failure', 1: 'S...
damage_spec: {'Critical success': '(2d6+3)x2 piercing plus 1d8 piercing ...What’s the mean damage of the three strikes?
damage.total_damage.mean("roll").to_pandas()
| off-guard | False | True |
|---|---|---|
| strike | ||
| 0 | 16.14455 | 21.21379 |
| 1 | 8.50421 | 10.18417 |
| 2 | 4.22000 | 5.89245 |
Since labelling dimensions as (in)dependent on every check and damage call can get tedious, you can alternatively declare that, a dimension is always (in)dependent within your session:
pf2.set_config(
check_independent_dims=["strike"],
check_dependent_dims=["off-guard"],
damage_independent_dims=["strike"],
damage_dependent_dims=["off-guard"],
)
strike = pf2.check(14 + MAP, DC=22 + off_guard)
damage = pf2.damage(strike, flaming_rapier)
Conditional buffs/debuffs#
Not everything in the game does damage; frequently it’s worthwhile to spend an action to give to yourself or an ally a buff, or to debuff an enemy. A buff/debuff translates to a bonus/penalty to a check or to damage, or maybe to adding or removing a flat check (e.g. when it gives or removes the Concealed or Hidden conditions).
Crucially though, debuffs frequently involve a check that can negate or mitigate them. In pathfinder2e_stats, we can chain multiple actions, e.g. a Fear spell followed by a Strike. On each of our 100,000 simulations, the AC of the target will change depending on the target’s saving throw.
We’re going to simulate this as follows:
Call
checkto roll a saving throw vs. Fear;Map its outcome with
map_outcome()to the severity of the Frightned condition;
will_saving_throw = pf2.check(13, DC=21)
frightened = pf2.map_outcome(
will_saving_throw.outcome,
{
pf2.DoS.critical_failure: 3,
pf2.DoS.failure: 2,
pf2.DoS.success: 1,
},
)
frightened.display("frightened")
| variable | frightened |
|---|---|
| count | 100000.000000 |
| mean | 1.248870 |
| std | 0.769389 |
| min | 0.000000 |
| 25% | 1.000000 |
| 50% | 1.000000 |
| 75% | 2.000000 |
| max | 3.000000 |
What is the probability to get each level of the Frightened condition from Fear?
(
frightened.value_counts("roll", new_dim="frightened", normalize=True)
.to_pandas()
.to_frame("%")
) * 100.0
| % | |
|---|---|
| frightened | |
| 0 | 15.098 |
| 1 | 50.070 |
| 2 | 29.679 |
| 3 | 5.153 |
Subtract the Frightened condition from the enemy’s AC;
Roll
checkagain for the Strike: theDCparameter will have arolldimension;Roll
damageas usual.
strike = pf2.check(14, DC=22 - frightened)
damage = pf2.damage(strike, flaming_rapier)
Note that we didn’t need to clarify that roll is an independent dimension: roll is a special dimension that is always considered independent.
But wait! We wonder, was it worth the effort to cast Fear to begin with (assuming we don’t care about anything but the extra damage on this Strike)? Let’s do a what-if analysis for it:
cast_fear = xarray.DataArray(
[False, True], dims=["cast_fear"], coords={"cast_fear": [False, True]}
)
strike = pf2.check(14, DC=22 - frightened * cast_fear, dependent_dims=["cast_fear"])
damage = pf2.damage(strike, flaming_rapier, dependent_dims=["cast_fear"])
damage.total_damage.display()
| variable | total_damage | |
|---|---|---|
| cast_fear | False | True |
| count | 100000.000000 | 100000.000000 |
| mean | 16.039760 | 19.197040 |
| std | 16.974328 | 18.518977 |
| min | 0.000000 | 0.000000 |
| 25% | 0.000000 | 0.000000 |
| 50% | 15.000000 | 16.000000 |
| 75% | 20.000000 | 22.000000 |
| max | 87.000000 | 87.000000 |
In other words: what was the expected % bonus to damage from casting Fear, before the saving throw was rolled?
mean_damage = damage.total_damage.mean("roll")
(mean_damage.sel(cast_fear=True) / mean_damage.sel(cast_fear=False)).item()
1.1968408504865409
The Fear spell, against this particular target and with this particular weapon (which, crucially, has the Deadly trait) gave us on average 20% extra damage on the Strike.
Flat checks, disrupting actions and conditional actions#
Some actions have a chance of failing. Among the most common causes, we have:
performing an action with the Manipulate trait (like casting most spells) while Grabbed.
To model this, we need to introduce a special degree of success, DoS.no_roll. damage() always translates DoS.no_roll to zero damage.
It works as follows:
roll
check()as normal;roll another flat
check()for the failure chance;override the outcome of the first check with
DoS.no_rollwhen the flat check failed;roll
damage()normally.
strike = pf2.check(14, DC=22)
flat_check = pf2.check(
DC=5, allow_critical_failure=False, allow_critical_success=False
) # Concealed
strike["flat_check"] = flat_check.outcome
strike.outcome[strike.flat_check == pf2.DoS.failure] = pf2.DoS.no_roll
damage = pf2.damage(strike, flaming_rapier)
pf2.outcome_counts(strike).to_pandas().to_frame("%") * 100.0
| % | |
|---|---|
| outcome | |
| Critical success | 11.994 |
| Success | 40.102 |
| Failure | 24.002 |
| Critical failure | 3.961 |
| No roll | 19.941 |
Actions can also be disrupted; for example we may want to simulate the impact of receiving a Reactive Strike while casting a spell and risk losing the spell on a critical hit.
It works in the same way as flat checks; you just update the check for the spell to DoS.no_roll as a function of the check for the reactive strike:
reactive_strike = pf2.check(14, DC=22)
saving_throw = pf2.check(reflex_bonus, DC=21, independent_dims=["target"])
saving_throw["disrupted"] = reactive_strike.outcome == pf2.DoS.critical_success
saving_throw.outcome[saving_throw.disrupted] = pf2.DoS.no_roll
fireball_damage = pf2.damage(saving_throw, fireball, dependent_dims=["target"])
saving_throw.disrupted.value_counts(
"roll", new_dim="disrupted", normalize=True
).to_pandas().to_frame("%") * 100.0
| % | |
|---|---|
| disrupted | |
| False | 84.941 |
| True | 15.059 |
Test that the fireball never did any damage whenever it was disrupted:
fireball_damage.total_damage[fireball_damage.disrupted].max().item()
0
A player or the GM may also completely change course of action depending on the outcome of a roll. For example, one may be Restrained and decide that they are going to try to Escape; if successful they’ll cast a spell otherwise they’ll try again, unless it’s a critical failure:
# Try up to 3 times per round...
escape = pf2.check(8 + MAP, DC=22)
# ..but a critical failure failure stops you from retrying this round.
# We're retrying exclusively when the previous attempt was exactly a failure.
for i in (1, 2):
escape.outcome.isel(strike=i)[
escape.outcome.isel(strike=i - 1) != pf2.DoS.failure
] = pf2.DoS.no_roll
pf2.outcome_counts(escape).to_pandas() * 100.0
| outcome | Critical success | Success | Failure | Critical failure | No roll |
|---|---|---|---|---|---|
| strike | |||||
| 0 | 5.054 | 30.198 | 44.828 | 19.920 | 0.000 |
| 1 | 2.356 | 2.224 | 20.075 | 20.173 | 55.172 |
| 2 | 0.000 | 1.041 | 5.080 | 13.954 | 79.925 |
What’s the probability of escaping by the end of the round?
(
(escape.outcome >= pf2.DoS.success)
.any("strike")
.value_counts(dim="roll", new_dim="escaped", normalize=True)
.to_pandas()
.to_frame("%")
) * 100.0
| % | |
|---|---|
| escaped | |
| False | 59.127 |
| True | 40.873 |
Now let’s cast the spell, but only if we escaped on the first round:
can_cast = escape.isel(strike=0).outcome >= pf2.DoS.success
reflex_save = pf2.check(10, DC=21)
reflex_save.outcome[~can_cast] = pf2.DoS.no_roll
pf2.outcome_counts(reflex_save).to_pandas().to_frame("%") * 100.0
| % | |
|---|---|
| outcome | |
| Critical success | 1.785 |
| Success | 15.764 |
| Failure | 15.905 |
| Critical failure | 1.798 |
| No roll | 64.748 |
Armory and tables#
For the sake of convenience, we don’t need to write by hand the damage spec of the +1 Striking Flaming Rapier from above every time. pf2.armory offers a wealth of weapons, runes, spells, and common class features:
(
pf2.armory.swords.rapier(dice=2)
+ pf2.armory.runes.flaming()
+ pf2.armory.class_features.sneak_attack(level=5)
)
Success 2d6 piercing plus 1d6 fire plus 2d6 precision
We don’t need to calculate our character’s attack bonus either. pf2.tables.PC offers a wealth of precalculated progressions over 20 levels for most optimized character builds:
pf2.tables.PC
- ability_bonus
- attack_item_bonus
- class_proficiency
- level
- polymorph_attack
- rage
- skill_item_bonus
- skill_proficiency
- spell_proficiency
- untamed_druid_attack
- weapon_dice
- weapon_proficiency
- weapon_specialization
Each table has a level dimension, plus variables and extra dimensions depending on the table:
pf2.tables.PC.weapon_proficiency.display()
| variable | alchemist | animist | barbarian | bard | champion | cleric | commander | druid | exemplar | fighter | guardian | gunslinger | inventor | investigator | kineticist | magus | monk | oracle | psychic | ranger | rogue | sorcerer | summoner | swashbuckler | thaumaturge | witch | wizard | fighter_dedication | ||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| doctrine | battle creed | cloistered cleric | warpriest | |||||||||||||||||||||||||||||
| mastery | True | False | True | False | ||||||||||||||||||||||||||||
| level | ||||||||||||||||||||||||||||||||
| 1 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 4 | 4 | 2 | 4 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 |
| 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 4 | 4 | 2 | 4 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 |
| 3 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 4 | 4 | 2 | 4 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 |
| 4 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 4 | 4 | 2 | 4 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 |
| 5 | 2 | 2 | 4 | 2 | 4 | 4 | 2 | 2 | 4 | 2 | 4 | 6 | 4 | 4 | 6 | 4 | 4 | 4 | 2 | 4 | 4 | 2 | 2 | 4 | 4 | 2 | 4 | 4 | 4 | 2 | 2 | 2 |
| 6 | 2 | 2 | 4 | 2 | 4 | 4 | 2 | 2 | 4 | 2 | 4 | 6 | 4 | 4 | 6 | 4 | 4 | 4 | 2 | 4 | 4 | 2 | 2 | 4 | 4 | 2 | 4 | 4 | 4 | 2 | 2 | 2 |
| 7 | 4 | 2 | 4 | 2 | 4 | 4 | 2 | 4 | 4 | 2 | 4 | 6 | 4 | 4 | 6 | 4 | 4 | 4 | 2 | 4 | 4 | 2 | 2 | 4 | 4 | 2 | 4 | 4 | 4 | 2 | 2 | 2 |
| 8 | 4 | 2 | 4 | 2 | 4 | 4 | 2 | 4 | 4 | 2 | 4 | 6 | 4 | 4 | 6 | 4 | 4 | 4 | 2 | 4 | 4 | 2 | 2 | 4 | 4 | 2 | 4 | 4 | 4 | 2 | 2 | 2 |
| 9 | 4 | 2 | 4 | 2 | 4 | 4 | 2 | 4 | 4 | 2 | 4 | 6 | 4 | 4 | 6 | 4 | 4 | 4 | 2 | 4 | 4 | 2 | 2 | 4 | 4 | 2 | 4 | 4 | 4 | 2 | 2 | 2 |
| 10 | 4 | 2 | 4 | 2 | 4 | 4 | 2 | 4 | 4 | 2 | 4 | 6 | 4 | 4 | 6 | 4 | 4 | 4 | 2 | 4 | 4 | 2 | 2 | 4 | 4 | 2 | 4 | 4 | 4 | 2 | 2 | 2 |
| 11 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 6 | 4 | 4 | 6 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 2 |
| 12 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 6 | 4 | 4 | 6 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 |
| 13 | 4 | 4 | 6 | 4 | 6 | 6 | 4 | 4 | 6 | 4 | 6 | 8 | 6 | 6 | 8 | 6 | 6 | 6 | 4 | 6 | 6 | 4 | 4 | 6 | 6 | 4 | 6 | 6 | 6 | 4 | 4 | 4 |
| 14 | 4 | 4 | 6 | 4 | 6 | 6 | 4 | 4 | 6 | 4 | 6 | 8 | 6 | 6 | 8 | 6 | 6 | 6 | 4 | 6 | 6 | 4 | 4 | 6 | 6 | 4 | 6 | 6 | 6 | 4 | 4 | 4 |
| 15 | 4 | 4 | 6 | 4 | 6 | 6 | 4 | 4 | 6 | 4 | 6 | 8 | 6 | 6 | 8 | 6 | 6 | 6 | 4 | 6 | 6 | 4 | 4 | 6 | 6 | 4 | 6 | 6 | 6 | 4 | 4 | 4 |
| 16 | 4 | 4 | 6 | 4 | 6 | 6 | 4 | 4 | 6 | 4 | 6 | 8 | 6 | 6 | 8 | 6 | 6 | 6 | 4 | 6 | 6 | 4 | 4 | 6 | 6 | 4 | 6 | 6 | 6 | 4 | 4 | 4 |
| 17 | 4 | 4 | 6 | 4 | 6 | 6 | 4 | 4 | 6 | 4 | 6 | 8 | 6 | 6 | 8 | 6 | 6 | 6 | 4 | 6 | 6 | 4 | 4 | 6 | 6 | 4 | 6 | 6 | 6 | 4 | 4 | 4 |
| 18 | 4 | 4 | 6 | 4 | 6 | 6 | 4 | 4 | 6 | 4 | 6 | 8 | 6 | 6 | 8 | 6 | 6 | 6 | 4 | 6 | 6 | 4 | 4 | 6 | 6 | 4 | 6 | 6 | 6 | 4 | 4 | 4 |
| 19 | 4 | 4 | 6 | 4 | 6 | 6 | 4 | 6 | 6 | 4 | 6 | 8 | 6 | 6 | 8 | 6 | 6 | 6 | 4 | 6 | 6 | 4 | 4 | 6 | 6 | 4 | 6 | 6 | 6 | 4 | 4 | 4 |
| 20 | 4 | 4 | 6 | 4 | 6 | 6 | 4 | 6 | 6 | 4 | 6 | 8 | 6 | 6 | 8 | 6 | 6 | 6 | 4 | 6 | 6 | 4 | 4 | 6 | 6 | 4 | 6 | 6 | 6 | 4 | 4 | 4 |
We can build the attack bonus of the rogue from earlier by picking what we want from the PC tables:
rogue_atk_bonus = (
# Start with DEX+4 at level 1 and always increase it
pf2.tables.PC.ability_bonus.boosts.sel(initial=4, drop=True)
# Get an Apex item at level 17 for +1 DEX
+ pf2.tables.PC.ability_bonus.apex
# Upgrade weapons as soon as possible: +1 at level 2, +2 at level 10, etc.
+ pf2.tables.PC.attack_item_bonus.potency_rune
# Trained (+2) at level 1, Expert (+4) at level 5, Master (+6) at level 13
+ pf2.tables.PC.weapon_proficiency.rogue
# Add level to proficiency
+ pf2.tables.PC.level
)
rogue_atk_bonus.display(transpose=True)
| level | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| variable | ||||||||||||||||||||
| (unnamed) | 7 | 9 | 10 | 11 | 14 | 15 | 16 | 17 | 18 | 21 | 22 | 23 | 26 | 27 | 28 | 30 | 32 | 33 | 34 | 36 |
Since adding up all of the above is tedious and error prone, pf2.tables.SIMPLE_PC is a simplified variant that makes a bunch of (opinable) choices, giving you an even more standardized progression:
rogue_atk_bonus = pf2.tables.SIMPLE_PC.weapon_attack_bonus.rogue
rogue_atk_bonus.display()
| variable | rogue | ||||
|---|---|---|---|---|---|
| component | level | proficiency | ability_boosts | ability_apex | item |
| level | |||||
| 1 | 1 | 2 | 4 | 0 | 0 |
| 2 | 2 | 2 | 4 | 0 | 1 |
| 3 | 3 | 2 | 4 | 0 | 1 |
| 4 | 4 | 2 | 4 | 0 | 1 |
| 5 | 5 | 4 | 4 | 0 | 1 |
| 6 | 6 | 4 | 4 | 0 | 1 |
| 7 | 7 | 4 | 4 | 0 | 1 |
| 8 | 8 | 4 | 4 | 0 | 1 |
| 9 | 9 | 4 | 4 | 0 | 1 |
| 10 | 10 | 4 | 5 | 0 | 2 |
| 11 | 11 | 4 | 5 | 0 | 2 |
| 12 | 12 | 4 | 5 | 0 | 2 |
| 13 | 13 | 6 | 5 | 0 | 2 |
| 14 | 14 | 6 | 5 | 0 | 2 |
| 15 | 15 | 6 | 5 | 0 | 2 |
| 16 | 16 | 6 | 5 | 0 | 3 |
| 17 | 17 | 6 | 5 | 1 | 3 |
| 18 | 18 | 6 | 5 | 1 | 3 |
| 19 | 19 | 6 | 5 | 1 | 3 |
| 20 | 20 | 6 | 6 | 1 | 3 |
So our level 5 rogue will have an attack bonus of
rogue_atk_bonus.sum("component").sel(level=5).item()
14
Note that the above is just a typical baseline, and does not take into consideration buffs, debuffs, suboptimal equipment, or uncommon character progression choices.
There are more Tables available:
pf2.tables.DCcontains the Difficulty Classes tables from the GM Core;pf2.tables.EARN_INCOMEis the Earn Income table from the Player Core;pf2.tables.NPCgives you the tables from the Building Creatures chapter of the GM Core;pf2.tables.SIMPLE_NPCgives you a simplified version ofNPCwith just three targets to blast with your attack and spells (or to get blasted by):a weak minion of your level - 2 with all stats rated Low;
a worthy foe of your level with all stats rated Moderate; and
a boss of your level + 2 with all stats rated High.
# One very easy, one average and one very hard enemy at level 5
pf2.tables.SIMPLE_NPC.sel(level=5).display(transpose=True)
| challenge | Weak | Matched | Boss | |
|---|---|---|---|---|
| variable | limited | |||
| abilities | 1 | 4 | 6 | |
| saving_throws | 6 | 12 | 18 | |
| perception | 6 | 12 | 18 | |
| skills | 7 | 12 | 17 | |
| AC | 16 | 21 | 25 | |
| HP | 31 | 75 | 148 | |
| strike_attack | 8 | 13 | 18 | |
| strike_damage_dice | 1d6+5 | 2d6+6 | 2d10+9 | |
| strike_damage_mean | 8 | 13 | 20 | |
| spell_DC | 0 | 19 | 25 | |
| spell_attack | 0 | 11 | 17 | |
| area_damage_dice | False | 2d8 | 2d10 | 4d6 |
| True | 4d6 | 6d6 | 8d6 | |
| area_damage_mean | False | 9 | 12 | 15 |
| True | 14 | 21 | 28 | |
| resistances | 3 | 6 | 10 | |
| recall_knowledge | 18 | 20 | 25 |
Next steps#
Congratulations, you finished the basic tutorial!
From here, you can go look at the Notebooks. In the API Reference, you will find many functions, flags and options that were omitted here for the sake of brevity.