Bloodrager#

Leshy Bloodrager barbarian, critfisher build

Feats: 1. Extended Reach, 2. Bloodrager Dedication, 4. Rising Blood Magic, 6. Siphon Magic, 10. Hematocritical, 12. Surging Blood Magic, 18. Exultant Blood Magic

Spells: cantrips ignition or live wire, electric arc; 1st Sure Strike; 2nd Brine Dragon Bile; 3rd Haste or Blazing Bolt or Breathe Fire or Fireball or Organsight (signature)

Equipment: Rooting Flaming Greatpick or Greatsword, (Greater) Phantasmal Doorknob

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

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

import pathfinder2e_stats as pf2
Note: you may need to restart the kernel to use updated packages.
level = 14

spell_slot_rank = (
    pf2.level2rank(level, dedication=True) - 1
)  # max - 1, recoverable with Siphon Magic
use_hematocritical = level >= 10
use_rooting_rune = level >= 7  # clumsy 1 on a crit
use_flaming_rune = level >= 10
use_sword = False  # off-guard to ranged spells on a crit
use_greater_phantasmal_doorknob = level >= 10  # off-guard to ranged spells on a crit

Attack bonus progrssion#

Weapon vs. spell vs. Organsight Medicine checks

atk_bonus = xarray.Dataset(
    {
        "weapon": pf2.tables.SIMPLE_PC.weapon_attack_bonus.barbarian.sum("component"),
        "spell": pf2.tables.SIMPLE_PC.spell_attack_bonus.barbarian.sum("component"),
        "organsight": (
            pf2.tables.PC.ability_bonus.boosts.sel(initial=1, drop=True)
            + pf2.tables.PC.skill_proficiency.others.sel(priority=1, drop=True)
            + pf2.tables.PC.skill_item_bonus.medicine
            + pf2.tables.PC.level
            + 2  # Circumstance
        ),
    }
)
atk_bonus.to_pandas()
weapon spell organsight
level
1 7 6 6
2 9 7 7
3 10 8 11
4 11 9 12
5 14 11 14
6 15 12 15
7 16 13 18
8 17 14 19
9 18 15 21
10 21 16 23
11 22 17 24
12 23 20 25
13 26 21 26
14 27 22 27
15 28 24 31
16 30 25 32
17 32 26 33
18 33 29 35
19 34 30 36
20 36 31 37

Let’s select 3 standard targets:

  • level -2 henchman, all defenses are low

  • at-level monster, all defenses are moderate

  • level +2 boss, all defenses are high

rank = pf2.level2rank(level)
defenses = pf2.tables.SIMPLE_NPC[["AC", "saving_throws", "recall_knowledge"]].sel(
    level=level
)
AC = defenses.AC
saves = defenses.saving_throws
defenses.to_array("kind").to_pandas()
challenge Weak Matched Boss
kind
AC 30 35 39
saving_throws 19 25 30
recall_knowledge 30 32 37

Build damage profiles for weapon and spells#

STR = (
    pf2.tables.PC.ability_bonus.boosts.sel(initial=4) + pf2.tables.PC.ability_bonus.apex
)
weapon_specialization = pf2.tables.PC.weapon_specialization.barbarian
rage_weapon = pf2.tables.PC.rage.bloodrager_weapon
weapon_dmg_bonus = (STR + weapon_specialization + rage_weapon).sel(level=level).item()
rage_bleed = pf2.tables.PC.rage.bloodrager_bleed.sel(level=level).item()
weapon_dice = pf2.tables.PC.weapon_dice.striking_rune.sel(level=level).item()

if use_sword:
    # Greatsword with extended reach
    weapon = pf2.armory.pathfinder.melee.greatsword(
        weapon_dice, weapon_dmg_bonus
    ).reduce_die()
else:
    # Greatpick with extended reach
    weapon = pf2.armory.pathfinder.melee.greatpick(
        weapon_dice, weapon_dmg_bonus
    ).reduce_die()
    if level >= 5:
        weapon += pf2.armory.critical_specialization.pick(weapon_dice)

if use_flaming_rune:
    weapon += pf2.armory.runes.flaming()

