Adv 2.0 D&D layer: Phases 1-8 + Phase 9 SP1+SP2

Combines all uncommitted D&D work into one checkpoint:

Phases 1-8 (per gogobee_dnd_session_summary.md):
- Race/class/stats system, !setup flow, !sheet, !respec
- d20-vs-AC combat with race/class passives, auto-migration
- XP/level-up, skill checks, NPC refund hooks
- Equipment slot mapping, rarity, !rest short/long
- Pre-arm active abilities, resource pools
- Bestiary, !roll/!stats/!level, onboarding DM
- Audit fixes A-I, flavor wiring under internal/flavor/
- Phase 8 weapon dice + armor AC tables (gogobee_equipment_appendix)

Phase 9 SP1 — spell system foundations:
- 79 SpellDefinitions (76 in-scope + 3 reaction-deferred)
- dnd_known_spells, dnd_spell_slots tables
- pending_cast / concentration_* columns on dnd_character
- Slot tables for Mage/Cleric/Ranger, DC + attack-bonus math
- Long-rest refreshes slots; respec wipes spell state
- Auto-grant default known list on !setup confirm and auto-migration

Phase 9 SP2 — !cast / !spells / !prepare commands:
- Out-of-combat HEAL and UTILITY resolve immediately
- Damage/control/buff queue as pending_cast for next combat
- Audit-style save-then-debit, slot refund on save failure
- !cast --drop, concentration supersession, prep-cap enforcement
- Reaction spells refused with Phase 11 note

SP3 (combat-time resolution of pending_cast) and SP4 (full prep/learn
tests) are next. Build clean, full test suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 08:21:44 -07:00
parent 8e0fe0230c
commit 9e1a1f606c
80 changed files with 19088 additions and 210 deletions

View File

