Files
gogobee/internal/plugin/proddb_simulation_test.go
prosolis c8849fd819 Adv 2.0 L5 close-out fix: bootstrap player_meta from legacy on Init
The L5 close-out (596b2b7) rewired loadAdvCharacter to read from
player_meta and simultaneously deleted the 13 backfill one-shots from
Init. That's only safe if every account already has a player_meta row
from prior dual-writes — fresh restores from old backups, dev
environments cloned from older snapshots, or a prod upgrade that
skipped the soak window all silently strand every character (loader
returns empty).

Adds bootstrapPlayerMetaFromLegacy: a single consolidated migrator that
walks adventure_characters, finds rows missing from player_meta, and
fans them out via the audited upsertAllPlayerMetaFromAdvChar helper.
Idempotent (re-runs touch zero rows once migrated; no-ops post-purge).
Wired into AdventurePlugin.Init before loadAllAdvCharacters.

proddb_simulation_test.go exercises the full bootstrap + fan-out +
overlay round-trip against a copy of data/gogobee.db with 11 subtests
covering every migrated subsystem (skills, arena, babysit, streak,
death/revive, NPC counters, pet incl. JSON-packed flags, house, save
idempotency, last_active advance, fresh createAdvCharacter).

dnd_proddb_integration_test.go also calls the bootstrap explicitly to
mirror prod startup ordering.

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

327 lines
12 KiB
Go

