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