{ "cells": [ { "cell_type": "markdown", "id": "c1cba98e-c242-47c9-aae2-76168c1db7e5", "metadata": {}, "source": [ "# Starfinder: Soldier with Fangblade\n", "\n", "Consider a {srd_classes}`Soldier<5-soldier>` (Close Quarters) with a {srd_weapons}`Fangblade <14-fangblade>` (d10 Backswing, Boost d12).\n", "\n", "Which attack routine is better?\n", "\n", "1. Primary Target, Area Fire, Strike (don't use the Boost trait)\n", "2. Boost, Primary Target, Area Fire (Boost damage goes to the Primary Target Strike)\n", "3. Boost, Area Fire (deliberately skip the Primary Target Strike to deal more damage on Area Fire)\n", "4. *Benchmark:* a max-level fireball from a dedicated spellcaster" ] }, { "cell_type": "code", "execution_count": null, "id": "9fa9a497-5cd9-4998-aeb5-90825a1aa4e4", "metadata": {}, "outputs": [], "source": [ "# Install in jupyterlite\n", "%pip install -q pathfinder2e-stats\n", "\n", "import numpy as np\n", "import xarray\n", "\n", "import pathfinder2e_stats as pf2" ] }, { "cell_type": "code", "execution_count": null, "id": "d7efd826-98ce-46bc-95ab-d7e6a8166d13", "metadata": {}, "outputs": [], "source": [ "level = 5\n", "whirling_swipe = True # +1 to area fire DC for Backswing and Swipe weapons\n", "\n", "atk = (\n", " pf2.tables.SIMPLE_PC.weapon_attack_bonus.soldier.sum(\"component\")\n", " .sel(level=level)\n", " .item()\n", ")\n", "area_fire_DC = (\n", " pf2.tables.SIMPLE_PC.area_fire_DC.soldier.sum(\"component\").sel(level=level).item()\n", ")\n", "fireball_DC = (\n", " pf2.tables.SIMPLE_PC.spell_DC.wizard.sum(\"component\").sel(level=level).item()\n", ")\n", "\n", "print(f\"{atk=}, {area_fire_DC=}, {fireball_DC=}\")" ] }, { "cell_type": "code", "execution_count": null, "id": "f9fbb465-410c-4fdf-ac60-e692af8c92ba", "metadata": {}, "outputs": [], "source": [ "weapon_dice = pf2.tables.PC.weapon_dice.improvement.sel(level=level).item()\n", "weapon_specialization = pf2.tables.PC.weapon_specialization.soldier.sel(\n", " level=level\n", ").item()\n", "strength = pf2.tables.PC.ability_bonus.boosts.sel(initial=3).sel(level=level).item()\n", "\n", "fangblade = pf2.armory.starfinder.melee.fangblade(\n", " weapon_dice, strength + weapon_specialization\n", ")\n", "fangblade" ] }, { "cell_type": "code", "execution_count": null, "id": "6dad6076-4d06-44b2-9cf8-bd702f77a1f8", "metadata": {}, "outputs": [], "source": [ "fireball = pf2.armory.spells.fireball(rank=pf2.level2rank(level))\n", "fireball" ] }, { "cell_type": "code", "execution_count": null, "id": "e36263e6-14ad-43d9-a428-989c7add467f", "metadata": {}, "outputs": [], "source": [ "enemy = pf2.tables.SIMPLE_NPC.sel(level=level, drop=True)[\n", " [\"AC\", \"saving_throws\", \"HP\"]\n", "] * xarray.DataArray(\n", " np.ones((3, 4), dtype=int),\n", " dims=[\"target\", \"routine\"],\n", " coords={\n", " \"target\": [\"primary\", \"secondary 1\", \"secondary 2\"],\n", " \"routine\": [\"no_boost\", \"boost_primary\", \"boost_area\", \"fireball\"],\n", " },\n", ")\n", "enemy.isel(target=0, routine=0, drop=True).display()" ] }, { "cell_type": "code", "execution_count": null, "id": "dac5ca8d-9a9f-43c6-b61c-3966338d9225", "metadata": {}, "outputs": [], "source": [ "# Primary and secondary targets use the same damage roll, but save independently.\n", "# 'challenge' and 'routine' are what-if analyses - let's compare the same dice\n", "# rolls in different situations.\n", "pf2.set_config(\n", " check_dependent_dims=(\"challenge\", \"routine\"),\n", " check_independent_dims=(\"target\",),\n", " damage_dependent_dims=(\"challenge\", \"routine\", \"target\"),\n", ")" ] }, { "cell_type": "code", "execution_count": null, "id": "034a1c49-ae10-4710-9b41-b67d9e31031d", "metadata": {}, "outputs": [], "source": [ "strike1 = pf2.check(atk, DC=enemy.AC)\n", "strike1[\"outcome\"] = xarray.where(\n", " (strike1.target != \"primary\")\n", " | (strike1.routine == \"boost_area\")\n", " | (strike1.routine == \"fireball\"),\n", " pf2.DoS.no_roll,\n", " strike1.outcome,\n", ")\n", "strike1 = xarray.concat(\n", " [\n", " pf2.damage(strike1.sel(routine=\"no_boost\"), fangblade.apply_boost(False)),\n", " pf2.damage(strike1.sel(routine=\"boost_primary\"), fangblade.apply_boost(True)),\n", " pf2.damage(strike1.sel(routine=\"boost_area\"), pf2.Damage(\"slashing\", 0, 0)),\n", " pf2.damage(strike1.sel(routine=\"fireball\"), pf2.Damage(\"fire\", 0, 0)),\n", " ],\n", " dim=\"routine\",\n", " join=\"outer\",\n", ")\n", "strike1.total_damage.mean(\"roll\").stack(col=[\"challenge\", \"routine\"]).display()" ] }, { "cell_type": "code", "execution_count": null, "id": "9b7d078b-291c-4592-9567-7dff47324f98", "metadata": {}, "outputs": [], "source": [ "ws_bonus = 1 if whirling_swipe else 0\n", "DC = xarray.DataArray(\n", " [\n", " area_fire_DC + ws_bonus,\n", " area_fire_DC + ws_bonus,\n", " area_fire_DC + ws_bonus,\n", " fireball_DC,\n", " ],\n", " dims=[\"routine\"],\n", ")\n", "\n", "area_fire_check = pf2.check(enemy.saving_throws, DC=DC, primary_target=strike1)\n", "\n", "fangblade_area = fangblade.area_fire()\n", "area_fire = xarray.concat(\n", " [\n", " pf2.damage(\n", " area_fire_check.sel(routine=\"no_boost\"), fangblade_area.apply_boost(False)\n", " ),\n", " pf2.damage(\n", " area_fire_check.sel(routine=\"boost_primary\"),\n", " fangblade_area.apply_boost(False),\n", " ),\n", " pf2.damage(\n", " area_fire_check.sel(routine=\"boost_area\"), fangblade_area.apply_boost(True)\n", " ),\n", " pf2.damage(area_fire_check.sel(routine=\"fireball\"), fireball),\n", " ],\n", " dim=\"routine\",\n", " join=\"outer\",\n", ")\n", "area_fire.total_damage.mean(\"roll\").stack(col=[\"challenge\", \"routine\"]).display()" ] }, { "cell_type": "markdown", "id": "1c04ba06-24a1-4201-95d7-63d71378eb2c", "metadata": {}, "source": [ "**Axes critical specialization:** deal damage equal to the weapon damage dice to an adjacent target.\n", "Conveniently, for 2+ targets in a 5-foot burst, all targets are adjacent by definition.\n", "\n", "Note that Soldiers only get critical specialization in melee weapons when they use them to Area Fire.\n", "\n", "For the sake of simplicity, we'll account for this damage on the target that received the critical hit instead of the one adjacent. We will remove it later for the use case when there is only one target." ] }, { "cell_type": "code", "execution_count": null, "id": "923c29f1-91b5-4b41-9077-45ea40435009", "metadata": {}, "outputs": [], "source": [ "fangblade_area_crit = pf2.armory.critical_specialization.axe(\n", " fangblade.apply_boost(False)\n", ").area_fire()\n", "fangblade_area_crit" ] }, { "cell_type": "code", "execution_count": null, "id": "ab15d246-17b3-4e04-b043-402464ef0cc6", "metadata": {}, "outputs": [], "source": [ "area_crit = xarray.concat(\n", " [\n", " pf2.damage(\n", " area_fire_check.sel(routine=~(area_fire.routine == \"fireball\")),\n", " fangblade_area_crit,\n", " ),\n", " area_fire_check.sel(routine=\"fireball\"),\n", " ],\n", " dim=\"routine\",\n", " join=\"outer\",\n", " data_vars=\"all\",\n", ").fillna(0)\n", "\n", "area_crit.total_damage.mean(\"roll\").stack(col=[\"challenge\", \"routine\"]).display()" ] }, { "cell_type": "code", "execution_count": null, "id": "b1598f88-e02d-4dda-b406-2ea30616b003", "metadata": {}, "outputs": [], "source": [ "# Note: Primary Target does not increase MAP, but Area Fire does\n", "backswing = xarray.where(strike1.outcome < pf2.DoS.success, 1, 0)\n", "strike2 = pf2.check(atk - 5 + backswing, DC=enemy.AC)\n", "strike2[\"outcome\"] = xarray.where(\n", " (strike2.target != \"primary\") | (strike2.routine != \"no_boost\"),\n", " pf2.DoS.no_roll,\n", " strike2.outcome,\n", ")\n", "strike2 = pf2.damage(strike2, fangblade.apply_boost(False))\n", "strike2.total_damage.mean(\"roll\").stack(col=[\"challenge\", \"routine\"]).display()" ] }, { "cell_type": "code", "execution_count": null, "id": "cc2291f6-5be5-4f6a-b6ad-dfa2abde1565", "metadata": {}, "outputs": [], "source": [ "full_round = xarray.concat(\n", " [strike1, area_fire, area_crit, strike2],\n", " dim=\"action\",\n", " join=\"outer\",\n", ")\n", "full_round[\"action\"] = [\"primary_target\", \"area_fire\", \"area_crit\", \"strike\"]" ] }, { "cell_type": "markdown", "id": "64b8ac45-d135-493e-987a-224ba7802d1c", "metadata": {}, "source": [ "## Mean damage" ] }, { "cell_type": "code", "execution_count": null, "id": "cafa521d-c952-4060-8e2e-16f1598cc2f6", "metadata": {}, "outputs": [], "source": [ "total_damage = full_round.total_damage.mean(\"roll\")\n", "total_damage = xarray.concat(\n", " [total_damage, total_damage.sum(\"action\").expand_dims(action=[\"TOTAL\"])],\n", " dim=\"action\",\n", ")\n", "total_damage.stack(row=[\"target\", \"action\"], col=[\"challenge\", \"routine\"]).to_pandas().T" ] }, { "cell_type": "code", "execution_count": null, "id": "7683dff2-d895-4156-bef5-4c565b6c5b8f", "metadata": {}, "outputs": [], "source": [ "grand_total = (\n", " full_round.total_damage.mean(\"roll\")\n", " * xarray.DataArray(\n", " [[1, 0, 0], [1, 1, 0], [1, 1, 1]],\n", " dims=[\"# targets\", \"target\"],\n", " coords={\"# targets\": [1, 2, 3]},\n", " )\n", ").sum(\"target\")" ] }, { "cell_type": "code", "execution_count": null, "id": "c4e2d9e8-4c52-4f68-b493-5874b8b0733a", "metadata": {}, "outputs": [], "source": [ "_, crit_spec_on_one_target = xarray.align(\n", " grand_total,\n", " grand_total.sel({\"# targets\": [1], \"action\": [\"area_crit\"]}),\n", " join=\"outer\",\n", " fill_value=0,\n", ")\n", "\n", "(grand_total - crit_spec_on_one_target).sum(\"action\").stack(\n", " col=[\"challenge\", \"routine\"]\n", ").to_pandas().T" ] }, { "cell_type": "markdown", "id": "6fc92d5c-928d-410d-a407-991d536a9ae9", "metadata": {}, "source": [ "## Conclusions\n", "\n", "The damage output of a Soldier with a Fangblade blows out of the water a fireball in all use cases.\n", "The soldier can repeat their attack every round at no cost.\n", "Additionally, when there are 3 or 4 targets, they have the flexibility to choose to concentrate the damage on the Primary Target (boost->primary target->area fire) or spread it equally (boost->area fire).\n", "\n", "However, the fireball features a much larger area and only costs 2 actions instead of 3." ] } ], "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 }