mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Mechanical `gofmt -w ./internal ./cmd`. Mostly struct-field realignment that had drifted, plus a few trailing-newline fixes. No behaviour change — gofmt is semantics-preserving, and build/vet/test are green either side. Split out from the code-review fixes that follow so those stay reviewable instead of hiding inside a wall of realignment.
259 lines
12 KiB
Go
259 lines
12 KiB
Go
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.
|
||
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 += 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
|
||
// 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.05
|
||
// 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++
|
||
// 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:
|
||
// Wild Resilience — multiplicative, so it stacks cleanly with the
|
||
// subclass DamageReduct riders. DamageReduct is initialized to 1.0
|
||
// by DerivePlayerStats before passives run.
|
||
mods.DamageReduct *= 0.95
|
||
// 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))
|
||
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
|
||
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))
|
||
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 := 3 + c.Level/3
|
||
mods.DivineStrikePerHit += smite
|
||
if c.Level >= 5 {
|
||
mods.ExtraAttacks += 1
|
||
}
|
||
}
|
||
}
|