{ "cells": [ { "cell_type": "markdown", "id": "82bf6bda-c640-4c26-9b71-f87f04b75ba7", "metadata": { "editable": true, "slideshow": { "slide_type": "" }, "tags": [] }, "source": [ "# Getting Started\n", "\n", "In this tutorial, we'll learn how to simulate probabilities with `pathfinder2e-stats`.\n", "\n", "To follow this tutorial, you'll need to have at least a basic understanding of\n", "- The rules of either [Pathfinder](https://2e.aonprd.com/PlayersGuide.aspx) or\n", " [Starfinder](https://2e.aonsrd.com/rules), and\n", "- Data science workflows, e.g. based on Python + pandas + Jupyter notebooks. See {ref}`audience`.\n", "\n", "If you don't have your Jupyter Notebook development environment ready yet, go back to {doc}`../installing`." ] }, { "cell_type": "markdown", "id": "f55479ba-059f-449f-bd3f-fd7c3c6ad698", "metadata": {}, "source": [ "## Rolling some dice\n", "Let's start simple - let's import the module and roll a d6.\n", "\n", "We're going to roll it *one hundred thousand times.*" ] }, { "cell_type": "code", "execution_count": null, "id": "9d66b39f-98fe-4a23-9f8a-82e000433e11", "metadata": {}, "outputs": [], "source": [ "# Install package on the fly in JupyterLite\n", "%pip install -q pathfinder2e-stats\n", "# Initialise matplotlib in JupyterLite\n", "import matplotlib as mpl # noqa: F401\n", "\n", "# Import the module\n", "import pathfinder2e_stats as pf2" ] }, { "cell_type": "code", "execution_count": null, "id": "b9cfd7fd-d478-448d-8aca-2056fc11ef0b", "metadata": {}, "outputs": [], "source": [ "# Roll 1d6\n", "oned6 = pf2.roll(1, 6)\n", "oned6" ] }, { "cell_type": "markdown", "id": "91350e96-82f3-458d-ba69-2c40885967a6", "metadata": {}, "source": [ "`pathfinder2e-stats` functions return standard {class}`xarray.DataArray` and {class}`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?" ] }, { "cell_type": "code", "execution_count": null, "id": "4f380ad7-ee57-45eb-b368-55261b7cad43", "metadata": {}, "outputs": [], "source": [ "oned6.mean()" ] }, { "cell_type": "markdown", "id": "54d3963c-9f2c-434e-acaa-e241b029b1d6", "metadata": {}, "source": [ "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.\n", "\n", "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 {func}`~pathfinder2e_stats.seed`." ] }, { "cell_type": "code", "execution_count": null, "id": "d244bf5b-15e6-4a5c-a17d-9fbef0dfe795", "metadata": {}, "outputs": [], "source": [ "pf2.roll(1, 6)" ] }, { "cell_type": "markdown", "id": "9c5715e5-96ad-4402-8b9a-40fbd3171d8d", "metadata": {}, "source": [ "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`:" ] }, { "cell_type": "code", "execution_count": null, "id": "68c04f8e-6f71-4c82-ac92-bb354da4e1c4", "metadata": {}, "outputs": [], "source": [ "pf2.roll(1, 6).display(\"1d6\")" ] }, { "cell_type": "markdown", "id": "2e60a391-eda4-4dcf-81bd-9e57e513ee2a", "metadata": {}, "source": [ "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 {prd_spells}`Fireball <1530>`!" ] }, { "cell_type": "code", "execution_count": null, "id": "e499e790-3e90-4db7-8efb-c0d9ee483290", "metadata": {}, "outputs": [], "source": [ "fireball = pf2.roll(6, 6)\n", "fireball" ] }, { "cell_type": "markdown", "id": "6078f92c-adc8-452d-a424-c6d318522aee", "metadata": {}, "source": [ "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``)." ] }, { "cell_type": "code", "execution_count": null, "id": "7a3ab996-2abe-45b9-9c6f-d386cab43db1", "metadata": {}, "outputs": [], "source": [ "fireball.value_counts(\"roll\").to_pandas().to_frame(\"count\")" ] }, { "cell_type": "code", "execution_count": null, "id": "5ad6ad50-3da1-474a-8874-b35fd0c3f5ce", "metadata": {}, "outputs": [], "source": [ "_ = fireball.to_pandas().hist(bins=30)" ] }, { "cell_type": "markdown", "id": "8e7cf060-ae24-49e9-bba4-6c84dc5fe6c0", "metadata": {}, "source": [ "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." ] }, { "cell_type": "markdown", "id": "5042eb79-21f4-4f40-835c-4e3279d7e5e3", "metadata": {}, "source": [ "## Rolling checks\n", "\n", "In Pathfinder and Starfinder, a *check* is whenever one rolls a d20+bonus against a DC; this includes attack rolls against AC.\n", "\n", "For example, a paladin with +8 Diplomacy tries to convince a guard to let them pass. The DC is 15.\n", "To simulate that, we call {func}`~pathfinder2e_stats.check`." ] }, { "cell_type": "code", "execution_count": null, "id": "df27b80c-a7be-412f-a019-499a1cef9edc", "metadata": {}, "outputs": [], "source": [ "request = pf2.check(8, DC=15)\n", "request" ] }, { "cell_type": "markdown", "id": "c1c58d67-f92a-4def-a046-4217a0d6cb96", "metadata": {}, "source": [ "The output of {func}`~pathfinder2e_stats.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:\n", "\n", "- **natural** is the bare d20 roll, 100,000 times\n", "- **outcome** is the roll's degree of success, taking into account critical success/failure rules, natural 1s and 20s.\n", "\n", "`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 {class}`~pathfinder2e_stats.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.\n", "\n", "Have a look at the {func}`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 {prd_equipment}`Keen <2843>` rune.\n", "\n", "You can aggregate the result by using handy helpers such as {func}`~pathfinder2e_stats.outcome_counts`:" ] }, { "cell_type": "code", "execution_count": null, "id": "71396468-4264-4e21-9623-9115ef810915", "metadata": {}, "outputs": [], "source": [ "# Probability to get each outcome\n", "pf2.outcome_counts(request).to_pandas().to_frame(\"%\") * 100.0" ] }, { "cell_type": "markdown", "id": "8149883e-db6d-44f4-a7a8-01d02c9f52ec", "metadata": {}, "source": [ "It's also common to compare against `DoS`. Operators `>`, `>=`, `<`, and `<=` are supported:" ] }, { "cell_type": "code", "execution_count": null, "id": "d45b4ace-5d80-4aa8-8c51-9b41eccf1ed5", "metadata": {}, "outputs": [], "source": [ "# Probability to get at least a success\n", "(request.outcome >= pf2.DoS.success).mean().item()" ] }, { "cell_type": "code", "execution_count": null, "id": "c229405d-515f-4835-8bc6-666ecccf29fb", "metadata": {}, "outputs": [], "source": [ "# Probability to get a critical success\n", "(request.outcome == pf2.DoS.critical_success).mean().item()" ] }, { "cell_type": "markdown", "id": "b96b1a8a-9cf8-47de-be3f-b517c7b67496", "metadata": {}, "source": [ "Attack rolls, saving throws, counteract checks, flat checks, etc. work exactly in the same way as skill checks.\n", "For example, the party rogue can Strike a bandit (AC22) with his +14 rapier:" ] }, { "cell_type": "code", "execution_count": null, "id": "b338d998-0a21-4602-a7a5-f193bd4d84d6", "metadata": {}, "outputs": [], "source": [ "strike = pf2.check(14, DC=22)\n", "pf2.outcome_counts(strike).to_pandas().to_frame(\"%\") * 100.0" ] }, { "cell_type": "markdown", "id": "31f07dca-ba17-429a-aea7-2a35e4736937", "metadata": {}, "source": [ "Or a wizard can blast the bandit, who has +10 reflex, with his DC21 fireball:" ] }, { "cell_type": "code", "execution_count": null, "id": "5c6ce152-ac20-4453-a5d7-c26a69fbbf7a", "metadata": {}, "outputs": [], "source": [ "reflex_save = pf2.check(10, DC=21)\n", "pf2.outcome_counts(reflex_save).to_pandas().to_frame(\"%\") * 100.0" ] }, { "cell_type": "markdown", "id": "579aff01-1ff3-442f-9d67-927ffbafdcbf", "metadata": {}, "source": [ "Finally, with {func}`~pathfinder2e_stats.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):" ] }, { "cell_type": "code", "execution_count": null, "id": "2bf67dea-250d-41f9-8e1c-36374fb7c393", "metadata": {}, "outputs": [], "source": [ "save_with_evasion = pf2.map_outcome(reflex_save, evasion=True)\n", "pf2.outcome_counts(save_with_evasion).to_pandas().to_frame(\"%\") * 100.0" ] }, { "cell_type": "markdown", "id": "98a69221-f57b-452c-be30-b81bd49fe4af", "metadata": {}, "source": [ "## Damage profiles\n", "\n", "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." ] }, { "cell_type": "code", "execution_count": null, "id": "0e470958-6de9-4601-83fa-eb79b3d4a910", "metadata": {}, "outputs": [], "source": [ "fireball = pf2.Damage(\"fire\", 6, 6, basic_save=True)\n", "fireball" ] }, { "cell_type": "markdown", "id": "2cf75d3b-27b7-4759-9390-8005f23145de", "metadata": {}, "source": [ "Damage offers many keyword arguments and supports addition.\n", "Let's have a rogue's 2d8+3 deadly d8 rapier, with 1d6 sneak attack:" ] }, { "cell_type": "code", "execution_count": null, "id": "2461a01c-973d-4ee0-a756-c06527e6173c", "metadata": {}, "outputs": [], "source": [ "rapier = pf2.Damage(\"piercing\", 2, 6, 3, deadly=8)\n", "sneak_attack = pf2.Damage(\"precision\", 1, 6)\n", "rapier + sneak_attack" ] }, { "cell_type": "markdown", "id": "09823138-cb2d-46da-8c3e-9592e886860b", "metadata": {}, "source": [ "When rolling damage in the next chapter, we'll see that `pathfinder2e-stats` automatically manages the deadly, fatal, etc. traits.\n", "To preview the breakdown of what's going to be rolled for each degree of success, we can call the {meth}`~pathfinder2e_stats.Damage.expand` method:" ] }, { "cell_type": "code", "execution_count": null, "id": "58969f4a-26f5-4307-9769-2da4c054ea23", "metadata": {}, "outputs": [], "source": [ "(rapier + sneak_attack).expand()" ] }, { "cell_type": "markdown", "id": "34c70f3f-4f95-402a-87fd-b7d1163b5450", "metadata": {}, "source": [ "Note how the `basic_save=True` flag on the fireball damage profile means it expands differently from a weapon:" ] }, { "cell_type": "code", "execution_count": null, "id": "f082146c-38a3-4440-a0e6-f9870e8da1a4", "metadata": {}, "outputs": [], "source": [ "fireball.expand()" ] }, { "cell_type": "markdown", "id": "c47bc79d-94e7-4b56-93e5-762f8731002a", "metadata": {}, "source": [ "If basic save/basic attack damage rules, deadly, fatal, etc. are not enough, it's possible to hand-craft more sophisticated damage profiles with {class}`~pathfinder2e_stats.ExpandedDamage` - which is what you get when you call {meth}`~pathfinder2e_stats.Damage.expand`. You can define an {class}`~pathfinder2e_stats.ExpandedDamage` by initialising the class directly or by adding it to {class}`~pathfinder2e_stats.Damage`. For example, let's define a {prd_equipment}`Flaming <2838>` rune:" ] }, { "cell_type": "code", "execution_count": null, "id": "76a63964-755d-45ea-86a8-604b58dacbfc", "metadata": {}, "outputs": [], "source": [ "flaming_rune = pf2.Damage(\"fire\", 1, 6) + {\n", " pf2.DoS.critical_success: [pf2.Damage(\"fire\", 1, 10, persistent=True)]\n", "}\n", "flaming_rune" ] }, { "cell_type": "markdown", "id": "744d44d9-b6f4-4ff0-a71d-69ba93377d2f", "metadata": {}, "source": [ "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." ] }, { "cell_type": "code", "execution_count": null, "id": "7ce9602d-caec-47b7-b4c4-a61455e070d5", "metadata": {}, "outputs": [], "source": [ "flaming_rapier = rapier + sneak_attack + flaming_rune\n", "flaming_rapier" ] }, { "cell_type": "markdown", "id": "f7912581-eb3b-498b-832a-19fc69935e4d", "metadata": {}, "source": [ "An {class}`~pathfinder2e_stats.ExpandedDamage` is just a fancy mapping of lists of {class}`~pathfinder2e_stats.Damage`, so usual mapping conversion techniques work:" ] }, { "cell_type": "code", "execution_count": null, "id": "8922d369-0898-4ac7-9f13-626460638c32", "metadata": {}, "outputs": [], "source": [ "dict(flaming_rapier)" ] }, { "cell_type": "markdown", "id": "0c4efbe2-8cf9-4a46-b475-7aaf07447a81", "metadata": {}, "source": [ "## Rolling damage\n", "\n", "Now that we have the output of a {func}`~pathfinder2e_stats.check`, like an attack roll or a saving throw, and the {class}`~pathfinder2e_stats.Damage` profile, we can finally roll some {func}`~pathfinder2e_stats.damage`.\n", "\n", "Let's reuse the strike outcome from above to roll damage for the flaming rapier:" ] }, { "cell_type": "code", "execution_count": null, "id": "cd880aaf-b0ca-4ba5-8183-203c6dd3b726", "metadata": {}, "outputs": [], "source": [ "flaming_rapier_damage = pf2.damage(strike, flaming_rapier)\n", "flaming_rapier_damage" ] }, { "cell_type": "markdown", "id": "9cf06c0f-58cb-45a3-adb6-c33e22988281", "metadata": {}, "source": [ "{func}`~pathfinder2e_stats.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:\n", "\n", "- **direct_damage** how much immediate, simple damage we got on each of the 100,000 attacks. This is broken down by `damage_type` between piercing, precision and fire.\n", "- **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.\n", "- **persistent_damage_DC** DC for persistent damage to end on its own each round.\n", "- **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.\n", "- **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.\n", "- **total_damage** the sum of direct damage, persistent damage over all the rounds, and splash damage over multiple targets, with the damage type squashed.\n", "\n", "{func}`~pathfinder2e_stats.damage` also supports defining weaknesses, resistances, and immunities." ] }, { "cell_type": "markdown", "id": "9ddb3fb8-1cff-45f8-b76b-47520f4cfaed", "metadata": {}, "source": [ "We can invoke `.display` here too for a quick overview:" ] }, { "cell_type": "code", "execution_count": null, "id": "b555984d-1c3b-46ce-b518-f1db86c4469c", "metadata": {}, "outputs": [], "source": [ "flaming_rapier_damage.display(transpose=True)" ] }, { "cell_type": "markdown", "id": "a7f7e750-381b-4b00-9a66-04eb413fb71d", "metadata": {}, "source": [ "From here we can start dicing and slicing with standard data science techniques. For example, let's plot the damage distribution:" ] }, { "cell_type": "code", "execution_count": null, "id": "99b9094b-08ed-470c-85b3-d20aec8e6bc3", "metadata": {}, "outputs": [], "source": [ "flaming_rapier_damage.total_damage.to_pandas().hist(\n", " bins=flaming_rapier_damage.total_damage.max().item() + 1\n", ")" ] }, { "cell_type": "markdown", "id": "b250c812-3f4b-4ed6-840e-6cdea4afdb66", "metadata": {}, "source": [ "The above clearly shows the three distributions depending on the outcome of the attack roll:\n", "- **Miss** and **Critical Miss** no damage\n", "- **Hit** 4d6+3\n", "- **Critical Hit** (4d6+3)x2 + 1d8 + 1d10 persistent over up to 3 rounds\n", "\n", "Let's exclude misses:" ] }, { "cell_type": "code", "execution_count": null, "id": "124be276-3385-40a7-8c59-95dfe0522006", "metadata": {}, "outputs": [], "source": [ "rapier_hit_dmg = flaming_rapier_damage.total_damage[\n", " flaming_rapier_damage.outcome >= pf2.DoS.success\n", "]\n", "rapier_hit_dmg.min().item()" ] }, { "cell_type": "code", "execution_count": null, "id": "4c5b0297-810e-4b15-885a-23438b796584", "metadata": {}, "outputs": [], "source": [ "rapier_hit_dmg.to_pandas().hist(bins=rapier_hit_dmg.max().item())" ] }, { "cell_type": "markdown", "id": "f70f5675-83e1-4d38-a782-138d644a3511", "metadata": {}, "source": [ "Let's do the same for the fireball and let's observe the 4 intersecting distributions for the different saving throw outcomes:\n", "- **Critical Success** no damage\n", "- **Success** (6d6)/2\n", "- **Failure** 6d6\n", "- **Critical Failure** (6d6)x2" ] }, { "cell_type": "code", "execution_count": null, "id": "b69ca976-f6c5-4e87-a472-df610c8772aa", "metadata": {}, "outputs": [], "source": [ "fireball_damage = pf2.damage(reflex_save, fireball)\n", "fireball_damage.total_damage.to_pandas().hist(\n", " bins=fireball_damage.total_damage.max().item() + 1\n", ")" ] }, { "cell_type": "markdown", "id": "e780d3f0-8bd9-47ef-949d-b644e5688d97", "metadata": {}, "source": [ "## Multiple targets and what-if analysis\n", "\n", "You may want to hit multiple targets with the same fireball, each rolling a separate saving throw.\n", "\n", "In almost all functions of `pathfinder2e-stats`, instead of scalars you can pass as inputs {class}`~xarray.DataArray` objects with arbitrary dimensions and coordinates.\n", "\n", "Earlier, we had a bandit with +10 Reflex roll against a DC21 fireball:" ] }, { "cell_type": "code", "execution_count": null, "id": "eb0d9de6-403a-4c52-9b8e-207d8ffa0e72", "metadata": {}, "outputs": [], "source": [ "reflex_save = pf2.check(10, DC=21)" ] }, { "cell_type": "markdown", "id": "7d7a7f4a-c6c7-4aa6-a504-5aa93fe9c127", "metadata": {}, "source": [ "Let's have the fireball hit three targets instead:" ] }, { "cell_type": "code", "execution_count": null, "id": "f778042f-eaa9-410f-9ec3-a002d6df873b", "metadata": {}, "outputs": [], "source": [ "import xarray\n", "\n", "reflex_bonus = xarray.DataArray(\n", " [10, 13, 11],\n", " dims=[\"target\"],\n", " coords={\"target\": [\"Alice\", \"Bob\", \"Charlie\"]},\n", ")\n", "reflex_bonus" ] }, { "cell_type": "markdown", "id": "5ac4049d-467c-400f-82b8-c0172859b647", "metadata": {}, "source": [ "In the above example, the `coords` parameter is optional, but helps track what's what in the next steps.\n", "\n", "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.\n", "\n", "In `pathfinder2e-stats`, every time you add extra dimensions to the inputs of {func}`~pathfinder2e_stats.check` or {func}`~pathfinder2e_stats.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`.\n", "\n", "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:" ] }, { "cell_type": "code", "execution_count": null, "id": "011b39a7-acdc-4555-ab26-d9b9d319a385", "metadata": {}, "outputs": [], "source": [ "reflex_save = pf2.check(reflex_bonus, DC=21, independent_dims=[\"target\"])\n", "fireball_damage = pf2.damage(reflex_save, fireball, dependent_dims=[\"target\"])\n", "fireball_damage" ] }, { "cell_type": "markdown", "id": "5e925fa1-9b48-4351-b8f4-3a73af9bfc1e", "metadata": {}, "source": [ "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:" ] }, { "cell_type": "code", "execution_count": null, "id": "27ee6759-674f-4c5a-9567-01c44825cc45", "metadata": {}, "outputs": [], "source": [ "fireball_damage.total_damage.isel(roll=slice(10)).to_pandas()" ] }, { "cell_type": "markdown", "id": "5e46897a-e8ae-47b9-9e4b-60c682756e44", "metadata": {}, "source": [ "Which dimensions are dependent and which are independent depends on the situation.\n", "Consider:\n", "\n", "- You want to hit 3 times with MAP. All attack and damage rolls are independent:" ] }, { "cell_type": "code", "execution_count": null, "id": "387b6dbd-3651-48da-9136-e75df6b98850", "metadata": {}, "outputs": [], "source": [ "MAP = xarray.DataArray([0, -5, -10], dims=[\"strike\"])\n", "strike = pf2.check(14 + MAP, DC=22, independent_dims=[\"strike\"])\n", "damage = pf2.damage(strike, flaming_rapier, independent_dims=[\"strike\"])" ] }, { "cell_type": "markdown", "id": "392bf790-52bc-48de-b448-2eaae1e1acd5", "metadata": {}, "source": [ "- You want to hit two targets with {prd_feats}`Swipe <4795>`. 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:" ] }, { "cell_type": "code", "execution_count": null, "id": "6d1c4900-5ca3-4257-b753-b2c4eb97bc17", "metadata": {}, "outputs": [], "source": [ "AC = xarray.DataArray([22, 15], dims=[\"target\"])\n", "strike = pf2.check(14, DC=AC, dependent_dims=[\"target\"])\n", "damage = pf2.damage(strike, flaming_rapier, dependent_dims=[\"target\"])" ] }, { "cell_type": "markdown", "id": "4bd25ed9-8970-4057-8c3b-4c6720091a2e", "metadata": {}, "source": [ "- 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.\n", "This type hypothetical study is commonly called a *what-if analysis*:" ] }, { "cell_type": "code", "execution_count": null, "id": "2b96c7d0-40cd-41fb-8050-7e93b0539979", "metadata": {}, "outputs": [], "source": [ "off_guard = xarray.DataArray(\n", " [0, -2], dims=[\"off-guard\"], coords={\"off-guard\": [False, True]}\n", ")\n", "strike = pf2.check(14, DC=22 + off_guard, dependent_dims=[\"off-guard\"])\n", "damage = pf2.damage(strike, flaming_rapier, dependent_dims=[\"off-guard\"])" ] }, { "cell_type": "markdown", "id": "5832bca3-ffbe-4d90-9085-686a0848dac1", "metadata": {}, "source": [ "What's the impact on the damage distribution of the off-guard condition, given this specific attack bonus, base AC, and weapon?" ] }, { "cell_type": "code", "execution_count": null, "id": "9b03e69a-317e-4f18-b414-65e928401991", "metadata": {}, "outputs": [], "source": [ "damage.total_damage.display()" ] }, { "cell_type": "markdown", "id": "9da9535a-9885-4fcd-9b63-f1dcf749f87b", "metadata": {}, "source": [ "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:" ] }, { "cell_type": "code", "execution_count": null, "id": "f078d6ef-8285-4738-a2e9-71ecb3c04e9c", "metadata": {}, "outputs": [], "source": [ "strike = pf2.check(\n", " 14 + MAP,\n", " DC=22 + off_guard,\n", " independent_dims=[\"strike\"],\n", " dependent_dims=[\"off-guard\"],\n", ")\n", "damage = pf2.damage(\n", " strike,\n", " flaming_rapier,\n", " independent_dims=[\"strike\"],\n", " dependent_dims=[\"off-guard\"],\n", ")\n", "damage" ] }, { "cell_type": "markdown", "id": "ce3f66de-2de6-4094-84f1-c168e7a59f55", "metadata": {}, "source": [ "What's the mean damage of the three strikes?" ] }, { "cell_type": "code", "execution_count": null, "id": "a35f1cc6-0291-416c-91df-63eb73692f6e", "metadata": {}, "outputs": [], "source": [ "damage.total_damage.mean(\"roll\").to_pandas()" ] }, { "cell_type": "markdown", "id": "45b6b66e-2b80-4c6b-a2ee-5738246a2b21", "metadata": {}, "source": [ "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:" ] }, { "cell_type": "code", "execution_count": null, "id": "ba0aa0a9-db03-4552-a0d6-c2562e5d27ed", "metadata": {}, "outputs": [], "source": [ "pf2.set_config(\n", " check_independent_dims=[\"strike\"],\n", " check_dependent_dims=[\"off-guard\"],\n", " damage_independent_dims=[\"strike\"],\n", " damage_dependent_dims=[\"off-guard\"],\n", ")\n", "strike = pf2.check(14 + MAP, DC=22 + off_guard)\n", "damage = pf2.damage(strike, flaming_rapier)" ] }, { "cell_type": "markdown", "id": "9a63e4b8-c9c4-4305-ba13-8d63ee8a833e", "metadata": {}, "source": [ "## Conditional buffs/debuffs\n", "\n", "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 {prd_conditions}`Concealed <62>` or {prd_conditions}`Hidden <79>` conditions).\n", "\n", "Crucially though, debuffs frequently involve a check that can negate or mitigate them. In `pathfinder2e_stats`, we can chain multiple actions, e.g. a {prd_spells}`Fear <1524>` 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.\n", "\n", "We're going to simulate this as follows:\n", "\n", "1. Call `check` to roll a saving throw vs. Fear;\n", "2. Map its outcome with {func}`~pathfinder2e_stats.map_outcome` to the severity of the Frightned condition;" ] }, { "cell_type": "code", "execution_count": null, "id": "9732389e-4afc-4017-898e-275b6ddf76c2", "metadata": {}, "outputs": [], "source": [ "will_saving_throw = pf2.check(13, DC=21)\n", "frightened = pf2.map_outcome(\n", " will_saving_throw.outcome,\n", " {\n", " pf2.DoS.critical_failure: 3,\n", " pf2.DoS.failure: 2,\n", " pf2.DoS.success: 1,\n", " },\n", ")\n", "frightened.display(\"frightened\")" ] }, { "cell_type": "markdown", "id": "4aca5ac2-7531-4636-b2fe-ee987ce3db24", "metadata": {}, "source": [ "What is the probability to get each level of the Frightened condition from Fear?" ] }, { "cell_type": "code", "execution_count": null, "id": "d9c63715-8332-42cf-9a2f-d0b558c81c83", "metadata": {}, "outputs": [], "source": [ "(\n", " frightened.value_counts(\"roll\", new_dim=\"frightened\", normalize=True)\n", " .to_pandas()\n", " .to_frame(\"%\")\n", ") * 100.0" ] }, { "cell_type": "markdown", "id": "ba66dbb4-918d-4477-9ec7-a73082baf213", "metadata": {}, "source": [ "3. Subtract the Frightened condition from the enemy's AC;\n", "4. Roll `check` again for the Strike: the `DC` parameter will have a `roll` dimension;\n", "5. Roll `damage` as usual." ] }, { "cell_type": "code", "execution_count": null, "id": "0d6ebbf7-cfc7-4286-87eb-766e1e5e2197", "metadata": {}, "outputs": [], "source": [ "strike = pf2.check(14, DC=22 - frightened)\n", "damage = pf2.damage(strike, flaming_rapier)" ] }, { "cell_type": "markdown", "id": "d18e198a-0419-4948-b8be-15a739a7ffc8", "metadata": {}, "source": [ "Note that we didn't need to clarify that `roll` is an independent dimension: `roll` is a special dimension that is always considered independent.\n", "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:" ] }, { "cell_type": "code", "execution_count": null, "id": "7a50ea3e-a508-4f59-af6a-ffb47ac76678", "metadata": {}, "outputs": [], "source": [ "cast_fear = xarray.DataArray(\n", " [False, True], dims=[\"cast_fear\"], coords={\"cast_fear\": [False, True]}\n", ")\n", "strike = pf2.check(14, DC=22 - frightened * cast_fear, dependent_dims=[\"cast_fear\"])\n", "damage = pf2.damage(strike, flaming_rapier, dependent_dims=[\"cast_fear\"])\n", "damage.total_damage.display()" ] }, { "cell_type": "markdown", "id": "93fd38aa-fe13-4b98-a863-fa8b3f793e8a", "metadata": {}, "source": [ "In other words: what was the expected % bonus to damage from casting Fear, before the saving throw was rolled?" ] }, { "cell_type": "code", "execution_count": null, "id": "e24840cd-7a4a-4b51-bf33-7bfe9527bad8", "metadata": {}, "outputs": [], "source": [ "mean_damage = damage.total_damage.mean(\"roll\")\n", "(mean_damage.sel(cast_fear=True) / mean_damage.sel(cast_fear=False)).item()" ] }, { "cell_type": "markdown", "id": "6b1172a6-22ce-4b9d-8a59-b398304f6fac", "metadata": {}, "source": [ "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." ] }, { "cell_type": "markdown", "id": "cd444961-2242-4317-9589-4c1055de6b4a", "metadata": {}, "source": [ "## Flat checks, disrupting actions and conditional actions" ] }, { "cell_type": "markdown", "id": "7a41fac8-21b1-4801-9d9f-0ee549c05ed7", "metadata": {}, "source": [ "Some actions have a chance of failing. Among the most common causes, we have:\n", "- targeting anything that is {prd_conditions}`Concealed <62>` or {prd_conditions}`Hidden <79>`;\n", "- performing an action with the {prd_traits}`Manipulate <645>` trait (like casting most spells) while {prd_conditions}`Grabbed <77>`.\n", "\n", "To model this, we need to introduce a special degree of success, `DoS.no_roll`. {func}`~pathfinder2e_stats.damage` always translates `DoS.no_roll` to zero damage.\n", "It works as follows:\n", "1. roll {func}`~pathfinder2e_stats.check` as normal;\n", "2. roll another flat {func}`~pathfinder2e_stats.check` for the failure chance;\n", "3. override the outcome of the first check with `DoS.no_roll` when the flat check failed;\n", "4. roll {func}`~pathfinder2e_stats.damage` normally." ] }, { "cell_type": "code", "execution_count": null, "id": "d328e1c0-87c4-4b21-bd35-5ef46bf11040", "metadata": {}, "outputs": [], "source": [ "strike = pf2.check(14, DC=22)\n", "flat_check = pf2.check(\n", " DC=5, allow_critical_failure=False, allow_critical_success=False\n", ") # Concealed\n", "strike[\"flat_check\"] = flat_check.outcome\n", "strike.outcome[strike.flat_check == pf2.DoS.failure] = pf2.DoS.no_roll\n", "damage = pf2.damage(strike, flaming_rapier)\n", "\n", "pf2.outcome_counts(strike).to_pandas().to_frame(\"%\") * 100.0" ] }, { "cell_type": "markdown", "id": "50e6df07-dac8-4c2f-a2fa-48347147fca8", "metadata": {}, "source": [ "Actions can also be disrupted; for example we may want to simulate the impact of receiving a {prd_actions}`Reactive Strike <2256>` while casting a spell and risk losing the spell on a critical hit.\n", "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:" ] }, { "cell_type": "code", "execution_count": null, "id": "01f29cb4-b74b-44b0-aa76-2321aea40186", "metadata": {}, "outputs": [], "source": [ "reactive_strike = pf2.check(14, DC=22)\n", "saving_throw = pf2.check(reflex_bonus, DC=21, independent_dims=[\"target\"])\n", "saving_throw[\"disrupted\"] = reactive_strike.outcome == pf2.DoS.critical_success\n", "saving_throw.outcome[saving_throw.disrupted] = pf2.DoS.no_roll\n", "fireball_damage = pf2.damage(saving_throw, fireball, dependent_dims=[\"target\"])" ] }, { "cell_type": "code", "execution_count": null, "id": "82ed440b-e29a-4b65-905e-1b2f4b8428eb", "metadata": {}, "outputs": [], "source": [ "saving_throw.disrupted.value_counts(\n", " \"roll\", new_dim=\"disrupted\", normalize=True\n", ").to_pandas().to_frame(\"%\") * 100.0" ] }, { "cell_type": "markdown", "id": "07d01f0b-1a79-454f-a04d-24fdfd39d46a", "metadata": {}, "source": [ "Test that the fireball never did any damage whenever it was disrupted:" ] }, { "cell_type": "code", "execution_count": null, "id": "e5194529-9ded-4db0-b8d8-2245a64a4b0e", "metadata": {}, "outputs": [], "source": [ "fireball_damage.total_damage[fireball_damage.disrupted].max().item()" ] }, { "cell_type": "markdown", "id": "ca0e4c85-2fef-437b-b557-b837cea9e50a", "metadata": {}, "source": [ "A player or the GM may also completely change course of action depending on the outcome of a roll.\n", "For example, one may be {prd_conditions}`Restrained <90>` and decide that they are going to try to {prd_actions}`Escape <2296>`; if successful they'll cast a spell otherwise they'll try again, unless it's a critical failure:" ] }, { "cell_type": "code", "execution_count": null, "id": "32132e40-b8bf-499b-8600-def87b51bf4c", "metadata": {}, "outputs": [], "source": [ "# Try up to 3 times per round...\n", "escape = pf2.check(8 + MAP, DC=22)\n", "# ..but a critical failure failure stops you from retrying this round.\n", "# We're retrying exclusively when the previous attempt was exactly a failure.\n", "for i in (1, 2):\n", " escape.outcome.isel(strike=i)[\n", " escape.outcome.isel(strike=i - 1) != pf2.DoS.failure\n", " ] = pf2.DoS.no_roll" ] }, { "cell_type": "code", "execution_count": null, "id": "059d1489-9b76-4b6e-aa51-0b424bff8f2c", "metadata": {}, "outputs": [], "source": [ "pf2.outcome_counts(escape).to_pandas() * 100.0" ] }, { "cell_type": "markdown", "id": "51d390fd-f094-420e-82b7-9776e301b808", "metadata": {}, "source": [ "What's the probability of escaping by the end of the round?" ] }, { "cell_type": "code", "execution_count": null, "id": "5927a56e-7aa4-4935-a627-0f24beac150f", "metadata": {}, "outputs": [], "source": [ "(\n", " (escape.outcome >= pf2.DoS.success)\n", " .any(\"strike\")\n", " .value_counts(dim=\"roll\", new_dim=\"escaped\", normalize=True)\n", " .to_pandas()\n", " .to_frame(\"%\")\n", ") * 100.0" ] }, { "cell_type": "markdown", "id": "35ec86d1-e3c9-43b9-a8aa-6db9971ccd83", "metadata": {}, "source": [ "Now let's cast the spell, but only if we escaped on the first round:" ] }, { "cell_type": "code", "execution_count": null, "id": "b6799c42-bf57-456d-b18d-38be2ed13271", "metadata": {}, "outputs": [], "source": [ "can_cast = escape.isel(strike=0).outcome >= pf2.DoS.success\n", "reflex_save = pf2.check(10, DC=21)\n", "reflex_save.outcome[~can_cast] = pf2.DoS.no_roll\n", "pf2.outcome_counts(reflex_save).to_pandas().to_frame(\"%\") * 100.0" ] }, { "cell_type": "markdown", "id": "5c4d63ca-6874-4274-b234-6e25f452acf4", "metadata": {}, "source": [ "## Armory and tables\n", "\n", "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. {doc}`pf2.armory <../armory>` offers a wealth of weapons, runes, spells, and common class features:" ] }, { "cell_type": "code", "execution_count": null, "id": "4fb0985f-5bbc-4073-9149-c363426f98dd", "metadata": {}, "outputs": [], "source": [ "(\n", " pf2.armory.pathfinder.melee.rapier(dice=2)\n", " + pf2.armory.runes.flaming()\n", " + pf2.armory.class_features.rogue.sneak_attack(level=5)\n", ")" ] }, { "cell_type": "markdown", "id": "68bf1930-dc54-43a3-9d02-7e993e1cca08", "metadata": {}, "source": [ "We don't need to calculate our character's attack bonus either. {doc}`pf2.tables.PC ` offers a wealth of precalculated progressions over 20 levels for most optimized character builds:" ] }, { "cell_type": "code", "execution_count": null, "id": "4af716bd-ccba-423c-ae3b-09202e34f405", "metadata": {}, "outputs": [], "source": [ "pf2.tables.PC" ] }, { "cell_type": "markdown", "id": "56f34c6c-0b72-43f3-90bd-3cecab5ed616", "metadata": {}, "source": [ "Each table has a `level` dimension, plus variables and extra dimensions depending on the table:" ] }, { "cell_type": "code", "execution_count": null, "id": "b7a71b6b-88df-4fa0-aa4f-3c99b0d1f4ca", "metadata": {}, "outputs": [], "source": [ "pf2.tables.PC.weapon_proficiency.display()" ] }, { "cell_type": "markdown", "id": "c6a73649-67bb-4735-8977-533d81f4f120", "metadata": {}, "source": [ "We can build the attack bonus of the rogue from earlier by picking what we want from the PC tables:" ] }, { "cell_type": "code", "execution_count": null, "id": "18ed924f-7249-4d31-93d7-c80b85ce913e", "metadata": {}, "outputs": [], "source": [ "rogue_atk_bonus = (\n", " # Start with DEX+4 at level 1 and always increase it\n", " pf2.tables.PC.ability_bonus.boosts.sel(initial=4, drop=True)\n", " # Get an Apex item at level 17 for +1 DEX\n", " + pf2.tables.PC.ability_bonus.apex\n", " # Upgrade weapons as soon as possible: +1 at level 2, +2 at level 10, etc.\n", " + pf2.tables.PC.attack_item_bonus.potency_rune\n", " # Trained (+2) at level 1, Expert (+4) at level 5, Master (+6) at level 13\n", " + pf2.tables.PC.weapon_proficiency.rogue\n", " # Add level to proficiency\n", " + pf2.tables.PC.level\n", ")\n", "rogue_atk_bonus.display(transpose=True)" ] }, { "cell_type": "markdown", "id": "09c017c0-637b-49ab-b8f8-a5f5c37130ea", "metadata": {}, "source": [ "Since adding up all of the above is tedious and error prone, {doc}`pf2.tables.SIMPLE_PC ` is a simplified variant that makes a bunch of (opinable) choices, giving you an even more standardized progression:" ] }, { "cell_type": "code", "execution_count": null, "id": "21458310-8911-4b32-acca-22a368af1eee", "metadata": {}, "outputs": [], "source": [ "rogue_atk_bonus = pf2.tables.SIMPLE_PC.weapon_attack_bonus.rogue\n", "rogue_atk_bonus.display()" ] }, { "cell_type": "markdown", "id": "a92ffe79-331f-4c1c-ae6b-0263ab3f5762", "metadata": {}, "source": [ "So our level 5 rogue will have an attack bonus of" ] }, { "cell_type": "code", "execution_count": null, "id": "7795207a-3fc7-4809-ad37-a488a02bd33a", "metadata": {}, "outputs": [], "source": [ "rogue_atk_bonus.sum(\"component\").sel(level=5).item()" ] }, { "cell_type": "markdown", "id": "a57a85b3-f517-48fa-8623-1a0e0e908d46", "metadata": {}, "source": [ "Note that the above is just a *typical baseline*, and does not take into consideration buffs, debuffs, suboptimal equipment, or uncommon character progression choices.\n", "\n", "There are more {doc}`tables` available:\n", "\n", "- `pf2.tables.DC` contains the {prd_rules}`Difficulty Classes <2627>` tables from the GM Core;\n", "- `pf2.tables.EARN_INCOME` is the {prd_actions}`Earn Income <2364>` table from the Pathfinder and Starfinder Player Core;\n", "- `pf2.tables.NPC` gives you the tables from the {prd_rules}`Building Creatures <2874>` chapter of the GM Core;\n", "- `pf2.tables.SIMPLE_NPC` gives you a simplified version of `NPC` with just three targets to blast\n", " with your attack and spells (or to get blasted by):\n", " - a weak minion of your level - 2 with all stats rated Low;\n", " - a worthy foe of your level with all stats rated Moderate; and\n", " - a boss of your level + 2 with all stats rated High." ] }, { "cell_type": "code", "execution_count": null, "id": "1e4bb1cf-67ab-406d-a14a-e5d93bd45b0b", "metadata": {}, "outputs": [], "source": [ "# One very easy, one average and one very hard enemy at level 5\n", "pf2.tables.SIMPLE_NPC.sel(level=5).display(transpose=True)" ] }, { "cell_type": "markdown", "id": "a27dcdec-ddb7-483b-917d-6f2fac4af66e", "metadata": {}, "source": [ "## Next steps\n", "\n", "Congratulations, you finished the basic tutorial!\n", "\n", "From here, you can go look at the {doc}`index`.\n", "In the {doc}`../api`, you will find many functions, flags and options that were omitted here for the sake of brevity." ] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.13.7" } }, "nbformat": 4, "nbformat_minor": 5 }