{ "cells": [ { "cell_type": "markdown", "id": "a160988c-38c3-4fac-b676-39f8932a880a", "metadata": {}, "source": [ "# Exacting Strike\n", "\n", "## The question we want to answer\n", "*\"I'm a level 5 fighter with a +1 Striking Maul. What's my average damage? Is Exacting Strike a good feat?\"*\n", "\n", "Let's simulate striking 3 times in a round, against a target that is not off-guard.\n", "On your second strike, you _may_ use Exacting Strike. If you do, the outcome changes the MAP on your third strike.\n", "Every time you roll a critical hit, the target needs to pass a fortitude save or be knocked prone.\n", "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." ] }, { "cell_type": "code", "execution_count": null, "id": "2cebb647-1c86-4668-9efc-0ffbe1513763", "metadata": {}, "outputs": [], "source": [ "import xarray\n", "\n", "import pathfinder2e_stats as pf2\n", "\n", "%matplotlib inline" ] }, { "cell_type": "markdown", "id": "57bb5de4-f47d-45c8-b3bd-1106cd07c837", "metadata": {}, "source": [ "## Targets\n", "Let's define three targets using {prd_rules}`Building Creatures <2874>` from the GM Core:\n", "- an extra that's 2 levels below us, low AC, and low fortitude\n", "- a monster at our same level, medium AC, and medium fortitude\n", "- a boss that's 2 levels above us, high AC, and high fortitude" ] }, { "cell_type": "code", "execution_count": null, "id": "19eacfb8-3ea7-4b69-8929-0bd928afa707", "metadata": {}, "outputs": [], "source": [ "targets = (\n", " pf2.tables.SIMPLE_NPC[[\"AC\", \"saving_throws\"]]\n", " .rename({\"saving_throws\": \"fortitude\"})\n", " .sel(level=5, drop=True)\n", ")\n", "targets.to_pandas()" ] }, { "cell_type": "markdown", "id": "ae60bde8-b313-49d3-967f-39617d0391bf", "metadata": {}, "source": [ "As this is a what-if analysis, roll a single d20 and compare it against different targets, with and without exacting strike.\n", "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." ] }, { "cell_type": "code", "execution_count": null, "id": "be369445-7903-483f-9453-3793a2cd4653", "metadata": {}, "outputs": [], "source": [ "pf2.set_config(\n", " check_dependent_dims=[\"challenge\", \"exacting_strike\"],\n", " damage_dependent_dims=[\"challenge\", \"exacting_strike\"],\n", ")" ] }, { "cell_type": "markdown", "id": "8d2678b3-efe5-472c-a7c2-d9e5080c2a43", "metadata": {}, "source": [ "## Attacker\n", "Then we define our own stats.\n", "\n", "**Note:** the same way we defined multiple targets using an xarray.Dataset, we could have multiple attackers, for example a fighter vs. a barbarian." ] }, { "cell_type": "code", "execution_count": null, "id": "c295098f-b4fc-4347-9bc3-23cef836e20f", "metadata": {}, "outputs": [], "source": [ "level = 5\n", "\n", "attack_bonus = (\n", " pf2.tables.SIMPLE_PC.weapon_attack_bonus.fighter.sum(\"component\")\n", " .sel(mastery=True, level=level, drop=True)\n", " .item()\n", ")\n", "\n", "class_DC = (\n", " (\n", " 10\n", " + pf2.tables.PC.level\n", " + pf2.tables.PC.class_proficiency.fighter\n", " + pf2.tables.PC.ability_bonus.boosts.sel(initial=4)\n", " )\n", " .sel(level=level)\n", " .item()\n", ")\n", "\n", "print(f\"{attack_bonus=}\\n{class_DC=}\")" ] }, { "cell_type": "code", "execution_count": null, "id": "c9bbc12e-9381-409e-81d8-0234b1b9b56f", "metadata": {}, "outputs": [], "source": [ "damage_dice = pf2.tables.PC.weapon_dice.striking_rune.sel(level=level).item()\n", "damage_bonus = (\n", " (\n", " pf2.tables.PC.ability_bonus.boosts.sel(initial=4)\n", " + pf2.tables.PC.weapon_specialization.fighter.sel(mastery=True)\n", " )\n", " .sel(level=level)\n", " .item()\n", ")\n", "\n", "damage_spec = pf2.armory.hammers.maul(damage_dice, damage_bonus)\n", "damage_spec" ] }, { "cell_type": "markdown", "id": "ddedc5dd-9c4b-4b12-80c7-8c45f8bd9e02", "metadata": {}, "source": [ "## First strike" ] }, { "cell_type": "code", "execution_count": null, "id": "da4d1d4b-28f9-4605-a765-9ce38dcc1cc4", "metadata": {}, "outputs": [], "source": [ "strike1 = pf2.damage(\n", " # This is a what-if analysis of the same attack against multiple targets,\n", " # so we'll roll attack and damage only once and compare it against the\n", " # different ACs.\n", " pf2.check(attack_bonus, DC=targets.AC),\n", " damage_spec,\n", ")\n", "strike1" ] }, { "cell_type": "markdown", "id": "5ad92b21-a220-4ca9-aed4-ae1edc3c4de0", "metadata": {}, "source": [ "The target must roll a Fortitude save after every critical hit or be knocked prone.\n", "Let's pre-roll them in advance as the bonus and DC don't change." ] }, { "cell_type": "code", "execution_count": null, "id": "d38f75f7-36d6-481a-bad4-4d4eb6c2af53", "metadata": {}, "outputs": [], "source": [ "fort_saves = pf2.check(\n", " bonus=targets.fortitude,\n", " DC=class_DC,\n", " allow_critical_success=False,\n", " allow_critical_failure=False,\n", " # Rerun the random number generator 4 times for 4 strikes\n", " # (but all targets use the same roll; see config setting for dependent_dims above)\n", " independent_dims={\"strike\": 4},\n", ")\n", "fort_saves.coords[\"strike\"] = [1, 2, 3, \"reactive\"]\n", "knocked_prone = fort_saves.outcome == pf2.DoS.failure" ] }, { "cell_type": "markdown", "id": "bf4e3893-a7c7-4f85-be95-29a5c9f23c30", "metadata": {}, "source": [ "Visualize the outcome of failing the fortitude save, and consequently getting knocked prone,\n", "on a critical hit. Most of these saving throws won't actually be rolled as we haven't filtered by attack outcome yet.\n", "Note how they're rolled once for the three challenges (with progressively improving outcomes\n", "depending on the different Fortitude bonuses of the three targets), but are rolled independently\n", "for each of the 4 strikes and each of the 100,000 rolls." ] }, { "cell_type": "code", "execution_count": null, "id": "dfbae9bb-0c58-400c-b428-f4b21d3cbf95", "metadata": {}, "outputs": [], "source": [ "knocked_prone.sel(roll=slice(10)).stack(col=[\"strike\", \"challenge\"]).to_pandas()" ] }, { "cell_type": "markdown", "id": "72aa45c4-e6e7-4e12-b16d-3e911bdbc975", "metadata": {}, "source": [ "Calculate the chance of being knocked prone by the first strike" ] }, { "cell_type": "code", "execution_count": null, "id": "c406ebfc-58f8-4438-bec2-57d2b177a23c", "metadata": {}, "outputs": [], "source": [ "knocked_prone_1 = knocked_prone.sel(strike=\"1\", drop=True).where(\n", " strike1.outcome == pf2.DoS.critical_success, False\n", ")\n", "knocked_prone_1.mean(\"roll\").round(2).to_pandas()" ] }, { "cell_type": "markdown", "id": "f65b71fb-4e02-4a58-acfc-d7ed7b93fdcd", "metadata": {}, "source": [ "## Second strike\n", "The AC changes depending if the target has been knocked prone by the first strike or not." ] }, { "cell_type": "code", "execution_count": null, "id": "3120f2e6-fc91-4b84-9b88-3814eea85a26", "metadata": {}, "outputs": [], "source": [ "strike2 = pf2.damage(\n", " pf2.check(attack_bonus - 5, DC=targets.AC - knocked_prone_1 * 2),\n", " damage_spec,\n", ")" ] }, { "cell_type": "markdown", "id": "fdb12dfa-0442-4b6e-93a8-0a1c846da23a", "metadata": {}, "source": [ "What is the chance of being knocked prone by the first or the second strike?" ] }, { "cell_type": "code", "execution_count": null, "id": "ff1f3d01-37b9-4802-be78-3e2740bdc32f", "metadata": {}, "outputs": [], "source": [ "knocked_prone_2 = (\n", " knocked_prone.sel(strike=\"2\", drop=True).where(\n", " strike2.outcome == pf2.DoS.critical_success, False\n", " )\n", ") | knocked_prone_1\n", "knocked_prone_2.mean(\"roll\").to_pandas().to_frame(\"%\") * 100.0" ] }, { "cell_type": "markdown", "id": "bc92b81b-7444-406b-b324-e09bf86cae2e", "metadata": {}, "source": [ "## Third strike\n", "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." ] }, { "cell_type": "code", "execution_count": null, "id": "9cc12837-5ec7-4905-bd8a-1c55cf5992c4", "metadata": {}, "outputs": [], "source": [ "MAP3 = xarray.concat(\n", " [xarray.DataArray(10), xarray.where(strike2.outcome == pf2.DoS.failure, 5, 10)],\n", " dim=\"exacting_strike\",\n", ")\n", "MAP3.coords[\"exacting_strike\"] = [False, True]\n", "\n", "strike3 = pf2.damage(\n", " pf2.check(\n", " attack_bonus - MAP3,\n", " DC=targets.AC - knocked_prone_2 * 2,\n", " ),\n", " damage_spec,\n", ")" ] }, { "cell_type": "markdown", "id": "a5bbaba4-97ab-421e-9586-c2bad05df06a", "metadata": {}, "source": [ "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`." ] }, { "cell_type": "code", "execution_count": null, "id": "c1aff80d-241e-4116-9fdd-8b4bd0a23ae1", "metadata": {}, "outputs": [], "source": [ "strike3.bonus.isel(roll=slice(10)).stack(\n", " col=[\"exacting_strike\", \"challenge\"]\n", ").to_pandas()" ] }, { "cell_type": "markdown", "id": "7f83b526-43b9-4d9f-9b68-bce0d5bd56b5", "metadata": {}, "source": [ "What is the chance that the target will be prone by the end of the round?" ] }, { "cell_type": "code", "execution_count": null, "id": "7ddef3e6-4944-4ba2-b24f-3d44c88d8caa", "metadata": {}, "outputs": [], "source": [ "knocked_prone_3 = (\n", " knocked_prone.sel(strike=\"3\", drop=True).where(\n", " strike3.outcome == pf2.DoS.critical_success, False\n", " )\n", ") | knocked_prone_2\n", "knocked_prone_3.mean(\"roll\").round(2).to_pandas()" ] }, { "cell_type": "markdown", "id": "6ee02b33-d047-473c-bf0a-7a9f7f707911", "metadata": {}, "source": [ "And finally the reactive strike, which happens only if the target is prone by the end of the round." ] }, { "cell_type": "code", "execution_count": null, "id": "2e4681cf-7d1f-4577-9754-1fa68a87e68f", "metadata": {}, "outputs": [], "source": [ "# Reactive Strike does not benefit from prone\n", "reactive_strike_check = pf2.check(attack_bonus, DC=targets.AC)\n", "# Don't roll reactive strike unless the target is prone\n", "reactive_strike_check[\"outcome\"] = reactive_strike_check.outcome.where(\n", " knocked_prone_3, pf2.DoS.no_roll\n", ")\n", "reactive_strike = pf2.damage(reactive_strike_check, damage_spec)\n", "\n", "knocked_prone_by_reactive_strike = knocked_prone.sel(\n", " strike=\"reactive\", drop=True\n", ").where(reactive_strike.outcome == pf2.DoS.critical_success, False)" ] }, { "cell_type": "markdown", "id": "90246eda-1881-4906-b2fa-7b9c361deb1f", "metadata": {}, "source": [ "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?" ] }, { "cell_type": "code", "execution_count": null, "id": "a653dc97-d72c-497b-a023-65969316f5d1", "metadata": {}, "outputs": [], "source": [ "knocked_prone_by_reactive_strike.mean(\"roll\").to_pandas() * 100.0" ] }, { "cell_type": "markdown", "id": "45c26193-c1e9-461d-b97f-cf5e238ec3c9", "metadata": {}, "source": [ "We're done! Let's assemble our aggregated object." ] }, { "cell_type": "code", "execution_count": null, "id": "c1854aac-9fcc-42b8-b055-0924e31e7206", "metadata": {}, "outputs": [], "source": [ "all_strikes = xarray.concat([strike1, strike2, strike3, reactive_strike], dim=\"strike\")\n", "all_strikes.coords[\"strike\"] = fort_saves.strike\n", "all_strikes[\"prone_at_end_of_round\"] = knocked_prone_3\n", "all_strikes[\"prone_on_reactive_strike\"] = knocked_prone_by_reactive_strike\n", "all_strikes" ] }, { "cell_type": "markdown", "id": "f6e20160-4949-4632-8d7f-64c17e43e60c", "metadata": {}, "source": [ "We can finally aggregate our measures to gather insights." ] }, { "cell_type": "code", "execution_count": null, "id": "f68e686a-3edb-4d48-aae7-e07bcd806ad1", "metadata": {}, "outputs": [], "source": [ "agg_measures = all_strikes.sum(\"strike\").mean(\"roll\")\n", "agg_measures[\"any_damage\"] = (all_strikes[\"total_damage\"].sum(\"strike\") > 0).mean(\n", " \"roll\"\n", ")\n", "agg_measures = agg_measures[\n", " [\"total_damage\", \"any_damage\", \"prone_at_end_of_round\", \"prone_on_reactive_strike\"]\n", "]\n", "agg_measures.stack(idx=[\"challenge\", \"exacting_strike\"]).to_array(\"measure\").round(\n", " 2\n", ").T.to_pandas()" ] }, { "cell_type": "markdown", "id": "ae02ab1e-004b-47b8-803e-f17eec9824ac", "metadata": {}, "source": [ "What's the % benefit of exacting strike compared to three regular strikes?" ] }, { "cell_type": "code", "execution_count": null, "id": "9c72d45a-cd80-4251-b08b-fbb3270b28a8", "metadata": {}, "outputs": [], "source": [ "(\n", " agg_measures.sel(exacting_strike=True) / agg_measures.sel(exacting_strike=False)\n", ").to_pandas()" ] }, { "cell_type": "markdown", "id": "17dc719b-b8b0-4c62-9bf9-c178ec211f98", "metadata": {}, "source": [ "## Conclusions" ] }, { "cell_type": "markdown", "id": "d4b32a51-7762-4f77-b75b-a2ef35e9e9e7", "metadata": {}, "source": [ "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.\n", "\n", "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.\n", "\n", "What's the damage distribution?" ] }, { "cell_type": "code", "execution_count": null, "id": "825a7bef-5db5-475b-9603-5376bf48c6a3", "metadata": {}, "outputs": [], "source": [ "total_damage = all_strikes[\"total_damage\"].sum(\"strike\")\n", "means = total_damage.mean(\"roll\").to_pandas()\n", "stds = total_damage.std(\"roll\").to_pandas()\n", "_ = means.plot.barh(\n", " xerr=stds,\n", " title=\"Damage over 3 strikes, with and without Exacting Strike: mean+stddev\",\n", ")" ] }, { "cell_type": "markdown", "id": "7dceffb0-6be4-4f7c-b75a-6de07089981f", "metadata": {}, "source": [ "## Homework\n", "\n", "- How does Exacting Strike perform compared to Vicious Swing?\n", "- 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)?\n", "- How does a two-hander (e.g. maul) perform compared to two one-handers (e.g. warhammer and light hammer) with Double Slice?\n", "- What's the damage distribution of a barbarian vs. that of a fighter?\n", "- How much extra damage, on average, does a +1 to hit (or a -1 to AC) yield?\n", "\n", "## Last words\n", "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. " ] } ], "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.5" } }, "nbformat": 4, "nbformat_minor": 5 }