mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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>
This commit is contained in:
@@ -171,6 +171,11 @@ func (p *AdventurePlugin) Commands() []CommandDef {
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) Init() error {
|
||||
// Bootstrap: migrate any legacy adventure_characters rows that don't yet
|
||||
// have a player_meta row. Idempotent. Required for DBs that didn't go
|
||||
// through the L4-L5h dual-write soak (fresh deploys, restored backups).
|
||||
bootstrapPlayerMetaFromLegacy()
|
||||
|
||||
// Rehydrate DM room mappings for existing characters
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
|
||||
201
internal/plugin/bootstrap_player_meta.go
Normal file
201
internal/plugin/bootstrap_player_meta.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// bootstrapPlayerMetaFromLegacy migrates rows from adventure_characters into
|
||||
// player_meta for any user_id not already present, using the canonical
|
||||
// upsertAllPlayerMetaFromAdvChar fan-out. Idempotent — re-runs touch zero
|
||||
// rows once every legacy character has been migrated.
|
||||
//
|
||||
// Required when deploying L5 close-out code onto a database that hasn't gone
|
||||
// through the L4-L5h dual-write soak (fresh restores from old backups, dev
|
||||
// environments cloned from older snapshots, or — most importantly — a prod
|
||||
// upgrade that skipped the soak window). Without this, the rewired
|
||||
// loadAdvCharacter (which sources from player_meta) returns zero characters
|
||||
// and silently strands every account.
|
||||
//
|
||||
// No-ops post-purge — the sqlite_master existence check short-circuits once
|
||||
// adventure_characters is dropped.
|
||||
func bootstrapPlayerMetaFromLegacy() {
|
||||
d := db.Get()
|
||||
|
||||
var legacyExists int
|
||||
if err := d.QueryRow(
|
||||
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='adventure_characters'`,
|
||||
).Scan(&legacyExists); err != nil || legacyExists == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := d.Query(`
|
||||
SELECT user_id FROM adventure_characters
|
||||
WHERE user_id NOT IN (SELECT user_id FROM player_meta)`)
|
||||
if err != nil {
|
||||
slog.Error("bootstrap: legacy enumeration failed", "err", err)
|
||||
return
|
||||
}
|
||||
var todo []id.UserID
|
||||
for rows.Next() {
|
||||
var uid string
|
||||
if err := rows.Scan(&uid); err != nil {
|
||||
rows.Close()
|
||||
slog.Error("bootstrap: scan failed", "err", err)
|
||||
return
|
||||
}
|
||||
todo = append(todo, id.UserID(uid))
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if len(todo) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
migrated := 0
|
||||
for _, uid := range todo {
|
||||
char, err := loadAdvCharacterFromLegacyTable(uid)
|
||||
if err != nil {
|
||||
slog.Error("bootstrap: failed to load legacy AdvCharacter", "user", uid, "err", err)
|
||||
continue
|
||||
}
|
||||
// Seed the player_meta row first so the fan-out's per-subsystem
|
||||
// upserts have something to ON CONFLICT update.
|
||||
if _, err := d.Exec(
|
||||
`INSERT INTO player_meta (user_id, display_name, created_at, last_active_at, alive)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO NOTHING`,
|
||||
string(uid), char.DisplayName, char.CreatedAt, char.LastActiveAt,
|
||||
boolToInt(char.Alive),
|
||||
); err != nil {
|
||||
slog.Error("bootstrap: failed to seed player_meta", "user", uid, "err", err)
|
||||
continue
|
||||
}
|
||||
if err := upsertAllPlayerMetaFromAdvChar(char); err != nil {
|
||||
slog.Error("bootstrap: fan-out failed", "user", uid, "err", err)
|
||||
continue
|
||||
}
|
||||
migrated++
|
||||
}
|
||||
slog.Warn("bootstrap: legacy AdvCharacter rows migrated to player_meta",
|
||||
"migrated", migrated, "total", len(todo))
|
||||
}
|
||||
|
||||
// loadAdvCharacterFromLegacyTable runs the pre-L5h 70-column SELECT against
|
||||
// adventure_characters. Used only by the bootstrap path; the live loader
|
||||
// goes through player_meta + applyPlayerMetaOverlay.
|
||||
func loadAdvCharacterFromLegacyTable(userID id.UserID) (*AdventureCharacter, error) {
|
||||
d := db.Get()
|
||||
c := &AdventureCharacter{}
|
||||
var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int
|
||||
var deadUntil, reprieveLast, babysitExp, pardonUsed sql.NullTime
|
||||
var mistyLastSeen, arinaLastSeen, mistyBuffExp, mistyDebuffExp, arinaBuffExp sql.NullTime
|
||||
var houseFrozen, houseAutopay int
|
||||
var petChasedAway, petReactivated, petArrived, thomAnimalLine, petSupplyUnlocked, petMorningDef int
|
||||
var autoBabysit, streakDecayed, treasuresLocked int
|
||||
|
||||
err := d.QueryRow(`
|
||||
SELECT user_id, display_name,
|
||||
combat_level, mining_skill, foraging_skill, fishing_skill,
|
||||
combat_xp, mining_xp, foraging_xp, fishing_xp,
|
||||
alive, dead_until, action_taken_today, holiday_action_taken,
|
||||
arena_wins, arena_losses, invasion_score, title,
|
||||
current_streak, best_streak, last_action_date, grudge_location,
|
||||
created_at, last_active_at, death_reprieve_last,
|
||||
masterwork_drops_received,
|
||||
rival_pool, rival_unlocked_notified,
|
||||
babysit_active, babysit_expires_at, babysit_skill_focus,
|
||||
hospital_visits, robbie_visit_count, last_death_date,
|
||||
combat_actions_used, harvest_actions_used,
|
||||
last_pardon_used,
|
||||
misty_last_seen, arina_last_seen,
|
||||
misty_buff_expires, misty_debuff_expires, arina_buff_expires,
|
||||
npc_msg_count, npc_msg_count_date,
|
||||
misty_roll_target, arina_roll_target,
|
||||
house_tier, house_loan_balance, house_loan_frozen, house_missed_payments,
|
||||
house_autopay, house_current_rate,
|
||||
pet_type, pet_name, pet_xp, pet_level, pet_armor_tier,
|
||||
pet_chased_away, pet_reactivated, pet_arrived,
|
||||
misty_encounter_count, misty_donated_count,
|
||||
thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date,
|
||||
pet_morning_defense, auto_babysit, streak_decayed, crafts_succeeded,
|
||||
death_source, death_location, auto_babysit_focus, treasures_locked
|
||||
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
|
||||
&c.UserID, &c.DisplayName,
|
||||
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
||||
&c.CombatXP, &c.MiningXP, &c.ForagingXP, &c.FishingXP,
|
||||
&alive, &deadUntil, &actionTaken, &holidayTaken,
|
||||
&c.ArenaWins, &c.ArenaLosses, &c.InvasionScore, &c.Title,
|
||||
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
|
||||
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
|
||||
&c.MasterworkDropsReceived,
|
||||
&c.RivalPool, &rivalUnlocked,
|
||||
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
|
||||
&c.HospitalVisits, &c.RobbieVisitCount, &c.LastDeathDate,
|
||||
&c.CombatActionsUsed, &c.HarvestActionsUsed,
|
||||
&pardonUsed,
|
||||
&mistyLastSeen, &arinaLastSeen,
|
||||
&mistyBuffExp, &mistyDebuffExp, &arinaBuffExp,
|
||||
&c.NPCMsgCount, &c.NPCMsgCountDate,
|
||||
&c.MistyRollTarget, &c.ArinaRollTarget,
|
||||
&c.HouseTier, &c.HouseLoanBalance, &houseFrozen, &c.HouseMissedPayments,
|
||||
&houseAutopay, &c.HouseCurrentRate,
|
||||
&c.PetType, &c.PetName, &c.PetXP, &c.PetLevel, &c.PetArmorTier,
|
||||
&petChasedAway, &petReactivated, &petArrived,
|
||||
&c.MistyEncounterCount, &c.MistyDonatedCount,
|
||||
&thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date,
|
||||
&petMorningDef, &autoBabysit, &streakDecayed, &c.CraftsSucceeded,
|
||||
&c.DeathSource, &c.DeathLocation, &c.AutoBabysitFocus, &treasuresLocked,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Alive = alive == 1
|
||||
c.ActionTakenToday = actionTaken == 1
|
||||
c.HolidayActionTaken = holidayTaken == 1
|
||||
c.RivalUnlockedNotified = rivalUnlocked == 1
|
||||
c.BabysitActive = babysitAct == 1
|
||||
c.HouseLoanFrozen = houseFrozen == 1
|
||||
c.HouseAutopay = houseAutopay == 1
|
||||
c.PetChasedAway = petChasedAway == 1
|
||||
c.PetReactivated = petReactivated == 1
|
||||
c.PetArrived = petArrived == 1
|
||||
c.ThomAnimalLineFired = thomAnimalLine == 1
|
||||
c.PetSupplyShopUnlocked = petSupplyUnlocked == 1
|
||||
c.PetMorningDefense = petMorningDef == 1
|
||||
c.AutoBabysit = autoBabysit == 1
|
||||
c.StreakDecayed = streakDecayed == 1
|
||||
c.TreasuresLocked = treasuresLocked == 1
|
||||
if deadUntil.Valid {
|
||||
c.DeadUntil = &deadUntil.Time
|
||||
}
|
||||
if reprieveLast.Valid {
|
||||
c.DeathReprieveLast = &reprieveLast.Time
|
||||
}
|
||||
if babysitExp.Valid {
|
||||
c.BabysitExpiresAt = &babysitExp.Time
|
||||
}
|
||||
if pardonUsed.Valid {
|
||||
c.LastPardonUsed = &pardonUsed.Time
|
||||
}
|
||||
if mistyLastSeen.Valid {
|
||||
c.MistyLastSeen = &mistyLastSeen.Time
|
||||
}
|
||||
if arinaLastSeen.Valid {
|
||||
c.ArinaLastSeen = &arinaLastSeen.Time
|
||||
}
|
||||
if mistyBuffExp.Valid {
|
||||
c.MistyBuffExpires = &mistyBuffExp.Time
|
||||
}
|
||||
if mistyDebuffExp.Valid {
|
||||
c.MistyDebuffExpires = &mistyDebuffExp.Time
|
||||
}
|
||||
if arinaBuffExp.Valid {
|
||||
c.ArinaBuffExpires = &arinaBuffExp.Time
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -35,6 +35,12 @@ func TestProdDB_DnDLayer(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
// Mirror AdventurePlugin.Init: bootstrap legacy adventure_characters rows
|
||||
// into player_meta for any user_id not yet present. Required for DBs that
|
||||
// haven't gone through the L4-L5h dual-write soak (the L5 close-out
|
||||
// rewired loadAdvCharacter to source from player_meta only).
|
||||
bootstrapPlayerMetaFromLegacy()
|
||||
|
||||
// 1. Load every adventure character. They must scan without error.
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
|
||||
326
internal/plugin/proddb_simulation_test.go
Normal file
326
internal/plugin/proddb_simulation_test.go
Normal file
@@ -0,0 +1,326 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user