{ "cells": [ { "cell_type": "markdown", "id": "82bf6bda-c640-4c26-9b71-f87f04b75ba7", "metadata": {}, "source": [ "# Add injury to insult\n", "*A case study of murdering someone with the right choice of words.*" ] }, { "cell_type": "code", "execution_count": null, "id": "5d47a62d-23f9-4230-9750-faa3ec1707b8", "metadata": {}, "outputs": [], "source": [ "# Install in JupyterLite\n", "%pip install -q pathfinder2e-stats\n", "\n", "import matplotlib as mpl # noqa: F401 # Needed by JupyterLite\n", "import numpy as np\n", "import xarray\n", "\n", "import pathfinder2e_stats as pf2" ] }, { "cell_type": "markdown", "id": "9144b37a-3e64-4ab5-aeb9-ed64bfa38bf3", "metadata": {}, "source": [ "## Attacker\n", "Nyah, level 5 witch (The Resentment)\n", "**Skills** Diplomacy +14 (Bon Mot)\n", "\n", "**Occult Spells** DC 21; **3rd** Paralyze, Biting Words; **2nd** Blistering Invective; **1st** Sure Strike; **Cantrips (3rd)** Evil Eye, Guidance\n", "\n", "## Attack routine\n", "1. Bon Mot ➡ Blistering Invective\n", "2. Paralyze ➡ Evil Eye\n", "3. Evil Eye ➡ Biting Words\n", "4. Evil Eye ➡ Sure Strike ➡ Biting Words attack\n", "5. Evil Eye ➡ Guidance ➡ Biting Words attack\n", "\n", "## Assumptions\n", "- The target attempts to clear neither Bon Mot nor Sickened\n", "- No movement is needed; the target remains within 30ft at all times\n", "- Spellcasting is not disrupted or obstructed in any way\n", "- Ignoring damage dealt to other creatures by casting heightened blistering invective" ] }, { "cell_type": "code", "execution_count": null, "id": "ee0024ad-5f26-4ce4-aeb9-020adc16587c", "metadata": {}, "outputs": [], "source": [ "level = 5\n", "\n", "diplomacy = (\n", " (\n", " pf2.tables.PC.level\n", " + pf2.tables.PC.skill_proficiency.others.sel(priority=1)\n", " + pf2.tables.PC.ability_bonus.boosts.sel(initial=3)\n", " + pf2.tables.PC.skill_item_bonus.diplomacy\n", " )\n", " .sel(level=level)\n", " .item()\n", ")\n", "\n", "spell_DC = pf2.tables.SIMPLE_PC.spell_DC.witch.sum(\"component\").sel(level=level).item()\n", "\n", "print(f\"{diplomacy=} {spell_DC=}\")" ] }, { "cell_type": "code", "execution_count": null, "id": "b7874760-1960-49f9-843a-db8a7a012941", "metadata": {}, "outputs": [], "source": [ "# You can change any of these to upcast or downcast them;\n", "# damage and incapacitation trait are adjusted automatically\n", "blistering_invective_rank = 2\n", "paralyze_rank = 3\n", "biting_words_rank = 3" ] }, { "cell_type": "markdown", "id": "c4a1a3a1-68c5-46f6-b7a3-649bb91c10ea", "metadata": {}, "source": [ "## Targets" ] }, { "cell_type": "code", "execution_count": null, "id": "cd2630cf-e49e-48e7-965c-df82d79703ca", "metadata": {}, "outputs": [], "source": [ "targets = xarray.Dataset(\n", " {\n", " \"target\": [\n", " \"The Stag Lord\",\n", " \"Ettin\",\n", " \"Vampire Count\",\n", " \"Hill Giant\",\n", " \"Dweomercat\",\n", " \"Sphinx\",\n", " ],\n", " \"level\": (\"target\", [6, 6, 6, 7, 7, 8]),\n", " \"HP\": (\"target\", [110, 110, 65, 140, 100, 135]),\n", " \"AC\": (\"target\", [23, 21, 24, 24, 25, 27]),\n", " \"Will\": (\"target\", [9, 12, 17, 13, 17, 19]),\n", " \"bonus_save_vs_magic\": (\"target\", [0, 0, 0, 0, 1, 0]),\n", " \"sickened\": (\"target\", [1, 0, 0, 0, 0, 0]),\n", " }\n", ")\n", "targets[\"rank\"] = pf2.level2rank(targets.level)\n", "targets.to_pandas()" ] }, { "cell_type": "markdown", "id": "3e365d6f-5fa4-4243-9717-87bf53fd587f", "metadata": {}, "source": [ "As this is a what-if analysis, roll a single d20 and compare it against different targets.\n", "We don't want to repeat `dependent_dims=[\"target\"]` for every call of check() and damage(); so we're going to set it as thread-wide configuration. As we're going to analyse multiple rounds and we're going to use a ``round`` independent dimension later, let's take the opportunity to configure it now too." ] }, { "cell_type": "code", "execution_count": null, "id": "d22d8a0c-1bc7-4ce4-8a5f-866d22d73b88", "metadata": {}, "outputs": [], "source": [ "pf2.set_config(\n", " check_independent_dims=[\"round\"],\n", " check_dependent_dims=[\"target\"],\n", " damage_independent_dims=[\"round\"],\n", " damage_dependent_dims=[\"target\"],\n", ")" ] }, { "cell_type": "markdown", "id": "2343a359-2bd0-4316-9963-bebca28d28c5", "metadata": {}, "source": [ "## Attack routine\n", "### Round 1: Bon Mot ➡ Blistering Invective" ] }, { "cell_type": "code", "execution_count": null, "id": "242db822-3827-4fd3-9d3e-2bf6757c6b1e", "metadata": {}, "outputs": [], "source": [ "bon_mot = pf2.check(\n", " diplomacy,\n", " DC=targets.Will + 10 - targets.sickened,\n", ")\n", "bon_mot[\"Will_penalty\"] = pf2.map_outcome(\n", " bon_mot.outcome,\n", " {pf2.DoS.success: 2, pf2.DoS.critical_success: 3},\n", ")\n", "\n", "sickened = [targets.sickened]\n", "will = [\n", " pf2.sum_bonuses(\n", " (\"untyped\", targets.Will),\n", " (\"status\", targets.bonus_save_vs_magic),\n", " (\"status\", -targets.sickened),\n", " (\"status\", -bon_mot.Will_penalty),\n", " )\n", "]" ] }, { "cell_type": "code", "execution_count": null, "id": "03a31c1f-3f9d-4e8a-8592-a3176a15195c", "metadata": {}, "outputs": [], "source": [ "blistering_invective_spec = pf2.armory.spells.blistering_invective(\n", " blistering_invective_rank\n", ")\n", "blistering_invective_spec" ] }, { "cell_type": "code", "execution_count": null, "id": "9010ef00-0eed-42c3-a93f-9ab4db5b09f8", "metadata": {}, "outputs": [], "source": [ "blistering_invective = pf2.damage(\n", " pf2.check(will[0], DC=spell_DC),\n", " blistering_invective_spec,\n", " persistent_damage_rounds=5,\n", ").rename({\"persistent_round\": \"round\"})\n", "\n", "blistering_invective_damage = (\n", " blistering_invective[\"persistent_damage\"]\n", " .where(blistering_invective[\"apply_persistent_damage\"], 0)\n", " .sum(\"damage_type\")\n", ")" ] }, { "cell_type": "markdown", "id": "f8c0f9b0-3a7a-4ba1-bb8a-e10fb548eab6", "metadata": {}, "source": [ "Probability of being on fire, by target by round" ] }, { "cell_type": "code", "execution_count": null, "id": "700bacd9-8206-4f3f-8f60-3b461579066f", "metadata": {}, "outputs": [], "source": [ "(blistering_invective_damage > 0).mean(\"roll\").round(2).to_pandas()" ] }, { "cell_type": "markdown", "id": "f2d5c287-19e7-4f20-b6c2-945b05509dde", "metadata": {}, "source": [ "Mean damage of Blistering Invective every round (assuming no actions are spent putting the fire out)" ] }, { "cell_type": "code", "execution_count": null, "id": "75cb2313-befc-4734-9f68-7bbe8bd3fb52", "metadata": {}, "outputs": [], "source": [ "blistering_invective_damage.mean(\"roll\").round(2).to_pandas()" ] }, { "cell_type": "code", "execution_count": null, "id": "b1db4fea-1333-4811-989d-e7bd8ff70ba3", "metadata": {}, "outputs": [], "source": [ "frightened = pf2.map_outcome(\n", " blistering_invective[\"outcome\"],\n", " {pf2.DoS.failure: 1, pf2.DoS.critical_failure: 2},\n", ")\n", "# The frightened condition decays with every round that passes\n", "frightened = np.maximum(0, frightened - blistering_invective[\"round\"])" ] }, { "cell_type": "code", "execution_count": null, "id": "9d78f700-ca6f-4a9f-995b-99423e6115bf", "metadata": {}, "outputs": [], "source": [ "roll_with_high_frightened = np.unique(\n", " frightened,\n", " return_index=True,\n", " axis=frightened.dims.index(\"roll\"),\n", ")[1][-2]\n", "frightened.isel(roll=roll_with_high_frightened).to_pandas()" ] }, { "cell_type": "markdown", "id": "f4e2d5be-fd8b-4aa4-8ba9-f1cc790581f4", "metadata": {}, "source": [ "### Round 2: Paralyze ➡ Evil Eye" ] }, { "cell_type": "code", "execution_count": null, "id": "915619b3-9deb-459d-bf02-ee998d4499b5", "metadata": {}, "outputs": [], "source": [ "will.append(\n", " pf2.sum_bonuses(\n", " (\"untyped\", targets.Will),\n", " (\"status\", targets.bonus_save_vs_magic),\n", " (\"status\", -sickened[-1]),\n", " (\"status\", -bon_mot.Will_penalty),\n", " (\"status\", -frightened.isel(round=1, drop=True)),\n", " )\n", ")\n", "paralyze = pf2.check(\n", " bonus=will[-1],\n", " DC=spell_DC,\n", " incapacitation=targets[\"rank\"] > paralyze_rank,\n", ")\n", "\n", "# In case of failure, we use Evil Eye to extend the paralysis for the whole combat\n", "paralyze[\"need_evil_eye\"] = paralyze.outcome <= pf2.DoS.failure\n", "paralyze[\"off_guard\"] = paralyze.outcome <= pf2.DoS.failure" ] }, { "cell_type": "markdown", "id": "c3fcbd43-d30a-4064-8621-226d570e4a61", "metadata": {}, "source": [ "Probability of the target being paralyzed, as well as of needing to cast Evil Eye every round in order to maintain the condition for the whole fight.\n", "\n", "**FIXME:** a critical failure on the initial saving throw followed by a critical success on any of the consecutive rounds will cause the target to snap out of paralysis early. This is not modelled here yet." ] }, { "cell_type": "code", "execution_count": null, "id": "f6266502-7bea-4825-8555-f1bc292a4d57", "metadata": {}, "outputs": [], "source": [ "paralyze[\"need_evil_eye\"].mean(\"roll\").round(2).to_pandas().to_frame(\"% paralyzed\")" ] }, { "cell_type": "code", "execution_count": null, "id": "598280de-cdb5-4131-aa01-25a3d33a426d", "metadata": {}, "outputs": [], "source": [ "def evil_eye(will_bonus, spell_DC):\n", " c = pf2.check(will_bonus, DC=spell_DC).outcome\n", " return pf2.map_outcome(c, {pf2.DoS.critical_failure: 2, pf2.DoS.failure: 1})\n", "\n", "\n", "sickened.append(np.maximum(sickened[-1], evil_eye(will[-1], spell_DC)))" ] }, { "cell_type": "markdown", "id": "4a97dac9-2bc9-4a64-b93b-b0a6e478956e", "metadata": {}, "source": [ "### Round 3: Evil Eye ➡ Biting Words\n", "If the target scored a simple failure vs. Paralyze in round 2, extend its duration with Evil Eye.\n", "Then, cast Biting Words.\n", "### Round 4: Evil Eye ➡ Sure Strike ➡ Biting Words attack\n", "### Round 5: Evil Eye ➡ Guidance ➡ Biting Words attack" ] }, { "cell_type": "code", "execution_count": null, "id": "d4fcaf4a-9895-4fdf-902c-ad0daeb3786e", "metadata": {}, "outputs": [], "source": [ "for _round in range(2, 5):\n", " will.append(\n", " pf2.sum_bonuses(\n", " (\"untyped\", targets.Will),\n", " (\"status\", targets.bonus_save_vs_magic),\n", " (\"status\", -sickened[-1]),\n", " (\"status\", -bon_mot.Will_penalty),\n", " )\n", " )\n", " sickened.append(np.maximum(sickened[-1], evil_eye(will[-1], spell_DC)))\n", "\n", "assert len(will) == 5\n", "assert len(sickened) == 5\n", "\n", "will = xarray.concat(will, dim=\"round\")\n", "sickened = xarray.concat(sickened, dim=\"round\")\n", "sure_strike = xarray.DataArray([False, False, False, True, False], dims=[\"round\"])\n", "guidance = xarray.DataArray([False, False, False, False, True], dims=[\"round\"])" ] }, { "cell_type": "markdown", "id": "2e30cf6d-51ed-4a79-958f-52aac06e2770", "metadata": {}, "source": [ "Mean Will saves debuff by target and round" ] }, { "cell_type": "code", "execution_count": null, "id": "78d7ed3a-ca46-4f8e-bb70-b234aec93344", "metadata": {}, "outputs": [], "source": [ "(will - targets.Will).mean(\"roll\").round(2).T.to_pandas()" ] }, { "cell_type": "code", "execution_count": null, "id": "32c36db4-8219-4ca8-aec4-1c9c44908b73", "metadata": {}, "outputs": [], "source": [ "off_guard = xarray.concat(\n", " [\n", " xarray.DataArray(False),\n", " paralyze.off_guard.expand_dims(round=4),\n", " ],\n", " dim=\"round\",\n", ")\n", "AC = pf2.sum_bonuses(\n", " (\"untyped\", targets.AC),\n", " (\"status\", -frightened),\n", " (\"status\", -sickened),\n", " (\"circumstance\", off_guard.astype(int) * -2),\n", ")" ] }, { "cell_type": "markdown", "id": "5efe611c-07b5-477e-9157-11ac16400291", "metadata": {}, "source": [ "Mean Armor Class debuff by target and round" ] }, { "cell_type": "code", "execution_count": null, "id": "fa833fe2-6290-43c2-ba77-12ca1f8ef245", "metadata": {}, "outputs": [], "source": [ "(AC - targets.AC).mean(\"roll\").round(2).sel(drop=True).to_pandas()" ] }, { "cell_type": "markdown", "id": "8010d78c-d606-46fb-91b6-299ba09c7e5c", "metadata": {}, "source": [ "In round 3, we use Sure Strike only if we don't need to extend the duration of paralyze.\n", "In round 4 and 5, we always use Sure Strike." ] }, { "cell_type": "code", "execution_count": null, "id": "edcca9de-1703-4ced-844a-9364cd832be0", "metadata": {}, "outputs": [], "source": [ "biting_words_check = pf2.check(\n", " spell_DC - 10 + guidance,\n", " DC=AC,\n", " fortune=sure_strike,\n", ")\n", "biting_words_check[\"outcome\"] = biting_words_check[\"outcome\"].where(\n", " AC[\"round\"] >= 2, pf2.DoS.no_roll\n", ")\n", "\n", "biting_words_damage = pf2.damage(\n", " biting_words_check,\n", " pf2.armory.spells.biting_words(biting_words_rank),\n", ").total_damage" ] }, { "cell_type": "markdown", "id": "57c9b85d-7736-46d5-94ec-8cea6f5d616a", "metadata": {}, "source": [ "Mean damage of Biting Words by target and round" ] }, { "cell_type": "code", "execution_count": null, "id": "9c2f3708-6c4b-4d6d-ba41-7db6a2adfcb3", "metadata": {}, "outputs": [], "source": [ "(biting_words_damage.mean(\"roll\").round(2).to_pandas())" ] }, { "cell_type": "markdown", "id": "42c3e885-be0b-4c70-a44a-7afacfa79e0e", "metadata": {}, "source": [ "## Put it all together" ] }, { "cell_type": "code", "execution_count": null, "id": "57d9947b-3922-4815-82eb-3436e09ec3c5", "metadata": {}, "outputs": [], "source": [ "final = xarray.Dataset(\n", " {\n", " \"AC\": AC,\n", " \"Will\": will,\n", " \"off_guard\": paralyze.off_guard,\n", " \"need_evil_eye\": paralyze.need_evil_eye,\n", " \"blistering_invective\": blistering_invective_damage,\n", " \"biting_words\": biting_words_damage,\n", " \"total_damage\": blistering_invective_damage + biting_words_damage,\n", " }\n", ").transpose(\"target\", \"roll\", \"round\")\n", "final[\"harmed\"] = final.total_damage.sum(\"round\") > 0\n", "final[\"bloodied\"] = final.total_damage.sum(\"round\") > targets.HP // 2\n", "final[\"killed\"] = final.total_damage.sum(\"round\") >= targets.HP\n", "final" ] }, { "cell_type": "markdown", "id": "da162a65-2559-4fb2-8e37-7bde09415721", "metadata": {}, "source": [ "## Let's analyse our results!\n", "### Mean cumulative damage by the end of the attack routine" ] }, { "cell_type": "code", "execution_count": null, "id": "69d45f7b-6634-4486-81f4-d61eec4ff43e", "metadata": {}, "outputs": [], "source": [ "(\n", " final[[\"blistering_invective\", \"biting_words\", \"total_damage\"]]\n", " .mean(\"roll\")\n", " .sum(\"round\")\n", " .round(2)\n", " .to_pandas()\n", ")" ] }, { "cell_type": "markdown", "id": "7bdfbff6-4fa1-4f70-8392-ca0496e44314", "metadata": {}, "source": [ "### Various probabilities\n", "\n", "- Probability of dealing any HP damage at all\n", "- Probability of dealing more than 50% HP damage\n", "- Probability of solo killing the target\n", "- Probability of paralyzing the target in round 2\n", "- Probability of needing to spam evil eye every round to keep the target paralyzed" ] }, { "cell_type": "code", "execution_count": null, "id": "927bfc4e-527e-4914-9a6a-1c6f52b47d41", "metadata": {}, "outputs": [], "source": [ "(\n", " final[[\"harmed\", \"bloodied\", \"killed\", \"off_guard\", \"need_evil_eye\"]]\n", " .mean(\"roll\")\n", " .round(2)\n", " .to_pandas()\n", ")" ] }, { "cell_type": "markdown", "id": "176f0f15-0ba3-4856-ab19-59b013f3017e", "metadata": {}, "source": [ "### Damage distribution, normalized by target's Hit Points total" ] }, { "cell_type": "code", "execution_count": null, "id": "4a157fb7-8dcb-4f60-9a98-611646b0c0dc", "metadata": {}, "outputs": [], "source": [ "_ = (\n", " (final[\"total_damage\"].sum(\"round\") / targets.HP)\n", " .to_pandas()\n", " .T.describe()\n", " .T.plot(\n", " kind=\"barh\",\n", " y=\"mean\",\n", " xerr=\"std\",\n", " legend=False,\n", " title=\"Damage as % of total HP after round 5; mean+stddev\",\n", " )\n", ")" ] } ], "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 }