diff --git a/internal/plugin/bestiary_srd_test.go b/internal/plugin/bestiary_srd_test.go index 0f83d16..deaea5d 100644 --- a/internal/plugin/bestiary_srd_test.go +++ b/internal/plugin/bestiary_srd_test.go @@ -74,8 +74,8 @@ func TestTurnAbilityFires(t *testing.T) { } // TestApplyAbility_Slice2Effects covers the immediate-resolution monster -// ability effects: damage riders, the enemy self-heal, and the flavor-only -// placeholders. All resolve fully within applyAbility with no persistent state. +// ability effects: damage riders and the enemy self-heal. All resolve fully +// within applyAbility with no persistent state. func TestApplyAbility_Slice2Effects(t *testing.T) { newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) { st := &combatState{ @@ -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) } - // Flavor-only placeholders emit an event but change no HP. - for _, eff := range []string{"spell_resist", "reveal_action", "fear_immune", "ally_buff"} { - st, player, enemy := newState(100, 60) - enemy.Ability = &MonsterAbility{Name: "Test " + eff, Effect: eff} - applyAbility(st, &player, &enemy, phase, res) - if st.playerHP != 100 || st.enemyHP != 60 { - t.Errorf("%s: HP changed (%d/%d), want 100/60 (flavor-only)", eff, st.playerHP, st.enemyHP) - } - if len(st.events) != 1 || st.events[0].Action != "ability_flavor" { - t.Errorf("%s: want one ability_flavor event, got %+v", eff, st.events) - } - } - // A damage rider that drops the player returns true (no death save armed). stKill, plK, enK := newState(1, 60) enK.Ability = &MonsterAbility{Name: "Smash", Effect: "bonus_damage"} @@ -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 // registered multiattack creature resolves one attack roll per profile entry, // while an unregistered creature still resolves exactly one. diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index 31cd050..9c9c24f 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -202,9 +202,8 @@ type MonsterAbility struct { // 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 // 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. + // regenerate, survive_at_1, stat_drain, debuff, spell_resist, reveal_action, + // fear_immune, ally_buff). Anything else is a silent no-op. Effect string } @@ -301,6 +300,13 @@ type combatState struct { playerACDebuff int // debuff: flat reduction to the player's effective AC 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 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. if player.Mods.SpellPreDamageDesc != "" { dmg := player.Mods.SpellPreDamage + resisted := dmg > 0 && enemyResistsSpells(&enemy, st) + if resisted { + dmg = max(1, dmg/2) + } if dmg > 0 { 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, 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 { return finalize(result, st, player, enemy) } @@ -455,11 +471,20 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase enemyHeldThisRound := false if st.enemySkipFirst { st.enemySkipFirst = false - enemyHeldThisRound = true - st.events = append(st.events, CombatEvent{ - Round: st.round, Phase: phaseName, Actor: "enemy", Action: "spell_held", - PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - }) + if enemyImmuneToControl(enemy, st) { + // fear_immune: the control spell can't take hold — the enemy acts + // as normal this round. + 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 @@ -665,6 +690,18 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba } 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. if roll == 1 && player.Mods.LuckyReroll && !st.luckyUsed { st.luckyUsed = true @@ -856,7 +893,7 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat if st.enraged { 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) 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 { 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) dmg = max(1, dmg) st.playerHP = max(0, st.playerHP-dmg) @@ -1014,7 +1051,7 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase, if st.enraged { 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) dmg1 = max(1, 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) } - case "spell_resist", "reveal_action", "fear_immune", "ally_buff": - // Slice-2 placeholder: no mechanical effect yet (these need persistent - // per-fight state, deferred to the next slice), but the ability still - // announces itself so the fight log doesn't silently swallow a proc. + case "spell_resist": + // Spell Immunity: the enemy is wrapped in anti-magic. Player spell damage + // (the pre-combat cast in auto-resolve, mid-fight !cast in the turn engine) + // 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{ - 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, }) + + 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 @@ -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 // burst). Enrage's 1.5x attack multiplier is honored, matching cleave/lifesteal. func abilityHitDamage(st *combatState, player, enemy *Combatant, phase *CombatPhase, atkFrac, defFrac float64) int { - atk := float64(enemy.Stats.Attack) * atkFrac + mult := atkFrac 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) return max(1, dmg) } @@ -1251,6 +1324,29 @@ func playerDefense(player *Combatant, st *combatState) int { 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, // 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) diff --git a/internal/plugin/combat_narrative.go b/internal/plugin/combat_narrative.go index dee293f..5593026 100644 --- a/internal/plugin/combat_narrative.go +++ b/internal/plugin/combat_narrative.go @@ -332,6 +332,22 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul case "max_hp_drain": 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": 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.", } +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 var narrativeNearDeathWin = []string{ "You survived by the skin of your teeth. Barely standing. The healers are on standby.", diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 1f75751..77618f0 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -110,6 +110,13 @@ type CombatStatuses struct { PlayerACDebuff int `json:"player_ac_debuff,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 // deltas against the freshly-rebuilt combatant. applySessionBuffs folds // these back onto the player every round; diffTurnBuff produces them. diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index 1d057d9..5746d0d 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -144,7 +144,12 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R playerAtkDrain: sess.Statuses.PlayerAtkDrain, playerACDebuff: sess.Statuses.PlayerACDebuff, 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{ sess: sess, @@ -270,8 +275,20 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) { te.sess.Phase = CombatPhaseEnemyTurn return } - if eff.EnemyDamage > 0 { - st.enemyHP = max(0, st.enemyHP-eff.EnemyDamage) + action := eff.Action + 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 { // 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) st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal) } - action := eff.Action - if action == "" { - action = "spell_cast" - } st.events = append(st.events, CombatEvent{ 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) { te.finish(CombatStatusWon) return @@ -296,21 +315,38 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) { return } 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 } 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 { te.st.enemySkipFirst = false - 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 + if enemyImmuneToControl(te.enemy, te.st) { + te.st.events = append(te.st.events, CombatEvent{ + Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist", + PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP, + }) + } 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 @@ -458,6 +494,10 @@ func (te *turnEngine) commit() { s.PlayerAtkDrain = st.playerAtkDrain s.PlayerACDebuff = st.playerACDebuff 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...) }