Files
gogobee/internal/plugin/dnd_subclass_combat.go
prosolis 98ba416359 Class identity audit close-out: Fighter/Ranger/Paladin/Druid + Bard-Valor
Phase 2 Monte Carlo tuning had papered over four classes whose 5e-defining
mechanics never actually existed in the engine — only flat DamageBonus or
FlatDmgStart compensation riders that hit the right aggregate win rate.
This pass replaces the proxies with the real primitives.

New CombatModifiers fields: ExtraAttacks (additional swings/round, looped
in a new resolvePlayerSwings helper); HuntersMarkDie (per-hit Nd6 bonus,
same path as Sneak Attack); ThornLashDmg (flat counter on landed enemy
hits, fires in resolveEnemyAttack after ward/block).

Fighter L5/11/20 Extra Attack, Ranger L5 Extra Attack + Hunter's Mark
(1→4 d6 by level), Paladin L5 Extra Attack + per-hit Divine Smite
(replaces one-shot opener), Druid thorn lash. Bard College of Valor L7
"Extra Attack" subclass tier converted from +1 attack/+10% damage proxy
to the real primitive.

Post-fix T5 class-balance: martials cluster at top (Fighter/Rogue 0.90,
Ranger 0.89, Paladin 0.88), casters keep their relative ordering, spread
14pp top-to-bottom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:53:00 -07:00

