Combat: back the flavor-only monster abilities with real effects

Turn the four placeholder ability effects into working mechanics:
spell_resist halves player spell damage, reveal_action rolls the
player's next swing at disadvantage, fear_immune fizzles control
spells, and ally_buff grants an accumulating enemy attack bonus.
All four are armed by applyAbility, read by the shared resolution
primitives, and round-tripped through CombatStatuses for turn-based
suspend/resume. 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:59:51 -07:00
parent e629f8fd4d
commit 0cd8fd3337
5 changed files with 312 additions and 50 deletions

View File

@@ -74,8 +74,8 @@ func TestTurnAbilityFires(t *testing.T) {
} }
// TestApplyAbility_Slice2Effects covers the immediate-resolution monster // TestApplyAbility_Slice2Effects covers the immediate-resolution monster
// ability effects: damage riders, the enemy self-heal, and the flavor-only // ability effects: damage riders and the enemy self-heal. All resolve fully
// placeholders. All resolve fully within applyAbility with no persistent state. // within applyAbility with no persistent state.
func TestApplyAbility_Slice2Effects(t *testing.T) { func TestApplyAbility_Slice2Effects(t *testing.T) {
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) { newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
st := &combatState{ st := &combatState{
@@ -123,19 +123,6 @@ func TestApplyAbility_Slice2Effects(t *testing.T) {
t.Errorf("self_heal: enemy HP = %d, want in (30, %d]", 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). // A damage rider that drops the player returns true (no death save armed).
stKill, plK, enK := newState(1, 60) stKill, plK, enK := newState(1, 60)
enK.Ability = &MonsterAbility{Name: "Smash", Effect: "bonus_damage"} enK.Ability = &MonsterAbility{Name: "Smash", Effect: "bonus_damage"}
@@ -232,6 +219,80 @@ func TestApplyAbility_Slice3Effects(t *testing.T) {
} }
} }
// TestApplyAbility_Slice4Effects covers the former flavor-only placeholders,
// now backed by real state: applyAbility arms a combatState field and announces
// itself, and the shared resolution primitives / helpers read that state.
func TestApplyAbility_Slice4Effects(t *testing.T) {
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
st := &combatState{
playerHP: playerHP, enemyHP: enemyHP, round: 1,
rng: rand.New(rand.NewPCG(11, 11)),
}
return st, basePlayer(), baseEnemy()
}
phase := &turnCombatPhase
res := &CombatResult{}
armChecks := []struct {
effect string
armed func(*combatState) bool
}{
{"spell_resist", func(s *combatState) bool { return s.enemySpellResist }},
{"reveal_action", func(s *combatState) bool { return s.enemyRevealNext }},
{"fear_immune", func(s *combatState) bool { return s.enemyFearImmune }},
{"ally_buff", func(s *combatState) bool { return s.enemyAtkBuff > 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 field not armed", c.effect)
}
if st.playerHP != 100 || st.enemyHP != 60 {
t.Errorf("%s: HP changed (%d/%d), want 100/60", c.effect, st.playerHP, st.enemyHP)
}
if len(st.events) != 1 {
t.Errorf("%s: want one announcement event, got %d", c.effect, len(st.events))
}
}
// The passive immunities are honoured straight off the ability profile,
// before the per-round proc has armed the flag.
st, _, en := newState(100, 60)
en.Ability = &MonsterAbility{Name: "Spell Immunity", Effect: "spell_resist"}
if !enemyResistsSpells(&en, st) {
t.Error("enemyResistsSpells: should be true from the ability profile alone")
}
en.Ability = &MonsterAbility{Name: "Draconic Loyalty", Effect: "fear_immune"}
if !enemyImmuneToControl(&en, st) {
t.Error("enemyImmuneToControl: should be true from the ability profile alone")
}
// ally_buff accumulates but is capped.
stB, plB, enB := newState(100, 60)
enB.Ability = &MonsterAbility{Name: "Rally", Effect: "ally_buff"}
for i := 0; i < 20; i++ {
applyAbility(stB, &plB, &enB, phase, res)
}
if stB.enemyAtkBuff != 15 {
t.Errorf("ally_buff: accumulated to %d, want the 15 cap", stB.enemyAtkBuff)
}
if got := enemyAttackStat(&enB, stB, 1.0); got != enB.Stats.Attack+15 {
t.Errorf("enemyAttackStat: got %d, want %d (base + buff)", got, enB.Stats.Attack+15)
}
// reveal_action is consumed by the next player weapon attack.
stR, plR, enR := newState(100, 60)
stR.enemyRevealNext = true
resolvePlayerAttack(stR, &plR, &enR, phase, res)
if stR.enemyRevealNext {
t.Error("reveal_action: flag should be cleared after the player attack")
}
}
// 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

@@ -202,9 +202,8 @@ type MonsterAbility struct {
// 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, max_hp_drain); stateful effects armed here and read by the // self_heal, max_hp_drain); stateful effects armed here and read by the
// shared resolution primitives (evade, block, advantage, retaliate, // shared resolution primitives (evade, block, advantage, retaliate,
// regenerate, survive_at_1, stat_drain, debuff); flavor-only placeholders // regenerate, survive_at_1, stat_drain, debuff, spell_resist, reveal_action,
// (spell_resist, reveal_action, fear_immune, ally_buff). Anything else is a // fear_immune, ally_buff). Anything else is a silent no-op.
// silent no-op.
Effect string Effect string
} }
@@ -301,6 +300,13 @@ type combatState struct {
playerACDebuff int // debuff: flat reduction to the player's effective AC playerACDebuff int // debuff: flat reduction to the player's effective AC
maxHPDrain int // max_hp_drain: reduction to the player's effective MaxHP maxHPDrain int // max_hp_drain: reduction to the player's effective MaxHP
// Phase 13 bestiary slice 4 — the former flavor-only placeholders, now
// backed by real state.
enemySpellResist bool // spell_resist: player spell damage against this enemy is halved
enemyRevealNext bool // reveal_action: the player's next weapon attack is rolled at disadvantage
enemyFearImmune bool // fear_immune: player control spells (enemy-skip) fizzle against this enemy
enemyAtkBuff int // ally_buff: flat, accumulating bonus to the enemy's attack damage
round int round int
events []CombatEvent events []CombatEvent
@@ -392,6 +398,10 @@ func simulateCombatWithRNG(player, enemy Combatant, phases []CombatPhase, rng *r
// runs — the modifiers carry the resolved damage and narrative hook. // runs — the modifiers carry the resolved damage and narrative hook.
if player.Mods.SpellPreDamageDesc != "" { if player.Mods.SpellPreDamageDesc != "" {
dmg := player.Mods.SpellPreDamage dmg := player.Mods.SpellPreDamage
resisted := dmg > 0 && enemyResistsSpells(&enemy, st)
if resisted {
dmg = max(1, dmg/2)
}
if dmg > 0 { if dmg > 0 {
st.enemyHP = max(0, st.enemyHP-dmg) st.enemyHP = max(0, st.enemyHP-dmg)
} }
@@ -400,6 +410,12 @@ func simulateCombatWithRNG(player, enemy Combatant, phases []CombatPhase, rng *r
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Desc: player.Mods.SpellPreDamageDesc, Desc: player.Mods.SpellPreDamageDesc,
}) })
if resisted {
st.events = append(st.events, CombatEvent{
Round: 0, Phase: "pre_combat", Actor: "enemy", Action: "spell_fizzle",
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
if st.enemyHP <= 0 { if st.enemyHP <= 0 {
return finalize(result, st, player, enemy) return finalize(result, st, player, enemy)
} }
@@ -455,11 +471,20 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase
enemyHeldThisRound := false enemyHeldThisRound := false
if st.enemySkipFirst { if st.enemySkipFirst {
st.enemySkipFirst = false st.enemySkipFirst = false
enemyHeldThisRound = true if enemyImmuneToControl(enemy, st) {
st.events = append(st.events, CombatEvent{ // fear_immune: the control spell can't take hold — the enemy acts
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "spell_held", // as normal this round.
PlayerHP: st.playerHP, EnemyHP: st.enemyHP, st.events = append(st.events, CombatEvent{
}) Round: st.round, Phase: phaseName, Actor: "enemy", Action: "fear_resist",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
} else {
enemyHeldThisRound = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "spell_held",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
} }
// Monster ability: check at round start // Monster ability: check at round start
@@ -665,6 +690,18 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
} }
roll := 1 + st.roll(20) roll := 1 + st.roll(20)
// Reveal action (monster ability): the enemy read this swing coming — it's
// rolled at disadvantage (2d20, keep the lower). One-shot, consumed here.
if st.enemyRevealNext {
st.enemyRevealNext = false
if alt := 1 + st.roll(20); alt < roll {
roll = alt
}
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "revealed",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
// Halfling Lucky: reroll the first nat 1 of the fight. // Halfling Lucky: reroll the first nat 1 of the fight.
if roll == 1 && player.Mods.LuckyReroll && !st.luckyUsed { if roll == 1 && player.Mods.LuckyReroll && !st.luckyUsed {
st.luckyUsed = true st.luckyUsed = true
@@ -856,7 +893,7 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat
if st.enraged { if st.enraged {
atkMult = 1.5 atkMult = 1.5
} }
dmg := calcDamage(st.rng, int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus, dmg := calcDamage(st.rng, enemyAttackStat(enemy, st, atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct) playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
blocked := player.Stats.BlockRate > 0 && st.randFloat() < player.Stats.BlockRate blocked := player.Stats.BlockRate > 0 && st.randFloat() < player.Stats.BlockRate
@@ -995,7 +1032,7 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase,
if st.enraged { if st.enraged {
atkMult = 1.5 atkMult = 1.5
} }
dmg := calcDamage(st.rng, int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus, dmg := calcDamage(st.rng, enemyAttackStat(enemy, st, atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct) playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
dmg = max(1, dmg) dmg = max(1, dmg)
st.playerHP = max(0, st.playerHP-dmg) st.playerHP = max(0, st.playerHP-dmg)
@@ -1014,7 +1051,7 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase,
if st.enraged { if st.enraged {
atkMult = 1.5 atkMult = 1.5
} }
dmg1 := calcDamage(st.rng, int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus, dmg1 := calcDamage(st.rng, enemyAttackStat(enemy, st, atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct) playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
dmg1 = max(1, dmg1) dmg1 = max(1, dmg1)
st.playerHP = max(0, st.playerHP-dmg1) st.playerHP = max(0, st.playerHP-dmg1)
@@ -1192,14 +1229,50 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase,
return !trySave(st, player, phaseName) return !trySave(st, player, phaseName)
} }
case "spell_resist", "reveal_action", "fear_immune", "ally_buff": case "spell_resist":
// Slice-2 placeholder: no mechanical effect yet (these need persistent // Spell Immunity: the enemy is wrapped in anti-magic. Player spell damage
// per-fight state, deferred to the next slice), but the ability still // (the pre-combat cast in auto-resolve, mid-fight !cast in the turn engine)
// announces itself so the fight log doesn't silently swallow a proc. // is halved — read by enemyResistsSpells. Arm once; it's a passive, so a
// repeat proc is a silent no-op.
if !st.enemySpellResist {
st.enemySpellResist = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "spell_resist",
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
case "reveal_action":
// The enemy reads the player's intent — their next weapon swing comes in
// at disadvantage (2d20 keep-lower). One-shot, consumed by the next
// resolvePlayerAttack. Re-armed (and re-announced) on every proc.
st.enemyRevealNext = true
st.events = append(st.events, CombatEvent{ st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "ability_flavor", Round: st.round, Phase: phaseName, Actor: "enemy", Action: "reveal_armed",
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
}) })
case "fear_immune":
// The enemy's resolve can't be broken: player control spells (Hold Person,
// Sleep, Command — the enemy-skip mechanic) fizzle against it. Read by
// enemyImmuneToControl. Arm once; a passive, so a repeat proc is a no-op.
if !st.enemyFearImmune {
st.enemyFearImmune = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "fear_immune",
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
case "ally_buff":
// The enemy rallies — a flat, accumulating bonus to the damage its attacks
// deal (read by enemyAttackStat). Capped so a long fight can't run away.
buff := 2 + st.roll(3)
st.enemyAtkBuff = min(15, st.enemyAtkBuff+buff)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "ally_buff",
Damage: buff, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
} }
return false return false
@@ -1211,11 +1284,11 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase,
// player's Defense applies (1.0 = a normal hit, lower = an armor-piercing // 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. // 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 { func abilityHitDamage(st *combatState, player, enemy *Combatant, phase *CombatPhase, atkFrac, defFrac float64) int {
atk := float64(enemy.Stats.Attack) * atkFrac mult := atkFrac
if st.enraged { if st.enraged {
atk *= 1.5 mult *= 1.5
} }
dmg := calcDamage(st.rng, int(atk), phase.AttackWeight, enemy.Mods.DamageBonus, dmg := calcDamage(st.rng, enemyAttackStat(enemy, st, mult), phase.AttackWeight, enemy.Mods.DamageBonus,
playerDefense(player, st), phase.DefenseWeight*defFrac, player.Mods.DamageReduct) playerDefense(player, st), phase.DefenseWeight*defFrac, player.Mods.DamageReduct)
return max(1, dmg) return max(1, dmg)
} }
@@ -1251,6 +1324,29 @@ func playerDefense(player *Combatant, st *combatState) int {
return def return def
} }
// enemyAttackStat is the enemy's effective Attack for a damage roll: the base
// stat scaled by mult (1.0 = normal, 1.5 = enraged, <1 = a partial-power rider)
// plus any accumulated ally_buff bonus. With no buff (every characterization
// scenario) it collapses to the pre-slice-4 expression.
func enemyAttackStat(enemy *Combatant, st *combatState, mult float64) int {
return max(1, int(float64(enemy.Stats.Attack)*mult)+st.enemyAtkBuff)
}
// enemyResistsSpells reports whether the enemy's spell_resist (Spell Immunity)
// ability is in play. It's a passive — the per-round proc may not have armed
// st.enemySpellResist yet, so the ability profile is checked too, letting the
// pre-combat spell and a round-1 mid-fight cast be resisted all the same.
func enemyResistsSpells(enemy *Combatant, st *combatState) bool {
return st.enemySpellResist || (enemy.Ability != nil && enemy.Ability.Effect == "spell_resist")
}
// enemyImmuneToControl reports whether the enemy's fear_immune ability is in
// play. Like spell_resist it's a passive, so the ability profile is checked
// alongside the armed flag.
func enemyImmuneToControl(enemy *Combatant, st *combatState) bool {
return st.enemyFearImmune || (enemy.Ability != nil && enemy.Ability.Effect == "fear_immune")
}
// effPlayerMaxHP is the player's MaxHP after any max_hp_drain monster ability, // 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 // 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) // up past the lowered ceiling. With no drain (every characterization scenario)

View File

@@ -332,6 +332,22 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
case "max_hp_drain": case "max_hp_drain":
return fmt.Sprintf(pickRand(narrativeMaxHPDrain), e.Damage) return fmt.Sprintf(pickRand(narrativeMaxHPDrain), e.Damage)
// Monster abilities — slice 4 (former flavor-only placeholders)
case "spell_resist":
return pickRand(narrativeSpellResist)
case "spell_fizzle":
return fmt.Sprintf(pickRand(narrativeSpellFizzle), e.Damage)
case "reveal_armed":
return pickRand(narrativeRevealArmed)
case "revealed":
return pickRand(narrativeRevealed)
case "fear_immune":
return pickRand(narrativeFearImmune)
case "fear_resist":
return pickRand(narrativeFearResist)
case "ally_buff":
return pickRand(narrativeAllyBuff)
case "timeout": case "timeout":
return pickRand(narrativeTimeout) return pickRand(narrativeTimeout)
@@ -743,6 +759,48 @@ var narrativeMaxHPDrain = []string{
"☠️ %d HP siphoned away, max and all. There's less of you to work with now.", "☠️ %d HP siphoned away, max and all. There's less of you to work with now.",
} }
var narrativeSpellResist = []string{
"🚫 The enemy's form shimmers with anti-magic. Spells are going to land soft from here.",
"🚫 A warding sheen wraps the enemy — magic slides off it like rain off glass.",
"🚫 The enemy shrugs the weave aside. Whatever you cast, it'll only half-stick.",
}
var narrativeSpellFizzle = []string{
"🚫 Your spell breaks against the enemy's anti-magic — only %d damage bleeds through.",
"🚫 The weave fizzles on contact. The enemy's resistance eats half of it; %d lands.",
"🚫 Half your magic just evaporates. %d damage is all the enemy lets through.",
}
var narrativeRevealArmed = []string{
"👁️ The enemy's eyes track your intent — it knows what you're about to do.",
"👁️ The enemy reads you. Your next swing is telegraphed before you've thrown it.",
"👁️ Something behind the enemy's gaze clicks. It's already seen your next move.",
}
var narrativeRevealed = []string{
"👁️ The enemy saw it coming — your strike is forced wide before it begins.",
"👁️ Telegraphed and countered. The enemy was already moving as you committed.",
"👁️ Your swing meets a defence that read it a beat early. No clean angle left.",
}
var narrativeFearImmune = []string{
"🐲 The enemy's resolve is iron — nothing you do is going to rattle it.",
"🐲 Whatever fear you were counting on, the enemy doesn't have the receptors for it.",
"🐲 The enemy stands utterly unshaken. Control magic is going to slide right off.",
}
var narrativeFearResist = []string{
"🐲 Your control spell washes over the enemy and finds no purchase. It acts anyway.",
"🐲 The enemy shrugs off the compulsion mid-cast and keeps coming.",
"🐲 The spell should have held it. The enemy's will simply refuses the idea.",
}
var narrativeAllyBuff = []string{
"📢 The enemy rallies itself with a roar — its blows are about to hit harder. (+%d attack)",
"📢 Something emboldens the enemy. Its strikes pick up weight. (+%d attack)",
"📢 The enemy steadies and surges. Expect heavier hits from here. (+%d attack)",
}
// 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

@@ -110,6 +110,13 @@ type CombatStatuses struct {
PlayerACDebuff int `json:"player_ac_debuff,omitempty"` PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
MaxHPDrain int `json:"max_hp_drain,omitempty"` MaxHPDrain int `json:"max_hp_drain,omitempty"`
// Slice-4 monster-ability effects — the former flavor-only placeholders.
// EnemyRevealNext is a one-shot; the other three persist for the fight.
EnemySpellResist bool `json:"enemy_spell_resist,omitempty"`
EnemyRevealNext bool `json:"enemy_reveal_next,omitempty"`
EnemyFearImmune bool `json:"enemy_fear_immune,omitempty"`
EnemyAtkBuff int `json:"enemy_atk_buff,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

@@ -144,7 +144,12 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
playerAtkDrain: sess.Statuses.PlayerAtkDrain, playerAtkDrain: sess.Statuses.PlayerAtkDrain,
playerACDebuff: sess.Statuses.PlayerACDebuff, playerACDebuff: sess.Statuses.PlayerACDebuff,
maxHPDrain: sess.Statuses.MaxHPDrain, maxHPDrain: sess.Statuses.MaxHPDrain,
rng: rng, // Slice-4 monster-ability effects — the former flavor-only placeholders.
enemySpellResist: sess.Statuses.EnemySpellResist,
enemyRevealNext: sess.Statuses.EnemyRevealNext,
enemyFearImmune: sess.Statuses.EnemyFearImmune,
enemyAtkBuff: sess.Statuses.EnemyAtkBuff,
rng: rng,
} }
return &turnEngine{ return &turnEngine{
sess: sess, sess: sess,
@@ -270,8 +275,20 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
te.sess.Phase = CombatPhaseEnemyTurn te.sess.Phase = CombatPhaseEnemyTurn
return return
} }
if eff.EnemyDamage > 0 { action := eff.Action
st.enemyHP = max(0, st.enemyHP-eff.EnemyDamage) if action == "" {
action = "spell_cast"
}
// spell_resist (Spell Immunity): a spell's damage against the enemy is
// halved. Consumables that happen to deal damage are not spells, so the
// resist is gated on the spell_cast action.
enemyDmg := eff.EnemyDamage
spellFizzled := enemyDmg > 0 && action == "spell_cast" && enemyResistsSpells(te.enemy, st)
if spellFizzled {
enemyDmg = max(1, enemyDmg/2)
}
if enemyDmg > 0 {
st.enemyHP = max(0, st.enemyHP-enemyDmg)
} }
if eff.PlayerHeal > 0 { if eff.PlayerHeal > 0 {
// Respect any max_hp_drain monster ability — a drained player can't be // Respect any max_hp_drain monster ability — a drained player can't be
@@ -279,14 +296,16 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
hpCap := max(1, te.sess.PlayerHPMax-st.maxHPDrain) hpCap := max(1, te.sess.PlayerHPMax-st.maxHPDrain)
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal) st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
} }
action := eff.Action
if action == "" {
action = "spell_cast"
}
st.events = append(st.events, CombatEvent{ st.events = append(st.events, CombatEvent{
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: enemyDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: eff.Label,
}) })
if spellFizzled {
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_fizzle",
Damage: enemyDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
if enemyDown(st, turnCombatPhase.Name) { if enemyDown(st, turnCombatPhase.Name) {
te.finish(CombatStatusWon) te.finish(CombatStatusWon)
return return
@@ -296,21 +315,38 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
return return
} }
if eff.EnemySkip { if eff.EnemySkip {
st.enemySkipFirst = true // fear_immune enemies shrug off control spells — the skip never arms.
if enemyImmuneToControl(te.enemy, st) {
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
} else {
st.enemySkipFirst = true
}
} }
te.sess.Phase = CombatPhaseEnemyTurn te.sess.Phase = CombatPhaseEnemyTurn
} }
func (te *turnEngine) stepEnemyTurn() { func (te *turnEngine) stepEnemyTurn() {
// A control spell cast last phase forfeits the enemy's attack this round. // A control spell cast last phase forfeits the enemy's attack this round
// unless the enemy is fear_immune, in which case the control fizzled and it
// acts as normal.
if te.st.enemySkipFirst { if te.st.enemySkipFirst {
te.st.enemySkipFirst = false te.st.enemySkipFirst = false
te.st.events = append(te.st.events, CombatEvent{ if enemyImmuneToControl(te.enemy, te.st) {
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held", te.st.events = append(te.st.events, CombatEvent{
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP, Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist",
}) PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
te.sess.Phase = CombatPhaseRoundEnd })
return } else {
te.st.events = append(te.st.events, CombatEvent{
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held",
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
})
te.sess.Phase = CombatPhaseRoundEnd
return
}
} }
// Monster ability fires at the top of the enemy turn. cleave / lifesteal // Monster ability fires at the top of the enemy turn. cleave / lifesteal
@@ -458,6 +494,10 @@ func (te *turnEngine) commit() {
s.PlayerAtkDrain = st.playerAtkDrain s.PlayerAtkDrain = st.playerAtkDrain
s.PlayerACDebuff = st.playerACDebuff s.PlayerACDebuff = st.playerACDebuff
s.MaxHPDrain = st.maxHPDrain s.MaxHPDrain = st.maxHPDrain
s.EnemySpellResist = st.enemySpellResist
s.EnemyRevealNext = st.enemyRevealNext
s.EnemyFearImmune = st.enemyFearImmune
s.EnemyAtkBuff = st.enemyAtkBuff
te.sess.TurnLog = append(te.sess.TurnLog, st.events...) te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
} }