From f1aa9981f8dd545d0bb32dc85d49c065de476a91 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 14 May 2026 08:17:11 -0700 Subject: [PATCH] Combat: implement immediate-resolution monster ability effects Wires up the ability effects that resolve fully within applyAbility with no new persistent state: damage riders (bonus_damage, aoe/aoe_fire/ death_aoe, execute) via the shared calcDamage formula, self_heal, and flavor-only placeholders for effects still pending per-fight state. Works in both auto-resolve and the turn engine since both call applyAbility. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/bestiary_srd_test.go | 71 ++++++++++++++++++++++++ internal/plugin/combat_bridge.go | 2 + internal/plugin/combat_engine.go | 82 +++++++++++++++++++++++++++- internal/plugin/combat_narrative.go | 40 ++++++++++++++ 4 files changed, 194 insertions(+), 1 deletion(-) diff --git a/internal/plugin/bestiary_srd_test.go b/internal/plugin/bestiary_srd_test.go index 0762d7f..5b00fbc 100644 --- a/internal/plugin/bestiary_srd_test.go +++ b/internal/plugin/bestiary_srd_test.go @@ -73,6 +73,77 @@ func TestTurnAbilityFires(t *testing.T) { } } +// TestApplyAbility_Slice2Effects covers the immediate-resolution monster +// ability effects: damage riders, the enemy self-heal, and the flavor-only +// placeholders. All resolve fully within applyAbility with no persistent state. +func TestApplyAbility_Slice2Effects(t *testing.T) { + newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) { + st := &combatState{ + playerHP: playerHP, enemyHP: enemyHP, round: 1, + rng: rand.New(rand.NewPCG(7, 7)), + } + return st, basePlayer(), baseEnemy() + } + phase := &turnCombatPhase + res := &CombatResult{} + + damageEffects := []string{"bonus_damage", "aoe", "aoe_fire", "death_aoe", "execute"} + for _, eff := range damageEffects { + st, player, enemy := newState(100, 60) + enemy.Ability = &MonsterAbility{Name: "Test " + eff, Effect: eff} + if applyAbility(st, &player, &enemy, phase, res) { + t.Errorf("%s: should not down a full-HP player", eff) + } + if st.playerHP >= 100 { + t.Errorf("%s: player HP = %d, want < 100 (damage rider)", eff, st.playerHP) + } + if len(st.events) != 1 || st.events[0].Damage <= 0 { + t.Errorf("%s: want one event with positive damage, got %+v", eff, st.events) + } + } + + // execute hits harder once the player is under 30% HP. + stLow, pl, en := newState(20, 60) + en.Ability = &MonsterAbility{Name: "Wail", Effect: "execute"} + applyAbility(stLow, &pl, &en, phase, res) + lowDmg := 20 - stLow.playerHP + stHigh, pl2, en2 := newState(100, 60) + en2.Ability = &MonsterAbility{Name: "Wail", Effect: "execute"} + applyAbility(stHigh, &pl2, &en2, phase, res) + highDmg := 100 - stHigh.playerHP + if lowDmg <= highDmg { + t.Errorf("execute: low-HP damage %d should exceed full-HP damage %d", lowDmg, highDmg) + } + + // self_heal restores enemy HP, capped at MaxHP. + stHeal, plH, enH := newState(100, 30) + enH.Ability = &MonsterAbility{Name: "Regrow", Effect: "self_heal"} + applyAbility(stHeal, &plH, &enH, phase, res) + if stHeal.enemyHP <= 30 || stHeal.enemyHP > enH.Stats.MaxHP { + t.Errorf("self_heal: enemy HP = %d, want in (30, %d]", stHeal.enemyHP, enH.Stats.MaxHP) + } + + // Flavor-only placeholders emit an event but change no HP. + for _, eff := range []string{"spell_resist", "reveal_action", "fear_immune", "ally_buff"} { + st, player, enemy := newState(100, 60) + enemy.Ability = &MonsterAbility{Name: "Test " + eff, Effect: eff} + applyAbility(st, &player, &enemy, phase, res) + if st.playerHP != 100 || st.enemyHP != 60 { + t.Errorf("%s: HP changed (%d/%d), want 100/60 (flavor-only)", eff, st.playerHP, st.enemyHP) + } + if len(st.events) != 1 || st.events[0].Action != "ability_flavor" { + t.Errorf("%s: want one ability_flavor event, got %+v", eff, st.events) + } + } + + // A damage rider that drops the player returns true (no death save armed). + stKill, plK, enK := newState(1, 60) + enK.Ability = &MonsterAbility{Name: "Smash", Effect: "bonus_damage"} + if !applyAbility(stKill, &plK, &enK, phase, res) { + t.Error("bonus_damage: lethal hit on a 1-HP player should return true") + } +} + // 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 e3413bc..16655b2 100644 --- a/internal/plugin/combat_bridge.go +++ b/internal/plugin/combat_bridge.go @@ -469,6 +469,8 @@ 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"): + damage[SlotArmor] += 1 + rand.IntN(2) case ev.Actor == "enemy" && ev.Action == "lifesteal": damage[SlotArmor] += 1 + rand.IntN(2) case ev.Actor == "player" && (ev.Action == "hit" || ev.Action == "crit"): diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index e98cc50..07d4ee3 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -197,7 +197,12 @@ type MonsterAbility struct { Name string Phase string // "opening", "clash", "decisive", "any" ProcChance float64 - Effect string // "poison", "enrage", "armor_break", "stun", "lifesteal", "cleave" + // 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. + Effect string } // ── Default Phase Definitions ──────────────────────────────────────────────── @@ -951,11 +956,86 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase, return !trySave(st, player, phaseName) } } + + case "bonus_damage": + // A single extra strike riding on top of the round's normal attack — + // the multiattack below is left intact (this is a rider, not a stand-in). + dmg := abilityHitDamage(st, player, enemy, phase, 0.6, 1.0) + st.playerHP = max(0, st.playerHP-dmg) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "bonus_damage", + Damage: dmg, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + if st.playerHP <= 0 { + return !trySave(st, player, phaseName) + } + + case "aoe", "aoe_fire", "death_aoe": + // An area burst that partly bypasses armor (defWeight cut to 0.4), so a + // high-defense build still feels it. Rider on top of the normal attack. + dmg := abilityHitDamage(st, player, enemy, phase, 0.7, 0.4) + st.playerHP = max(0, st.playerHP-dmg) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "aoe", + Damage: dmg, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + if st.playerHP <= 0 { + return !trySave(st, player, phaseName) + } + + case "execute": + // Finishing blow: lands hard once the player is under 30% HP, a glancing + // hit otherwise so the ability isn't dead weight at full health. + mult := 0.5 + if st.playerHP*100 < player.Stats.MaxHP*30 { + mult = 1.3 + } + dmg := abilityHitDamage(st, player, enemy, phase, mult, 0.7) + st.playerHP = max(0, st.playerHP-dmg) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "execute", + Damage: dmg, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + if st.playerHP <= 0 { + return !trySave(st, player, phaseName) + } + + case "self_heal": + heal := enemy.Stats.MaxHP/5 + st.roll(max(1, enemy.Stats.MaxHP/10)) + st.enemyHP = min(enemy.Stats.MaxHP, st.enemyHP+heal) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "self_heal", + Damage: heal, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + + 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 + // announces itself so the fight log doesn't silently swallow a proc. + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "ability_flavor", + Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) } return false } +// abilityHitDamage resolves a monster-ability damage rider through the shared +// penetration formula (calcDamage), so a rider scales identically to a normal +// hit. atkFrac scales the enemy's Attack stat; defFrac scales how much of the +// player's Defense applies (1.0 = a normal hit, lower = an armor-piercing +// burst). Enrage's 1.5x attack multiplier is honored, matching cleave/lifesteal. +func abilityHitDamage(st *combatState, player, enemy *Combatant, phase *CombatPhase, atkFrac, defFrac float64) int { + atk := float64(enemy.Stats.Attack) * atkFrac + if st.enraged { + atk *= 1.5 + } + dmg := calcDamage(st.rng, int(atk), phase.AttackWeight, enemy.Mods.DamageBonus, + playerDefense(player, st), phase.DefenseWeight*defFrac, player.Mods.DamageReduct) + return max(1, dmg) +} + // ── Helpers ────────────────────────────────────────────────────────────────── // calcDamage uses a penetration model: defense provides diminishing returns diff --git a/internal/plugin/combat_narrative.go b/internal/plugin/combat_narrative.go index 213b194..7da86ad 100644 --- a/internal/plugin/combat_narrative.go +++ b/internal/plugin/combat_narrative.go @@ -295,6 +295,16 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul return fmt.Sprintf(pickRand(narrativeLifesteal), e.Damage) case "cleave": return fmt.Sprintf(pickRand(narrativeCleave), e.Damage) + case "bonus_damage": + return fmt.Sprintf(pickRand(narrativeBonusDamage), e.Damage) + case "aoe": + return fmt.Sprintf(pickRand(narrativeAoE), e.Damage) + case "execute": + return fmt.Sprintf(pickRand(narrativeExecute), e.Damage) + case "self_heal": + return fmt.Sprintf(pickRand(narrativeSelfHeal), e.Damage) + case "ability_flavor": + return pickRand(narrativeAbilityFlavor) case "timeout": return pickRand(narrativeTimeout) @@ -605,6 +615,36 @@ var narrativeCleave = []string{ "⚔️⚔️ A devastating combo. %d damage from both hits. The second one was personal.", } +var narrativeBonusDamage = []string{ + "💢 The enemy finds an opening you didn't know you'd left. %d extra damage. Lesson noted.", + "💢 A second strike slips past your guard before the first one finished. %d damage on top.", + "💢 The enemy presses the advantage — %d more damage than the round had any right to deal.", +} + +var narrativeAoE = []string{ + "💥 The attack erupts outward. %d damage, and your armor barely slows it. Nowhere to dodge.", + "💥 A burst of force washes over you. %d damage — there was no good way to stand for that.", + "💥 The enemy unleashes something wide and indiscriminate. %d damage. Cover would have been nice.", +} + +var narrativeExecute = []string{ + "☠️ The enemy goes for the kill — %d damage aimed squarely at finishing you.", + "☠️ A finishing blow. %d damage. The enemy can smell the end of this fight.", + "☠️ The enemy commits everything to one last strike. %d damage. They want this over.", +} + +var narrativeSelfHeal = []string{ + "✨ The enemy knits its wounds closed. +%d HP. That's going to make this longer.", + "✨ The enemy mends itself before your eyes. +%d HP restored. Rude.", + "✨ Something restorative passes over the enemy. +%d HP. The progress bar moved the wrong way.", +} + +var narrativeAbilityFlavor = []string{ + "🌀 The enemy does *something* — you feel the shape of it more than the effect. Stay wary.", + "🌀 The air shifts around the enemy. Whatever that was, it wasn't nothing.", + "🌀 The enemy invokes a power you can't quite read. File it under 'concerning'.", +} + // Outcome flavor var narrativeNearDeathWin = []string{ "You survived by the skin of your teeth. Barely standing. The healers are on standby.",