diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 761f33a..611aa13 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -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 { diff --git a/internal/plugin/bootstrap_player_meta.go b/internal/plugin/bootstrap_player_meta.go new file mode 100644 index 0000000..cf977b9 --- /dev/null +++ b/internal/plugin/bootstrap_player_meta.go @@ -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 +} diff --git a/internal/plugin/dnd_proddb_integration_test.go b/internal/plugin/dnd_proddb_integration_test.go index 7a6e70b..b44a1f0 100644 --- a/internal/plugin/dnd_proddb_integration_test.go +++ b/internal/plugin/dnd_proddb_integration_test.go @@ -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 { diff --git a/internal/plugin/proddb_simulation_test.go b/internal/plugin/proddb_simulation_test.go new file mode 100644 index 0000000..51dd5d2 --- /dev/null +++ b/internal/plugin/proddb_simulation_test.go @@ -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) + }) +}