961 lines
36 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
// Phase 10 SUB2a — subclass combat hooks.
//
// applySubclassPassives layers subclass-driven flags onto CombatModifiers
// the same way applyClassPassives does. Called from combat_bridge after
// applyClassPassives so subclass effects can stack on top of (or override)
// class baseline.
//
// Subclass active abilities (currently just Berserker's rage) are
// registered via init() into dndActiveAbilities below, gated by
// DnDAbility.Subclass.
//
// Comment convention: `// internal note (not user-facing)` marks block
// comments documenting tuning history (Phase 2/3 balance notes, scaling
// rationale, etc.). They are engineering context and must NOT be lifted
// into Description / Flavor strings by codegen.
func applySubclassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacter) {
if c == nil || c.Subclass == "" {
return
}
switch c.Subclass {
case SubclassChampion:
// L5 Improved Critical: crit on nat 19+. L15 Superior Critical
// lowers this further to 18.
if c.Level >= 5 {
mods.CritThreshold = 19
}
// Phase 10 SUB3a-i — L10 Additional Fighting Style. 5e lets the
// player pick a second style; in 1v1 we can't surface a UI for
// that, so we collapse to the two most generally useful
// approximations stacked: Defense (+1 AC) and Dueling (+10%
// damage when one-handed — we don't track grip state, so this is
// a flat damage bump regardless).
if c.Level >= 10 {
stats.AC++
mods.DamageBonus += 0.10
}
// Phase 10 SUB3a-i — L15 Superior Critical: crit floor drops to 18.
if c.Level >= 15 {
mods.CritThreshold = 18
}
case SubclassBerserker:
// L5/L7 Berserker is rage-driven (active ability — see init() below)
// plus L7 Mindless Rage which is condition immunity, irrelevant in
// our 1v1 model. Phase 10 SUB3a-i adds the always-on L10/L15 layer.
//
// L10 Intimidating Presence: 5e is an Action that frightens one
// target on a failed WIS save. One-shot combat has no action
// economy to spend on a non-damage Action, so we proxy as 2 rounds
// of 15% enemy miss chance via SporeCloud (the same channel
// fog-cloud / Cloak of Shadows uses).
// L15 Retaliation: 5e Reaction — when struck in melee, one melee
// attack back. We don't model reactions, so the average per-fight
// damage uplift is approximated as a +15% damage multiplier.
if c.Level >= 10 {
mods.SporeCloud += 2
}
if c.Level >= 15 {
mods.DamageBonus += 0.15
}
case SubclassBattleMaster:
// L7 Know Your Enemy: 5e gives factual info about the target after
// 1 min observation. We don't model "observation time" — proxy the
// tactical edge as a flat +1 to attack rolls from L7 onward.
if c.Level >= 7 {
stats.AttackBonus++
}
case SubclassThief:
// Phase 10 SUB3a-iii — Thief L10/L15.
//
// L10 Use Magic Device (5e L13: ignore class/race/level requirements
// on magic items, including spell scrolls and wands). We don't have a
// scroll/wand item system, so we proxy the fantasy as a small free
// "magical implement" effect every fight: a pre-combat wand-zap and a
// flat damage bump representing better tactical use of magic items
// the Thief is now allowed to wield.
// L15 Thief's Reflexes (5e L17: take two turns during the first round
// of combat). Engine has no "extra turn" primitive, so we ride the
// AssassinateBonusDmg + AssassinateAdvantage channel — the same one
// Gloom Stalker's Dread Ambusher uses for bonus-opener damage. The
// flat-damage proxy approximates the value of one extra turn's hit.
if c.Level >= 10 {
mods.FlatDmgStart += 6
mods.DamageBonus += 0.05
}
if c.Level >= 15 {
mods.AssassinateAdvantage = true
mods.AssassinateBonusDmg += 8
}
case SubclassAbjuration:
// L5 Arcane Ward: HP buffer = 2× Mage level. 5e fluffs this as
// recharged when casting an abjuration spell of L1+; we approximate
// "always-on" by refilling at the start of every combat — the player
// can refresh by simply casting any abjuration spell pre-combat
// (mage_armor, shield_of_faith, etc.), which our combat already
// handles via pending_cast. L7 Improved Abjuration: + proficiency
// bonus to ward HP (5e: + prof to Counterspell/Dispel checks; we
// fold the static piece into the durability buff since Counterspell
// is reaction-deferred to Phase 11).
if c.Level >= 5 {
ward := 2 * c.Level
if c.Level >= 7 {
ward += proficiencyBonus(c.Level)
}
mods.ArcaneWardHP = ward
}
// Phase 10 SUB3b-i — L10 Spell Resistance: 5e gives advantage on
// saves vs. spells *and* resistance to spell damage. Our 1v1 enemies
// rarely force spell saves, so the "advantage" half is largely inert;
// we collapse the effect to a flat 8% incoming-damage reduction
// (DamageReduct is multiplicative, so 0.92 = ~8% off everything,
// which is close to "resistance against the spell-damage subset").
if c.Level >= 10 {
mods.DamageReduct *= 0.92
}
// Phase 10 SUB3b-i — L15 Spell Reflection: 5e lets a successful
// counterspell redirect the spell back at the caster. We don't model
// counterspell (reaction primitive deferred to Phase 11), so we ride
// the ReflectNext channel — the first incoming hit reflects 30% of
// its damage back. Closest in-engine analogue to "the magic hits the
// caster instead".
if c.Level >= 15 {
mods.ReflectNext += 0.30
}
case SubclassNecromancy:
// Phase 10 SUB3b-i — L15 Improved Undead Thralls. 5e: undead thralls
// gain +CON mod to HP and attack damage. We don't model an Animate
// Dead summoning system (SUB2b skipped Undead Thralls for the same
// reason), but the L15 capstone is too central to skip — proxy a
// permanent skeletal minion via the PetAttack channel. CON-mod scales
// dmg, mirroring the 5e formula. L10 Command Undead's combat surface
// is folded into grimHarvestHeal (deeper authority over death = more
// life harvested per spell-kill); see dnd_subclass_combat.go below.
if c.Level >= 15 {
conMod := abilityModifier(c.CON)
if conMod < 0 {
conMod = 0
}
thrallDmg := 6 + conMod
if mods.PetAttackProc < 0.30 {
mods.PetAttackProc = 0.30
}
if mods.PetAttackDmg < thrallDmg {
mods.PetAttackDmg = thrallDmg
}
}
case SubclassAssassin:
// L5 Assassinate: advantage on the opening strike + bonus damage
// stacked on top of the Rogue's existing Sneak Attack auto-crit
// (proxy for "crits vs. surprised targets"). Bonus scales with
// level; L7 Impostor — flavored as deeper study of the mark — adds
// a small surprise-damage bump.
if c.Level >= 5 {
mods.AssassinateAdvantage = true
bonus := c.Level
if c.Level >= 7 {
bonus += 3
}
mods.AssassinateBonusDmg = bonus
}
case SubclassLifeDomain:
// Phase 10 SUB3c — Life Domain L10 Divine Strike. 5e: +1d8 radiant
// damage on a weapon hit, once per turn (+2d8 at L14). We collapse
// the once-per-turn cadence to "every weapon hit" since our 1v1 model
// has no turn-boundary primitive; avg 1d8 = 4, 2d8 = 9 at L14+.
// L5 Disciple of Life rides lifeDomainHealBonus and L15 Supreme
// Healing rides lifeDomainSupremeHealing — both out-of-combat heal
// hooks, so they don't surface here.
if c.Level >= 14 {
mods.DivineStrikePerHit += 9
} else if c.Level >= 10 {
mods.DivineStrikePerHit += 4
}
case SubclassWarDomain:
// L5 War Priest: bonus-action weapon attack, usable WIS-mod times per
// long rest. One-shot combat can't model an extra discrete attack, so
// we proxy throughput with a flat +1 attack bonus and a small damage
// bump. L7 War God's Blessing is a reaction granting an ally +10 to
// attack — no allies in 1v1 combat, so it's a no-op.
if c.Level >= 5 {
stats.AttackBonus++
mods.DamageBonus += 0.15
}
// Phase 10 SUB3c — War Domain L10 Divine Strike: +1d8 weapon-type
// damage on every weapon hit (+2d8 at L14). Same channel as Life
// Domain's radiant version — engine doesn't track damage types.
if c.Level >= 14 {
mods.DivineStrikePerHit += 9
} else if c.Level >= 10 {
mods.DivineStrikePerHit += 4
}
// Phase 10 SUB3c — L15 Avatar of Battle: 5e gives resistance to
// bludgeoning/piercing/slashing from non-magical weapons. Most enemy
// damage in our model is non-magical physical, so resistance lands
// on the bulk of incoming damage. We collapse to a flat 20% incoming
// reduction — softer than full 50% physical resistance to account
// for magical/elemental hits the resistance wouldn't catch.
if c.Level >= 15 {
mods.DamageReduct *= 0.80
}
case SubclassTrickeryDomain:
// L7 Cloak of Shadows: turn invisible until you attack/cast/end of
// next turn. We proxy the brief defensive window as 2 rounds of 15%
// enemy miss chance via SporeCloud (same channel a fog-cloud-style
// consumable would use). L5 Blessing of the Trickster (Stealth
// advantage on an ally) is non-combat flavor — skipped.
if c.Level >= 7 {
mods.SporeCloud += 2
}
// Phase 10 SUB3c — Trickery Domain L10 Divine Strike: +1d8 poison
// (+2d8 at L14). Same engine channel as the other two domains.
if c.Level >= 14 {
mods.DivineStrikePerHit += 9
} else if c.Level >= 10 {
mods.DivineStrikePerHit += 4
}
// Phase 10 SUB3c — L15 Improved Duplicity: 5e spawns up to 4
// duplicates and grants advantage to allies adjacent to one. No
// allies in 1v1, so the literal effect is inert. We proxy the
// permanent-illusion fantasy passively: +1 round of SporeCloud
// (duplicates flicker around the foe creating extra confusion on
// top of Cloak of Shadows) and a small flanking damage bump.
if c.Level >= 15 {
mods.SporeCloud++
mods.DamageBonus += 0.05
}
case SubclassHunter:
// L5 Hunter's Prey: 5e gives a one-of-three pick (Colossus Slayer:
// +1d8 vs. damaged foes once per turn / Giant Killer reaction /
// Horde Breaker second attack). One-shot combat can't model the
// pick UI, so we collapse to the most generally-applicable flavor
// — Colossus Slayer — as a flat +10% damage bump (any hit after
// the opener lands on an already-damaged target).
// L7 Defensive Tactics: collapse to Multiattack Defense — +1 AC.
if c.Level >= 5 {
mods.DamageBonus += 0.10
}
if c.Level >= 7 {
stats.AC++
}
// Phase 10 SUB3d — Hunter L10 Multiattack (Volley/Whirlwind). Both 5e
// options are AoE; in 1v1 there's only one target, so we collapse the
// extra-target throughput to "extra hit per round on the one foe" — a
// flat +15% damage bump (close to a half-extra-attack's value, soft
// on the upside since AoE wouldn't all land on a single target).
if c.Level >= 10 {
mods.DamageBonus += 0.15
}
// Phase 10 SUB3d — Hunter L15 Superior Hunter's Defense. 5e picks one
// of Evasion / Stand Against the Tide / Uncanny Dodge. Uncanny Dodge
// (halve damage from one attack/turn) is the most generally-applicable
// — proxy as a permanent ~15% incoming damage reduction.
if c.Level >= 15 {
mods.DamageReduct *= 0.85
}
case SubclassBeastMaster:
// L5 Ranger's Companion + Exceptional Training: a baseline combat
// pet shows up on initiative and pokes the enemy. Use max-style
// floors — if the player already has a stronger NPC pet active
// (e.g. a higher-level adventure pet via combat_stats.go), keep
// theirs. PetAttackDmg scales with level so the companion grows
// with the Ranger.
// L7 Bestial Fury: pet attacks twice when commanded. We can't
// schedule a second swing in the existing pet hook, so we bump
// the proc rate (extra swings per fight) and per-hit damage.
if c.Level >= 5 {
proc := 0.20
dmg := 2 + c.Level/2
if c.Level >= 7 {
proc = 0.35
dmg += 3
}
if mods.PetAttackProc < proc {
mods.PetAttackProc = proc
}
if mods.PetAttackDmg < dmg {
mods.PetAttackDmg = dmg
}
}
// Phase 10 SUB3d — Beast Master L10 Share Spells. 5e: any spell the
// Ranger casts on themselves can also affect the pet within 30 ft.
// In practice this means the pet rides the Ranger's self-buffs
// (Hunter's Mark damage rider, Pass without Trace, etc.). We fold
// that into the pet channel: bump per-hit damage and proc rate to
// reflect the buffed pet, plus a small DamageBonus rider matching
// the player's own buffed swings sharing.
if c.Level >= 10 {
if mods.PetAttackProc < 0.45 {
mods.PetAttackProc = 0.45
}
if mods.PetAttackDmg < 6+c.Level/2 {
mods.PetAttackDmg = 6 + c.Level/2
}
mods.DamageBonus += 0.05
}
// Phase 10 SUB3d — Beast Master L15 Superior Bond. 5e: pet immune to
// charm/fright, returns at 1 HP after a long rest if killed. We don't
// simulate pet death, so we proxy the "always-there partner" fantasy
// as a further pet-throughput bump and a small DamageReduct (pet
// body-blocks more reliably).
if c.Level >= 15 {
if mods.PetAttackProc < 0.55 {
mods.PetAttackProc = 0.55
}
extra := 8 + c.Level/2
if mods.PetAttackDmg < extra {
mods.PetAttackDmg = extra
}
mods.DamageReduct *= 0.92
}
case SubclassGloomStalker:
// L5 Dread Ambusher: +WIS to initiative and +1 attack with +1d8 in
// the first round. Initiative rides on Speed in our model; the
// extra opener damage rides AssassinateBonusDmg (consumed on first
// hit only — the same channel the Assassin uses).
// L5 Umbral Sight: the in-darkness stealth advantage is wired in
// subclassSkillAdvantage; here we just give the combat half.
// L7 Stalker's Flurry: 5e re-rolls a missed attack. The closest
// engine primitive is AssassinateAdvantage (best of two on the
// first roll), which is a slightly tighter expression of the
// same fantasy ("you don't whiff your opener").
if c.Level >= 5 {
stats.Speed += abilityModifier(c.WIS)
mods.AssassinateBonusDmg += 5 + abilityModifier(c.WIS)
}
if c.Level >= 7 {
mods.AssassinateAdvantage = true
}
// Phase 10 SUB3d — Gloom Stalker L10 Stalker's Flurry. 5e: re-roll a
// missed attack once per turn. AssassinateAdvantage already grants
// "best of two" on the opener (L7 Stalker's Flurry proxy), so the
// L10 expansion to "every turn" rides as steady throughput — a flat
// +12% damage bump representing recovered misses across the fight.
if c.Level >= 10 {
mods.DamageBonus += 0.12
}
// Phase 10 SUB3d — Gloom Stalker L15 Shadowy Dodge. 5e: Reaction —
// when attacked, impose disadvantage on that attack. We don't model
// reactions, so collapse the "one attack per round at disadvantage"
// to a permanent ~10% incoming damage reduction.
if c.Level >= 15 {
mods.DamageReduct *= 0.90
}
// ── Open5e caster scaffold — Druid / Bard / Sorcerer / Warlock /
// Paladin subclasses. Passive-only, consistent with the majority of the
// original fifteen (Champion, Thief, the Mage and Ranger subclasses):
// no new resource pools or active abilities. Each L5/7/10/15 tier rides
// an existing CombatModifiers channel — per-case notes name the 5e
// ability each proxy stands in for. DamageReduct is initialized to 1.0
// by DerivePlayerStats, so the `*=` reductions compose cleanly.
case SubclassCircleLand:
// L5 Natural Recovery: a measured self-heal — rides HealItem (fires
// once at <50% HP). L7 Land's Stride: +2 Speed (initiative). L10
// Nature's Ward: resistance to a slice of elemental/poison damage,
// collapsed to a flat 8% incoming reduction. L15 Nature's Sanctuary:
// foes hesitate to close — 1 round of SporeCloud — plus a further 10%.
if c.Level >= 5 {
mods.HealItem += 5
}
if c.Level >= 7 {
stats.Speed += 2
}
if c.Level >= 10 {
mods.DamageReduct *= 0.92
}
if c.Level >= 15 {
mods.SporeCloud++
mods.DamageReduct *= 0.90
}
case SubclassCircleMoon:
// L5 Combat Wild Shape: a bestial form's HP pool — an ArcaneWardHP
// buffer scaling with level — plus the form's heavier hits (+10%
// damage). L7 Primal Strike: beast attacks bite harder (+5%). L10
// Elemental Wild Shape: an elemental form's opening slam — a
// FlatDmgStart burst. L15 Archdruid: the form barely tires — a flat
// 15% incoming reduction.
if c.Level >= 5 {
if buf := 3 * c.Level; mods.ArcaneWardHP < buf {
mods.ArcaneWardHP = buf
}
mods.DamageBonus += 0.10
}
if c.Level >= 7 {
mods.DamageBonus += 0.05
}
if c.Level >= 10 {
mods.FlatDmgStart += 8
}
if c.Level >= 15 {
mods.DamageReduct *= 0.85
}
case SubclassCircleShepherd:
// L5 Spirit Totem (Bear): a warding spirit grants a temp-HP buffer.
// L7 Mighty Summoner: a conjured beast joins the fight — rides the
// pet channel. L10 Guardian Spirit: the spirits mend you — HealItem.
// L15 Faithful Summons: the conjured ally is tougher and strikes
// more often.
if c.Level >= 5 {
if buf := 2 * c.Level; mods.ArcaneWardHP < buf {
mods.ArcaneWardHP = buf
}
}
if c.Level >= 7 {
if mods.PetAttackProc < 0.25 {
mods.PetAttackProc = 0.25
}
if dmg := 3 + c.Level/2; mods.PetAttackDmg < dmg {
mods.PetAttackDmg = dmg
}
}
if c.Level >= 10 {
mods.HealItem += c.Level / 2
}
if c.Level >= 15 {
if mods.PetAttackProc < 0.45 {
mods.PetAttackProc = 0.45
}
if dmg := 8 + c.Level/2; mods.PetAttackDmg < dmg {
mods.PetAttackDmg = dmg
}
}
case SubclassCollegeLore:
// L5 Cutting Words: a barbed quip throws the foe off — 2 rounds of
// SporeCloud miss chance. L7 Additional Magical Secrets: borrowed
// versatility, a flat +1 to attack rolls. L10 Peerless Skill: a
// self-aimed Bardic Inspiration die on the opener (FirstAttackBonus).
// L15: deeper Magical Secrets keep paying out — +10% damage.
if c.Level >= 5 {
mods.SporeCloud += 2
}
if c.Level >= 7 {
stats.AttackBonus++
}
if c.Level >= 10 {
mods.FirstAttackBonus += 5
}
if c.Level >= 15 {
mods.DamageBonus += 0.10
}
case SubclassCollegeValor:
// L5 Combat Inspiration: songs that sharpen the blade (+10% damage).
// L7 Extra Attack: a real second swing (5e literal Extra Attack).
// Class-identity audit (2026-05-16) — previously proxied as +1
// AttackBonus + 10% damage; now uses ExtraAttacks. L10 Battle
// Magic: a bonus-action strike after a spell — a FlatDmgStart
// burst. L15: +1 AC, late-game shield half of Combat Inspiration.
if c.Level >= 5 {
mods.DamageBonus += 0.10
}
if c.Level >= 7 {
mods.ExtraAttacks += 1
}
if c.Level >= 10 {
mods.FlatDmgStart += 6
}
if c.Level >= 15 {
stats.AC++
}
case SubclassCollegeGlamour:
// L5 Mantle of Inspiration: a fey-glamour temp-HP buffer. L7 Mantle
// of Majesty: the foe is briefly enthralled — skips its first
// attack. L10 Enthralling Performance: lingering fascination — 2
// rounds of SporeCloud miss chance. L15 Unbreakable Majesty: foes
// struggle to bring themselves to strike you — a flat 15% reduction.
if c.Level >= 5 {
if buf := 2 * c.Level; mods.ArcaneWardHP < buf {
mods.ArcaneWardHP = buf
}
}
if c.Level >= 7 {
mods.SpellEnemySkipFirst = true
}
if c.Level >= 10 {
mods.SporeCloud += 2
}
if c.Level >= 15 {
mods.DamageReduct *= 0.85
}
case SubclassDraconicBloodline:
// L5 Draconic Resilience: scaled hide — +1 AC and an HP buffer. L7
// Elemental Affinity: CHA folded into spell damage — +10%. L10
// Dragon Wings: mobility and a tougher frame — an 8% reduction and a
// small damage bump. L15 Draconic Presence: a frightful aura — 2
// rounds of SporeCloud miss chance.
// Phase 2 class-balance: L5 was defense-only; gave it a small bite
// (10% damage) so the L5 chassis isn't pure attrition vs T4 monsters.
if c.Level >= 5 {
stats.AC++
if mods.ArcaneWardHP < c.Level {
mods.ArcaneWardHP = c.Level
}
mods.DamageBonus += 0.10
}
if c.Level >= 7 {
mods.DamageBonus += 0.10
}
if c.Level >= 10 {
mods.DamageReduct *= 0.92
mods.DamageBonus += 0.05
}
if c.Level >= 15 {
mods.SporeCloud += 2
}
case SubclassWildMagic:
// L5 Tides of Chaos: fortune favors the opener — advantage on the
// first attack. L7 Bend Luck: nudge a roll your way —
// FirstAttackBonus. L10 Controlled Chaos: a surge you actually aimed
// — a FlatDmgStart burst. L15 Spell Bombardment: dice that keep
// exploding — +15% damage.
// Phase 2 class-balance: bare advantage-on-opener left this subclass
// the weakest L5 caster pick (0.065 at T4 pre-tune vs Champion 0.92).
// Added a 10% damage rider — Wild Magic surges as flavor, ~+1d6 of
// chaos damage per round in mechanics.
if c.Level >= 5 {
mods.AssassinateAdvantage = true
mods.DamageBonus += 0.10
}
if c.Level >= 7 {
mods.FirstAttackBonus += 3
}
if c.Level >= 10 {
mods.FlatDmgStart += 10
}
if c.Level >= 15 {
mods.DamageBonus += 0.15
}
case SubclassStormSorcery:
// L5 Tempestuous Magic: the storm moves first — +1 initiative and
// +2 Speed. L7 Heart of the Storm: a thunderclap as you cast — a
// level-scaled FlatDmgStart burst. L10 Storm Guide: you ride the
// weather — a small 5% reduction and 1 round of SporeCloud. L15
// Storm's Fury: incoming blows are answered with lightning —
// ReflectNext on the first hit.
// Phase 2 class-balance: init/speed alone left this subclass at 0.035
// T4 pre-tune. Pull a small slice of the L7 thunderclap forward so
// the L5 chassis has actual damage output.
if c.Level >= 5 {
mods.InitiativeBias += 1
stats.Speed += 2
mods.FlatDmgStart += 4 + c.Level/2
}
if c.Level >= 7 {
mods.FlatDmgStart += 5 + c.Level/2
}
if c.Level >= 10 {
mods.DamageReduct *= 0.95
mods.SporeCloud++
}
if c.Level >= 15 {
mods.ReflectNext += 0.30
}
case SubclassFiendPatron:
// L5 Dark One's Blessing: temp HP as foes fall — modeled as an
// up-front CHA-scaled buffer. L7 Dark One's Own Luck: a nudged roll
// — FirstAttackBonus. L10 Fiendish Resilience: chosen resistance — a
// flat 10% incoming reduction. L15 Hurl Through Hell: a foe sent
// briefly to the lower planes — a heavy FlatDmgStart burst.
if c.Level >= 5 {
chaMod := abilityModifier(c.CHA)
if chaMod < 0 {
chaMod = 0
}
if buf := c.Level + chaMod; mods.ArcaneWardHP < buf {
mods.ArcaneWardHP = buf
}
}
if c.Level >= 7 {
mods.FirstAttackBonus += 3
}
if c.Level >= 10 {
mods.DamageReduct *= 0.90
}
if c.Level >= 15 {
mods.FlatDmgStart += 12
}
case SubclassArchfeyPatron:
// L5 Fey Presence: the foe is briefly charmed/frightened — skips its
// first attack. L7 Misty Escape: you blink out of harm's way — 2
// rounds of SporeCloud miss chance. L10 Beguiling Defenses: hostile
// magic rebounds — ReflectNext on the first hit. L15 Dark Delirium:
// the foe is lost in illusion — more SporeCloud and a small edge.
if c.Level >= 5 {
mods.SpellEnemySkipFirst = true
}
if c.Level >= 7 {
mods.SporeCloud += 2
}
if c.Level >= 10 {
mods.ReflectNext += 0.25
}
if c.Level >= 15 {
mods.SporeCloud += 2
mods.DamageBonus += 0.05
}
case SubclassGreatOldOne:
// L5 Entropic Ward: a missed attack tilts the next exchange — a
// small reduction and 1 round of SporeCloud. L7 Thought Shield:
// psychic resilience — a flat 8% incoming reduction. L10 Create
// Thrall: a dominated mind fights beside you — the pet channel. L15:
// deeper psychic dominion keeps the thrall in the fight and sharpens
// your own assault (+10% damage).
// Phase 2 class-balance: L5 was defense-only (0.130 T4 pre-tune).
// Added a small bite so the L5 chassis can press the advantage when
// the foe misses.
if c.Level >= 5 {
mods.DamageReduct *= 0.95
mods.SporeCloud++
mods.DamageBonus += 0.10
}
if c.Level >= 7 {
mods.DamageReduct *= 0.92
}
if c.Level >= 10 {
if mods.PetAttackProc < 0.30 {
mods.PetAttackProc = 0.30
}
if dmg := 4 + c.Level/2; mods.PetAttackDmg < dmg {
mods.PetAttackDmg = dmg
}
}
if c.Level >= 15 {
mods.DamageBonus += 0.10
}
case SubclassOathDevotion:
// L5 Sacred Weapon: a blade lit with the oath — +1 attack and
// radiant damage on every hit (DivineStrikePerHit, the channel the
// Cleric domains use). L7 Aura of Devotion: a small warding aura —
// 5% reduction. L10 Aura of Courage/Purity: the aura hardens — a
// further 8% reduction. L15 Holy Nimbus: a radiant aura that sears
// the foe on engagement — a FlatDmgStart burst.
if c.Level >= 5 {
stats.AttackBonus++
mods.DivineStrikePerHit += 4
}
if c.Level >= 7 {
mods.DamageReduct *= 0.95
}
if c.Level >= 10 {
mods.DamageReduct *= 0.92
}
if c.Level >= 15 {
mods.FlatDmgStart += 10
}
case SubclassOathVengeance:
// L5 Vow of Enmity: advantage against the marked foe plus the focus
// to press it — advantage on the opener and +10% damage. L7
// Relentless Avenger: you keep pace with a fleeing target — +2 Speed
// and a small damage bump. L10 Soul of Vengeance: an extra strike at
// the marked foe — +10% damage. L15: the hunt never lets up —
// a FlatDmgStart opener.
if c.Level >= 5 {
mods.AssassinateAdvantage = true
mods.DamageBonus += 0.10
}
if c.Level >= 7 {
stats.Speed += 2
mods.DamageBonus += 0.05
}
if c.Level >= 10 {
mods.DamageBonus += 0.10
}
if c.Level >= 15 {
mods.FlatDmgStart += 6
}
case SubclassOathAncients:
// L5 Nature's Wrath: the green and growing things slow the foe — a
// small reduction. L7 Aura of Warding: resistance to spell damage —
// a further 8% reduction. L10 Undying Sentinel: you refuse to fall —
// survive one otherwise-lethal hit (DeathSave). L15 Elder Champion:
// the old light blazes up — +1 attack and +10% damage.
if c.Level >= 5 {
mods.DamageReduct *= 0.95
}
if c.Level >= 7 {
mods.DamageReduct *= 0.92
}
if c.Level >= 10 {
mods.DeathSave = true
}
if c.Level >= 15 {
stats.AttackBonus++
mods.DamageBonus += 0.10
}
}
}
func init() {
// Berserker rage — active ability that piggybacks on the Fighter
// stamina pool. 5e specs 3 uses/long-rest as a separate pool, but
// stamina is already sized at 3 for Fighter and the design's
// "approachability" constraint preferred reusing the existing pool
// over introducing per-subclass resource types in SUB2a.
//
// While armed and fired:
// - +2 flat damage per hit (RageMeleeDmg)
// - incoming weapon damage halved (PhysicalResistRage)
// - +50% damage multiplier for Frenzy approximation
// (one-shot combat can't model "1 bonus attack per turn" literally)
//
// Post-combat exhaustion increment lives in
// persistDnDPostCombatSubclass — Frenzy specs +1 exhaustion after rage
// ends, and in our model rage spans the whole one-shot combat so we
// always tick exhaustion when rage fired.
dndActiveAbilities["rage"] = DnDAbility{
ID: "rage",
Name: "Rage",
Class: ClassFighter,
Subclass: SubclassBerserker,
Resource: "stamina",
Description: "Enter rage for the next combat: +2 damage per hit, halve incoming weapon damage, Frenzy bonus attack approximation. +1 exhaustion after.",
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
mods.BerserkerRage = true
mods.RageMeleeDmg = 2
mods.PhysicalResistRage = true
mods.FrenzyDmgBonus = 0.5
},
}
// Phase 10 SUB2a-ii — Battle Master superiority-die maneuvers. Each
// consumes one die from the new "superiority" resource pool (4/long-rest
// at L5; long-rest refresh in our model — 5e refreshes on short rest).
// We don't model 5e's interactive "before/after the roll" choice; a die
// commits at !arm time and fires on the next combat.
//
// Three non-reaction maneuvers covered:
// - Precision Attack → +d8 (≈+4 flat) on first attack roll
// - Tripping Attack → enemy skips its first attack (reuses Phase 9
// SpellEnemySkipFirst)
// - Rally → temp-HP buffer at <50% (reuses HealItem; self
// buff since one-shot combat has no ally-target UI)
dndActiveAbilities["precision_attack"] = DnDAbility{
ID: "precision_attack",
Name: "Precision Attack",
Class: ClassFighter,
Subclass: SubclassBattleMaster,
Resource: "superiority",
Description: "Add +d8 (≈+4, +5 at L10) to your first attack roll this combat (consumes 1 superiority die).",
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
// Phase 10 SUB3a-ii — L10 Improved Combat Superiority bumps the
// die from d8 (avg 4.5) to d10 (avg 5.5).
bonus := 4
if c != nil && c.Level >= 10 {
bonus = 5
}
mods.FirstAttackBonus += bonus
},
}
dndActiveAbilities["trip_attack"] = DnDAbility{
ID: "trip_attack",
Name: "Tripping Attack",
Class: ClassFighter,
Subclass: SubclassBattleMaster,
Resource: "superiority",
Description: "Trip the enemy on engagement: they skip their first attack (consumes 1 superiority die).",
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
mods.SpellEnemySkipFirst = true
},
}
dndActiveAbilities["rally"] = DnDAbility{
ID: "rally",
Name: "Rally",
Class: ClassFighter,
Subclass: SubclassBattleMaster,
Resource: "superiority",
Description: "Steel yourself for the fight: heal at low HP for d8 + CHA mod (d10 at L10) (consumes 1 superiority die).",
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
// Phase 10 SUB3a-ii — L10 Improved Combat Superiority: d8 → d10.
base := 4
if c != nil && c.Level >= 10 {
base = 5
}
mods.HealItem += base + abilityModifier(c.CHA)
},
}
// Three more maneuvers using existing CombatModifiers fields. The
// remaining four spec maneuvers (Pushing, Goading, Riposte, Commander's
// Strike) need ally-targeting / reaction mechanics that the one-shot
// engine doesn't model — they're omitted rather than approximated
// inaccurately.
dndActiveAbilities["disarming_attack"] = DnDAbility{
ID: "disarming_attack",
Name: "Disarming Attack",
Class: ClassFighter,
Subclass: SubclassBattleMaster,
Resource: "superiority",
Description: "Knock the enemy's weapon aside: their damage is reduced for the rest of the fight (consumes 1 superiority die).",
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
// 25% incoming-damage cut models a fighter who is now
// swinging an improvised weapon at -d4-ish. DamageReduct
// is multiplicative (1.0 neutral, lower = less damage),
// so multiply down.
mods.DamageReduct *= 0.75
},
}
dndActiveAbilities["menacing_attack"] = DnDAbility{
ID: "menacing_attack",
Name: "Menacing Attack",
Class: ClassFighter,
Subclass: SubclassBattleMaster,
Resource: "superiority",
Description: "Snarl as you swing — the enemy hesitates and skips its first attack (consumes 1 superiority die).",
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
mods.SpellEnemySkipFirst = true
},
}
dndActiveAbilities["parry"] = DnDAbility{
ID: "parry",
Name: "Parry",
Class: ClassFighter,
Subclass: SubclassBattleMaster,
Resource: "superiority",
Description: "Set yourself for incoming blows — physical damage taken is sharply reduced (consumes 1 superiority die).",
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
// 40% reduction; multiplicative (see disarming_attack note).
mods.DamageReduct *= 0.60
},
}
// Phase 10 SUB2c — Cleric Channel Divinity. All three Cleric subclasses
// share the "channel_divinity" resource pool (2/long-rest in our model;
// 5e refreshes 1/short-rest at L2 and 2/short-rest at L6). One armed
// expression per fight, mirroring the Battle Master pattern.
dndActiveAbilities["preserve_life"] = DnDAbility{
ID: "preserve_life",
Name: "Preserve Life",
Class: ClassCleric,
Subclass: SubclassLifeDomain,
Resource: "channel_divinity",
Description: "Channel Divinity: pour life force in at low HP — heals 5× Cleric level (consumes 1 channel divinity).",
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
// 5e caps the heal at half max HP per target; in 1v1 the entire
// pool lands on the caster. HealItem fires once when player
// drops below 50%, which matches Preserve Life's "in trouble"
// fantasy.
heal := 5 * c.Level
if c.HPMax > 0 && heal > c.HPMax/2 {
heal = c.HPMax / 2
}
mods.HealItem += heal
},
}
dndActiveAbilities["guided_strike"] = DnDAbility{
ID: "guided_strike",
Name: "Guided Strike",
Class: ClassCleric,
Subclass: SubclassWarDomain,
Resource: "channel_divinity",
Description: "Channel Divinity: +10 to your first attack roll this combat (consumes 1 channel divinity).",
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
mods.FirstAttackBonus += 10
},
}
dndActiveAbilities["invoke_duplicity"] = DnDAbility{
ID: "invoke_duplicity",
Name: "Invoke Duplicity",
Class: ClassCleric,
Subclass: SubclassTrickeryDomain,
Resource: "channel_divinity",
Description: "Channel Divinity: an illusory duplicate flanks the foe — advantage on your first attack and +10% damage (consumes 1 channel divinity).",
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
mods.AssassinateAdvantage = true
mods.DamageBonus += 0.10
},
}
}
// persistDnDPostCombatSubclass handles end-of-combat subclass bookkeeping.
// Bumps Exhaustion if Berserker rage fired and applies Grim Harvest healing
// if a Necromancy Mage's queued spell delivered the killing blow. Called
// from combat_bridge after SimulateCombat returns.
//
// raged is captured before combat (mods.BerserkerRage at combat-start time);
// result + mods are read here to detect the spell-kill and look up the
// stashed slot level / damage type.
func persistDnDPostCombatSubclass(c *DnDCharacter, raged bool, result CombatResult, mods CombatModifiers) error {
if c == nil {
return nil
}
dirty := false
if raged {
c.Exhaustion++
dirty = true
}
if heal := grimHarvestHeal(c, result, mods); heal > 0 {
c.HPCurrent = min(c.HPMax, c.HPCurrent+heal)
dirty = true
}
if !dirty {
return nil
}
return SaveDnDCharacter(c)
}
// lifeDomainHealBonus returns the extra HP a Life Domain Cleric's healing
// spell restores. Disciple of Life (L5): +(2 + spell level) HP, with cantrips
// counting as level 1 for the formula. Blessed Healer (L7) — "when casting a
// healing spell on an ally, you regain 2 + slot level HP" — has no ally in
// 1v1 play and is intentionally skipped here. Returns 0 for non-Life-Domain
// callers, non-heal spells, or pre-L5 levels.
func lifeDomainHealBonus(c *DnDCharacter, spell SpellDefinition, slotLevel int) int {
if c == nil || c.Class != ClassCleric || c.Subclass != SubclassLifeDomain {
return 0
}
if c.Level < 5 || spell.Effect != EffectSpellHeal {
return 0
}
lvl := slotLevel
if lvl < 1 {
lvl = 1
}
return 2 + lvl
}
// lifeDomainSupremeHealing reports whether a Life Domain Cleric has reached
// L15 Supreme Healing — when true, healing-spell dice should resolve at max
// face value instead of being rolled. resolveHealOutOfCombat consults this to
// decide whether to roll or take max for each die.
func lifeDomainSupremeHealing(c *DnDCharacter) bool {
return c != nil && c.Class == ClassCleric &&
c.Subclass == SubclassLifeDomain && c.Level >= 15
}
// grimHarvestHeal returns the HP to restore when a Necromancy Mage's queued
// spell delivered the killing blow. 5e: heal 2× spell level on kill (3× if
// the spell is necrotic). Slot=0 = nothing was stashed → no heal. Returns 0
// if the player lost or the killing blow wasn't the pre-combat spell.
func grimHarvestHeal(c *DnDCharacter, result CombatResult, mods CombatModifiers) int {
if c == nil || c.Class != ClassMage || c.Subclass != SubclassNecromancy || c.Level < 5 {
return 0
}
if !result.PlayerWon || mods.GrimHarvestSlot <= 0 {
return 0
}
// The pre-combat spell killed the enemy iff the spell_cast event itself
// dropped EnemyHP to 0. If a later round-event finished the kill, no heal.
for _, ev := range result.Events {
if ev.Action == "spell_cast" {
if ev.EnemyHP > 0 {
return 0
}
break
}
}
// Phase 10 SUB3b-i — L10 Command Undead. The 5e ability is "take control
// of any undead", which has no surface in our 1v1 combat (no allies to
// hand summons over to). We re-fluff it here as deeper authority over
// death amplifying the harvest itself: multipliers tick up by 1 at L10
// (2× → 3× non-necrotic, 3× → 4× necrotic).
mult := 2
if mods.GrimHarvestNecrotic {
mult = 3
}
if c.Level >= 10 {
mult++
}
return mult * mods.GrimHarvestSlot
}