Files
gogobee/internal/plugin/dnd_passives.go
prosolis b29dcf4360 combat: relabel DamageReduct comments to match calcDamage player-defender direction
Review follow-up on the caster-floor rebaseline: the survival-half doc in
casterBlasterFloor described an HP add the code never makes (Defense only),
and the pre-existing Druid *0.95 rider still read as a damage cut when, for a
player-defender through calcDamage, DamageReduct<1 raises damage taken. Comment
-only; no runtime change, guardrail test unaffected.
2026-07-17 17:22:06 -07:00

332 lines
16 KiB
Go
Raw 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 3 — class passives applied via CombatModifiers.
//
// Phase 3 keeps abilities passive-only: they auto-trigger via the existing
// CombatModifiers machinery rather than being player-activated mid-fight
// (the combat engine is one-shot, no turn-based UI). Active/reactive
// abilities arrive once we have a model for them — likely tied to !arena
// pre-arming or to a turn-based PvP variant.
//
// Comment convention: `// internal note (not user-facing)` marks block
// comments that document tuning history (Phase 2/3 balance notes, scaling
// rationale, etc.). Anything inside such a block is engineering context,
// NOT a candidate for codegen to lift into a Description string.
// ── Class passive definitions ────────────────────────────────────────────────
// DnDClassAbility is the lore/UI representation of a class's signature
// passive. Used by !abilities and !sheet for display; mechanics live in
// applyClassPassives below.
type DnDClassAbility struct {
Name string
Description string
}
var dndClassAbilities = map[DnDClass]DnDClassAbility{
ClassFighter: {
Name: "Battle Trained",
Description: "Years of weapon drill make every swing hit a little harder than it has any right to.",
},
ClassRogue: {
Name: "Sneak Attack",
Description: "Your opening strike each fight finds a seam and crits — and the same trained precision keeps every follow-up sharper than it looks.",
},
ClassMage: {
Name: "Arcane Focus",
Description: "Practiced channeling steadies your aim and lets a faint arcane crackle leak into every blow you land.",
},
ClassCleric: {
Name: "Divine Favor",
Description: "When the fight turns against you, a sliver of divine grace patches you up. Once per combat.",
},
ClassRanger: {
Name: "Hunter's Mark",
Description: "You read your prey's tells — openings come easier, and the hits land where it hurts.",
},
ClassDruid: {
Name: "Wild Resilience",
Description: "The wild lends you its hide — blows land softer — and the moment you engage, a thorn surge lashes back at whatever picked the fight.",
},
ClassBard: {
Name: "Bardic Inspiration",
Description: "Wit and timing put you a beat ahead of the fight: you move first, swing truer, and turn the opening line into a small, mean encore.",
},
ClassSorcerer: {
Name: "Innate Sorcery",
Description: "Raw magic boils over the moment the fight starts, scorching whatever's nearest — and every swing rides the leftover heat.",
},
ClassWarlock: {
Name: "Agonizing Blast",
Description: "Your pact whispers in the back of your skull; the favor it returns lands on your enemies, harder and surer than ought to be allowed.",
},
ClassPaladin: {
Name: "Divine Smite",
Description: "On engagement you channel a burst of radiant power — a sworn promise, paid in light — that hits harder as your oath deepens.",
},
}
// clampNonNeg returns max(0, x). Used by Phase-2 class passives to keep
// ability-mod-scaled FlatDmgStart additions from going negative when a
// caster's casting stat happens to be below 10 (or when tests construct
// a DnDCharacter with zero-value stats).
func clampNonNeg(x int) int {
if x < 0 {
return 0
}
return x
}
// applyRacePassives sets the combat-impacting flags from the player's race.
// Elf "trance" (resilience flavor), Half-Elf "adaptable" (cross-cultural know-
// how), and the Human floating +1 are non-combat or stat-only and have no
// engine hook here.
func applyRacePassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacter) {
switch c.Race {
case RaceHalfling:
mods.LuckyReroll = true
case RaceOrc:
mods.RageReady = true
case RaceDwarf:
mods.PoisonResist = true
case RaceTiefling:
// Fiendish heritage: incoming fire damage is halved by the combat
// engine (enemy attack path + aoe_fire ability) and by the trap
// resolver (fire-tagged traps).
mods.FireResist = true
}
}
// applyClassPassives mutates a player's CombatModifiers to apply their
// class passive. Called after applyDnDPlayerLayer + DerivePlayerStats but
// BEFORE consumable application (so consumables can stack on top).
//
// Some passives ride on existing CombatModifiers fields:
//
// Rogue's Sneak Attack reuses AutoCritFirst (the consumable Crystal Berry
// field) — the engine already implements first-hit-auto-crit semantics.
// Cleric's Divine Favor reuses HealItem, the under-50%-HP heal trigger.
//
// This means:
// - A Rogue carrying a Crystal Berry doesn't get *two* auto-crits;
// AutoCritFirst is already a one-shot bool.
// - A Cleric carrying a healing potion stacks: passive 5 + potion 8 = 13.
// The passive heal triggers first since both use the same threshold.
// cantripDice is the 5e at-will cantrip die progression (Fire Bolt / Eldritch
// Blast): 1 die L14, 2 at L5, 3 at L11, 4 at L17. Drives the per-round arcane
// blaster damage (CantripPerRound) that models a caster's sustained at-will
// floor — see CombatModifiers.CantripPerRound.
func cantripDice(level int) int {
switch {
case level >= 17:
return 4
case level >= 11:
return 3
case level >= 5:
return 2
default:
return 1
}
}
// casterBlasterFloor gives the arcane blasters (Mage/Sorcerer/Warlock) their
// shared sustained-DPS floor at T5. Two knobs, both LEVEL-SCALED so the L20
// floor lift stays negligible at low tiers — a flat +40 HP doubled a L1 mage
// and facerolled T5, which the class-balance guardrail (dnd_class_balance_test)
// correctly rejected.
//
// - Cantrip: kill-speed is the T5 currency (fights are truncation-bound), so
// the primary lever is damage. Multiplicative form — the spell modifier
// (INT for Mage, CHA for Sorcerer/Warlock) rides EVERY die, matching 5e
// Agonizing Blast. cantripDice scales 1→4 across levels.
// - Survival: just enough Defense (scaled by level) that the caster lives long
// enough for the cantrip to connect the kill — NOT a tank rider. This adds
// Defense only (no HP add); calcDamage's diminishing returns keep the L20
// +20 Def from becoming a wall.
//
// casterCantripBase / casterDefPerLevel are the caster tuning dials; see the
// rebaseline plan for the sweep that set them. The cantrip floor only becomes a
// live lever once combat_cmd.go bridges CantripPerRound into the turn engine
// (the swing engine, combat_engine.go:590, is not the auto-resolve path). Mage
// and Sorcerer take casterCantripBase; Warlock passes 0 — its bare-dice cantrip
// plus its structural edge already lands it mid-band, so an added floor would
// overshoot the 45 ceiling.
const (
casterCantripBase = 3 // per-die base before the ability modifier rides in
casterDefPerLevel = 1 // ~+20 Def at L20, +1 at L1
)
func casterBlasterFloor(stats *CombatStats, mods *CombatModifiers, level, abilityMod, base int, cantrip string) {
mods.CantripPerRound = cantripDice(level) * (base + clampNonNeg(abilityMod))
mods.CantripDesc = cantrip
stats.Defense += casterDefPerLevel * level
}
func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacter) {
switch c.Class {
case ClassFighter:
mods.DamageBonus += 0.05
// Class-identity audit (2026-05-16) — Extra Attack. 5e Fighter is
// defined by attack-action economy: L5 +1 swing/round, L11 +2,
// L20 +3. Prior to this the Fighter had only +5% Battle Trained
// at every level, which left the L5 "now I'm a real fighter"
// power-spike completely invisible. The +5% rider stays as the
// L1-4 floor; Extra Attack is what carries the chassis from L5.
// Other martials that 5e gives Extra Attack to (Paladin/Ranger/
// Barbarian) keep their existing DamageBonus proxies for now —
// re-tune in a follow-up if their win curves drift after this.
switch {
case c.Level >= 20:
mods.ExtraAttacks += 2 // rebaseline: ceiling nerf — 3 swings at L20, was 4 (the engine-ceiling faceroll)
case c.Level >= 11:
mods.ExtraAttacks += 2
case c.Level >= 5:
mods.ExtraAttacks += 1
}
stats.AttackBonus -= 2 // rebaseline: sub-swing to-hit trim — 3 swings lands the Fighter in the ~60 band
case ClassRogue:
mods.AutoCritFirst = true
if c.Level >= 5 { // rebaseline: 2nd swing (was 1) fixes the 1-swing action-economy floor at T5
mods.ExtraAttacks += 1
}
stats.AttackBonus -= 3 // rebaseline: to-hit trim so the 2nd swing lands the Rogue in band, not the +75pp nuke
// Phase 2 class-balance: rogue's once-per-fight auto-crit goes stale
// at high tiers (T5 mean trails leaders by ~10pp pre-tune). Add a
// modest steady-DPS rider so post-opener rounds aren't pure attrition.
mods.DamageBonus += -0.10 // rebaseline: fine-trim the 2-swing Rogue down into the ~60 band
// Class-identity audit (2026-05-16) — actual Sneak Attack as Nd6
// per hit, scaling with level per 5e (1d6 L1-2 ... 10d6 L19-20).
// AutoCritFirst + DamageBonus alone left the rogue's defining
// mechanic invisible at the table: monte-carlo win rates looked
// fine but a player hitting once per round saw weapon-only damage
// every swing after the opener. Engine path uses the same "every
// hit" treatment as Divine Strike (rogues swing 1×/round so this
// matches the 5e once-per-turn cadence in practice).
sneakDice := (c.Level + 1) / 2
if sneakDice > 10 {
sneakDice = 10
}
mods.SneakAttackDie += sneakDice
case ClassMage:
stats.AttackBonus++
// At-will Fire Bolt + level-scaled survival — the sustained arcane floor.
casterBlasterFloor(stats, mods, c.Level, abilityModifier(c.INT), casterCantripBase, "Fire Bolt")
// Phase 2 class-balance: +1 attack alone left Mage mid-pack on damage
// per round. A modest damage rider lifts weapon hits (DamageBonus does
// not multiply queued SpellPreDamage — that path is its own field).
mods.DamageBonus += 0.05
// Phase 2 class-balance: Arcane focus also produces a small pre-combat
// arcane burst, scaling with level and INT. Helps the L1-4 chassis,
// which would otherwise rely on a single weak L1 spell + quarterstaff
// against T2-T3 monsters. Saturates harmlessly at L10+ where every
// class wins anyway.
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.INT))
case ClassCleric:
// Passive heal at <50% HP. Stacks additively with consumable HealItem.
mods.HealItem += 5
case ClassRanger:
// Class-identity audit (2026-05-16) — Hunter's Mark as actual
// per-hit Nd6, plus L5 Extra Attack from the martial chassis. The
// previous +5% damage + +1 to-hit was a Phase-2 compensation
// rider for both being missing; +1 to-hit stays as the "read the
// prey's tells" half (it's not Hunter's Mark, that's the d6).
// HuntersMarkDie scales 1/5/10/15 per the 5e upcast progression
// (1d6 at L1, 2d6 L5+, 3d6 L11+, 4d6 L17+ as a soft proxy).
stats.AttackBonus++
switch {
case c.Level >= 17:
mods.HuntersMarkDie += 4
case c.Level >= 11:
mods.HuntersMarkDie += 3
case c.Level >= 5:
mods.HuntersMarkDie += 2
default:
mods.HuntersMarkDie += 1
}
if c.Level >= 5 {
mods.ExtraAttacks += 1
}
case ClassDruid:
// Multiplicative, so it stacks cleanly with the subclass DamageReduct
// riders (DamageReduct is initialized to 1.0 by DerivePlayerStats before
// passives run). NOTE the player-defender direction: DamageReduct feeds
// calcDamage as a defense multiplier, so <1 = MORE damage taken. This
// line is therefore a mild survival trim in the same direction as the
// rebaseline *0.2 below — not the damage cut the "Wild Resilience" name
// suggests. Left as-is because the rebaseline sweep is tuned to it.
mods.DamageReduct *= 0.95
// rebaseline: the Druid wins T5 rooms on the survival tiebreak, immune to
// every damage lever. DamageReduct is a defense multiplier (calcDamage) —
// <1 = take MORE damage. Combined with a damage trim to pull it to band.
mods.DamageReduct *= 0.2
mods.DamageBonus += -0.20
// Phase 3 class-balance: druid was the only caster chassis with a
// purely defensive passive, and the off-tier numbers showed it —
// L1/T2 mean 0.04 vs Mage 0.27. Mirror the other caster bursts so
// the L1-4 druid can chip a T2 monster: WIS-scaled FlatDmgStart on
// engage. No DamageBonus rider; the chassis keeps its defensive
// identity, and the burst alone (plus the existing DR) lifts the
// off-tier cells without pushing in-tier wins past 1.0.
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.WIS))
// Class-identity audit (2026-05-16) — thorn lash, the offensive
// half of Wild Resilience the tooltip already promises ("a thorn
// surge lashes back at whatever picked the fight"). Per-hit flat
// counter when the enemy lands on the player. Scales modestly so
// it's a steady tick, not a damage-race winner — the chassis
// stays defensive-first.
mods.ThornLashDmg += 2 + c.Level/4
case ClassBard:
// Phase 2 class-balance: bare +1 initiative left Bard the weakest
// class chassis (T5 mean 0.48 pre-tune). Add a Ranger-tier rider
// (+1 attack, +5% damage) so the chassis pulls weight before subclass
// kicks in at L5, plus a CHA-scaled opening flourish (FlatDmgStart) so
// the L1-4 chassis isn't dead at T3.
mods.InitiativeBias += 1
stats.AttackBonus++
mods.DamageBonus += 0.05
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA))
mods.DamageReduct *= 0.4 // rebaseline: Bard also wins T5 on the survival tiebreak — take more damage to reach band
case ClassSorcerer:
// Innate Sorcery — pre-combat burst, CHA-scaled like the Sorcerer's
// spellcasting stat. Floors at the flat base for low-CHA builds.
// Phase 2 class-balance: pure FlatDmgStart faded at higher tiers as
// monster HP grew. Adding a 5% damage rider plus level scaling on the
// burst keeps the chassis relevant past L1.
// Phase 3 class-balance: sorcerer was the second-worst off-tier caster
// (L1/T2 0.10 pre-tune), trailing mage by ~17pp despite a comparable
// stat line. Bump the burst base 3→5 to lift the L1-4 chassis without
// touching the +0.05 rider that already saturates at high tier.
mods.FlatDmgStart += 5 + c.Level + clampNonNeg(abilityModifier(c.CHA))
mods.DamageBonus += 0.05
stats.AttackBonus++ // rebaseline: Sorcerer lagged the other blasters — match their +1 to-hit
// At-will Fire Bolt + level-scaled survival — sustained arcane floor.
casterBlasterFloor(stats, mods, c.Level, abilityModifier(c.CHA), casterCantripBase, "Fire Bolt")
case ClassWarlock:
// Phase 2 class-balance: bumped from 10% to 12% damage + 1 attack —
// the Warlock chassis read mid-pack at T5 (0.52) pre-tune. Eldritch
// blast as an opener (FlatDmgStart, level + CHA-scaled) covers the
// caster's L1-4 quarterstaff weakness, same shape as the other three
// arcane chassis.
mods.DamageBonus += 0.12
stats.AttackBonus++
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA))
// At-will Eldritch Blast + level-scaled survival — sustained arcane floor.
casterBlasterFloor(stats, mods, c.Level, abilityModifier(c.CHA), 0, "Eldritch Blast")
case ClassPaladin:
// Class-identity audit (2026-05-16) — Divine Smite as actual
// per-hit radiant bonus + L5 Extra Attack. 5e: smite consumes a
// spell slot for Nd8 radiant on a hit. Our engine has no slot
// tracker for ad-hoc smites; we collapse "paladin has plenty of
// slots over an adventuring day" to a flat per-hit rider, scaled
// down so an extra-attack paladin doesn't trivialize every fight.
// Rides DivineStrikePerHit (already in the weapon-hit damage path).
// Previous FlatDmgStart opener felt like Lay on Hands, not Smite.
smite := 4 + c.Level/2 // rebaseline: bigger Divine Smite lifts the Paladin from floor into band
mods.DivineStrikePerHit += smite
mods.DamageReduct *= 0.9 // rebaseline: small survival trim between the two integer smite steps for a ~60 landing
if c.Level >= 5 {
mods.ExtraAttacks += 1
}
}
}