Files
gogobee/internal/plugin/bestiary_srd_test.go
prosolis 96b291d831 Combat: SRD multiattack profiles + monster abilities in turn engine
Elites/bosses in turn-based fights now swing a full SRD multiattack
profile and fire their special ability — abilities never fired in the
turn-based path before, and every enemy made a single attack.

bestiary_srd.go adds SRDAttack/SRDProfile and a hand-authored registry
for the named bosses and multiattack elites; auto-resolve keeps its
tuned single-Attack blocks untouched. turnAbilityFires remaps the
auto-resolve phase clock onto fight progress for the phase-less duel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 07:37:29 -07:00

114 lines
3.8 KiB
Go

package plugin
import (
"math/rand/v2"
"testing"
)
func TestEnemyAttackProfile_Fallback(t *testing.T) {
// An unregistered creature makes a single attack derived from its template
// stats — same behaviour as before the multiattack upgrade.
stats := CombatStats{Attack: 9, AttackBonus: 4}
got := enemyAttackProfile("not_in_registry", stats)
if len(got) != 1 {
t.Fatalf("len = %d, want 1 (single fallback attack)", len(got))
}
if got[0].Damage != 9 || got[0].AttackBonus != 4 {
t.Errorf("fallback attack = %+v, want damage 9 / bonus 4", got[0])
}
}
func TestEnemyAttackProfile_Registered(t *testing.T) {
// A registered elite returns its full multiattack profile.
got := enemyAttackProfile("owlbear", CombatStats{Attack: 99, AttackBonus: 99})
if len(got) != 2 {
t.Fatalf("owlbear len = %d, want 2 (Beak + Claws)", len(got))
}
// Profile values win over the template stats.
if got[0].AttackBonus == 99 || got[1].AttackBonus == 99 {
t.Errorf("profile should not inherit template AttackBonus: %+v", got)
}
// A zero per-attack AttackBonus falls back to the (tier-scaled) template
// bonus so authoring can leave it blank.
srdProfiles["__test_zero_bonus"] = SRDProfile{Attacks: []SRDAttack{{Name: "Slam", Damage: 5}}}
defer delete(srdProfiles, "__test_zero_bonus")
zb := enemyAttackProfile("__test_zero_bonus", CombatStats{Attack: 1, AttackBonus: 7})
if zb[0].AttackBonus != 7 {
t.Errorf("zero-bonus attack = %+v, want AttackBonus 7 from template", zb[0])
}
}
func TestTurnAbilityFires(t *testing.T) {
enemy := baseEnemy() // MaxHP 60
st := &combatState{rng: rand.New(rand.NewPCG(1, 1))}
cases := []struct {
name string
phase string
round int
enemyHP int
eligible bool
}{
{"any always eligible", "any", 5, 60, true},
{"opening round 1", "opening", 1, 60, true},
{"opening past round 1", "opening", 2, 60, false},
{"clash past round 1", "clash", 2, 60, true},
{"clash round 1", "clash", 1, 60, false},
{"decisive when bloodied", "decisive", 3, 20, true},
{"decisive when healthy", "decisive", 3, 60, false},
}
for _, c := range cases {
st.round = c.round
st.enemyHP = c.enemyHP
// ProcChance 1.0: fires iff eligible. ProcChance 0.0: never fires.
alwaysProc := &MonsterAbility{Phase: c.phase, ProcChance: 1.0}
if got := turnAbilityFires(alwaysProc, st, &enemy); got != c.eligible {
t.Errorf("%s: fired=%v, want %v", c.name, got, c.eligible)
}
neverProc := &MonsterAbility{Phase: c.phase, ProcChance: 0.0}
if turnAbilityFires(neverProc, st, &enemy) {
t.Errorf("%s: a 0%% proc chance ability should never fire", c.name)
}
}
}
// 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.
func TestTurnEngine_Multiattack(t *testing.T) {
countEnemyEvents := func(events []CombatEvent) int {
n := 0
for _, e := range events {
if e.Actor == "enemy" {
n++
}
}
return n
}
// Registered: owlbear has a 2-attack profile. Pools are huge so the fight
// can't end mid-loop and every swing emits its own enemy event.
sess := turnSession(CombatPhaseEnemyTurn, 100000, 100000)
sess.EnemyID = "owlbear"
player, enemy := basePlayer(), baseEnemy()
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
if err != nil {
t.Fatal(err)
}
if got := countEnemyEvents(events); got != 2 {
t.Errorf("owlbear enemy events = %d, want 2 (multiattack profile)", got)
}
// Unregistered: a single attack, as before the upgrade.
sess2 := turnSession(CombatPhaseEnemyTurn, 100000, 100000)
sess2.EnemyID = "goblin"
events2, err := stepEngine(sess2, &player, &enemy, PlayerAction{})
if err != nil {
t.Fatal(err)
}
if got := countEnemyEvents(events2); got != 1 {
t.Errorf("goblin enemy events = %d, want 1 (single-attack fallback)", got)
}
}