weapon += pf2.Damage("bleed", 0, 0, rage_bleed, persistent=True)
weapon
Critical success (3d12+12)x2 piercing plus 1d12+6 piercing plus (1d6)x2 fire plus 1d10 persistent fire plus 4 persistent bleed
Success 3d8+12 piercing plus 1d6 fire plus 2 persistent bleed
def rage_spell(
    level: int, type_: str, *, persistent: bool = False, drained: int = 2
) -> dict[pf2.DoS, list[pf2.Damage]]:
    raw = pf2.tables.PC.rage.bloodrager_spells.sel(level=level, drained=drained).item()
    d = pf2.Damage(type_, 0, 0, raw, persistent=persistent)
    return {
        pf2.DoS.critical_success: [d.copy(multiplier=2)],
        pf2.DoS.success: [d],
        pf2.DoS.failure: [d],
    }


ignition_melee = pf2.armory.cantrips.ignition(rank, melee=True) + rage_spell(
    level, "fire"
)
ignition_melee
Critical success (8d6+7)x2 fire plus 8d6 persistent fire
Success 8d6+7 fire
Failure 7 fire
ignition_ranged = pf2.armory.cantrips.ignition(rank, melee=False) + rage_spell(
    level, "fire"
)
ignition_ranged
Critical success (8d4+7)x2 fire plus 8d4 persistent fire
Success 8d4+7 fire
Failure 7 fire
live_wire = pf2.armory.cantrips.live_wire(rank) + rage_spell(level, "electricity")
live_wire
Critical success (4d4)x2 slashing plus (4d4+7)x2 electricity plus 4d4 persistent electricity
Success 4d4 slashing plus 4d4+7 electricity
Failure 4d4+7 electricity
electric_arc = pf2.armory.cantrips.electric_arc(rank)
electric_arc
Damage 8d4 electricity, with a basic saving throw
breathe_fire = pf2.armory.spells.breathe_fire(spell_slot_rank)
breathe_fire
Damage 8d6 fire, with a basic saving throw
brine_dragon_bile = pf2.armory.spells.brine_dragon_bile(spell_slot_rank) + rage_spell(
    level, "acid", persistent=True
)
brine_dragon_bile
Critical success (4d6+7)x2 persistent acid
Success 4d6+7 persistent acid
Failure 7 persistent acid
blazing_bolt_1action = pf2.armory.spells.blazing_bolt(
    spell_slot_rank, actions=1
) + rage_spell(level, "fire")
blazing_bolt_1action
Critical success (4d6+7)x2 fire
Success 4d6+7 fire
Failure 7 fire
blazing_bolt_3actions = pf2.armory.spells.blazing_bolt(
    spell_slot_rank, actions=3
) + rage_spell(level, "fire")
blazing_bolt_3actions
Critical success (8d6+7)x2 fire
Success 8d6+7 fire
Failure 7 fire
organsight = pf2.armory.spells.organsight(spell_slot_rank)
organsight
Damage 5d6 precision

Attack routine#

  • Strike (with flank) -> Hematocritical if crit -> spell, or

  • (if hasted) Sure Strike -> Strike (with flank) -> Hematocritical -> spell

Spell is one of:

  • Ignition (melee with flank)

  • Ignition (ranged due to reach)

  • Live Wire

  • Electric Arc (1-2 targets)

  • Breathe Fire / Fireball

  • Blazing Bolt (1-2-3 actions)

  • (out of round) Brine Dragon Bile

Spells from slots are at maximum rank -1, so that they can be cycled with Syphon Magic.

sure_strike = xarray.DataArray(
    [False, True, False],
    dims=["Sure Strike"],
    coords={"Sure Strike": ["Normal", "Sure Strike", "Only on melee crit"]},
)
  • Dimensions challenge and sure strike represent a what-if analysis. Roll dice only once and compare the results against different situations.

  • For AoE spells with a saving throw (Electric Arc, Breathe Fire) roll damage only once, but roll saving throw individually for every target.

  • For Blazing Bolt, roll both attack and damage individually for every target.

