{ "cells": [ { "cell_type": "markdown", "id": "c1cba98e-c242-47c9-aae2-76168c1db7e5", "metadata": {}, "source": [ "# Starfinder: Soldier's Punishing Salvo\n", "\n", "A {srd_classes}`Soldier<5-soldier>` with a {srd_weapons}`Stellar Cannon <62-stellar-cannon>` shoots with Area Fire at two targets (a primary and a secondary), followed by a Punishing Salvo against the same primary target. How's the damage distribution? What are the chances that the targets will be suppressed?" ] }, { "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 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": "code", "execution_count": null, "id": "d7efd826-98ce-46bc-95ab-d7e6a8166d13", "metadata": {}, "outputs": [], "source": [ "level = 5\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", "print(f\"{atk=}, {area_fire_DC=}\")" ] }, { "cell_type": "code", "execution_count": null, "id": "04a6ba38-976e-4328-9219-709456602d9d", "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", "stellar_cannon = pf2.armory.starfinder.ranged.stellar_cannon(\n", " weapon_dice, weapon_specialization\n", ") + pf2.armory.upgrades.auto(level=level)\n", "stellar_cannon" ] }, { "cell_type": "code", "execution_count": null, "id": "d93a6f5c-0b75-463d-9130-6be5b8361a04", "metadata": {}, "outputs": [], "source": [ "enemy = pf2.tables.SIMPLE_NPC.sel(level=level, drop=True)[\n", " [\"AC\", \"saving_throws\", \"HP\"]\n", "] * xarray.DataArray(\n", " [1, 1], dims=[\"target\"], coords={\"target\": [\"primary\", \"secondary\"]}\n", ")\n", "enemy.isel(target=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' is a what-if analysis - let's compare the same dice rolls against\n", "# progressively harder-to-hit enemies.\n", "pf2.set_config(\n", " check_dependent_dims=(\"challenge\",),\n", " check_independent_dims=(\"target\",),\n", " damage_dependent_dims=(\"challenge\", \"target\"),\n", ")" ] }, { "cell_type": "code", "execution_count": null, "id": "034a1c49-ae10-4710-9b41-b67d9e31031d", "metadata": {}, "outputs": [], "source": [ "primary_target = pf2.check(atk, DC=enemy.AC)\n", "primary_target[\"outcome\"] = xarray.where(\n", " primary_target.target == \"secondary\",\n", " pf2.DoS.no_roll,\n", " primary_target.outcome,\n", ")\n", "primary_target = pf2.damage(primary_target, stellar_cannon)" ] }, { "cell_type": "code", "execution_count": null, "id": "9b7d078b-291c-4592-9567-7dff47324f98", "metadata": {}, "outputs": [], "source": [ "area_fire = pf2.damage(\n", " pf2.check(enemy.saving_throws, DC=area_fire_DC, primary_target=primary_target),\n", " stellar_cannon.area_fire(),\n", " dependent_dims=[\"target\"],\n", ")" ] }, { "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", "punishing_salvo = pf2.check(atk - 5, DC=enemy.AC)\n", "punishing_salvo[\"outcome\"] = xarray.where(\n", " punishing_salvo.target == \"secondary\",\n", " pf2.DoS.no_roll,\n", " punishing_salvo.outcome,\n", ")\n", "punishing_salvo = pf2.damage(punishing_salvo, stellar_cannon)" ] }, { "cell_type": "code", "execution_count": null, "id": "cc2291f6-5be5-4f6a-b6ad-dfa2abde1565", "metadata": {}, "outputs": [], "source": [ "full_round = xarray.concat(\n", " [\n", " primary_target,\n", " area_fire,\n", " punishing_salvo,\n", " ],\n", " dim=\"action\",\n", ")\n", "full_round[\"action\"] = [\"primary_target\", \"area_fire\", \"punishing_salvo\"]" ] }, { "cell_type": "markdown", "id": "e31f2450-f644-439a-8fdf-02c6b2310783", "metadata": {}, "source": [ "## Suppressing Fire\n", "What is the probability of giving the targets the Suppressed condition?\n", "\n", "All targets are suppressed when rolling a failure or worse against Area Fire.\n", "With Bombard, they are suppressed on a success (but not a critical success).\n", "With Action Hero, a target is suppressed when hit by a Strike." ] }, { "cell_type": "code", "execution_count": null, "id": "77733939-747b-463f-8e94-f168e4edb9f4", "metadata": {}, "outputs": [], "source": [ "suppressed_area_fire_lt_outcome = xarray.DataArray(\n", " [pf2.DoS.success, pf2.DoS.critical_success, pf2.DoS.success],\n", " dims=[\"subclass\"],\n", " coords={\"subclass\": [\"Action Hero\", \"Bombard\", \"others\"]},\n", ")\n", "# Note: strikes against secondary targets have been masked with DoS.no_roll\n", "suppressed_strike_gt_outcome = xarray.DataArray(\n", " [pf2.DoS.failure, pf2.DoS.critical_success, pf2.DoS.critical_success],\n", " dims=[\"subclass\"],\n", ")\n", "suppressed = (area_fire.outcome < suppressed_area_fire_lt_outcome) | (\n", " np.maximum(primary_target.outcome, punishing_salvo.outcome)\n", " > suppressed_strike_gt_outcome\n", ")\n", "\n", "suppressed.mean(\"roll\").stack(col=[\"target\", \"subclass\"]).to_pandas()" ] }, { "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 = total_damage.stack(col=[\"challenge\", \"target\"]).to_pandas()\n", "total_damage" ] }, { "cell_type": "markdown", "id": "deed8321-d018-4725-88d7-edb8698cc784", "metadata": {}, "source": [ "## Damage distribution" ] }, { "cell_type": "code", "execution_count": null, "id": "3550df9c-093e-4c36-8464-764ca5969540", "metadata": {}, "outputs": [], "source": [ "bins = full_round.total_damage.max().item() + 1\n", "_ = (\n", " full_round.total_damage.stack(col=[\"challenge\", \"target\"])\n", " .sum(\"action\")\n", " .to_pandas()\n", " .hist(bins=bins, sharex=True, figsize=(10, 10))\n", ")" ] }, { "cell_type": "markdown", "id": "af079bbc-50df-411f-ba6d-f1499ce85da5", "metadata": {}, "source": [ "Let's break down the damage distribution to the primary target:" ] }, { "cell_type": "code", "execution_count": null, "id": "b2f1a417-1427-4821-83e4-fc8d08c901d3", "metadata": {}, "outputs": [], "source": [ "_ = (\n", " full_round.total_damage.sel(target=\"primary\")\n", " .stack(col=[\"action\", \"challenge\"])\n", " .to_pandas()\n", " .hist(bins=bins, sharex=True, figsize=(12, 10))\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 }