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) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-14 08:32:13 -07:00
parent f1aa9981f8
commit e629f8fd4d
6 changed files with 468 additions and 18 deletions

View File

@@ -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 // 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.

View File

@@ -469,7 +469,7 @@ 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"): 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) 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)

View File

@@ -200,8 +200,11 @@ type MonsterAbility struct {
// Effect — the mechanic the ability applies. Implemented in applyAbility: // Effect — the mechanic the ability applies. Implemented in applyAbility:
// stand-in attacks (cleave, lifesteal); riders on the normal attack (poison, // stand-in attacks (cleave, lifesteal); riders on the normal attack (poison,
// enrage, armor_break, stun, bonus_damage, aoe, aoe_fire, death_aoe, execute, // enrage, armor_break, stun, bonus_damage, aoe, aoe_fire, death_aoe, execute,
// self_heal); flavor-only placeholders pending per-fight state (spell_resist, // self_heal, max_hp_drain); stateful effects armed here and read by the
// reveal_action, fear_immune, ally_buff). Anything else is a silent no-op. // 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 Effect string
} }
@@ -284,6 +287,20 @@ type combatState struct {
// Phase 10 SUB2b — Abjuration Arcane Ward HP buffer. // Phase 10 SUB2b — Abjuration Arcane Ward HP buffer.
arcaneWardHP int 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 round int
events []CombatEvent 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", Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_attack",
Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
}) })
if st.enemyHP <= 0 { if enemyDown(st, phaseName) {
return true return true
} }
} }
@@ -565,7 +582,7 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase
// Misty heal // Misty heal
if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc { if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc {
healAmt := player.Mods.MistyHealAmt 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 result.MistyHealed = true
st.events = append(st.events, CombatEvent{ st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal", 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.playerHP > 0 && st.playerHP*5 < player.Stats.MaxHP*3 {
st.healChargesLeft-- st.healChargesLeft--
healAmt := player.Mods.HealItem 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{ st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "heal_item", Round: st.round, Phase: phaseName, Actor: "consumable", Action: "heal_item",
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, 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 return false
} }
@@ -613,6 +640,17 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
return false 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. // 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 // 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). // 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) 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 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 { if blocked {
dmg = max(1, dmg/2) 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, Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Roll: roll, RollAgainst: enemy.Stats.AC, Desc: desc, 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. // 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) 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 isFumble := roll == 1
isNat20 := roll == 20 isNat20 := roll == 20
total := roll + effectiveAttackBonus(enemy.Stats) 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 := "" desc := ""
if isFumble { if isFumble {
desc = "fumble" desc = "fumble"
@@ -756,7 +838,7 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat
st.events = append(st.events, CombatEvent{ st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "miss", Round: st.round, Phase: phaseName, Actor: "enemy", Action: "miss",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Roll: roll, RollAgainst: player.Stats.AC, Desc: desc, Roll: roll, RollAgainst: targetAC, Desc: desc,
}) })
return false return false
} }
@@ -831,7 +913,7 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat
st.events = append(st.events, CombatEvent{ st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: action, Round: st.round, Phase: phaseName, Actor: "enemy", Action: action,
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Roll: roll, RollAgainst: player.Stats.AC, Roll: roll, RollAgainst: targetAC,
}) })
if st.reflectFrac > 0 { 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", Round: st.round, Phase: phaseName, Actor: "consumable", Action: "reflect_damage",
Damage: reflected, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Damage: reflected, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
}) })
if st.enemyHP <= 0 { if enemyDown(st, phaseName) {
return true 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, 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": case "spell_resist", "reveal_action", "fear_immune", "ally_buff":
// Slice-2 placeholder: no mechanical effect yet (these need persistent // Slice-2 placeholder: no mechanical effect yet (these need persistent
// per-fight state, deferred to the next slice), but the ability still // 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 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 { func trySave(st *combatState, player *Combatant, phaseName string) bool {
if player.Mods.DeathSave && !st.deathSaveUsed { if player.Mods.DeathSave && !st.deathSaveUsed {
st.deathSaveUsed = true st.deathSaveUsed = true

View File

@@ -306,6 +306,32 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
case "ability_flavor": case "ability_flavor":
return pickRand(narrativeAbilityFlavor) 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": case "timeout":
return pickRand(narrativeTimeout) return pickRand(narrativeTimeout)
@@ -645,6 +671,78 @@ var narrativeAbilityFlavor = []string{
"🌀 The enemy invokes a power you can't quite read. File it under 'concerning'.", "🌀 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 // 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.",

View File

@@ -96,6 +96,20 @@ type CombatStatuses struct {
AssassinateReroll bool `json:"assassinate_reroll_used,omitempty"` AssassinateReroll bool `json:"assassinate_reroll_used,omitempty"`
AssassinateBonus bool `json:"assassinate_bonus_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 // Persistent stat buffs from mid-fight !cast / !consume, accumulated as
// deltas against the freshly-rebuilt combatant. applySessionBuffs folds // deltas against the freshly-rebuilt combatant. applySessionBuffs folds
// these back onto the player every round; diffTurnBuff produces them. // these back onto the player every round; diffTurnBuff produces them.

View File

@@ -133,7 +133,18 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
firstAttackBonusUsed: sess.Statuses.FirstAtkBonusUsed, firstAttackBonusUsed: sess.Statuses.FirstAtkBonusUsed,
assassinateRerollUsed: sess.Statuses.AssassinateReroll, assassinateRerollUsed: sess.Statuses.AssassinateReroll,
assassinateBonusUsed: sess.Statuses.AssassinateBonus, 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{ return &turnEngine{
sess: sess, sess: sess,
@@ -181,10 +192,15 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
te.stepPlayerActionEffect(action.Effect) te.stepPlayerActionEffect(action.Effect)
return return
} }
// Default: weapon attack. resolvePlayerAttack returns true once the // Default: weapon attack. resolvePlayerAttack returns true once the fight
// enemy is down. // 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) { 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 return
} }
if te.petStrike() { if te.petStrike() {
@@ -213,7 +229,7 @@ func (te *turnEngine) petStrike() bool {
Round: st.round, Phase: turnCombatPhase.Name, Actor: "pet", Action: "pet_attack", Round: st.round, Phase: turnCombatPhase.Name, Actor: "pet", Action: "pet_attack",
Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, 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 // 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) st.enemyHP = max(0, st.enemyHP-eff.EnemyDamage)
} }
if eff.PlayerHeal > 0 { 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 action := eff.Action
if action == "" { if action == "" {
@@ -268,7 +287,7 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: action, Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: action,
Damage: eff.EnemyDamage, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: eff.Label, Damage: eff.EnemyDamage, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: eff.Label,
}) })
if st.enemyHP <= 0 { if enemyDown(st, turnCombatPhase.Name) {
te.finish(CombatStatusWon) te.finish(CombatStatusWon)
return 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++ st.round++
te.sess.Phase = CombatPhasePlayerTurn te.sess.Phase = CombatPhasePlayerTurn
} }
@@ -422,6 +449,15 @@ func (te *turnEngine) commit() {
s.FirstAtkBonusUsed = st.firstAttackBonusUsed s.FirstAtkBonusUsed = st.firstAttackBonusUsed
s.AssassinateReroll = st.assassinateRerollUsed s.AssassinateReroll = st.assassinateRerollUsed
s.AssassinateBonus = st.assassinateBonusUsed 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...) te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
} }