Files
gogobee/internal/plugin/dnd_proddb_integration_test.go
prosolis 9e1a1f606c 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>
2026-05-09 14:25:21 -07:00

213 lines
6.5 KiB
Go

package plugin
import (
"io"
"os"
"path/filepath"
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// TestProdDB_DnDLayer exercises the D&D layer against a copy of the real
// prod database. Verifies that:
// - Existing AdventureCharacter rows load cleanly
// - ensureDnDCharacterForCombat creates a sensible auto-migrated sheet
// - The auto-migrated row is well-formed (HP > 0, AC ≥ 10, level ≥ 1)
// - !setup overwrite path works on auto-migrated chars
// - HP persistence after combat doesn't corrupt the sheet
//
// Skips silently if the prod DB isn't present.
func TestProdDB_DnDLayer(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 at " + src)
}
dir := t.TempDir()
dst := filepath.Join(dir, "gogobee.db")
copyTestFile(t, src, dst)
db.Close()
if err := db.Init(dir); err != nil {
t.Fatalf("Init: %v", err)
}
t.Cleanup(db.Close)
// 1. Load every adventure character. They must scan without error.
chars, err := loadAllAdvCharacters()
if err != nil {
t.Fatalf("loadAllAdvCharacters: %v", err)
}
if len(chars) == 0 {
t.Fatal("no adventure characters in prod DB")
}
t.Logf("loaded %d real adventure characters", len(chars))
// 2. For each, verify they have NO dnd_character row yet, then auto-migrate
// and verify the resulting row is sensible.
for i := range chars {
char := &chars[i]
uid := char.UserID
existing, err := LoadDnDCharacter(uid)
if err != nil {
t.Errorf("user=%s: LoadDnDCharacter pre-migrate failed: %v", uid, err)
continue
}
if existing != nil {
t.Errorf("user=%s: had dnd_character row pre-migrate (should be empty)", uid)
continue
}
// Auto-migrate via the production code path.
dnd, fresh, err := ensureDnDCharacterForCombat(uid, char)
if err != nil {
t.Errorf("user=%s: ensureDnDCharacterForCombat failed: %v", uid, err)
continue
}
if dnd == nil {
t.Errorf("user=%s: ensureDnDCharacterForCombat returned nil", uid)
continue
}
if !fresh {
t.Errorf("user=%s: expected fresh=true on first auto-migrate", uid)
}
// Sanity invariants on the auto-migrated sheet.
if dnd.UserID != uid {
t.Errorf("user=%s: dnd.UserID=%s mismatch", uid, dnd.UserID)
}
if !dnd.AutoMigrated {
t.Errorf("user=%s: AutoMigrated=false on fresh auto-migration", uid)
}
if dnd.PendingSetup {
t.Errorf("user=%s: PendingSetup=true on fresh auto-migration", uid)
}
if dnd.Race == "" || dnd.Class == "" {
t.Errorf("user=%s: race/class empty: race=%q class=%q", uid, dnd.Race, dnd.Class)
}
if dnd.Level < 1 {
t.Errorf("user=%s: level %d < 1", uid, dnd.Level)
}
// Migrated level = combat_level/5 clamped to [1, 20].
expectLevel := dndLevelFromCombatLevel(char.CombatLevel)
if dnd.Level != expectLevel {
t.Errorf("user=%s: dnd.Level=%d, want %d (from combat_level=%d)",
uid, dnd.Level, expectLevel, char.CombatLevel)
}
if dnd.HPMax < 1 || dnd.HPCurrent != dnd.HPMax {
t.Errorf("user=%s: HP invariant: max=%d cur=%d", uid, dnd.HPMax, dnd.HPCurrent)
}
if dnd.ArmorClass < 10 {
t.Errorf("user=%s: AC %d < 10 floor", uid, dnd.ArmorClass)
}
// Stat scores after racial mods should still be in legal range (1..20).
for j, score := range []int{dnd.STR, dnd.DEX, dnd.CON, dnd.INT, dnd.WIS, dnd.CHA} {
if score < 1 || score > 20 {
t.Errorf("user=%s: stat[%d]=%d out of range", uid, j, score)
}
}
t.Logf("auto-migrated user=%s → L%d %s %s HP=%d AC=%d STR/DEX/CON/INT/WIS/CHA=%d/%d/%d/%d/%d/%d",
uid, dnd.Level, dnd.Race, dnd.Class, dnd.HPMax, dnd.ArmorClass,
dnd.STR, dnd.DEX, dnd.CON, dnd.INT, dnd.WIS, dnd.CHA)
}
// 3. Re-load each auto-migrated char — round-trip integrity.
for i := range chars {
uid := chars[i].UserID
dnd, err := LoadDnDCharacter(uid)
if err != nil {
t.Errorf("user=%s: re-load failed: %v", uid, err)
continue
}
if dnd == nil {
t.Errorf("user=%s: re-load returned nil (auto-migrate didn't persist)", uid)
continue
}
if !dnd.AutoMigrated || dnd.PendingSetup {
t.Errorf("user=%s: post-reload flags wrong: auto=%v pending=%v",
uid, dnd.AutoMigrated, dnd.PendingSetup)
}
}
// 4. ensureDnDCharacterForCombat is idempotent — second call returns the
// same row, doesn't create a duplicate.
uid := chars[0].UserID
first, _ := LoadDnDCharacter(uid)
second, freshAgain, err := ensureDnDCharacterForCombat(uid, &chars[0])
if err != nil {
t.Errorf("idempotent ensure failed: %v", err)
}
if freshAgain {
t.Error("second ensure call returned fresh=true; should be false (already migrated)")
}
if first != nil && second != nil {
if first.Race != second.Race || first.Class != second.Class {
t.Errorf("ensure returned different sheet on second call: %v vs %v",
first, second)
}
}
// 5. HP persistence smoke test — simulate a combat that left the player
// at 50% HP. Verify dnd_character.hp_current shifts proportionally.
persistDnDHPAfterCombat(uid, 100, 50)
after, err := LoadDnDCharacter(uid)
if err != nil || after == nil {
t.Fatalf("post-persist load: %v", err)
}
want := after.HPMax / 2
if after.HPCurrent < want-1 || after.HPCurrent > want+1 {
t.Errorf("hp_current=%d after 50%% loss; want ~%d (hp_max=%d)",
after.HPCurrent, want, after.HPMax)
}
t.Logf("HP persistence verified: %d/%d after 50%% combat damage", after.HPCurrent, after.HPMax)
// 6. !setup overwrite path: an auto-migrated char should be wipeable via
// loadOrInitDraft → DELETE → fresh draft.
draft, err := loadOrInitDraft(uid)
if err != nil {
t.Fatalf("loadOrInitDraft on auto-migrated: %v", err)
}
if !draft.PendingSetup {
t.Error("draft should have PendingSetup=true")
}
// The auto-migrated row should now be deleted.
cleared, _ := LoadDnDCharacter(uid)
if cleared != nil {
t.Errorf("auto-migrated row not cleared by loadOrInitDraft: %+v", cleared)
}
// 7. Adventure characters list should still be loadable without
// interference from D&D-layer joins.
chars2, err := loadAllAdvCharacters()
if err != nil {
t.Fatalf("post-test reload failed: %v", err)
}
if len(chars2) != len(chars) {
t.Errorf("char count drift: pre=%d post=%d", len(chars), len(chars2))
}
}
func copyTestFile(t *testing.T, src, dst string) {
t.Helper()
in, err := os.Open(src)
if err != nil {
t.Fatal(err)
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
t.Fatal(err)
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
t.Fatal(err)
}
}
// silence unused warning if id pkg drifts
var _ = id.UserID("")