Adv 2.0 L4d: pets dual-write pet_* off AdvCharacter to player_meta

Eight pet_* columns added to player_meta (flags packed into pet_flags_json).
PetState + load/upsert/backfill helpers; dual-writes wired at every pet
mutation site (arrival, naming, supply-shop unlock, babysit trickle,
morning defense, misty reactivation). Backfill idempotent, runs on Init.

L4 grep-empty exit criterion intentionally NOT met for adventure_pets.go:
helpers still take *AdventureCharacter because external callers (babysit,
scheduler, npcs, combat_bridge, combat_stats, dnd_sheet, arena, character)
read pet fields directly. Cross-file signature flip belongs after the
dual-write soak.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 10:10:00 -07:00
parent 283f7adf5f
commit 690817f2ed
8 changed files with 395 additions and 0 deletions

View File

@@ -313,6 +313,16 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events,
### 6.4 L4d — Pets ### 6.4 L4d — Pets
- `adventure_pets.go`: pet fields move from AdvCharacter to `player_meta` (see §2.1 `pet_*` columns). Pet combat hooks (PetMorningDefense, pet damage rolls) target DnDCharacter HP. - `adventure_pets.go`: pet fields move from AdvCharacter to `player_meta` (see §2.1 `pet_*` columns). Pet combat hooks (PetMorningDefense, pet damage rolls) target DnDCharacter HP.
**Status (2026-05-09):** SHIPPED on `adv-2.0` (column + dual-write only; reader flip deferred — see note).
- `player_meta` gains `pet_type`, `pet_name`, `pet_xp`, `pet_level`, `pet_armor_tier`, `pet_flags_json`, `pet_supply_shop_unlocked`, `pet_level_10_date` via columnMigration in `internal/db/db.go`. The four flags (arrived, chased_away, reactivated, morning_defense) serialize as a single JSON column to avoid a four-bool ALTER train on each future flag addition.
- New helpers in `player_meta.go`: `PetState` struct, `upsertPlayerMetaPetState(userID, PetState)`, `loadPetState(userID)` (player_meta canonical, falls back to AdvCharacter when `pet_type` is empty), `backfillPlayerMetaPetState()` (idempotent — copies rows whose player_meta `pet_type` is still default empty), and `petStateFromAdvChar(*AdventureCharacter)` for the dual-write projection.
- Dual-writes wired at every pet mutation site: `resolvePetArrival` (chase), `resolvePetName` (adopt), `petMidnightCheck` (supply-shop unlock), `runBabysitDailyTrickle` save in scheduler, morning `PetMorningDefense` set, and `mistyReactivatePet` save in `adventure_npcs.go`.
- `petGrantXP` (in `adventure_pets.go`) is currently dead code — only exercised by tests; once the legacy daily-loop XP path is rewired by Phase R/zone integration, that caller will need to add a dual-write at its save site.
- Backfill: `backfillPlayerMetaPetState()` runs on every Init; idempotent (only fills rows whose `pet_type` is still empty).
- Tests: `TestPlayerMetaPetStateBackfill_Idempotent`, `TestLoadPetState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaPetState_RoundTrip` in `player_meta_test.go`.
- `go vet ./...` + `go test ./...` clean.
- **Reader flip deferred.** The L4 exit criterion (`grep 'AdventureCharacter\|CombatLevel' adventure_pets.go` empty) is NOT met yet: `adventure_pets.go`'s helpers (`petGrantXP`, `petRollCombatActions`, `petRollDitchRecovery`, `petVictoryText`, `petDeathText`, `petShouldArrive`, `petMorningEvent`, `petCheckSupplyShopUnlock`, `mistyHousingHint`, `mistyReactivatePet`) still take `*AdventureCharacter` because they're called from many external files (`adventure_babysit.go`, `adventure_scheduler.go`, `adventure_npcs.go`, `combat_bridge.go`, `combat_stats.go`, `dnd_sheet.go`, `adventure_arena.go`, `adventure_character.go`). Flipping these signatures is a cross-file refactor that can land after the soak window — at which point the helpers can take `userID` (and call `loadPetState`) and the call sites drop their `*AdventureCharacter` access. The DB-level migration (this step) is the prerequisite that unblocks that refactor.
### 6.5 L4e — Housing & mortgage ### 6.5 L4e — Housing & mortgage
- `adventure_housing.go`, `adventure_mortgage.go`: HouseTier/loan fields → `player_meta`. `houseHPBonus` → applied to `DnDCharacter.HPMax` calculation (already a parallel hook in `dnd_sheet.go`'s `recomputeHPMax`). - `adventure_housing.go`, `adventure_mortgage.go`: HouseTier/loan fields → `player_meta`. `houseHPBonus` → applied to `DnDCharacter.HPMax` calculation (already a parallel hook in `dnd_sheet.go`'s `recomputeHPMax`).
- Mortgage scheduler tick stays — only the field source changes. - Mortgage scheduler tick stays — only the field source changes.

