Starfinder: Soldier with Fangblade#
Consider a Soldier (Close Quarters) with a Fangblade (d10 Backswing, Boost d12).
Which attack routine is better?
Primary Target, Area Fire, Strike (don’t use the Boost trait)
Boost, Primary Target, Area Fire (Boost damage goes to the Primary Target Strike)
Boost, Area Fire (deliberately skip the Primary Target Strike to deal more damage on Area Fire)
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
upgrades = pf2.armory.upgrades.auto(level=level)
fangblade = base_fangblade.apply_boost(False) + upgrades
fangblade
fangblade_boost = base_fangblade.apply_boost(True) + upgrades
fangblade_boost
Success 2d12 slashing plus 2d10+4 slashing
fangblade.area_fire()
fangblade_boost.area_fire()
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
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
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.