From e629f8fd4dfe3c77e63425e1e9896b545fbf06fe Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 14 May 2026 08:32:13 -0700 Subject: [PATCH] Combat: implement stateful monster ability effects Slice 3 of the bestiary SRD upgrade: the monster abilities that need per-fight state (evade, block, advantage, retaliate, regenerate, survive_at_1, stat_drain, debuff, max_hp_drain). applyAbility arms combatState flags that the shared resolution primitives read, so both the auto-resolve and turn-based engines honor them; the turn-based engine round-trips them through CombatStatuses so a suspended fight resumes from exact mid-state. New branches are guarded by zero-valued state so the auto-resolve characterization golden is untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/bestiary_srd_test.go | 88 ++++++++++ internal/plugin/combat_bridge.go | 2 +- internal/plugin/combat_engine.go | 234 ++++++++++++++++++++++++-- internal/plugin/combat_narrative.go | 98 +++++++++++ internal/plugin/combat_session.go | 14 ++ internal/plugin/combat_turn_engine.go | 50 +++++- 6 files changed, 468 insertions(+), 18 deletions(-) diff --git a/internal/plugin/bestiary_srd_test.go b/internal/plugin/bestiary_srd_test.go index 5b00fbc..0f83d16 100644 --- a/internal/plugin/bestiary_srd_test.go +++ b/internal/plugin/bestiary_srd_test.go @@ -144,6 +144,94 @@ func TestApplyAbility_Slice2Effects(t *testing.T) { } } +// TestApplyAbility_Slice3Effects covers the stateful monster-ability effects: +// applyAbility arms a combatState flag/counter (and emits an announcement for +// all but evade), and the shared resolution primitives read that state. +func TestApplyAbility_Slice3Effects(t *testing.T) { + newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) { + st := &combatState{ + playerHP: playerHP, enemyHP: enemyHP, round: 1, + rng: rand.New(rand.NewPCG(9, 9)), + } + return st, basePlayer(), baseEnemy() + } + phase := &turnCombatPhase + res := &CombatResult{} + + // Each effect arms its combatState field and (except evade) announces itself. + st, pl, en := newState(100, 60) + en.Ability = &MonsterAbility{Name: "Scurry", Effect: "evade"} + applyAbility(st, &pl, &en, phase, res) + if !st.enemyEvadeNext || len(st.events) != 0 { + t.Errorf("evade: want enemyEvadeNext set and no event, got flag=%v events=%d", st.enemyEvadeNext, len(st.events)) + } + + armChecks := []struct { + effect string + armed func(*combatState) bool + }{ + {"block", func(s *combatState) bool { return s.enemyBlockUp }}, + {"advantage", func(s *combatState) bool { return s.enemyAdvantage }}, + {"retaliate", func(s *combatState) bool { return s.enemyRetaliateFrac > 0 }}, + {"regenerate", func(s *combatState) bool { return s.enemyRegen > 0 }}, + {"survive_at_1", func(s *combatState) bool { return s.enemySurviveArmed }}, + {"stat_drain", func(s *combatState) bool { return s.playerAtkDrain > 0 }}, + {"debuff", func(s *combatState) bool { return s.playerACDebuff > 0 }}, + } + for _, c := range armChecks { + st, pl, en := newState(100, 60) + en.Ability = &MonsterAbility{Name: "Test " + c.effect, Effect: c.effect} + if applyAbility(st, &pl, &en, phase, res) { + t.Errorf("%s: should not down a full-HP player", c.effect) + } + if !c.armed(st) { + t.Errorf("%s: combatState flag not armed", c.effect) + } + if len(st.events) != 1 { + t.Errorf("%s: want one announcement event, got %d", c.effect, len(st.events)) + } + } + + // max_hp_drain lowers effective MaxHP and deals that much immediate damage. + stD, plD, enD := newState(100, 60) + enD.Ability = &MonsterAbility{Name: "Corrupting Touch", Effect: "max_hp_drain"} + applyAbility(stD, &plD, &enD, phase, res) + if stD.maxHPDrain <= 0 || stD.playerHP != 100-stD.maxHPDrain { + t.Errorf("max_hp_drain: drain=%d playerHP=%d, want playerHP = 100-drain", stD.maxHPDrain, stD.playerHP) + } + + // enemyDown lets survive_at_1 cheat death exactly once. + stS := &combatState{enemyHP: 0, enemySurviveArmed: true} + if enemyDown(stS, "Duel") { + t.Error("survive_at_1: armed enemy at 0 HP should not be down") + } + if stS.enemyHP != 1 || stS.enemySurviveArmed { + t.Errorf("survive_at_1: want enemyHP=1 and flag cleared, got hp=%d armed=%v", stS.enemyHP, stS.enemySurviveArmed) + } + stS.enemyHP = 0 + if !enemyDown(stS, "Duel") { + t.Error("survive_at_1: second drop to 0 should be lethal (one-shot spent)") + } + + // evade is consumed by the next player weapon attack — a guaranteed miss. + stE, plE, enE := newState(100, 60) + stE.enemyEvadeNext = true + if resolvePlayerAttack(stE, &plE, &enE, phase, res) { + t.Error("evade: a whiffed attack should not end the fight") + } + if stE.enemyEvadeNext || stE.enemyHP != 60 { + t.Errorf("evade: want flag cleared and enemyHP unchanged, got flag=%v hp=%d", stE.enemyEvadeNext, stE.enemyHP) + } + + // retaliate reflects a fraction of every player hit back at the player. + stR, plR, enR := newState(100, 60) + stR.enemyRetaliateFrac = 0.5 + resolvePlayerAttack(stR, &plR, &enR, phase, res) + if stR.playerHP >= 100 { + t.Errorf("retaliate: player HP = %d, want < 100 (reflected damage)", stR.playerHP) + } +} + // TestTurnEngine_Multiattack drives a single enemy_turn and confirms a // registered multiattack creature resolves one attack roll per profile entry, // while an unregistered creature still resolves exactly one. diff --git a/internal/plugin/combat_bridge.go b/internal/plugin/combat_bridge.go index 16655b2..5244874 100644 --- a/internal/plugin/combat_bridge.go +++ b/internal/plugin/combat_bridge.go @@ -469,7 +469,7 @@ func combatDegradation(result CombatResult, equip map[EquipmentSlot]*AdvEquipmen damage[SlotHelmet] += 1 + rand.IntN(2) case ev.Actor == "enemy" && ev.Action == "cleave": damage[SlotArmor] += 2 + rand.IntN(3) - case ev.Actor == "enemy" && (ev.Action == "bonus_damage" || ev.Action == "aoe" || ev.Action == "execute"): + case ev.Actor == "enemy" && (ev.Action == "bonus_damage" || ev.Action == "aoe" || ev.Action == "execute" || ev.Action == "retaliate" || ev.Action == "max_hp_drain"): damage[SlotArmor] += 1 + rand.IntN(2) case ev.Actor == "enemy" && ev.Action == "lifesteal": damage[SlotArmor] += 1 + rand.IntN(2) diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index 07d4ee3..31cd050 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -200,8 +200,11 @@ type MonsterAbility struct { // Effect — the mechanic the ability applies. Implemented in applyAbility: // stand-in attacks (cleave, lifesteal); riders on the normal attack (poison, // enrage, armor_break, stun, bonus_damage, aoe, aoe_fire, death_aoe, execute, - // self_heal); flavor-only placeholders pending per-fight state (spell_resist, - // reveal_action, fear_immune, ally_buff). Anything else is a silent no-op. + // self_heal, max_hp_drain); stateful effects armed here and read by the + // shared resolution primitives (evade, block, advantage, retaliate, + // regenerate, survive_at_1, stat_drain, debuff); flavor-only placeholders + // (spell_resist, reveal_action, fear_immune, ally_buff). Anything else is a + // silent no-op. Effect string } @@ -284,6 +287,20 @@ type combatState struct { // Phase 10 SUB2b — Abjuration Arcane Ward HP buffer. arcaneWardHP int + // Phase 13 bestiary slice 3 — stateful monster-ability effects. Each is + // armed by applyAbility and read by the shared resolution primitives, so + // both engines honour them; the turn-based engine additionally round-trips + // them through CombatStatuses so they survive a suspend/resume. + enemyEvadeNext bool // evade: next player weapon attack auto-misses + enemyBlockUp bool // block: enemy holds a parry stance (~50% block on player hits) + enemyAdvantage bool // advantage: enemy rolls its attacks with advantage + enemyRetaliateFrac float64 // retaliate: fraction of a player hit reflected back + enemyRegen int // regenerate: enemy heals this much each round end + enemySurviveArmed bool // survive_at_1: enemy cheats death once, dropping to 1 HP + playerAtkDrain int // stat_drain: flat reduction to the player's hit damage + playerACDebuff int // debuff: flat reduction to the player's effective AC + maxHPDrain int // max_hp_drain: reduction to the player's effective MaxHP + round int events []CombatEvent @@ -557,7 +574,7 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_attack", Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, }) - if st.enemyHP <= 0 { + if enemyDown(st, phaseName) { return true } } @@ -565,7 +582,7 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase // Misty heal if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc { healAmt := player.Mods.MistyHealAmt - st.playerHP = min(player.Stats.MaxHP, st.playerHP+healAmt) + st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt) result.MistyHealed = true st.events = append(st.events, CombatEvent{ Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal", @@ -585,13 +602,23 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase st.playerHP > 0 && st.playerHP*5 < player.Stats.MaxHP*3 { st.healChargesLeft-- healAmt := player.Mods.HealItem - st.playerHP = min(player.Stats.MaxHP, st.playerHP+healAmt) + st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt) st.events = append(st.events, CombatEvent{ Round: st.round, Phase: phaseName, Actor: "consumable", Action: "heal_item", Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, }) } + // Regenerate (monster ability): the enemy knits its wounds at the close of + // every round once the ability has armed st.enemyRegen. + if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < enemy.Stats.MaxHP { + st.enemyHP = min(enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "regen_tick", + Damage: st.enemyRegen, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + } + return false } @@ -613,6 +640,17 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba return false } + // Evade (monster ability): the enemy slipped out of reach — this swing + // finds nothing. Consumed here, armed by applyAbility's "evade" case. + if st.enemyEvadeNext { + st.enemyEvadeNext = false + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "evade", + PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + return false + } + // Orc Rage: trigger on the first attack after dropping below 50% HP. // Use HP*2 < MaxHP rather than HP < MaxHP/2 so the threshold is exact // regardless of MaxHP parity (avoids per-character drift on odd MaxHP). @@ -701,7 +739,19 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba enemy.Stats.Defense, phase.DefenseWeight, enemy.Mods.DamageReduct) } + // Stat drain (monster ability): the player's strength has been sapped — a + // flat, accumulating reduction to the damage every hit deals. + if st.playerAtkDrain > 0 { + dmg = max(1, dmg-st.playerAtkDrain) + } + blocked := enemy.Stats.BlockRate > 0 && st.randFloat() < enemy.Stats.BlockRate + // Parry stance (monster ability): an enemy holding a block stance rolls a + // further ~50% chance to halve the hit. Guarded by enemyBlockUp so the + // extra randFloat is only drawn once the ability has actually fired. + if !blocked && st.enemyBlockUp && st.randFloat() < 0.5 { + blocked = true + } if blocked { dmg = max(1, dmg/2) } @@ -714,7 +764,23 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Roll: roll, RollAgainst: enemy.Stats.AC, Desc: desc, }) - return st.enemyHP <= 0 + + // Retaliate (monster ability): a damaging aura reflects a fraction of the + // hit straight back. Resolved per player hit; can drop the player, so this + // path can return true with the enemy still standing — callers disambiguate + // the outcome by inspecting HP (see stepPlayerTurn). + if st.enemyRetaliateFrac > 0 { + retal := max(1, int(float64(dmg)*st.enemyRetaliateFrac)) + st.playerHP = max(0, st.playerHP-retal) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "retaliate", + Damage: retal, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + if st.playerHP <= 0 && !trySave(st, player, phaseName) { + return true + } + } + return enemyDown(st, phaseName) } // resolveEnemyAttack — enemy rolls d20 + AttackBonus vs player AC. @@ -744,11 +810,27 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat } roll := 1 + st.roll(20) + // Advantage (monster ability): the enemy rolls a second d20 and keeps the + // better. Guarded by enemyAdvantage so the extra roll is only drawn once the + // ability has fired. + if st.enemyAdvantage { + if alt := 1 + st.roll(20); alt > roll { + roll = alt + } + } isFumble := roll == 1 isNat20 := roll == 20 total := roll + effectiveAttackBonus(enemy.Stats) - if !attackConnects(roll, total, player.Stats.AC, 20) { + // Debuff (monster ability): a flat, accumulating reduction to the player's + // effective AC, so the enemy's attacks land more often. Floored at 1. + // Guarded so an undebuffed player's AC passes through untouched. + targetAC := player.Stats.AC + if st.playerACDebuff > 0 { + targetAC = max(1, player.Stats.AC-st.playerACDebuff) + } + + if !attackConnects(roll, total, targetAC, 20) { desc := "" if isFumble { desc = "fumble" @@ -756,7 +838,7 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat st.events = append(st.events, CombatEvent{ Round: st.round, Phase: phaseName, Actor: "enemy", Action: "miss", PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - Roll: roll, RollAgainst: player.Stats.AC, Desc: desc, + Roll: roll, RollAgainst: targetAC, Desc: desc, }) return false } @@ -831,7 +913,7 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat st.events = append(st.events, CombatEvent{ Round: st.round, Phase: phaseName, Actor: "enemy", Action: action, Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - Roll: roll, RollAgainst: player.Stats.AC, + Roll: roll, RollAgainst: targetAC, }) if st.reflectFrac > 0 { @@ -842,7 +924,7 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat Round: st.round, Phase: phaseName, Actor: "consumable", Action: "reflect_damage", Damage: reflected, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, }) - if st.enemyHP <= 0 { + if enemyDown(st, phaseName) { return true } } @@ -1008,6 +1090,108 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase, Damage: heal, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, }) + case "evade": + // The enemy slips out of reach — its next-attacked-against weapon swing + // finds nothing. The flag is consumed (and the event emitted) by + // resolvePlayerAttack, so a fight log between here and there reads as the + // enemy turning evasive and the strike whiffing on the following beat. + st.enemyEvadeNext = true + + case "block": + // The enemy settles into a parry stance for the rest of the fight: every + // player hit from here on rolls a ~50% chance to be halved (on top of any + // innate BlockRate). resolvePlayerAttack reads st.enemyBlockUp. + if !st.enemyBlockUp { + st.enemyBlockUp = true + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "parry_stance", + Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + } + + case "advantage": + // The enemy gains the upper hand — it rolls its attacks with advantage + // (best of two d20s) for the rest of the fight. resolveEnemyAttack reads + // st.enemyAdvantage. + if !st.enemyAdvantage { + st.enemyAdvantage = true + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "advantage", + Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + } + + case "retaliate": + // A damaging aura: from now on a fraction of every player hit is reflected + // straight back. resolvePlayerAttack applies the reflected damage per hit. + if st.enemyRetaliateFrac < 0.5 { + st.enemyRetaliateFrac = 0.5 + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "retaliate_aura", + Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + } + + case "regenerate": + // The enemy starts knitting its wounds every round. The actual healing + // ticks in the round_end phase (turn-based) / round start (auto-resolve); + // here we just arm it. + if st.enemyRegen == 0 { + st.enemyRegen = max(1, enemy.Stats.MaxHP/12) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "regenerate", + Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + } + + case "survive_at_1": + // The enemy cheats death once: the next blow that would drop it to 0 + // leaves it at 1 HP instead (see enemyDown). Arm it the first time the + // ability procs; it stays armed until spent. + if !st.enemySurviveArmed { + st.enemySurviveArmed = true + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "survive_armed", + Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + } + + case "stat_drain": + // Saps the player's strength — a flat, accumulating reduction to the + // damage their hits deal. Capped so a long fight can't zero them out. + drain := 2 + st.roll(3) + st.playerAtkDrain = min(12, st.playerAtkDrain+drain) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "stat_drain", + Damage: drain, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + + case "debuff": + // Fouls the player's defence — a flat, accumulating reduction to their + // effective AC, so the enemy's attacks land more often. Capped. + drain := 1 + st.roll(2) + st.playerACDebuff = min(6, st.playerACDebuff+drain) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "debuff", + Damage: drain, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + + case "max_hp_drain": + // Drains life force: lowers the player's effective MaxHP and deals that + // much immediate damage (the drain hits current HP too). Effective MaxHP + // is floored at 1 via the accumulating cap. + headroom := max(0, player.Stats.MaxHP-1-st.maxHPDrain) + drain := min(headroom, player.Stats.MaxHP/10+st.roll(max(1, player.Stats.MaxHP/20))) + st.maxHPDrain += drain + st.playerHP = max(0, st.playerHP-drain) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "max_hp_drain", + Damage: drain, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + if st.playerHP <= 0 { + return !trySave(st, player, phaseName) + } + case "spell_resist", "reveal_action", "fear_immune", "ally_buff": // Slice-2 placeholder: no mechanical effect yet (these need persistent // per-fight state, deferred to the next slice), but the ability still @@ -1067,6 +1251,36 @@ func playerDefense(player *Combatant, st *combatState) int { return def } +// effPlayerMaxHP is the player's MaxHP after any max_hp_drain monster ability, +// floored at 1. Heal clamps use this so a drained player can't be topped back +// up past the lowered ceiling. With no drain (every characterization scenario) +// it returns player.Stats.MaxHP unchanged. +func effPlayerMaxHP(player *Combatant, st *combatState) int { + return max(1, player.Stats.MaxHP-st.maxHPDrain) +} + +// enemyDown reports whether the enemy is actually dead. It is the single choke +// point every "did this drop the enemy" check routes through, so the +// survive_at_1 ability can cheat death exactly once: an armed enemy at/below 0 +// HP is restored to 1, the flag is spent, and the fight continues. With no +// ability armed (every auto-resolve characterization scenario), this is a plain +// st.enemyHP <= 0 with no side effects. +func enemyDown(st *combatState, phaseName string) bool { + if st.enemyHP > 0 { + return false + } + if st.enemySurviveArmed { + st.enemySurviveArmed = false + st.enemyHP = 1 + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "survive_at_1", + PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + return false + } + return true +} + func trySave(st *combatState, player *Combatant, phaseName string) bool { if player.Mods.DeathSave && !st.deathSaveUsed { st.deathSaveUsed = true diff --git a/internal/plugin/combat_narrative.go b/internal/plugin/combat_narrative.go index 7da86ad..dee293f 100644 --- a/internal/plugin/combat_narrative.go +++ b/internal/plugin/combat_narrative.go @@ -306,6 +306,32 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul case "ability_flavor": return pickRand(narrativeAbilityFlavor) + // Monster abilities — slice 3 stateful effects + case "evade": + return pickRand(narrativeEvade) + case "parry_stance": + return pickRand(narrativeParryStance) + case "advantage": + return pickRand(narrativeAdvantage) + case "retaliate_aura": + return pickRand(narrativeRetaliateAura) + case "retaliate": + return fmt.Sprintf(pickRand(narrativeRetaliate), e.Damage) + case "regenerate": + return pickRand(narrativeRegenerate) + case "regen_tick": + return fmt.Sprintf(pickRand(narrativeRegenTick), e.Damage) + case "survive_armed": + return pickRand(narrativeSurviveArmed) + case "survive_at_1": + return pickRand(narrativeSurvive) + case "stat_drain": + return pickRand(narrativeStatDrain) + case "debuff": + return pickRand(narrativeDebuff) + case "max_hp_drain": + return fmt.Sprintf(pickRand(narrativeMaxHPDrain), e.Damage) + case "timeout": return pickRand(narrativeTimeout) @@ -645,6 +671,78 @@ var narrativeAbilityFlavor = []string{ "🌀 The enemy invokes a power you can't quite read. File it under 'concerning'.", } +var narrativeEvade = []string{ + "💨 Your strike lands on nothing — the enemy was never quite where it looked.", + "💨 The enemy slips the blow. Your weapon finds only the air it left behind.", + "💨 A clean swing, a clean miss. The enemy ghosts aside at the last instant.", +} + +var narrativeParryStance = []string{ + "🛡️ The enemy settles into a tight defensive guard. Hits are going to come harder now.", + "🛡️ The enemy raises its guard and *means* it — every blow from here will have to earn it.", + "🛡️ The enemy shifts its stance, weapon angled to turn your strikes aside.", +} + +var narrativeAdvantage = []string{ + "🎯 The enemy reads your rhythm. Its strikes start coming with unsettling certainty.", + "🎯 Something clicks for the enemy — it's anticipating you now, and it shows.", + "🎯 The enemy presses an advantage you can't quite see. Its aim has sharpened.", +} + +var narrativeRetaliateAura = []string{ + "🔥 The enemy flares with a punishing aura. Hitting it is about to cost you.", + "🔥 A searing field wraps the enemy — every blow you land will bite back.", + "🔥 The enemy wreathes itself in retaliation. Strike it and you share the pain.", +} + +var narrativeRetaliate = []string{ + "🔥 The enemy's aura lashes back — %d damage for the privilege of hitting it.", + "🔥 Your strike lands, and the recoil burns. %d damage bounces straight back into you.", + "🔥 %d damage reflected. The enemy made you pay for that hit in real time.", +} + +var narrativeRegenerate = []string{ + "♻️ The enemy's wounds begin to close on their own. This just got slower.", + "♻️ Torn flesh knits and seals. The enemy has started regenerating.", + "♻️ The enemy's body refuses to stay damaged — it's healing between blows now.", +} + +var narrativeRegenTick = []string{ + "♻️ The enemy mends another %d HP. The damage you're doing keeps un-doing itself.", + "♻️ +%d HP for the enemy as its wounds seal over. Frustrating.", + "♻️ The enemy claws back %d HP. You'll have to out-pace the regeneration.", +} + +var narrativeSurviveArmed = []string{ + "🕯️ The enemy refuses the idea of dying. Something keeps it on its feet past where it should fall.", + "🕯️ A grim resilience settles over the enemy — it will not go down easy.", + "🕯️ The enemy digs in. Whatever's holding it together, it isn't ready to let go.", +} + +var narrativeSurvive = []string{ + "🕯️ The killing blow lands clean — and the enemy *stays standing* at 1 HP. Unbelievable.", + "🕯️ That should have ended it. The enemy clings to a single point of HP through sheer spite.", + "🕯️ The enemy by all rights should be down. It is, instead, very barely up.", +} + +var narrativeStatDrain = []string{ + "🩸 The enemy saps your strength — your swings feel heavier, weaker. (-%d hit damage)", + "🩸 Something drains out of your limbs. Your hits won't bite as deep now. (-%d damage)", + "🩸 The enemy leeches your vigour. -%d damage on every strike from here.", +} + +var narrativeDebuff = []string{ + "😖 The enemy fouls your footing — your guard slips. (-%d AC)", + "😖 A wave of something sickly rolls over you. Harder to defend now. (-%d AC)", + "😖 The enemy's effect frays your defence. -%d AC, and their hits will notice.", +} + +var narrativeMaxHPDrain = []string{ + "☠️ The enemy drains your life force — %d HP gone, and your maximum drops with it.", + "☠️ Something cold pulls %d HP out of you and won't give it back. Your ceiling just fell.", + "☠️ %d HP siphoned away, max and all. There's less of you to work with now.", +} + // Outcome flavor var narrativeNearDeathWin = []string{ "You survived by the skin of your teeth. Barely standing. The healers are on standby.", diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 1f1c6ef..1f75751 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -96,6 +96,20 @@ type CombatStatuses struct { AssassinateReroll bool `json:"assassinate_reroll_used,omitempty"` AssassinateBonus bool `json:"assassinate_bonus_used,omitempty"` + // Slice-3 stateful monster-ability effects — armed by applyAbility, read by + // the shared resolution primitives, round-tripped through combatState so a + // suspended/resumed fight (or a reaper auto-play) keeps the same effect + // state. EnemyEvadeNext is a one-shot; the rest persist for the fight. + EnemyEvadeNext bool `json:"enemy_evade_next,omitempty"` + EnemyBlockUp bool `json:"enemy_block_up,omitempty"` + EnemyAdvantage bool `json:"enemy_advantage,omitempty"` + EnemyRetaliateFrac float64 `json:"enemy_retaliate_frac,omitempty"` + EnemyRegen int `json:"enemy_regen,omitempty"` + EnemySurviveArmed bool `json:"enemy_survive_armed,omitempty"` + PlayerAtkDrain int `json:"player_atk_drain,omitempty"` + PlayerACDebuff int `json:"player_ac_debuff,omitempty"` + MaxHPDrain int `json:"max_hp_drain,omitempty"` + // Persistent stat buffs from mid-fight !cast / !consume, accumulated as // deltas against the freshly-rebuilt combatant. applySessionBuffs folds // these back onto the player every round; diffTurnBuff produces them. diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index 1e078a3..1d057d9 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -133,7 +133,18 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R firstAttackBonusUsed: sess.Statuses.FirstAtkBonusUsed, assassinateRerollUsed: sess.Statuses.AssassinateReroll, assassinateBonusUsed: sess.Statuses.AssassinateBonus, - rng: rng, + // Slice-3 stateful monster-ability effects — armed by applyAbility, + // round-tripped here so they survive a suspend/resume or reaper auto-play. + enemyEvadeNext: sess.Statuses.EnemyEvadeNext, + enemyBlockUp: sess.Statuses.EnemyBlockUp, + enemyAdvantage: sess.Statuses.EnemyAdvantage, + enemyRetaliateFrac: sess.Statuses.EnemyRetaliateFrac, + enemyRegen: sess.Statuses.EnemyRegen, + enemySurviveArmed: sess.Statuses.EnemySurviveArmed, + playerAtkDrain: sess.Statuses.PlayerAtkDrain, + playerACDebuff: sess.Statuses.PlayerACDebuff, + maxHPDrain: sess.Statuses.MaxHPDrain, + rng: rng, } return &turnEngine{ sess: sess, @@ -181,10 +192,15 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) { te.stepPlayerActionEffect(action.Effect) return } - // Default: weapon attack. resolvePlayerAttack returns true once the - // enemy is down. + // Default: weapon attack. resolvePlayerAttack returns true once the fight + // is decided — usually the enemy is down, but a retaliate aura can drop the + // player on their own swing, so disambiguate the outcome by HP. if resolvePlayerAttack(te.st, te.player, te.enemy, &turnCombatPhase, te.result) { - te.finish(CombatStatusWon) + if te.st.playerHP <= 0 { + te.finish(CombatStatusLost) + } else { + te.finish(CombatStatusWon) + } return } if te.petStrike() { @@ -213,7 +229,7 @@ func (te *turnEngine) petStrike() bool { Round: st.round, Phase: turnCombatPhase.Name, Actor: "pet", Action: "pet_attack", Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, }) - return st.enemyHP <= 0 + return enemyDown(st, turnCombatPhase.Name) } // rollCombatSessionPetProc makes the one-and-only per-fight pet-attack roll and @@ -258,7 +274,10 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) { st.enemyHP = max(0, st.enemyHP-eff.EnemyDamage) } if eff.PlayerHeal > 0 { - st.playerHP = min(te.sess.PlayerHPMax, st.playerHP+eff.PlayerHeal) + // Respect any max_hp_drain monster ability — a drained player can't be + // healed back past the lowered ceiling. + hpCap := max(1, te.sess.PlayerHPMax-st.maxHPDrain) + st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal) } action := eff.Action if action == "" { @@ -268,7 +287,7 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) { Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: action, Damage: eff.EnemyDamage, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: eff.Label, }) - if st.enemyHP <= 0 { + if enemyDown(st, turnCombatPhase.Name) { te.finish(CombatStatusWon) return } @@ -377,6 +396,14 @@ func (te *turnEngine) stepRoundEnd() { } } } + // Regenerate (monster ability): the enemy knits its wounds at round end. + if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP { + st.enemyHP = min(te.enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "regen_tick", + Damage: st.enemyRegen, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + } st.round++ te.sess.Phase = CombatPhasePlayerTurn } @@ -422,6 +449,15 @@ func (te *turnEngine) commit() { s.FirstAtkBonusUsed = st.firstAttackBonusUsed s.AssassinateReroll = st.assassinateRerollUsed s.AssassinateBonus = st.assassinateBonusUsed + s.EnemyEvadeNext = st.enemyEvadeNext + s.EnemyBlockUp = st.enemyBlockUp + s.EnemyAdvantage = st.enemyAdvantage + s.EnemyRetaliateFrac = st.enemyRetaliateFrac + s.EnemyRegen = st.enemyRegen + s.EnemySurviveArmed = st.enemySurviveArmed + s.PlayerAtkDrain = st.playerAtkDrain + s.PlayerACDebuff = st.playerACDebuff + s.MaxHPDrain = st.maxHPDrain te.sess.TurnLog = append(te.sess.TurnLog, st.events...) }