View File

@@ -203,6 +203,19 @@ func runMigrations(d *sql.DB) error {
// MasterworkDropsReceived moves to player_meta; AdvCharacter column // MasterworkDropsReceived moves to player_meta; AdvCharacter column
// keeps dual-writing during soak (gogobee_legacy_migration.md §6.3). // keeps dual-writing during soak (gogobee_legacy_migration.md §6.3).
`ALTER TABLE player_meta ADD COLUMN masterwork_drops_received INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE player_meta ADD COLUMN masterwork_drops_received INTEGER NOT NULL DEFAULT 0`,
// Adv 2.0 Phase L4d — Pets migration off AdvCharacter.
// PetType / PetName / PetXP / PetLevel / PetArmorTier and the four
// pet flags (arrived, chased_away, reactivated, morning_defense)
// move to player_meta; AdvCharacter columns keep dual-writing during
// soak (gogobee_legacy_migration.md §6.4).
`ALTER TABLE player_meta ADD COLUMN pet_type TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE player_meta ADD COLUMN pet_name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE player_meta ADD COLUMN pet_xp INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE player_meta ADD COLUMN pet_level INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE player_meta ADD COLUMN pet_armor_tier INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE player_meta ADD COLUMN pet_flags_json TEXT NOT NULL DEFAULT '{}'`,
`ALTER TABLE player_meta ADD COLUMN pet_supply_shop_unlocked INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE player_meta ADD COLUMN pet_level_10_date TEXT NOT NULL DEFAULT ''`,
} }
for _, stmt := range columnMigrations { for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil { if _, err := d.Exec(stmt); err != nil {

View File

@@ -214,6 +214,11 @@ func (p *AdventurePlugin) Init() error {
if err := backfillPlayerMetaMasterworkDrops(); err != nil { if err := backfillPlayerMetaMasterworkDrops(); err != nil {
slog.Error("player_meta: masterwork_drops_received backfill failed", "err", err) slog.Error("player_meta: masterwork_drops_received backfill failed", "err", err)
} }
// Adv 2.0 Phase L4d — one-shot pet state backfill into player_meta.
// Idempotent (only fills rows whose pet_type is still empty).
if err := backfillPlayerMetaPetState(); err != nil {
slog.Error("player_meta: pet state backfill failed", "err", err)
}
// Phase L3 — cancel any open/active legacy coop dungeon runs and // Phase L3 — cancel any open/active legacy coop dungeon runs and
// refund member contributions + unsettled bets. Idempotent. // refund member contributions + unsettled bets. Idempotent.
closeAndRefundLegacyCoopRuns(p.euro) closeAndRefundLegacyCoopRuns(p.euro)

View File

@@ -239,6 +239,7 @@ func (p *AdventurePlugin) resolveMisty(ctx MessageContext, char *AdventureCharac
if err := saveAdvCharacter(char); err != nil { if err := saveAdvCharacter(char); err != nil {
slog.Error("npc: failed to save misty buff", "user", ctx.Sender, "err", err) slog.Error("npc: failed to save misty buff", "user", ctx.Sender, "err", err)
} }
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char))
reply := mistyAcceptLines[rand.IntN(len(mistyAcceptLines))] reply := mistyAcceptLines[rand.IntN(len(mistyAcceptLines))]

View File

@@ -241,6 +241,7 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
char.PetChasedAway = true char.PetChasedAway = true
char.PetReactivated = false char.PetReactivated = false
_ = saveAdvCharacter(char) _ = saveAdvCharacter(char)
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char))
return p.SendDM(ctx.Sender, "You chased it away. It disappeared around the corner and didn't come back.\n\nThe house is quiet again.") return p.SendDM(ctx.Sender, "You chased it away. It disappeared around the corner and didn't come back.\n\nThe house is quiet again.")
} }
@@ -345,6 +346,7 @@ func (p *AdventurePlugin) resolvePetName(ctx MessageContext) error {
if err := saveAdvCharacter(char); err != nil { if err := saveAdvCharacter(char); err != nil {
return p.SendDM(ctx.Sender, "Failed to save.") return p.SendDM(ctx.Sender, "Failed to save.")
} }
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char))
emoji := "🐶" emoji := "🐶"
if data.PetType == "cat" { if data.PetType == "cat" {
@@ -432,6 +434,7 @@ func (p *AdventurePlugin) petMidnightCheck() {
if petCheckSupplyShopUnlock(&char) { if petCheckSupplyShopUnlock(&char) {
char.PetSupplyShopUnlocked = true char.PetSupplyShopUnlocked = true
_ = saveAdvCharacter(&char) _ = saveAdvCharacter(&char)
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(&char))
slog.Info("pet: supply shop unlocked", "user", char.UserID, "pet", char.PetName) slog.Info("pet: supply shop unlocked", "user", char.UserID, "pet", char.PetName)
} }
} }

