mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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>
This commit is contained in:
175
internal/plugin/bestiary_srd.go
Normal file
175
internal/plugin/bestiary_srd.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package plugin
|
||||
|
||||
// SRD-shaped stat blocks for the turn-based elite/boss engine.
|
||||
//
|
||||
// The auto-resolve path (SimulateCombat) keeps the simplified one-attack
|
||||
// DnDMonsterTemplate shape — its Attack value is a gameplay-tuned average that
|
||||
// bakes a creature's whole action into a single number, and the 2026-05-10
|
||||
// rebalance is calibrated against it. Do NOT feed these profiles into
|
||||
// auto-resolve.
|
||||
//
|
||||
// The turn-based path can afford canonical SRD multiattack: the player issues a
|
||||
// command per round, so a creature making three attack rolls a turn is a
|
||||
// tactical problem the player can answer (heal, control, flee) rather than an
|
||||
// un-survivable spike. Each SRDAttack carries its own to-hit and average
|
||||
// damage, derived from the creature's SRD attack profile — not the tuned
|
||||
// auto-resolve Attack stat.
|
||||
//
|
||||
// File is intentionally NOT dnd_-prefixed (see memory: no new dnd_* identifiers).
|
||||
|
||||
// SRDAttack is one attack roll within a creature's multiattack action: a d20
|
||||
// to-hit and an average damage value for that single strike.
|
||||
type SRDAttack struct {
|
||||
Name string // "Longsword", "Bite", "Claw" — surfaces in the round log
|
||||
AttackBonus int // d20 to-hit modifier; 0 falls back to the template AttackBonus
|
||||
Damage int // average damage for this single attack
|
||||
}
|
||||
|
||||
// SRDProfile is a creature's full multiattack action — one SRDAttack per attack
|
||||
// roll it makes on its turn. A creature with no profile registered makes a
|
||||
// single attack using its DnDMonsterTemplate Attack / AttackBonus.
|
||||
type SRDProfile struct {
|
||||
Attacks []SRDAttack
|
||||
}
|
||||
|
||||
// srdProfiles is the turn-based multiattack registry, keyed by bestiary ID.
|
||||
// Only elites and bosses that reach the turn-based engine need an entry; every
|
||||
// other creature falls through to the single-attack template path. Damage
|
||||
// values are SRD attack-profile averages, rounded.
|
||||
var srdProfiles = map[string]SRDProfile{
|
||||
// ── Bosses ───────────────────────────────────────────────────────────
|
||||
"boss_grol_unbroken": {Attacks: []SRDAttack{
|
||||
{Name: "Morningstar", AttackBonus: 5, Damage: 11},
|
||||
{Name: "Javelin", AttackBonus: 5, Damage: 9},
|
||||
}},
|
||||
"boss_valdris_unburied": {Attacks: []SRDAttack{
|
||||
{Name: "Paralyzing Touch", AttackBonus: 7, Damage: 12},
|
||||
{Name: "Necrotic Bolt", AttackBonus: 7, Damage: 10},
|
||||
}},
|
||||
"boss_hollow_king": {Attacks: []SRDAttack{
|
||||
{Name: "Root Slam", AttackBonus: 7, Damage: 13},
|
||||
{Name: "Root Slam", AttackBonus: 7, Damage: 13},
|
||||
}},
|
||||
"boss_dreaming_aboleth": {Attacks: []SRDAttack{
|
||||
{Name: "Tentacle", AttackBonus: 9, Damage: 12},
|
||||
{Name: "Tentacle", AttackBonus: 9, Damage: 12},
|
||||
{Name: "Tentacle", AttackBonus: 9, Damage: 12},
|
||||
}},
|
||||
"boss_aldric_blackspire": {Attacks: []SRDAttack{
|
||||
{Name: "Unarmed Strike", AttackBonus: 9, Damage: 10},
|
||||
{Name: "Bite", AttackBonus: 9, Damage: 9},
|
||||
}},
|
||||
"boss_emberlord_thyrak": {Attacks: []SRDAttack{
|
||||
{Name: "Molten Maul", AttackBonus: 8, Damage: 15},
|
||||
{Name: "Molten Maul", AttackBonus: 8, Damage: 15},
|
||||
}},
|
||||
"boss_ilvaras_xunyl": {Attacks: []SRDAttack{
|
||||
{Name: "Scourge", AttackBonus: 9, Damage: 14},
|
||||
{Name: "Scourge", AttackBonus: 9, Damage: 14},
|
||||
}},
|
||||
"boss_thornmother": {Attacks: []SRDAttack{
|
||||
{Name: "Thorned Lash", AttackBonus: 8, Damage: 16},
|
||||
{Name: "Thorned Lash", AttackBonus: 8, Damage: 16},
|
||||
}},
|
||||
"boss_infernax": {Attacks: []SRDAttack{
|
||||
{Name: "Bite", AttackBonus: 11, Damage: 21},
|
||||
{Name: "Claw", AttackBonus: 11, Damage: 17},
|
||||
{Name: "Claw", AttackBonus: 11, Damage: 17},
|
||||
}},
|
||||
"boss_belaxath": {Attacks: []SRDAttack{
|
||||
{Name: "Longsword", AttackBonus: 11, Damage: 21},
|
||||
{Name: "Whip", AttackBonus: 11, Damage: 15},
|
||||
}},
|
||||
|
||||
// ── Multiattack elites ───────────────────────────────────────────────
|
||||
"hobgoblin_warchief": {Attacks: []SRDAttack{
|
||||
{Name: "Longsword", AttackBonus: 5, Damage: 8},
|
||||
{Name: "Longsword", AttackBonus: 5, Damage: 8},
|
||||
}},
|
||||
"bandit_captain": {Attacks: []SRDAttack{
|
||||
{Name: "Scimitar", AttackBonus: 5, Damage: 6},
|
||||
{Name: "Scimitar", AttackBonus: 5, Damage: 6},
|
||||
{Name: "Dagger", AttackBonus: 5, Damage: 5},
|
||||
}},
|
||||
"owlbear": {Attacks: []SRDAttack{
|
||||
{Name: "Beak", AttackBonus: 7, Damage: 10},
|
||||
{Name: "Claws", AttackBonus: 7, Damage: 14},
|
||||
}},
|
||||
"merrow": {Attacks: []SRDAttack{
|
||||
{Name: "Bite", AttackBonus: 5, Damage: 8},
|
||||
{Name: "Claws", AttackBonus: 5, Damage: 9},
|
||||
}},
|
||||
"helmed_horror": {Attacks: []SRDAttack{
|
||||
{Name: "Longsword", AttackBonus: 6, Damage: 8},
|
||||
{Name: "Longsword", AttackBonus: 6, Damage: 8},
|
||||
}},
|
||||
"drow_elite_warrior": {Attacks: []SRDAttack{
|
||||
{Name: "Shortsword", AttackBonus: 7, Damage: 7},
|
||||
{Name: "Shortsword", AttackBonus: 7, Damage: 7},
|
||||
{Name: "Hand Crossbow", AttackBonus: 7, Damage: 6},
|
||||
}},
|
||||
"salamander": {Attacks: []SRDAttack{
|
||||
{Name: "Spear", AttackBonus: 7, Damage: 10},
|
||||
{Name: "Tail", AttackBonus: 7, Damage: 10},
|
||||
}},
|
||||
"guard_drake": {Attacks: []SRDAttack{
|
||||
{Name: "Bite", AttackBonus: 5, Damage: 5},
|
||||
{Name: "Claws", AttackBonus: 5, Damage: 5},
|
||||
}},
|
||||
"young_red_dragon": {Attacks: []SRDAttack{
|
||||
{Name: "Bite", AttackBonus: 10, Damage: 17},
|
||||
{Name: "Claw", AttackBonus: 10, Damage: 13},
|
||||
{Name: "Claw", AttackBonus: 10, Damage: 13},
|
||||
}},
|
||||
"quickling": {Attacks: []SRDAttack{
|
||||
{Name: "Dagger", AttackBonus: 5, Damage: 7},
|
||||
{Name: "Dagger", AttackBonus: 5, Damage: 7},
|
||||
{Name: "Dagger", AttackBonus: 5, Damage: 7},
|
||||
}},
|
||||
"vrock": {Attacks: []SRDAttack{
|
||||
{Name: "Beak", AttackBonus: 6, Damage: 10},
|
||||
{Name: "Talons", AttackBonus: 6, Damage: 14},
|
||||
}},
|
||||
"hezrou": {Attacks: []SRDAttack{
|
||||
{Name: "Bite", AttackBonus: 7, Damage: 15},
|
||||
{Name: "Claw", AttackBonus: 7, Damage: 11},
|
||||
{Name: "Claw", AttackBonus: 7, Damage: 11},
|
||||
}},
|
||||
"nalfeshnee": {Attacks: []SRDAttack{
|
||||
{Name: "Bite", AttackBonus: 10, Damage: 32},
|
||||
{Name: "Claw", AttackBonus: 10, Damage: 15},
|
||||
{Name: "Claw", AttackBonus: 10, Damage: 15},
|
||||
}},
|
||||
"marilith": {Attacks: []SRDAttack{
|
||||
{Name: "Longsword", AttackBonus: 11, Damage: 13},
|
||||
{Name: "Longsword", AttackBonus: 11, Damage: 13},
|
||||
{Name: "Longsword", AttackBonus: 11, Damage: 13},
|
||||
{Name: "Longsword", AttackBonus: 11, Damage: 13},
|
||||
{Name: "Longsword", AttackBonus: 11, Damage: 13},
|
||||
{Name: "Longsword", AttackBonus: 11, Damage: 13},
|
||||
{Name: "Tail", AttackBonus: 11, Damage: 13},
|
||||
}},
|
||||
"vampire_spawn": {Attacks: []SRDAttack{
|
||||
{Name: "Unarmed Strike", AttackBonus: 6, Damage: 8},
|
||||
{Name: "Bite", AttackBonus: 6, Damage: 6},
|
||||
}},
|
||||
}
|
||||
|
||||
// enemyAttackProfile returns the attack rolls a creature makes on its turn.
|
||||
// Registered elites/bosses use their SRD multiattack profile; everyone else
|
||||
// falls back to a single attack derived from the template stats. A zero
|
||||
// per-attack AttackBonus falls back to the (tier-scaled) template bonus.
|
||||
func enemyAttackProfile(enemyID string, stats CombatStats) []SRDAttack {
|
||||
prof, ok := srdProfiles[enemyID]
|
||||
if !ok || len(prof.Attacks) == 0 {
|
||||
return []SRDAttack{{AttackBonus: stats.AttackBonus, Damage: stats.Attack}}
|
||||
}
|
||||
out := make([]SRDAttack, len(prof.Attacks))
|
||||
for i, a := range prof.Attacks {
|
||||
if a.AttackBonus == 0 {
|
||||
a.AttackBonus = stats.AttackBonus
|
||||
}
|
||||
out[i] = a
|
||||
}
|
||||
return out
|
||||
}
|
||||
113
internal/plugin/bestiary_srd_test.go
Normal file
113
internal/plugin/bestiary_srd_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -236,21 +236,73 @@ func (te *turnEngine) stepEnemyTurn() {
|
||||
te.sess.Phase = CombatPhaseRoundEnd
|
||||
return
|
||||
}
|
||||
// resolveEnemyAttack returns true when the fight is decided — either the
|
||||
// player went down without a death save, or a reflect consumable killed
|
||||
// the enemy. Disambiguate by inspecting HP.
|
||||
decided := resolveEnemyAttack(te.st, te.player, te.enemy, &turnCombatPhase, te.result, false, false, false)
|
||||
if te.st.playerHP <= 0 {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
|
||||
// Monster ability fires at the top of the enemy turn. cleave / lifesteal
|
||||
// resolve their own damage and stand in for the attack action this round;
|
||||
// every other effect (poison / stun / enrage / armor_break) is a rider that
|
||||
// leaves the multiattack below intact. applyAbility returns true when the
|
||||
// player went down without a save.
|
||||
abilityDealtDamage := false
|
||||
if te.enemy.Ability != nil && turnAbilityFires(te.enemy.Ability, te.st, te.enemy) {
|
||||
if applyAbility(te.st, te.player, te.enemy, &turnCombatPhase, te.result) {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
switch te.enemy.Ability.Effect {
|
||||
case "cleave", "lifesteal":
|
||||
abilityDealtDamage = true
|
||||
}
|
||||
}
|
||||
if decided {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
|
||||
if !abilityDealtDamage {
|
||||
// SRD multiattack: each profile entry is one attack roll resolved
|
||||
// through the shared primitive. A registered elite/boss swings its full
|
||||
// profile; everyone else gets a single attack from the template stats.
|
||||
// resolveEnemyAttack returns true when the fight is decided — either the
|
||||
// player went down without a death save, or a reflect consumable killed
|
||||
// the enemy. Disambiguate by inspecting HP.
|
||||
for _, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) {
|
||||
swing := *te.enemy
|
||||
swing.Stats.Attack = atk.Damage
|
||||
swing.Stats.AttackBonus = atk.AttackBonus
|
||||
decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, false, false, false)
|
||||
if te.st.playerHP <= 0 {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
if decided || te.st.enemyHP <= 0 {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
te.sess.Phase = CombatPhaseRoundEnd
|
||||
}
|
||||
|
||||
// turnAbilityFires decides whether a monster ability triggers this enemy turn.
|
||||
// The auto-resolve engine gates abilities on its named phase clock (Opening /
|
||||
// Clash / Decisive); the turn-based duel has no phase clock, so the phase tag
|
||||
// is remapped to fight progress: "opening" is round-1 only, "decisive" unlocks
|
||||
// once the enemy is bloodied (<40% HP), "clash" covers the rounds in between,
|
||||
// and "any" is always eligible. ProcChance is rolled once eligible.
|
||||
func turnAbilityFires(ability *MonsterAbility, st *combatState, enemy *Combatant) bool {
|
||||
eligible := false
|
||||
switch ability.Phase {
|
||||
case "any":
|
||||
eligible = true
|
||||
case "opening":
|
||||
eligible = st.round <= 1
|
||||
case "clash":
|
||||
eligible = st.round > 1
|
||||
case "decisive":
|
||||
eligible = st.enemyHP < int(float64(enemy.Stats.MaxHP)*0.4)
|
||||
}
|
||||
if !eligible {
|
||||
return false
|
||||
}
|
||||
return st.randFloat() < ability.ProcChance
|
||||
}
|
||||
|
||||
// stepRoundEnd applies between-round status effects, then opens the next round.
|
||||
func (te *turnEngine) stepRoundEnd() {
|
||||
st := te.st
|
||||
|
||||
Reference in New Issue
Block a user