@@ -0,0 +1,247 @@
package plugin
import (
"io"
"os"
"path/filepath"
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── Race passives ────────────────────────────────────────────────────────────
func TestApplyRacePassives(t *testing.T) {
cases := []struct {
race DnDRace
wantLucky, wantRage, wantPoisonOK bool
}{
{RaceHalfling, true, false, false},
{RaceOrc, false, true, false},
{RaceDwarf, false, false, true},
{RaceHuman, false, false, false},
{RaceElf, false, false, false},
{RaceTiefling, false, false, false},
{RaceHalfElf, false, false, false},
}
for _, tc := range cases {
mods := CombatModifiers{}
stats := CombatStats{}
applyRacePassives(&stats, &mods, &DnDCharacter{Race: tc.race})
if mods.LuckyReroll != tc.wantLucky {
t.Errorf("%s LuckyReroll = %v, want %v", tc.race, mods.LuckyReroll, tc.wantLucky)
}
if mods.RageReady != tc.wantRage {
t.Errorf("%s RageReady = %v, want %v", tc.race, mods.RageReady, tc.wantRage)
}
if mods.PoisonResist != tc.wantPoisonOK {
t.Errorf("%s PoisonResist = %v, want %v", tc.race, mods.PoisonResist, tc.wantPoisonOK)
}
}
}
// TestHalflingLuckyEmitsReroll: a Halfling fighter rolling many d20s should
// emit at least one lucky_reroll event over a long fight, and never more than
// one (single-use per combat).
func TestHalflingLuckyEmitsReroll(t *testing.T) {
player := Combatant{
IsPlayer: true,
Stats: CombatStats{MaxHP: 100000, Attack: 5, Defense: 0, Speed: 50, AC: 10, AttackBonus: 5},
Mods: CombatModifiers{DamageReduct: 1.0, LuckyReroll: true},
}
enemy := Combatant{
Stats: CombatStats{MaxHP: 100000, Attack: 1, Defense: 0, Speed: 50, AC: 13, AttackBonus: 0},
Mods: CombatModifiers{DamageReduct: 1.0},
}
phases := []CombatPhase{{Name: "Long", Rounds: 200, AttackWeight: 1.0, DefenseWeight: 1.0, SpeedWeight: 1.0}}
saw := 0
for trial := 0; trial < 5; trial++ {
result := SimulateCombat(player, enemy, phases)
rerolls := 0
for _, ev := range result.Events {
if ev.Action == "lucky_reroll" {
rerolls++
}
}
if rerolls > 1 {
t.Errorf("trial %d: %d lucky_reroll events in one fight (should be ≤1)", trial, rerolls)
}
if rerolls == 1 {
saw++
}
}
if saw == 0 {
t.Error("never observed a Halfling Lucky reroll over 5 long fights — extremely unlikely (~5%^5)")
}
}
// TestOrcRageFiresOnLowHP: an Orc whose HP gets driven below 50% should emit
// a "rage" event and have higher damage on the following attack.
func TestOrcRageFiresOnLowHP(t *testing.T) {
// Player starts with 50 max HP, low attack. Enemy is a tank that hits
// back hard so the player crosses the 50% threshold.
player := Combatant{
IsPlayer: true,
Stats: CombatStats{MaxHP: 50, Attack: 10, Defense: 5, Speed: 5, AC: 10, AttackBonus: 5},
Mods: CombatModifiers{DamageReduct: 1.0, RageReady: true},
}
enemy := Combatant{
Stats: CombatStats{MaxHP: 100000, Attack: 30, Defense: 5, Speed: 5, AC: 10, AttackBonus: 8},
Mods: CombatModifiers{DamageReduct: 1.0},
}
phases := []CombatPhase{{Name: "Brawl", Rounds: 40, AttackWeight: 1.0, DefenseWeight: 1.0, SpeedWeight: 1.0}}
rageFiredEver := false
for trial := 0; trial < 5; trial++ {
result := SimulateCombat(player, enemy, phases)
rageCount := 0
for _, ev := range result.Events {
if ev.Action == "rage" {
rageCount++
}
}
if rageCount > 1 {
t.Errorf("trial %d: %d rage events (should be ≤1)", trial, rageCount)
}
if rageCount == 1 {
rageFiredEver = true
}
}
if !rageFiredEver {
t.Error("Orc Rage never fired over 5 brutal fights — should always trigger when player crosses 50%")
}
}
// TestDwarfPoisonResistance: poison_tick damage applied to a Dwarf should be
// roughly half of the unprotected baseline. We measure by summing the actual
// poison_tick events rather than HP delta, since the engine's exhaustion
// tiebreaker can zero HP independently of poison damage.
func TestDwarfPoisonResistance(t *testing.T) {
enemy := Combatant{
Stats: CombatStats{MaxHP: 1000, Attack: 5, Defense: 0, Speed: 5, AC: 10, AttackBonus: 0},
Mods: CombatModifiers{DamageReduct: 1.0},
Ability: &MonsterAbility{Name: "Spit", Phase: "any", ProcChance: 1.0, Effect: "poison"},
}
phases := []CombatPhase{{Name: "Tick", Rounds: 5, AttackWeight: 0.1, DefenseWeight: 1.0, SpeedWeight: 0.1}}
makePlayer := func(resist bool) Combatant {
return Combatant{
IsPlayer: true,
Stats: CombatStats{MaxHP: 200, Attack: 1, Defense: 100, Speed: 100, AC: 99, AttackBonus: 0},
Mods: CombatModifiers{DamageReduct: 1.0, PoisonResist: resist},
}
}
sumPoison := func(r CombatResult) int {
s := 0
for _, ev := range r.Events {
if ev.Action == "poison_tick" {
s += ev.Damage
}
}
return s
}
totalUnprot, totalProt := 0, 0
const trials = 300
for i := 0; i < trials; i++ {
totalUnprot += sumPoison(SimulateCombat(makePlayer(false), enemy, phases))
totalProt += sumPoison(SimulateCombat(makePlayer(true), enemy, phases))
}
if totalUnprot == 0 {
t.Fatal("no unprotected poison damage observed; test setup broken")
}
avgU := float64(totalUnprot) / float64(trials)
avgP := float64(totalProt) / float64(trials)
ratio := avgP / avgU
if ratio < 0.35 || ratio > 0.65 {
t.Errorf("dwarf poison ratio = %.3f (avg unprot=%.1f, prot=%.1f); want ~0.5",
ratio, avgU, avgP)
}
}
// ── combat_level freeze ──────────────────────────────────────────────────────
func TestCheckAdvLevelUp_FrozenForDnDChars(t *testing.T) {
src := "/home/reala-misaki/git/gogobee/data/gogobee.db"
if _, err := os.Stat(src); err != nil {
t.Skip("prod db not present")
}
dir := t.TempDir()
dst := filepath.Join(dir, "gogobee.db")
in, _ := os.Open(src)
defer in.Close()
out, _ := os.Create(dst)
defer out.Close()
io.Copy(out, in)
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
uid := id.UserID("@freeze_test:example")
// Case 1: no dnd_character → combat XP advances combat_level normally.
char := &AdventureCharacter{
UserID: uid,
DisplayName: "freeze_test",
CombatLevel: 5,
CombatXP: 1000, // far over the threshold for L6
}
leveled, newLvl := checkAdvLevelUp(char, "combat")
if !leveled || newLvl <= 5 {
t.Errorf("non-DnD player: leveled=%v newLvl=%d, want leveled=true, newLvl>5", leveled, newLvl)
}
// Case 2: confirmed dnd_character → frozen.
dnd := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 1,
STR: 15, DEX: 14, CON: 13, INT: 12, WIS: 10, CHA: 8,
HPMax: 12, HPCurrent: 12, ArmorClass: 16,
PendingSetup: false, AutoMigrated: false,
}
if err := SaveDnDCharacter(dnd); err != nil {
t.Fatal(err)
}
char2 := &AdventureCharacter{
UserID: uid,
DisplayName: "freeze_test",
CombatLevel: 5,
CombatXP: 1000,
}
leveled, newLvl = checkAdvLevelUp(char2, "combat")
if leveled || newLvl != 5 {
t.Errorf("DnD player: leveled=%v newLvl=%d, want leveled=false, newLvl=5", leveled, newLvl)
}
// Case 3: skill levels still advance for the same player.
char2.MiningSkill = 5
char2.MiningXP = 1000
leveled, newLvl = checkAdvLevelUp(char2, "mining")
if !leveled || newLvl <= 5 {
t.Errorf("DnD player mining: leveled=%v newLvl=%d, want leveled=true", leveled, newLvl)
}
}
// ── !respec cooldown logic ──────────────────────────────────────────────────
func TestRespecCooldownFormat(t *testing.T) {
cases := []struct {
d time.Duration
want string
}{
{7 * 24 * time.Hour, "7d"},
{6*24*time.Hour + 5*time.Hour, "6d 5h"},
{3 * time.Hour, "3h"},
{45 * time.Minute, "45m"},
}
for _, c := range cases {
got := formatRespecDuration(c.d)
if got != c.want {
t.Errorf("formatRespecDuration(%v) = %q, want %q", c.d, got, c.want)
}
}
}