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:
prosolis
2026-05-09 13:16:27 -07:00
parent 9d6e8f6bf2
commit c8849fd819
4 changed files with 538 additions and 0 deletions

View File

@@ -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 {

View 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
}

View File

@@ -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 {

View 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)
})
}