mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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>
327 lines
12 KiB
Go
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)
|
|
})
|
|
}
|