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

@@ -533,3 +533,146 @@ func TestUpsertPlayerMetaMasterworkDrops_RoundTrip(t *testing.T) {
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)
}
}