Starfinder: Soldier with Fangblade#

Consider a Soldier (Close Quarters) with a Fangblade (d10 Backswing, Boost d12).

Which attack routine is better?

  1. Primary Target, Area Fire, Strike (don’t use the Boost trait)

  2. Boost, Primary Target, Area Fire (Boost damage goes to the Primary Target Strike)

  3. Boost, Area Fire (deliberately skip the Primary Target Strike to deal more damage on Area Fire)

  4. Benchmark: a max-level fireball from a dedicated spellcaster

# 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
whirling_swipe = True  # +1 to area fire DC for Backswing and Swipe weapons

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()
)
fireball_DC = (
    pf2.tables.SIMPLE_PC.spell_DC.wizard.sum("component").sel(level=level).item()
)

print(f"{atk=}, {area_fire_DC=}, {fireball_DC=}")
atk=14, area_fire_DC=22, fireball_DC=21
weapon_dice = pf2.tables.PC.weapon_dice.improvement.sel(level=level).item()
damage_bonus = (
    (
        pf2.tables.PC.ability_bonus.boosts.sel(initial=3)
        + pf2.tables.PC.weapon_specialization.soldier
    )
    .sel(level=level)
    .item()
)

base_fangblade = pf2.armory.starfinder.melee.fangblade(weapon_dice, damage_bonus)
base_fangblade
Damage 2d10+4 slashing boost d12
upgrades = pf2.armory.upgrades.auto(level=level)
fangblade = base_fangblade.apply_boost(False) + upgrades
fangblade
Damage 2d10+4 slashing
fangblade_boost = base_fangblade.apply_boost(True) + upgrades
fangblade_boost
Critical success (2d10+4)x2 slashing plus 2d12 slashing
Success 2d12 slashing plus 2d10+4 slashing
fangblade.area_fire()
Damage 2d10+4 slashing, with a basic saving throw
fangblade_boost.area_fire()
Success (2d12)/2 slashing plus (2d10+4)/2 slashing
Failure 2d12 slashing plus 2d10+4 slashing
Critical failure (2d10+4)x2 slashing plus 2d12 slashing
fireball = pf2.armory.spells.fireball(rank=pf2.level2rank(level))
fireball
Damage 6d6 fire, with a basic saving throw
enemy = pf2.tables.SIMPLE_NPC.sel(level=level, drop=True)[
    ["AC", "saving_throws", "HP"]
] * xarray.DataArray(
    np.ones((3, 4), dtype=int),
    dims=["target", "routine"],
    coords={
        "target": ["primary", "secondary 1", "secondary 2"],
        "routine": ["no_boost", "boost_primary", "boost_area", "fireball"],
    },
)
enemy.isel(target=0, routine=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' and 'routine' are what-if analyses - let's compare the same dice
# rolls in different situations.
pf2.set_config(
    check_dependent_dims=("challenge", "routine"),
    check_independent_dims=("target",),
    damage_dependent_dims=("challenge", "routine", "target"),
)
strike1 = pf2.check(atk, DC=enemy.AC)
strike1["outcome"] = xarray.where(
    (strike1.target != "primary")
    | (strike1.routine == "boost_area")
    | (strike1.routine == "fireball"),
    pf2.DoS.no_roll,
    strike1.outcome,
)
strike1 = xarray.concat(
    [
        pf2.damage(strike1.sel(routine="no_boost"), fangblade),
        pf2.damage(strike1.sel(routine="boost_primary"), fangblade_boost),
        pf2.damage(strike1.sel(routine="boost_area"), pf2.Damage("slashing", 0, 0)),
        pf2.damage(strike1.sel(routine="fireball"), pf2.Damage("fire", 0, 0)),
    ],
    dim="routine",
    join="outer",
)
strike1.total_damage.mean("roll").stack(col=["challenge", "routine"]).display()
variable total_damage
target primary secondary 1 secondary 2
challenge routine
Weak no_boost 20.98050 0.0 0.0
boost_primary 33.33793 0.0 0.0
boost_area 0.00000 0.0 0.0
fireball 0.00000 0.0 0.0
Matched no_boost 13.45111 0.0 0.0
boost_primary 22.56126 0.0 0.0
boost_area 0.00000 0.0 0.0
fireball 0.00000 0.0 0.0
Boss no_boost 8.20976 0.0 0.0
boost_primary 14.70422 0.0 0.0
boost_area 0.00000 0.0 0.0
fireball 0.00000 0.0 0.0
ws_bonus = 1 if whirling_swipe else 0
DC = xarray.DataArray(
    [
        area_fire_DC + ws_bonus,
        area_fire_DC + ws_bonus,
        area_fire_DC + ws_bonus,
        fireball_DC,
    ],
    dims=["routine"],
)

area_fire_check = pf2.check(enemy.saving_throws, DC=DC, primary_target=strike1)

area_fire = xarray.concat(
    [
        pf2.damage(area_fire_check.sel(routine="no_boost"), fangblade.area_fire()),
        pf2.damage(
            area_fire_check.sel(routine="boost_primary"),
            fangblade.area_fire(),
        ),
        pf2.damage(
            area_fire_check.sel(routine="boost_area"), fangblade_boost.area_fire()
        ),
        pf2.damage(area_fire_check.sel(routine="fireball"), fireball),
    ],
    dim="routine",
    join="outer",
)
area_fire.total_damage.mean("roll").stack(col=["challenge", "routine"]).display()
variable total_damage
target primary secondary 1 secondary 2
challenge routine
Weak no_boost 19.42785 18.36644 18.32832
boost_primary 19.43787 18.36350 18.31393
boost_area 29.65549 29.68948 29.63246
fireball 22.53207 22.53088 22.51443
Matched no_boost 13.96458 11.52577 11.51522
boost_primary 13.94731 11.51974 11.50959
boost_area 20.81111 20.82265 20.78144
fireball 14.59225 14.58296 14.55799
Boss no_boost 9.32533 7.37758 7.39129
boost_primary 9.32331 7.37487 7.39276
boost_area 13.10450 13.09764 13.09926
fireball 8.30095 8.27848 8.28296

Axes critical specialization: deal damage equal to the weapon damage dice to an adjacent target. Conveniently, for 2+ targets in a 5-foot burst, all targets are adjacent by definition.

Note that Soldiers only get critical specialization in melee weapons when they use them to Area Fire.

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.

fangblade_area_crit = pf2.armory.critical_specialization.axe(
    base_fangblade.apply_boost(False)
).area_fire()
fangblade_area_crit
Critical failure 2d10 slashing
area_crit = xarray.concat(
    [
        pf2.damage(
            area_fire_check.sel(routine=~(area_fire.routine == "fireball")),
            fangblade_area_crit,
        ),
        area_fire_check.sel(routine="fireball"),
    ],
    dim="routine",
    join="outer",
    data_vars="all",
).fillna(0)

area_crit.total_damage.mean("roll").stack(col=["challenge", "routine"]).display()
variable total_damage
target primary secondary 1 secondary 2
challenge routine
Weak no_boost 3.85655 3.84996 3.82219
boost_primary 3.85655 3.84996 3.82219
boost_area 3.85655 3.84996 3.82219
fireball 0.00000 0.00000 0.00000
Matched no_boost 0.55521 0.54148 0.55511
boost_primary 0.55521 0.54148 0.55511
boost_area 0.55521 0.54148 0.55511
fireball 0.00000 0.00000 0.00000
Boss no_boost 0.55521 0.54148 0.55511
boost_primary 0.55521 0.54148 0.55511
boost_area 0.55521 0.54148 0.55511
fireball 0.00000 0.00000 0.00000
# Note: Primary Target does not increase MAP, but Area Fire does
backswing = xarray.where(strike1.outcome < pf2.DoS.success, 1, 0)
strike2 = pf2.check(atk - 5 + backswing, DC=enemy.AC)
strike2["outcome"] = xarray.where(
    (strike2.target != "primary") | (strike2.routine != "no_boost"),
    pf2.DoS.no_roll,
    strike2.outcome,
)
strike2 = pf2.damage(strike2, fangblade)
strike2.total_damage.mean("roll").stack(col=["challenge", "routine"]).display()
variable total_damage
target primary secondary 1 secondary 2
challenge routine
Weak no_boost 13.59976 0.0 0.0
boost_primary 0.00000 0.0 0.0
boost_area 0.00000 0.0 0.0
fireball 0.00000 0.0 0.0
Matched no_boost 7.72956 0.0 0.0
boost_primary 0.00000 0.0 0.0
boost_area 0.00000 0.0 0.0
fireball 0.00000 0.0 0.0
Boss no_boost 4.88743 0.0 0.0
boost_primary 0.00000 0.0 0.0
boost_area 0.00000 0.0 0.0
fireball 0.00000 0.0 0.0
full_round = xarray.concat(
    [strike1, area_fire, area_crit, strike2],
    dim="action",
    join="outer",
)
full_round["action"] = ["primary_target", "area_fire", "area_crit", "strike"]

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.stack(row=["target", "action"], col=["challenge", "routine"]).to_pandas().T
target primary secondary 1 secondary 2
action primary_target area_fire area_crit strike TOTAL primary_target area_fire area_crit strike TOTAL primary_target area_fire area_crit strike TOTAL
challenge routine
Weak no_boost 20.98050 19.42785 3.85655 13.59976 57.86466 0.0 18.36644 3.84996 0.0 22.21640 0.0 18.32832 3.82219 0.0 22.15051
boost_primary 33.33793 19.43787 3.85655 0.00000 56.63235 0.0 18.36350 3.84996 0.0 22.21346 0.0 18.31393 3.82219 0.0 22.13612
boost_area 0.00000 29.65549 3.85655 0.00000 33.51204 0.0 29.68948 3.84996 0.0 33.53944 0.0 29.63246 3.82219 0.0 33.45465
fireball 0.00000 22.53207 0.00000 0.00000 22.53207 0.0 22.53088 0.00000 0.0 22.53088 0.0 22.51443 0.00000 0.0 22.51443
Matched no_boost 13.45111 13.96458 0.55521 7.72956 35.70046 0.0 11.52577 0.54148 0.0 12.06725 0.0 11.51522 0.55511 0.0 12.07033
boost_primary 22.56126 13.94731 0.55521 0.00000 37.06378 0.0 11.51974 0.54148 0.0 12.06122 0.0 11.50959 0.55511 0.0 12.06470
boost_area 0.00000 20.81111 0.55521 0.00000 21.36632 0.0 20.82265 0.54148 0.0 21.36413 0.0 20.78144 0.55511 0.0 21.33655
fireball 0.00000 14.59225 0.00000 0.00000 14.59225 0.0 14.58296 0.00000 0.0 14.58296 0.0 14.55799 0.00000 0.0 14.55799
Boss no_boost 8.20976 9.32533 0.55521 4.88743 22.97773 0.0 7.37758 0.54148 0.0 7.91906 0.0 7.39129 0.55511 0.0 7.94640
boost_primary 14.70422 9.32331 0.55521 0.00000 24.58274 0.0 7.37487 0.54148 0.0 7.91635 0.0 7.39276 0.55511 0.0 7.94787
boost_area 0.00000 13.10450 0.55521 0.00000 13.65971 0.0 13.09764 0.54148 0.0 13.63912 0.0 13.09926 0.55511 0.0 13.65437
fireball 0.00000 8.30095 0.00000 0.00000 8.30095 0.0 8.27848 0.00000 0.0 8.27848 0.0 8.28296 0.00000 0.0 8.28296
grand_total = (
    full_round.total_damage.mean("roll")
    * xarray.DataArray(
        [[1, 0, 0], [1, 1, 0], [1, 1, 1]],
        dims=["# targets", "target"],
        coords={"# targets": [1, 2, 3]},
    )
).sum("target")
_, crit_spec_on_one_target = xarray.align(
    grand_total,
    grand_total.sel({"# targets": [1], "action": ["area_crit"]}),
    join="outer",
    fill_value=0,
)

(grand_total - crit_spec_on_one_target).sum("action").stack(
    col=["challenge", "routine"]
).to_pandas().T
# targets 1 2 3
challenge routine
Weak no_boost 54.00811 80.08106 102.23157
boost_primary 52.77580 78.84581 100.98193
boost_area 29.65549 67.05148 100.50613
fireball 22.53207 45.06295 67.57738
Matched no_boost 35.14525 47.76771 59.83804
boost_primary 36.50857 49.12500 61.18970
boost_area 20.81111 42.73045 64.06700
fireball 14.59225 29.17521 43.73320
Boss no_boost 22.42252 30.89679 38.84319
boost_primary 24.02753 32.49909 40.44696
boost_area 13.10450 27.29883 40.95320
fireball 8.30095 16.57943 24.86239

Conclusions#

The damage output of a Soldier with a Fangblade blows out of the water a fireball in all use cases. The soldier can repeat their attack every round at no cost. 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).

However, the fireball features a much larger area and only costs 2 actions instead of 3.