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))
)
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))
)