pf2.set_config(
    check_independent_dims=["AoE_target", "BB_target"],
    check_dependent_dims=["challenge", "Sure Strike"],
    damage_independent_dims=["BB_target"],
    damage_dependent_dims=["challenge", "Sure Strike", "AoE_target"],
)
strike = pf2.damage(
    pf2.check(
        bonus=atk_bonus.weapon.sel(level=level).item(),
        DC=AC - 2,
        fortune=sure_strike,
    ),
    weapon,
)

What are the chances of a critical hit on the initial weapon strike?#

melee_crit = strike.outcome == pf2.DoS.critical_success
melee_crit.loc[{"Sure Strike": "Only on melee crit"}] = True
melee_crit.mean("roll").round(3).to_pandas() * 100.0
challenge Weak Matched Boss
Sure Strike
Normal 50.1 25.1 5.0
Sure Strike 75.1 43.8 9.8
Only on melee crit 100.0 100.0 100.0

The conditions of the next spell change depending on the strike and equipment:

  • If the strike was critical, we can use Hematocritical

  • If the weapon was rooting, the target is now Clumsy 1

  • If the weapon was a sword, th target is now off-guard even if not flanked

hematocritical = melee_crit if use_hematocritical else xarray.DataArray(False)
clumsy = melee_crit if use_rooting_rune else xarray.DataArray(0)
ranged_off_guard = (
    2 * melee_crit
    if (use_sword or use_greater_phantasmal_doorknob)
    else xarray.DataArray(0)
)

Roll damage for the spells#

ignition_melee_dmg = pf2.damage(
    pf2.check(
        atk_bonus.spell.sel(level=level) - 5,
        DC=AC - 2 - clumsy,
        fortune=hematocritical,
    ),
    ignition_melee,
)

ignition_ranged_dmg = pf2.damage(
    pf2.check(
        atk_bonus.spell.sel(level=level) - 5,
        DC=AC - clumsy - ranged_off_guard,
        fortune=hematocritical,
    ),
    ignition_ranged,
)

live_wire_dmg = pf2.damage(
    pf2.check(
        atk_bonus.spell.sel(level=level) - 5,
        DC=AC - clumsy - ranged_off_guard,
        fortune=hematocritical,
    ),
    live_wire,
)

AoE_target = xarray.DataArray(
    [1, 0, 0],
    dims=["AoE_target"],
    coords={"AoE_target": ["Strike target", "target 2", "target 3"]},
)

electric_arc_dmg = pf2.damage(
    pf2.check(
        saves - AoE_target[:2] * clumsy,
        DC=atk_bonus.spell.sel(level=level) + 10,
        misfortune=hematocritical,
    ),
    electric_arc,
).rename({"AoE_target": "target"})  # Align with Blazing Bolt target later

breathe_fire_dmg = pf2.damage(
    pf2.check(
        saves - AoE_target * clumsy,
        DC=atk_bonus.spell.sel(level=level) + 10,
        misfortune=hematocritical,
    ),
    breathe_fire,
).rename({"AoE_target": "target"})

We need to use a different dimension from before because the above AoEs have dependent damage rolls (roll only once for all targets), whereas Blazing Bolt is independent (roll separately for each target). See set_config above.

bb_target = AoE_target.rename({"AoE_target": "BB_target"})

blazing_bolt_check = pf2.check(
    atk_bonus.spell.sel(level=level) - 5,
    DC=AC - (clumsy + ranged_off_guard) * bb_target,
    fortune=hematocritical,
)
blazing_bolt_1action_dmg = pf2.damage(
    blazing_bolt_check,
    blazing_bolt_1action,
).isel(BB_target=0, drop=True)

blazing_bolt_23actions_dmg = pf2.damage(
    blazing_bolt_check,
    blazing_bolt_3actions,
).rename({"BB_target": "target"})

Also show:

  • A second iterative strike

  • a standalone 3-actions Blazing Bolt

  • an out-of-round Brine Dragon Bile

  • additional damage from Organsight, applied to the initial and iterative Strike on each round

strike2 = pf2.damage(
    pf2.check(
        bonus=atk_bonus.spell.sel(level=level).item() - 5,
        DC=AC - 2 - clumsy,
    ),
    weapon,
)