package plugin
import (
"os"
"path/filepath"
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// TestProdDB_PlayerActionRoundTrip simulates real player actions against a
// copy of the prod DB and verifies the bootstrap + fan-out + overlay
// round-trip preserves state across save/reload boundaries. Targets every
// subsystem the L4-L5h migration touched.
//
// Skips silently if the prod DB isn't present.
func TestProdDB_PlayerActionRoundTrip(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)
bootstrapPlayerMetaFromLegacy()
chars, err := loadAllAdvCharacters()
if err != nil {
t.Fatalf("loadAllAdvCharacters: %v", err)
}
if len(chars) == 0 {
t.Fatal("bootstrap produced zero chars; nothing to simulate against")
}
// Pick first char as the simulation target.
uid := chars[0].UserID
t.Logf("=== simulating actions for %s ===", uid)
// Snapshot original state for end-of-test no-regression check on
// non-mutated subsystems.
orig, err := loadAdvCharacter(uid)
if err != nil || orig == nil {
t.Fatalf("initial load failed: %v", err)
}
t.Logf("baseline: L%d combat / mining=%d foraging=%d fishing=%d, arena=%d-%d, hospital=%d, streak=%d/%d, masterwork=%d, pet=%q L%d",
orig.CombatLevel, orig.MiningSkill, orig.ForagingSkill, orig.FishingSkill,
orig.ArenaWins, orig.ArenaLosses, orig.HospitalVisits,
orig.CurrentStreak, orig.BestStreak, orig.MasterworkDropsReceived,
orig.PetType, orig.PetLevel)
// ── 1. Skill XP / level (foraging craft success) ───────────────────────
t.Run("foraging_xp_persists", func(t *testing.T) {
c, _ := loadAdvCharacter(uid)
preXP := c.ForagingXP
preCrafts := c.CraftsSucceeded
c.ForagingXP += 25
c.CraftsSucceeded++
if err := saveAdvCharacter(c); err != nil {
t.Fatalf("save: %v", err)
}
got, _ := loadAdvCharacter(uid)
if got.ForagingXP != preXP+25 {
t.Errorf("foraging_xp: pre=%d post=%d (want %d)", preXP, got.ForagingXP, preXP+25)
}
if got.CraftsSucceeded != preCrafts+1 {
t.Errorf("crafts_succeeded: pre=%d post=%d (want %d)", preCrafts, got.CraftsSucceeded, preCrafts+1)
}
t.Logf("foraging_xp=%d→%d, crafts=%d→%d", preXP, got.ForagingXP, preCrafts, got.CraftsSucceeded)
})
// ── 2. Arena counters ──────────────────────────────────────────────────
t.Run("arena_counters_persist", func(t *testing.T) {
c, _ := loadAdvCharacter(uid)
preWins := c.ArenaWins
preLosses := c.ArenaLosses
preInv := c.InvasionScore
c.ArenaWins++
c.ArenaLosses++
c.InvasionScore += 10
if err := saveAdvCharacter(c); err != nil {
t.Fatalf("save: %v", err)
}
got, _ := loadAdvCharacter(uid)
if got.ArenaWins != preWins+1 || got.ArenaLosses != preLosses+1 || got.InvasionScore != preInv+10 {
t.Errorf("arena: wins %d→%d, losses %d→%d, inv %d→%d",
preWins, got.ArenaWins, preLosses, got.ArenaLosses, preInv, got.InvasionScore)
}
t.Logf("arena: wins %d, losses %d, inv %d", got.ArenaWins, got.ArenaLosses, got.InvasionScore)
})
// ── 3. Babysit (start session) ─────────────────────────────────────────
t.Run("babysit_state_persists", func(t *testing.T) {
c, _ := loadAdvCharacter(uid)
future := time.Now().UTC().Add(2 * time.Hour).Truncate(time.Second)
c.BabysitActive = true
c.BabysitExpiresAt = &future
c.BabysitSkillFocus = "foraging"
if err := saveAdvCharacter(c); err != nil {
t.Fatalf("save: %v", err)
}
got, _ := loadAdvCharacter(uid)
if !got.BabysitActive {
t.Error("babysit_active not persisted")
}
if got.BabysitExpiresAt == nil || !got.BabysitExpiresAt.Equal(future) {
t.Errorf("babysit_expires: want %v got %v", future, got.BabysitExpiresAt)
}
if got.BabysitSkillFocus != "foraging" {
t.Errorf("babysit_focus: want foraging got %q", got.BabysitSkillFocus)
}
t.Logf("babysit: active=%v expires=%v focus=%s", got.BabysitActive, got.BabysitExpiresAt, got.BabysitSkillFocus)
// Cancel
c2, _ := loadAdvCharacter(uid)
c2.BabysitActive = false
c2.BabysitExpiresAt = nil
c2.BabysitSkillFocus = ""
_ = saveAdvCharacter(c2)
got2, _ := loadAdvCharacter(uid)
if got2.BabysitActive || got2.BabysitExpiresAt != nil {
t.Errorf("babysit clear failed: active=%v expires=%v", got2.BabysitActive, got2.BabysitExpiresAt)
}
})
// ── 4. Lifecycle (streak update) ───────────────────────────────────────
t.Run("streak_persists", func(t *testing.T) {
c, _ := loadAdvCharacter(uid)
preStreak := c.CurrentStreak
preBest := c.BestStreak
c.CurrentStreak = preStreak + 3
if c.CurrentStreak > c.BestStreak {
c.BestStreak = c.CurrentStreak
}
c.LastActionDate = "2026-05-09"
c.StreakDecayed = false
if err := saveAdvCharacter(c); err != nil {
t.Fatalf("save: %v", err)
}
got, _ := loadAdvCharacter(uid)
if got.CurrentStreak != preStreak+3 {
t.Errorf("current_streak: want %d got %d", preStreak+3, got.CurrentStreak)
}
if got.BestStreak < preBest || got.BestStreak < got.CurrentStreak {
t.Errorf("best_streak invariant broken: best=%d cur=%d", got.BestStreak, got.CurrentStreak)
}
t.Logf("streak: cur %d→%d, best=%d, last_action=%s", preStreak, got.CurrentStreak, got.BestStreak, got.LastActionDate)
})
// ── 5. Death state (kill + revive) ─────────────────────────────────────
t.Run("death_state_persists", func(t *testing.T) {
c, _ := loadAdvCharacter(uid)
preVisits := c.HospitalVisits
future := time.Now().UTC().Add(6 * time.Hour).Truncate(time.Second)
c.Alive = false
c.DeadUntil = &future
c.DeathSource = "zone"
c.DeathLocation = "Forest of Shadows"
if err := saveAdvCharacter(c); err != nil {
t.Fatalf("save kill: %v", err)
}
got, _ := loadAdvCharacter(uid)
if got.Alive {
t.Error("alive flag should be false after kill")
}
if got.DeadUntil == nil || !got.DeadUntil.Equal(future) {
t.Errorf("dead_until: want %v got %v", future, got.DeadUntil)
}
if got.DeathSource != "zone" || got.DeathLocation != "Forest of Shadows" {
t.Errorf("death source/location: %q / %q", got.DeathSource, got.DeathLocation)
}
// Revive
c2, _ := loadAdvCharacter(uid)
c2.Alive = true
c2.DeadUntil = nil
c2.HospitalVisits++
_ = saveAdvCharacter(c2)
got2, _ := loadAdvCharacter(uid)
if !got2.Alive || got2.DeadUntil != nil {
t.Errorf("revive: alive=%v dead_until=%v", got2.Alive, got2.DeadUntil)
}
if got2.HospitalVisits != preVisits+1 {
t.Errorf("hospital_visits: want %d got %d", preVisits+1, got2.HospitalVisits)
}
t.Logf("death cycle: alive=%v hospital_visits %d→%d", got2.Alive, preVisits, got2.HospitalVisits)
})
// ── 6. NPC counters (Misty encounter) ──────────────────────────────────
t.Run("npc_counters_persist", func(t *testing.T) {
c, _ := loadAdvCharacter(uid)
pre := c.MistyEncounterCount
now := time.Now().UTC().Truncate(time.Second)
c.MistyEncounterCount++
c.MistyLastSeen = &now
c.MistyRollTarget = 50
if err := saveAdvCharacter(c); err != nil {
t.Fatalf("save: %v", err)
}
got, _ := loadAdvCharacter(uid)
if got.MistyEncounterCount != pre+1 {
t.Errorf("misty_encounter_count: want %d got %d", pre+1, got.MistyEncounterCount)
}
if got.MistyLastSeen == nil || !got.MistyLastSeen.Equal(now) {
t.Errorf("misty_last_seen: want %v got %v", now, got.MistyLastSeen)
}
if got.MistyRollTarget != 50 {
t.Errorf("misty_roll_target: want 50 got %d", got.MistyRollTarget)
}
t.Logf("misty: encounters %d→%d, last_seen=%v, roll_target=%d", pre, got.MistyEncounterCount, got.MistyLastSeen, got.MistyRollTarget)
})
// ── 7. Pet state (level + JSON-packed flags) ───────────────────────────
t.Run("pet_state_persists", func(t *testing.T) {
c, _ := loadAdvCharacter(uid)
c.PetType = "fox"
c.PetName = "TestFox"
c.PetXP = 250
c.PetLevel = 3
c.PetArmorTier = 1
c.PetArrived = true
c.PetMorningDefense = true
if err := saveAdvCharacter(c); err != nil {
t.Fatalf("save: %v", err)
}
got, _ := loadAdvCharacter(uid)
if got.PetType != "fox" || got.PetName != "TestFox" {
t.Errorf("pet identity: type=%q name=%q", got.PetType, got.PetName)
}
if got.PetLevel != 3 || got.PetXP != 250 || got.PetArmorTier != 1 {
t.Errorf("pet stats: L%d XP=%d armor=%d", got.PetLevel, got.PetXP, got.PetArmorTier)
}
if !got.PetArrived || !got.PetMorningDefense {
t.Errorf("pet flags lost: arrived=%v morning_def=%v", got.PetArrived, got.PetMorningDefense)
}
t.Logf("pet: %s %q L%d XP=%d armor=T%d arrived=%v morning_def=%v",
got.PetType, got.PetName, got.PetLevel, got.PetXP, got.PetArmorTier,
got.PetArrived, got.PetMorningDefense)
})
// ── 8. House state (loan transitions) ──────────────────────────────────
t.Run("house_state_persists", func(t *testing.T) {
c, _ := loadAdvCharacter(uid)
c.HouseTier = 2
c.HouseLoanBalance = 50000
c.HouseCurrentRate = 6.5
c.HouseAutopay = true
c.HouseMissedPayments = 0
c.HouseLoanFrozen = false
if err := saveAdvCharacter(c); err != nil {
t.Fatalf("save: %v", err)
}
got, _ := loadAdvCharacter(uid)
if got.HouseTier != 2 || got.HouseLoanBalance != 50000 {
t.Errorf("house: tier=%d loan=%d", got.HouseTier, got.HouseLoanBalance)
}
if got.HouseCurrentRate != 6.5 || !got.HouseAutopay {
t.Errorf("house terms: rate=%.2f autopay=%v", got.HouseCurrentRate, got.HouseAutopay)
}
t.Logf("house: T%d loan=€%d rate=%.2f%% autopay=%v", got.HouseTier, got.HouseLoanBalance, got.HouseCurrentRate, got.HouseAutopay)
})
// ── 9. Idempotency: saving the same char twice produces no drift ───────
t.Run("save_idempotent", func(t *testing.T) {
c1, _ := loadAdvCharacter(uid)
_ = saveAdvCharacter(c1)
c2, _ := loadAdvCharacter(uid)
_ = saveAdvCharacter(c2)
c3, _ := loadAdvCharacter(uid)
// Compare a representative cross-section. (Equal pointers like
// DeadUntil get compared by dereferenced value.)
if c2.CombatLevel != c3.CombatLevel || c2.ArenaWins != c3.ArenaWins ||
c2.PetLevel != c3.PetLevel || c2.HouseTier != c3.HouseTier ||
c2.MistyEncounterCount != c3.MistyEncounterCount ||
c2.HospitalVisits != c3.HospitalVisits {
t.Errorf("save not idempotent: c2 vs c3 differs")
}
t.Logf("save idempotency confirmed (CL/arena/pet/house/misty/hospital all stable)")
})
// ── 10. LastActiveAt always advances on save ───────────────────────────
t.Run("last_active_advances", func(t *testing.T) {
c, _ := loadAdvCharacter(uid)
preActive := c.LastActiveAt
time.Sleep(10 * time.Millisecond)
_ = saveAdvCharacter(c)
got, _ := loadAdvCharacter(uid)
if !got.LastActiveAt.After(preActive) {
t.Errorf("last_active didn't advance: pre=%v post=%v", preActive, got.LastActiveAt)
}
t.Logf("last_active: %v → %v (Δ=%v)", preActive, got.LastActiveAt, got.LastActiveAt.Sub(preActive))
})
// ── 11. createAdvCharacter for a brand-new user_id ─────────────────────
t.Run("create_new_character", func(t *testing.T) {
newUID := id.UserID("@simtest:parodia.dev")
if err := createAdvCharacter(newUID, "SimTest"); err != nil {
t.Fatalf("create: %v", err)
}
got, err := loadAdvCharacter(newUID)
if err != nil || got == nil {
t.Fatalf("load fresh: %v", err)
}
if got.DisplayName != "SimTest" {
t.Errorf("display_name: want SimTest got %q", got.DisplayName)
}
if !got.Alive {
t.Error("fresh char should be Alive=true")
}
if got.CreatedAt.IsZero() {
t.Error("created_at should be set on createAdvCharacter")
}
t.Logf("fresh char: name=%q alive=%v created=%v", got.DisplayName, got.Alive, got.CreatedAt)
})
}