View File

@@ -95,6 +95,7 @@ func (p *AdventurePlugin) sendMorningDMs() {
if err := saveAdvCharacter(&char); err != nil { if err := saveAdvCharacter(&char); err != nil {
slog.Error("babysit: failed to save after daily trickle", "user", char.UserID, "err", err) slog.Error("babysit: failed to save after daily trickle", "user", char.UserID, "err", err)
} }
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(&char))
continue continue
} }
@@ -124,6 +125,7 @@ func (p *AdventurePlugin) sendMorningDMs() {
if petEvent != "" { if petEvent != "" {
char.PetMorningDefense = true char.PetMorningDefense = true
_ = saveAdvCharacter(&char) _ = saveAdvCharacter(&char)
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(&char))
} }
// Send morning DM with choices // Send morning DM with choices

View File

@@ -2,6 +2,7 @@ package plugin
import ( import (
"database/sql" "database/sql"
"encoding/json"
"log/slog" "log/slog"
"gogobee/internal/db" "gogobee/internal/db"
@@ -383,6 +384,223 @@ func backfillPlayerMetaMasterworkDrops() error {
return nil return nil
} }
// PetState is the in-memory mirror of player_meta's pet_* columns. Phase L4d
// ports pet ownership and progression off AdvCharacter (gogobee_legacy_migration.md
// §6.4). The four flag bools serialize as a single pet_flags_json column to
// avoid an explosion of boolean ALTERs when future pet behaviors land.
type PetState struct {
Type string
Name string
XP int
Level int
ArmorTier int
SupplyShopUnlocked bool
Level10Date string
Arrived bool
ChasedAway bool
Reactivated bool
MorningDefense bool
}
// petFlagsJSON is the on-disk encoding of the four pet bools. Adding a new
// flag is an additive JSON field — old rows decode as false.
type petFlagsJSON struct {
Arrived bool `json:"arrived"`
ChasedAway bool `json:"chased_away"`
Reactivated bool `json:"reactivated"`
MorningDefense bool `json:"morning_defense"`
}
// upsertPlayerMetaPetState writes the full pet column set for a user. Pet
// state mutates as a unit (arrival sets type/name/level/flags together;
// level-ups touch xp/level/level10date), so this helper takes the whole
// PetState rather than splitting per-field. Used by the dual-write path
// during the L4d soak window.
func upsertPlayerMetaPetState(userID id.UserID, s PetState) error {
flags, err := json.Marshal(petFlagsJSON{
Arrived: s.Arrived,
ChasedAway: s.ChasedAway,
Reactivated: s.Reactivated,
MorningDefense: s.MorningDefense,
})
if err != nil {
return err
}
supplyInt := 0
if s.SupplyShopUnlocked {
supplyInt = 1
}
_, err = db.Get().Exec(
`INSERT INTO player_meta (
user_id, pet_type, pet_name, pet_xp, pet_level, pet_armor_tier,
pet_flags_json, pet_supply_shop_unlocked, pet_level_10_date
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
pet_type = excluded.pet_type,
pet_name = excluded.pet_name,
pet_xp = excluded.pet_xp,
pet_level = excluded.pet_level,
pet_armor_tier = excluded.pet_armor_tier,
pet_flags_json = excluded.pet_flags_json,
pet_supply_shop_unlocked = excluded.pet_supply_shop_unlocked,
pet_level_10_date = excluded.pet_level_10_date`,
string(userID), s.Type, s.Name, s.XP, s.Level, s.ArmorTier,
string(flags), supplyInt, s.Level10Date,
)
return err
}
// loadPetState returns the player's pet state from player_meta when populated,
// falling back to adventure_characters during the L4d soak window. A
// player_meta row whose pet_type is empty is treated as unmigrated and falls
// through to AdvCharacter — matching the "type is the canonical
// has-a-pet marker" semantics used by HasPet().
func loadPetState(userID id.UserID) (PetState, error) {
var (
s PetState
flagsRaw string
supplyInt int
)
err := db.Get().QueryRow(
`SELECT pet_type, pet_name, pet_xp, pet_level, pet_armor_tier,
pet_flags_json, pet_supply_shop_unlocked, pet_level_10_date
FROM player_meta WHERE user_id = ?`,
string(userID),
).Scan(&s.Type, &s.Name, &s.XP, &s.Level, &s.ArmorTier,
&flagsRaw, &supplyInt, &s.Level10Date)
if err == nil && s.Type != "" {
s.SupplyShopUnlocked = supplyInt == 1
var f petFlagsJSON
if flagsRaw != "" {
_ = json.Unmarshal([]byte(flagsRaw), &f)
}
s.Arrived = f.Arrived
s.ChasedAway = f.ChasedAway
s.Reactivated = f.Reactivated
s.MorningDefense = f.MorningDefense
return s, nil
}
if err != nil && err != sql.ErrNoRows {
return PetState{}, err
}
// Fallback to AdvCharacter during soak.
var (
legacyType, legacyName, legacyL10Date string
legacyXP, legacyLevel, legacyArmor int
legacySupply int
arrived, chased, reactivated, morningDefense int
)
err = db.Get().QueryRow(
`SELECT pet_type, pet_name, pet_xp, pet_level, pet_armor_tier,
pet_arrived, pet_chased_away, pet_reactivated, pet_morning_defense,
pet_supply_shop_unlocked, pet_level_10_date
FROM adventure_characters WHERE user_id = ?`,
string(userID),
).Scan(&legacyType, &legacyName, &legacyXP, &legacyLevel, &legacyArmor,
&arrived, &chased, &reactivated, &morningDefense,
&legacySupply, &legacyL10Date)
if err == sql.ErrNoRows {
return PetState{}, nil
}
if err != nil {
return PetState{}, err
}
return PetState{
Type: legacyType,
Name: legacyName,
XP: legacyXP,
Level: legacyLevel,
ArmorTier: legacyArmor,
SupplyShopUnlocked: legacySupply == 1,
Level10Date: legacyL10Date,
Arrived: arrived == 1,
ChasedAway: chased == 1,
Reactivated: reactivated == 1,
MorningDefense: morningDefense == 1,
}, nil
}
// backfillPlayerMetaPetState copies the pet_* columns from adventure_characters
// into player_meta for any row whose pet_type is still the empty default.
// Idempotent: only updates rows that haven't been populated yet (via backfill
// or dual-write). Pet flags are encoded as JSON to match the runtime helper.
func backfillPlayerMetaPetState() error {
if _, err := db.Get().Exec(`
INSERT OR IGNORE INTO player_meta (user_id)
SELECT user_id FROM adventure_characters
`); err != nil {
return err
}
rows, err := db.Get().Query(`
SELECT ac.user_id, ac.pet_type, ac.pet_name, ac.pet_xp, ac.pet_level,
ac.pet_armor_tier, ac.pet_arrived, ac.pet_chased_away,
ac.pet_reactivated, ac.pet_morning_defense,
ac.pet_supply_shop_unlocked, ac.pet_level_10_date
FROM adventure_characters ac
JOIN player_meta pm ON pm.user_id = ac.user_id
WHERE pm.pet_type = ''
AND ac.pet_type <> ''
`)
if err != nil {
return err
}
defer rows.Close()
type pending struct {
uid id.UserID
s PetState
}
var todo []pending
for rows.Next() {
var (
uid string
s PetState
arrived, chased, reactivated, morningDefense int
supply int
)
if err := rows.Scan(&uid, &s.Type, &s.Name, &s.XP, &s.Level,
&s.ArmorTier, &arrived, &chased, &reactivated, &morningDefense,
&supply, &s.Level10Date); err != nil {
return err
}
s.Arrived = arrived == 1
s.ChasedAway = chased == 1
s.Reactivated = reactivated == 1
s.MorningDefense = morningDefense == 1
s.SupplyShopUnlocked = supply == 1
todo = append(todo, pending{uid: id.UserID(uid), s: s})
}
if err := rows.Err(); err != nil {
return err
}
for _, p := range todo {
if err := upsertPlayerMetaPetState(p.uid, p.s); err != nil {
return err
}
}
slog.Info("player_meta: pet state backfilled", "rows", len(todo))
return nil
}
// petStateFromAdvChar projects the pet-related fields off an AdventureCharacter
// into a PetState. Used by the dual-write path: every site that mutates
// AdvCharacter pet fields then calls upsertPlayerMetaPetState with this
// projection so the column moves cleanly during the L4d soak window.
func petStateFromAdvChar(c *AdventureCharacter) PetState {
return PetState{
Type: c.PetType,
Name: c.PetName,
XP: c.PetXP,
Level: c.PetLevel,
ArmorTier: c.PetArmorTier,
SupplyShopUnlocked: c.PetSupplyShopUnlocked,
Level10Date: c.PetLevel10Date,
Arrived: c.PetArrived,
ChasedAway: c.PetChasedAway,
Reactivated: c.PetReactivated,
MorningDefense: c.PetMorningDefense,
}
}
// backfillPlayerMetaDisplayName copies adventure_characters.display_name // backfillPlayerMetaDisplayName copies adventure_characters.display_name
// into player_meta.display_name for any row whose display_name is still // into player_meta.display_name for any row whose display_name is still
// the empty default. Idempotent: safe to re-run; only updates rows that // the empty default. Idempotent: safe to re-run; only updates rows that

View File

@@ -533,3 +533,146 @@ func TestUpsertPlayerMetaMasterworkDrops_RoundTrip(t *testing.T) {
t.Errorf("update: got %d want 9", got2) t.Errorf("update: got %d want 9", got2)
} }
} }
// Phase L4d — Pet state migration. Backfill is idempotent and only touches
// rows with empty pet_type; the upsert helper round-trips the full PetState
// (including the four flag bools encoded into pet_flags_json); loadPetState
// falls back to AdvCharacter when player_meta hasn't been populated yet.
func TestPlayerMetaPetStateBackfill_Idempotent(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@meta-pet-bf:example")
if err := createAdvCharacter(uid, "PetOwner"); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
if _, err := db.Get().Exec(
`UPDATE adventure_characters
SET pet_type = ?, pet_name = ?, pet_xp = ?, pet_level = ?,
pet_armor_tier = ?, pet_arrived = ?, pet_chased_away = ?,
pet_reactivated = ?, pet_morning_defense = ?,
pet_supply_shop_unlocked = ?, pet_level_10_date = ?
WHERE user_id = ?`,
"dog", "Rex", 750, 4, 2, 1, 0, 0, 1, 0, "", string(uid),
); err != nil {
t.Fatalf("seed: %v", err)
}
if _, err := db.Get().Exec(
`UPDATE player_meta SET pet_type = '' WHERE user_id = ?`,
string(uid),
); err != nil {
t.Fatalf("clear: %v", err)
}
if err := backfillPlayerMetaPetState(); err != nil {
t.Fatalf("backfill 1: %v", err)
}
got, err := loadPetState(uid)
if err != nil {
t.Fatalf("load: %v", err)
}
if got.Type != "dog" || got.Name != "Rex" || got.XP != 750 || got.Level != 4 ||
got.ArmorTier != 2 || !got.Arrived || got.ChasedAway || got.Reactivated ||
!got.MorningDefense {
t.Errorf("after backfill: got %+v", got)
}
// Layer a dual-write that promotes the pet (level-up + supply shop).
got.Level = 10
got.XP = 0
got.Level10Date = "2026-05-09"
got.SupplyShopUnlocked = true
if err := upsertPlayerMetaPetState(uid, got); err != nil {
t.Fatalf("dual-write: %v", err)
}
if err := backfillPlayerMetaPetState(); err != nil {
t.Fatalf("backfill 2: %v", err)
}
got2, _ := loadPetState(uid)
if got2.Level != 10 || !got2.SupplyShopUnlocked || got2.Level10Date != "2026-05-09" {
t.Errorf("backfill clobbered dual-write: got %+v", got2)
}
}
func TestLoadPetState_FallsBackToAdvCharacter(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@meta-pet-fb:example")
if err := createAdvCharacter(uid, "PetFallback"); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
if _, err := db.Get().Exec(
`UPDATE adventure_characters
SET pet_type = ?, pet_name = ?, pet_xp = ?, pet_level = ?,
pet_arrived = ?, pet_chased_away = ?, pet_reactivated = ?
WHERE user_id = ?`,
"cat", "Whiskers", 50, 2, 1, 1, 1, string(uid),
); err != nil {
t.Fatalf("seed: %v", err)
}
if _, err := db.Get().Exec(
`UPDATE player_meta SET pet_type = '' WHERE user_id = ?`,
string(uid),
); err != nil {
t.Fatalf("clear: %v", err)
}
got, err := loadPetState(uid)
if err != nil {
t.Fatalf("load: %v", err)
}
if got.Type != "cat" || got.Name != "Whiskers" || got.Level != 2 ||
!got.Arrived || !got.ChasedAway || !got.Reactivated {
t.Errorf("fallback: got %+v", got)
}
// Unknown user → zero PetState, no error.
got2, err := loadPetState(id.UserID("@meta-pet-nobody:example"))
if err != nil {
t.Fatalf("load missing: %v", err)
}
if got2.Type != "" || got2.Level != 0 {
t.Errorf("missing user: got %+v", got2)
}
}
func TestUpsertPlayerMetaPetState_RoundTrip(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@meta-pet-rt:example")
in := PetState{
Type: "dog",
Name: "Boon",
XP: 1234,
Level: 7,
ArmorTier: 3,
SupplyShopUnlocked: true,
Level10Date: "2026-05-09",
Arrived: true,
ChasedAway: true,
Reactivated: true,
MorningDefense: true,
}
if err := upsertPlayerMetaPetState(uid, in); err != nil {
t.Fatalf("upsert insert: %v", err)
}
got, err := loadPetState(uid)
if err != nil {
t.Fatalf("load: %v", err)
}
if got != in {
t.Errorf("round-trip mismatch:\n got=%+v\nwant=%+v", got, in)
}
// Mutating one field updates only that field via the same upsert.
in.Level = 8
in.XP = 0
in.MorningDefense = false
if err := upsertPlayerMetaPetState(uid, in); err != nil {
t.Fatalf("upsert update: %v", err)
}
got2, _ := loadPetState(uid)
if got2 != in {
t.Errorf("update mismatch:\n got=%+v\nwant=%+v", got2, in)
}
}