Files
gogobee/internal/plugin/dnd_passives.go
prosolis 41adfce9f1 Expedition autopilot Phase 4 + rogue sneak attack + TwinBee voice sweep
Tedium-removal pass driven by live play feedback. Three big threads:

Expedition autopilot Phase 4 — background auto-run + harvest-until-dry:
- New expeditionAutoRunTicker walks active expeditions every 15min
  (5min tick, per-expedition CAS on new last_autorun_at column). Skips
  combat sessions, briefing/recap quiet windows, expeditions <30min old.
- Walks up to 3 rooms/tick with compact narration; suppresses DMs when
  0 rooms walked or just hitting the per-tick cap (no "stretch complete"
  filler). Player only hears from the bot when a real decision is needed.
- Compact mode: one-line combat narration for trash/elite, auto-resolves
  elite doorways via the forward-sim engine (boss still pauses). Threaded
  via new advanceOnceWithOpts → resolveRoom → resolveCombatRoom.
- Auto-harvest now grinds each Common/Uncommon node until dry (cap at 8
  attempts/visit), mirroring manual !scavenge retries. Rare+ still pauses.
- Ambient ticker: anti-repeat by Kind via new last_ambient_kind column
  (avoids two pack_rat DMs in a row when the pool only has 6 lines).
- !expedition go <n> now routes to the fork-choice handler when active +
  numeric; fork footer rewritten to suggest !expedition go instead of
  !zone go. Boss/Elite doorway: formatNextRoomMessage routes the action
  hint by next room type and autopilot loop breaks via new nextRoomType
  field so "Room X/Y — Boss" doesn't double-print with contradictory
  hints (!zone advance vs !fight).
- runAutopilotWalk extracted from expeditionCmdRun so foreground and
  background share the loop body.

Rogue Sneak Attack actually exists now:
- Audit found the rogue's "Sneak Attack" passive was AutoCritFirst +
  5% damage rider. No Nd6, ever. Phase 2 Monte Carlo masked this
  with a small flat buff; the class's defining mechanic never matched
  its tooltip or 5e identity.
- Added SneakAttackDie int to CombatModifiers, per-hit Nd6 in
  combat_primitives.go (same lane as DivineStrikePerHit). Scales with
  level: 1d6 at L1-2 ... 4d6 at L7-8 ... capped at 10d6 at L19-20.
- AutoCritFirst + 5% rider retained as bonuses on top of working sneak
  attack — preserves the opener-burst feel.
- L7 rogue expected per-hit ~8 → ~22 damage. Valdris fight math goes
  from "16 rounds to kill, die in 8" to winnable.

TwinBee voice sweep (Phase B3):
- ~530 line changes across 24 files replacing third-person "TwinBee
  notes/files/tracks/respects/..." constructions with first-person
  inside flavor string literals. Code identifiers (TwinBeeLine,
  twinBeeLine, etc.), chat-prefix labels (🎭 **TwinBee:**), item names
  (TwinBee's Bell), achievements, and game-title references in
  fun.go (Konami TwinBee ship) left intact.
- Catches a real bug: Valdris fight rendered "TwinBee marks the
  Legendary Resistance" mid-combat in third person, contradicting
  the Phase B2 convention.

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

205 lines
9.2 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.
func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacter) {
switch c.Class {
case ClassFighter:
mods.DamageBonus += 0.05
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:
mods.DamageBonus += 0.05
stats.AttackBonus++
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))
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:
// Divine Smite — radiant burst on engage, scaling with level so it
// stays relevant against tougher foes.
mods.FlatDmgStart += 4 + c.Level/2
}
}