mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
// TestTurnEngine_Multiattack drives a single enemy_turn and confirms a
|
||||||
// registered multiattack creature resolves one attack roll per profile entry,
|
// registered multiattack creature resolves one attack roll per profile entry,
|
||||||
// while an unregistered creature still resolves exactly one.
|
// while an unregistered creature still resolves exactly one.
|
||||||
|
|||||||
@@ -469,6 +469,8 @@ func combatDegradation(result CombatResult, equip map[EquipmentSlot]*AdvEquipmen
|
|||||||
damage[SlotHelmet] += 1 + rand.IntN(2)
|
damage[SlotHelmet] += 1 + rand.IntN(2)
|
||||||
case ev.Actor == "enemy" && ev.Action == "cleave":
|
case ev.Actor == "enemy" && ev.Action == "cleave":
|
||||||
damage[SlotArmor] += 2 + rand.IntN(3)
|
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":
|
case ev.Actor == "enemy" && ev.Action == "lifesteal":
|
||||||
damage[SlotArmor] += 1 + rand.IntN(2)
|
damage[SlotArmor] += 1 + rand.IntN(2)
|
||||||
case ev.Actor == "player" && (ev.Action == "hit" || ev.Action == "crit"):
|
case ev.Actor == "player" && (ev.Action == "hit" || ev.Action == "crit"):
|
||||||
|
|||||||
@@ -197,7 +197,12 @@ type MonsterAbility struct {
|
|||||||
Name string
|
Name string
|
||||||
Phase string // "opening", "clash", "decisive", "any"
|
Phase string // "opening", "clash", "decisive", "any"
|
||||||
ProcChance float64
|
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 ────────────────────────────────────────────────
|
// ── Default Phase Definitions ────────────────────────────────────────────────
|
||||||
@@ -951,11 +956,86 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase,
|
|||||||
return !trySave(st, player, phaseName)
|
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
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// calcDamage uses a penetration model: defense provides diminishing returns
|
// calcDamage uses a penetration model: defense provides diminishing returns
|
||||||
|
|||||||
@@ -295,6 +295,16 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
|
|||||||
return fmt.Sprintf(pickRand(narrativeLifesteal), e.Damage)
|
return fmt.Sprintf(pickRand(narrativeLifesteal), e.Damage)
|
||||||
case "cleave":
|
case "cleave":
|
||||||
return fmt.Sprintf(pickRand(narrativeCleave), e.Damage)
|
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":
|
case "timeout":
|
||||||
return pickRand(narrativeTimeout)
|
return pickRand(narrativeTimeout)
|
||||||
@@ -605,6 +615,36 @@ var narrativeCleave = []string{
|
|||||||
"⚔️⚔️ A devastating combo. %d damage from both hits. The second one was personal.",
|
"⚔️⚔️ 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
|
// Outcome flavor
|
||||||
var narrativeNearDeathWin = []string{
|
var narrativeNearDeathWin = []string{
|
||||||
"You survived by the skin of your teeth. Barely standing. The healers are on standby.",
|
"You survived by the skin of your teeth. Barely standing. The healers are on standby.",
|
||||||
|
|||||||
Reference in New Issue
Block a user