blazing_bolt_23actions_noMAP_dmg = pf2.damage(
    pf2.check(
        atk_bonus.spell.sel(level=level),
        DC=AC,
        independent_dims={"BB_target": 3},
    ),
    blazing_bolt_3actions,
).rename({"BB_target": "target"})

brine_dragon_bile_dmg = pf2.damage(
    pf2.check(
        atk_bonus.spell.sel(level=level),
        DC=AC,
    ),
    brine_dragon_bile,
)
organsight_check = pf2.check(
    atk_bonus.organsight.sel(level=level),
    DC=defenses.recall_knowledge,
)

organsight_check["recall_knowledge_outcome"] = organsight_check.outcome
organsight_check["strike_outcome"] = xarray.concat(
    [
        strike.outcome,
        xarray.where(
            strike.outcome >= pf2.DoS.success,
            pf2.DoS.no_roll,
            strike2.outcome,
        ),
    ],
    dim="strike",
)
organsight_check["outcome"] = xarray.where(
    organsight_check.outcome >= pf2.DoS.success,
    xarray.where(
        organsight_check.strike_outcome >= pf2.DoS.success,
        organsight_check.strike_outcome,
        pf2.DoS.no_roll,
    ),
    pf2.DoS.no_roll,
)
organsight_check.coords["strike"] = ["initial", "iterative"]
# "Only on melee crit" makes no sense here
organsight_check = organsight_check.isel({"Sure Strike": slice(2)})
organsight_dmg = pf2.damage(
    organsight_check,
    organsight,
    independent_dims=["strike"],
)

Mean damage for every action#

rows = {
    "Weapon Strike (flanked)": strike,
    "Iterative Weapon Strike (flanked) (MAP-5)": strike2,
    "Organsight (first strike)": organsight_dmg.isel(strike=0, drop=True),
    "Organsight (iterative strike)": organsight_dmg.isel(strike=1, drop=True),
    "Ignition (melee, flanked) (MAP-5)": ignition_melee_dmg,
    "Ignition (ranged) (MAP-5)": ignition_ranged_dmg,
    "Live Wire (MAP-5)": live_wire_dmg,
    "Electric Arc (1 target)": electric_arc_dmg.isel(target=slice(1)),
    "Electric Arc (2 targets)": electric_arc_dmg,
    "Breathe Fire (1 target)": breathe_fire_dmg.isel(target=slice(1)),
    "Breathe Fire (2 targets)": breathe_fire_dmg.isel(target=slice(2)),
    "Breathe Fire (3 targets)": breathe_fire_dmg,
    "Blazing Bolt > (MAP-5)": blazing_bolt_1action_dmg,
    "Blazing Bolt >> (MAP-5)": blazing_bolt_23actions_dmg.isel(target=slice(2)),
    "Blazing Bolt >>> (MAP-5)": blazing_bolt_23actions_dmg,
    "Blazing Bolt >>> (standalone)": blazing_bolt_23actions_noMAP_dmg,
    "Brine Dragon Bile (standalone)": brine_dragon_bile_dmg,
}

damages = []
for dmg in rows.values():
    dmg = dmg.total_damage.mean("roll")
    if "target" in dmg.dims:
        dmg = dmg.sum("target")
    damages.append(dmg)

total_damage = xarray.concat(damages, dim="activity", join="outer", coords="minimal")
total_damage.coords["activity"] = list(rows)

