Starfinder: Soldier’s Punishing Salvo#

A Soldier with a 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?

# Install in JupyterLite
%pip install -q pathfinder2e-stats

import matplotlib as mpl  # noqa: F401  # Needed by JupyterLite
import numpy as np
import xarray

import pathfinder2e_stats as pf2
Note: you may need to restart the kernel to use updated packages.
level = 5
atk = (
    pf2.tables.SIMPLE_PC.weapon_attack_bonus.soldier.sum("component")
    .sel(level=level)
    .item()
)
area_fire_DC = (
    pf2.tables.SIMPLE_PC.area_fire_DC.soldier.sum("component").sel(level=level).item()
)
print(f"{atk=}, {area_fire_DC=}")
atk=14, area_fire_DC=22
weapon_dice = pf2.tables.PC.weapon_dice.improvement.sel(level=level).item()
weapon_specialization = pf2.tables.PC.weapon_specialization.soldier.sel(
    level=level
).item()
stellar_cannon = pf2.armory.starfinder.ranged.stellar_cannon(
    weapon_dice, weapon_specialization
) + pf2.armory.upgrades.auto(level=level)
stellar_cannon
Damage 2d10 piercing
enemy = pf2.tables.SIMPLE_NPC.sel(level=level, drop=True)[
    ["AC", "saving_throws", "HP"]
] * xarray.DataArray(
    [1, 1], dims=["target"], coords={"target": ["primary", "secondary"]}
)
enemy.isel(target=0, drop=True).display()
variable AC saving_throws HP
challenge
Weak 16 6 31
Matched 21 12 75
Boss 25 18 148
# Primary and secondary targets use the same damage roll, but save independently.
# 'challenge' is a what-if analysis - let's compare the same dice rolls against
# progressively harder-to-hit enemies.
pf2.set_config(
    check_dependent_dims=("challenge",),
    check_independent_dims=("target",),
    damage_dependent_dims=("challenge", "target"),
)
primary_target = pf2.check(atk, DC=enemy.AC)
primary_target["outcome"] = xarray.where(
    primary_target.target == "secondary",
    pf2.DoS.no_roll,
    primary_target.outcome,
)
primary_target = pf2.damage(primary_target, stellar_cannon)
area_fire = pf2.damage(
    pf2.check(enemy.saving_throws, DC=area_fire_DC, primary_target=primary_target),
    stellar_cannon.area_fire(),
    dependent_dims=["target"],
)
# Note: Primary Target does not increase MAP, but Area Fire does
punishing_salvo = pf2.check(atk - 5, DC=enemy.AC)
punishing_salvo["outcome"] = xarray.where(
    punishing_salvo.target == "secondary",
    pf2.DoS.no_roll,
    punishing_salvo.outcome,
)
punishing_salvo = pf2.damage(punishing_salvo, stellar_cannon)
full_round = xarray.concat(
    [
        primary_target,
        area_fire,
        punishing_salvo,
    ],
    dim="action",
)
full_round["action"] = ["primary_target", "area_fire", "punishing_salvo"]

Suppressing Fire#

What is the probability of giving the targets the Suppressed condition?

All targets are suppressed when rolling a failure or worse against Area Fire. With Bombard, they are suppressed on a success (but not a critical success). With Action Hero, a target is suppressed when hit by a Strike.

suppressed_area_fire_lt_outcome = xarray.DataArray(
    [pf2.DoS.success, pf2.DoS.critical_success, pf2.DoS.success],
    dims=["subclass"],
    coords={"subclass": ["Action Hero", "Bombard", "others"]},
)
# Note: strikes against secondary targets have been masked with DoS.no_roll
suppressed_strike_gt_outcome = xarray.DataArray(
    [pf2.DoS.failure, pf2.DoS.critical_success, pf2.DoS.critical_success],
    dims=["subclass"],
)
suppressed = (area_fire.outcome < suppressed_area_fire_lt_outcome) | (
    np.maximum(primary_target.outcome, punishing_salvo.outcome)
    > suppressed_strike_gt_outcome
)

suppressed.mean("roll").stack(col=["target", "subclass"]).to_pandas()
target primary secondary
subclass Action Hero Bombard others Action Hero Bombard others
challenge
Weak 0.99616 0.94924 0.93882 0.74969 0.94954 0.74969
Matched 0.91010 0.94924 0.80043 0.45024 0.94954 0.45024
Boss 0.68163 0.65114 0.40156 0.15012 0.64953 0.15012

Mean damage#

total_damage = full_round.total_damage.mean("roll")
total_damage = xarray.concat(
    [total_damage, total_damage.sum("action").expand_dims(action=["TOTAL"])],
    dim="action",
)
total_damage = total_damage.stack(col=["challenge", "target"]).to_pandas()
total_damage
challenge Weak Matched Boss
target primary secondary primary secondary primary secondary
action
primary_target 15.40943 0.00000 9.92385 0.00000 6.06280 0.00000
area_fire 13.68238 12.61777 10.15106 8.14128 6.28547 4.83232
punishing_salvo 9.91375 0.00000 5.52224 0.00000 3.31635 0.00000
TOTAL 39.00556 12.61777 25.59715 8.14128 15.66462 4.83232

Damage distribution#

bins = full_round.total_damage.max().item() + 1
_ = (
    full_round.total_damage.stack(col=["challenge", "target"])
    .sum("action")
    .to_pandas()
    .hist(bins=bins, sharex=True, figsize=(10, 10))
)
../_images/3eb10060963688a296365c7181865543ec9088a663b1e9b7fbe2d96990444a77.png

Let’s break down the damage distribution to the primary target:

_ = (
    full_round.total_damage.sel(target="primary")
    .stack(col=["action", "challenge"])
    .to_pandas()
    .hist(bins=bins, sharex=True, figsize=(12, 10))
)
../_images/79f460a64aaba951b101698b82b7f5504eb464f09cb318c4d2ab0c76eb2708a8.png