mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user