total_damage.loc[
    {"activity": total_damage.activity[0], "Sure Strike": "Only on melee crit"}
] = float("nan")
total_damage.loc[
    {"activity": total_damage.activity[-2:], "Sure Strike": "Only on melee crit"}
] = float("nan")
total_damage.loc[
    {"activity": total_damage.activity[-3:], "Sure Strike": "Sure Strike"}
] = float("nan")
total_damage.stack(col=["challenge", "Sure Strike"]).to_pandas().round(1)
challenge Weak Matched Boss
Sure Strike Normal Only on melee crit Sure Strike Normal Only on melee crit Sure Strike Normal Only on melee crit Sure Strike
activity
Weapon Strike (flanked) 66.7 NaN 85.9 42.6 NaN 62.0 21.9 NaN 33.5
Iterative Weapon Strike (flanked) (MAP-5) 21.0 21.8 21.4 12.2 13.5 12.5 5.3 6.8 5.3
Organsight (first strike) 22.8 NaN 27.5 14.0 NaN 19.3 5.8 NaN 8.6
Organsight (iterative strike) 0.4 NaN 0.0 1.1 NaN 0.2 0.4 NaN 0.2
Ignition (melee, flanked) (MAP-5) 32.2 38.7 35.5 20.2 30.3 22.7 10.3 20.3 10.9
Ignition (ranged) (MAP-5) 23.5 29.8 26.6 13.8 23.5 16.2 4.4 16.2 4.9
Live Wire (MAP-5) 25.3 29.6 27.4 17.0 25.7 19.2 7.9 20.1 8.5
Electric Arc (1 target) 22.2 26.0 24.1 13.3 17.4 14.3 7.2 12.1 7.4
Electric Arc (2 targets) 43.4 49.9 46.7 26.3 34.0 28.2 14.2 22.9 14.7
Breathe Fire (1 target) 31.0 36.3 33.6 18.6 24.4 20.0 10.0 17.0 10.4
Breathe Fire (2 targets) 60.6 69.6 65.1 36.9 47.6 39.6 20.0 32.0 20.6
Breathe Fire (3 targets) 90.2 103.0 96.6 55.3 70.7 59.1 29.9 47.1 30.7
Blazing Bolt > (MAP-5) 16.3 20.1 18.2 9.4 15.7 10.9 3.8 10.2 4.1
Blazing Bolt >> (MAP-5) 49.2 60.8 55.0 25.5 41.2 29.4 9.0 21.4 9.6
Blazing Bolt >>> (MAP-5) 72.8 89.0 NaN 37.4 58.3 NaN 13.3 28.6 NaN
Blazing Bolt >>> (standalone) 90.3 NaN NaN 56.7 NaN NaN 35.8 NaN NaN
Brine Dragon Bile (standalone) 41.4 NaN NaN 27.7 NaN NaN 18.4 NaN NaN

Outcome probability for the initial Strike#

(
    pf2.outcome_counts(strike)
    .isel({"Sure Strike": slice(2)})
    .stack(col=["challenge", "Sure Strike"])
    .to_pandas()
    .round(3)
    * 100.0
)
challenge Weak Matched Boss
Sure Strike Normal Sure Strike Normal Sure Strike Normal Sure Strike
outcome
Critical success 50.1 75.1 25.1 43.8 5.0 9.8
Success 44.8 24.6 49.9 50.1 50.0 70.1
Failure 5.1 0.2 19.9 5.9 39.9 19.9
Critical failure 0.0 0.0 5.1 0.2 5.1 0.2

Outcome probability for the iterative Strike#

(
    pf2.outcome_counts(strike2)
    .stack(col=["challenge", "Sure Strike"])
    .to_pandas()
    .round(3)
    * 100.0
)
challenge Weak Matched Boss
Sure Strike Normal Sure Strike Only on melee crit Normal Sure Strike Only on melee crit Normal Sure Strike Only on melee crit
outcome
Critical success 5.0 5.0 5.0 5.0 5.0 5.0 5.0 5.0 5.0
Success 47.3 48.7 49.9 21.1 22.1 24.9 0.2 0.5 5.0
Failure 42.6 41.3 40.1 45.2 45.2 45.1 44.9 44.9 44.9
Critical failure 5.0 5.0 5.0 28.7 27.8 25.0 49.9 49.6 45.1

Outcome probability for Ignition (melee)#

(
    pf2.outcome_counts(ignition_melee_dmg)
    .stack(col=["challenge", "Sure Strike"])
    .to_pandas()
    .round(3)
    * 100.0
)
challenge Weak Matched Boss
Sure Strike Normal Sure Strike Only on melee crit Normal Sure Strike Only on melee crit Normal Sure Strike Only on melee crit
outcome
Critical success 7.4 8.6 9.8 6.2 7.2 9.8 5.3 5.6 9.8
Success 57.5 63.7 69.9 25.4 29.3 41.3 0.5 0.9 9.2
Failure 32.5 26.3 20.1 44.4 44.0 42.6 45.7 46.3 60.7
Critical failure 2.5 1.3 0.2 24.0 19.6 6.3 48.5 47.2 20.3

