From 98ba41635936623aa471787298f1586de929bc77 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 16 May 2026 12:53:00 -0700 Subject: [PATCH] Class identity audit close-out: Fighter/Ranger/Paladin/Druid + Bard-Valor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/plugin/combat_engine.go | 67 +++++++++++++++++++++++++- internal/plugin/combat_primitives.go | 8 +++ internal/plugin/dnd_passives.go | 61 +++++++++++++++++++++-- internal/plugin/dnd_subclass_combat.go | 12 ++--- internal/plugin/dnd_xp_test.go | 10 +++- 5 files changed, 144 insertions(+), 14 deletions(-) diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index 16169e5..e79f4f7 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -116,6 +116,16 @@ type CombatModifiers struct { // no Weapon → no Divine Strike. DivineStrikePerHit int + // Class-identity audit (2026-05-16) — Fighter Extra Attack as actual + // additional swings per round. 0 = legacy single swing. Each extra is + // a full resolvePlayerAttack call (own d20, own damage roll). 5e + // progression for Fighter: 1 at L5, 2 at L11, 3 at L20. Other martials + // (Paladin/Ranger/Barbarian/Bard-Valor) cap at 1 at L5 — wire as needed. + // Once-per-fight effects (AutoCritFirst, FirstAttackBonus, Assassinate + // reroll, SneakAttackDie applies every hit but Rogue has no extras) are + // guarded by st flags, so they correctly fire only on the first swing. + ExtraAttacks int + // Class-identity audit (2026-05-16) — Rogue Sneak Attack as actual // per-hit Nd6 bonus damage. Number of d6 to roll on every player hit. // 5e gates this on "once per turn given advantage/ally adjacent"; our @@ -126,6 +136,23 @@ type CombatModifiers struct { // at L7-8 → 10d6 at L19-20, per 5e progression). SneakAttackDie int + // Class-identity audit (2026-05-16) — Ranger Hunter's Mark as actual + // Nd6 bonus damage per hit. 5e: bonus action to mark a target, then + // +1d6 to every weapon hit vs. that target until concentration drops. + // In 1v1 there's effectively always a marked target (the only foe), + // so we model the mark as "always-on" — applies to every player hit. + // Number of d6 to roll: scales with class level (1 at L1, +1 every + // 5 levels per the 5e upcast progression, capped at 4d6). + HuntersMarkDie int + + // Class-identity audit (2026-05-16) — Druid thorn-lash. Flat damage + // returned to the enemy whenever the enemy lands a hit on the player. + // 5e analogue: Spike Growth / Thorn Whip / the various druidic + // retaliation cantrips collapsed into a passive aura. Fires after + // damage is applied (and after ward absorption), so a "blocked but + // touched" hit still pricks back. Independent of crit doubling. + ThornLashDmg int + // Phase 10 SUB2b — Mage subclasses. // ArcaneWardHP: flat HP buffer absorbed before player HP. Refilled at the // start of each combat by Abjuration L5+ (2× Mage level, +prof at L7). @@ -558,7 +585,7 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase playerFirst := playerInit >= enemyInit if playerFirst { - if resolvePlayerAttack(st, player, enemy, phase, result) { + if resolvePlayerSwings(st, player, enemy, phase, result) { return true } if !abilityDealtDamage { @@ -572,7 +599,7 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase return true } } - if resolvePlayerAttack(st, player, enemy, phase, result) { + if resolvePlayerSwings(st, player, enemy, phase, result) { return true } } @@ -694,6 +721,26 @@ func maybeTriggerOrcRage(st *combatState, player *Combatant, phaseName string) { // ── Attack Resolution ──────────────────────────────────────────────────────── +// resolvePlayerSwings calls resolvePlayerAttack once, then up to +// player.Mods.ExtraAttacks more times (5e Extra Attack). Each extra swing is +// a full attack roll + damage roll. Stops early on enemy KO. The first swing +// consumes once-per-fight openers (AutoCritFirst, FirstAttackBonus, +// AssassinateAdvantage) via st flags — extras roll vanilla. +func resolvePlayerSwings(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool { + if resolvePlayerAttack(st, player, enemy, phase, result) { + return true + } + for i := 0; i < player.Mods.ExtraAttacks; i++ { + if st.enemyHP <= 0 || st.playerHP <= 0 { + return false + } + if resolvePlayerAttack(st, player, enemy, phase, result) { + return true + } + } + return false +} + // resolvePlayerAttack — d20 + AttackBonus vs enemy AC. // Nat 20 = auto-hit + crit. Nat 1 = auto-miss tagged "fumble". // Block (on hit) halves damage. autoCrit consumable forces a crit on hit. @@ -1013,6 +1060,22 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat } } + // Class-identity audit (2026-05-16) — Druid thorn lash. Fires on any + // landed enemy hit (dmg>0 after ward/block); independent flat-damage + // counter, not a fraction. Can drop the enemy. + if player.Mods.ThornLashDmg > 0 && dmg > 0 { + lash := player.Mods.ThornLashDmg + st.enemyHP = max(0, st.enemyHP-lash) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "player", Action: "thorn_lash", + Damage: lash, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + Desc: "Wild Resilience", + }) + if enemyDown(st, phaseName) { + return true + } + } + if st.playerHP <= 0 { return !trySave(st, player, phaseName) } diff --git a/internal/plugin/combat_primitives.go b/internal/plugin/combat_primitives.go index a14d73f..91cda7b 100644 --- a/internal/plugin/combat_primitives.go +++ b/internal/plugin/combat_primitives.go @@ -103,6 +103,14 @@ func applyPlayerHitDamageMods(st *combatState, player *Combatant, dmg int, isCri dmg += 1 + st.roll(6) } } + // Class-identity audit (2026-05-16) — Ranger Hunter's Mark. +Nd6 on + // every player hit. The implicit "you marked this enemy on round 0" + // model: in 1v1, the only foe is always the marked one. + if player.Mods.HuntersMarkDie > 0 { + for i := 0; i < player.Mods.HuntersMarkDie; i++ { + dmg += 1 + st.roll(6) + } + } // Phase 10 SUB2a-ii — Assassin Death Strike proxy: bonus damage on the // first hit only. Stacks on top of the Rogue's Sneak Attack auto-crit // (which already doubled the base damage above) — the bonus itself is diff --git a/internal/plugin/dnd_passives.go b/internal/plugin/dnd_passives.go index f9dc3fa..81d90aa 100644 --- a/internal/plugin/dnd_passives.go +++ b/internal/plugin/dnd_passives.go @@ -115,6 +115,23 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact 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 += 3 + case c.Level >= 11: + mods.ExtraAttacks += 2 + case c.Level >= 5: + mods.ExtraAttacks += 1 + } case ClassRogue: mods.AutoCritFirst = true // Phase 2 class-balance: rogue's once-per-fight auto-crit goes stale @@ -150,8 +167,27 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact // Passive heal at <50% HP. Stacks additively with consumable HealItem. mods.HealItem += 5 case ClassRanger: - mods.DamageBonus += 0.05 + // 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: // Wild Resilience — multiplicative, so it stacks cleanly with the // subclass DamageReduct riders. DamageReduct is initialized to 1.0 @@ -165,6 +201,13 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact // 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 @@ -197,8 +240,18 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact stats.AttackBonus++ mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA)) case ClassPaladin: - // Divine Smite — radiant burst on engage, scaling with level so it - // stays relevant against tougher foes. - mods.FlatDmgStart += 4 + c.Level/2 + // 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 := 3 + c.Level/3 + mods.DivineStrikePerHit += smite + if c.Level >= 5 { + mods.ExtraAttacks += 1 + } } } diff --git a/internal/plugin/dnd_subclass_combat.go b/internal/plugin/dnd_subclass_combat.go index 66e7bb9..8fbf5ac 100644 --- a/internal/plugin/dnd_subclass_combat.go +++ b/internal/plugin/dnd_subclass_combat.go @@ -442,16 +442,16 @@ func applySubclassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDChar } case SubclassCollegeValor: // L5 Combat Inspiration: songs that sharpen the blade (+10% damage). - // L7 Extra Attack: throughput — +1 attack and a further +10% damage - // proxy for the second swing. L10 Battle Magic: a bonus-action - // strike after a spell — a FlatDmgStart burst. L15: +1 AC, the - // late-game shield half of Combat Inspiration. + // 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 { - stats.AttackBonus++ - mods.DamageBonus += 0.10 + mods.ExtraAttacks += 1 } if c.Level >= 10 { mods.FlatDmgStart += 6 diff --git a/internal/plugin/dnd_xp_test.go b/internal/plugin/dnd_xp_test.go index 93fefd9..f760156 100644 --- a/internal/plugin/dnd_xp_test.go +++ b/internal/plugin/dnd_xp_test.go @@ -236,12 +236,18 @@ func TestApplyClassPassives(t *testing.T) { {ClassRogue, 0.05, 0, true, 0, 1.0, 0, 0}, {ClassMage, 0.05, 1, false, 0, 1.0, 1, 0}, {ClassCleric, 0, 0, false, 5, 1.0, 0, 0}, - {ClassRanger, 0.05, 1, false, 0, 1.0, 0, 0}, + // Class-identity audit (2026-05-16): Ranger Hunter's Mark is now + // per-hit HuntersMarkDie (not asserted here — same shape as + // SneakAttackDie); Paladin Divine Smite is now per-hit + // DivineStrikePerHit (not asserted here). The +5% damage and + // FlatDmgStart compensation riders are gone; +1 to-hit stays on + // Ranger as the "read prey tells" half. + {ClassRanger, 0, 1, false, 0, 1.0, 0, 0}, {ClassDruid, 0, 0, false, 0, 0.95, 1, 0}, {ClassBard, 0.05, 1, false, 0, 1.0, 1, 1}, {ClassSorcerer, 0.05, 0, false, 0, 1.0, 6, 0}, {ClassWarlock, 0.12, 1, false, 0, 1.0, 1, 0}, - {ClassPaladin, 0, 0, false, 0, 1.0, 4, 0}, + {ClassPaladin, 0, 0, false, 0, 1.0, 0, 0}, } for _, tc := range cases { stats := CombatStats{AttackBonus: 5}