mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user