From 96b291d831c324435f8816343e5b7bf21d715bcb Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 14 May 2026 07:37:29 -0700 Subject: [PATCH] Combat: SRD multiattack profiles + monster abilities in turn engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/plugin/bestiary_srd.go | 175 ++++++++++++++++++++++++++ internal/plugin/bestiary_srd_test.go | 113 +++++++++++++++++ internal/plugin/combat_turn_engine.go | 72 +++++++++-- 3 files changed, 350 insertions(+), 10 deletions(-) create mode 100644 internal/plugin/bestiary_srd.go create mode 100644 internal/plugin/bestiary_srd_test.go diff --git a/internal/plugin/bestiary_srd.go b/internal/plugin/bestiary_srd.go new file mode 100644 index 0000000..49bc7e9 --- /dev/null +++ b/internal/plugin/bestiary_srd.go @@ -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 +} diff --git a/internal/plugin/bestiary_srd_test.go b/internal/plugin/bestiary_srd_test.go new file mode 100644 index 0000000..0762d7f --- /dev/null +++ b/internal/plugin/bestiary_srd_test.go @@ -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) + } +} diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index 1bf92a1..bb570c8 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -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