Outcome probability for Electric Arc / Breathe Fire / Fireball#

(
    pf2.outcome_counts(electric_arc_dmg)
    .stack(row=["target", "outcome"])
    .stack(col=["challenge", "Sure Strike"])
    .to_pandas()
    .round(3)
    * 100.0
)
challenge Weak Matched Boss
Sure Strike Normal Sure Strike Only on melee crit Normal Sure Strike Only on melee crit Normal Sure Strike Only on melee crit
target outcome
Strike target Critical success 2.6 1.4 0.2 15.8 12.3 2.2 43.6 42.2 16.1
Success 23.6 17.8 12.1 47.2 45.4 40.0 50.6 51.4 64.8
Failure 48.1 49.8 51.5 30.7 35.0 47.8 0.5 0.9 9.2
Critical failure 25.7 31.0 36.2 6.3 7.2 9.9 5.3 5.6 9.9
target 2 Critical success 2.6 1.5 0.3 15.8 12.9 3.9 43.9 42.8 20.3
Success 25.5 20.7 15.9 49.0 48.0 45.0 50.9 51.8 69.9
Failure 50.4 53.1 55.9 29.0 32.1 41.3 0.0 0.0 0.0
Critical failure 21.4 24.7 28.0 6.2 7.1 9.8 5.2 5.5 9.8

Outcome probability for Organsight#

Recall Knowledege#

(
    pf2.outcome_counts(organsight_dmg.recall_knowledge_outcome).to_pandas().T.round(3)
    * 100.0
)
challenge Weak Matched Boss
outcome
Critical success 39.9 29.8 5.0
Success 50.0 50.1 49.9
Failure 5.0 15.0 40.0
Critical failure 5.1 5.1 5.1

Triggering Strike#

The iterative strike is “no roll” whenever the initial strike connects, as the damage from Organsight can only be discharged once per round.

(
    pf2.outcome_counts(organsight_dmg.strike_outcome)
    .stack(row=["strike", "outcome"])
    .stack(col=["challenge", "Sure Strike"])
    .to_pandas()
    .round(3)
    * 100.0
)
challenge Weak Matched Boss
Sure Strike Normal Sure Strike Normal Sure Strike Normal Sure Strike
strike outcome
initial Critical success 50.1 75.1 25.1 43.8 5.0 9.8
Success 44.8 24.6 49.9 50.1 50.0 70.1
Failure 5.1 0.2 19.9 5.9 39.9 19.9
Critical failure 0.0 0.0 5.1 0.2 5.1 0.2
No roll 0.0 0.0 0.0 0.0 0.0 0.0
iterative Critical success 0.3 0.0 1.3 0.3 2.3 1.0
Success 2.3 0.1 5.0 1.2 0.0 0.0
Failure 2.3 0.1 11.3 2.8 20.4 9.1
Critical failure 0.2 0.0 7.4 1.8 22.3 10.0
No roll 94.9 99.8 75.0 93.9 55.0 79.8

Combined chance to apply Organsight#

(
    pf2.outcome_counts(organsight_dmg)
    .stack(row=["strike", "outcome"])
    .stack(col=["challenge", "Sure Strike"])
    .to_pandas()
    .round(3)
    * 100.0
)
challenge Weak Matched Boss
Sure Strike Normal Sure Strike Normal Sure Strike Normal Sure Strike
strike outcome
initial Critical success 45.0 67.6 20.0 34.9 2.8 5.3
Success 40.3 22.1 40.0 40.2 27.4 38.5
No roll 14.7 10.3 40.1 24.9 69.8 56.2
iterative Critical success 0.2 0.0 1.0 0.2 1.2 0.6
Success 2.1 0.1 4.0 0.9 0.0 0.0
No roll 97.7 99.9 95.0 98.8 98.8 99.4