From 690817f2ed97cc498144079df60369b84ea36566 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 9 May 2026 10:10:00 -0700 Subject: [PATCH] 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) --- gogobee_legacy_migration.md | 10 ++ internal/db/db.go | 13 ++ internal/plugin/adventure.go | 5 + internal/plugin/adventure_npcs.go | 1 + internal/plugin/adventure_pets.go | 3 + internal/plugin/adventure_scheduler.go | 2 + internal/plugin/player_meta.go | 218 +++++++++++++++++++++++++ internal/plugin/player_meta_test.go | 143 ++++++++++++++++ 8 files changed, 395 insertions(+) diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index 74c18f4..b716e78 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -313,6 +313,16 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events, ### 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. +**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 - `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. diff --git a/internal/db/db.go b/internal/db/db.go index 42b7cd4..83104a1 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -203,6 +203,19 @@ func runMigrations(d *sql.DB) error { // MasterworkDropsReceived moves to player_meta; AdvCharacter column // 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`, + // 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 { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index b81c6c3..edce0a3 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -214,6 +214,11 @@ func (p *AdventurePlugin) Init() error { if err := backfillPlayerMetaMasterworkDrops(); err != nil { 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 // refund member contributions + unsettled bets. Idempotent. closeAndRefundLegacyCoopRuns(p.euro) diff --git a/internal/plugin/adventure_npcs.go b/internal/plugin/adventure_npcs.go index c150700..3e96c93 100644 --- a/internal/plugin/adventure_npcs.go +++ b/internal/plugin/adventure_npcs.go @@ -239,6 +239,7 @@ func (p *AdventurePlugin) resolveMisty(ctx MessageContext, char *AdventureCharac if err := saveAdvCharacter(char); err != nil { slog.Error("npc: failed to save misty buff", "user", ctx.Sender, "err", err) } + _ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char)) reply := mistyAcceptLines[rand.IntN(len(mistyAcceptLines))] diff --git a/internal/plugin/adventure_pets.go b/internal/plugin/adventure_pets.go index fc568cb..00c1781 100644 --- a/internal/plugin/adventure_pets.go +++ b/internal/plugin/adventure_pets.go @@ -241,6 +241,7 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error { char.PetChasedAway = true char.PetReactivated = false _ = 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.") } @@ -345,6 +346,7 @@ func (p *AdventurePlugin) resolvePetName(ctx MessageContext) error { if err := saveAdvCharacter(char); err != nil { return p.SendDM(ctx.Sender, "Failed to save.") } + _ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char)) emoji := "🐶" if data.PetType == "cat" { @@ -432,6 +434,7 @@ func (p *AdventurePlugin) petMidnightCheck() { if petCheckSupplyShopUnlock(&char) { char.PetSupplyShopUnlocked = true _ = saveAdvCharacter(&char) + _ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(&char)) slog.Info("pet: supply shop unlocked", "user", char.UserID, "pet", char.PetName) } } diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index e838377..9faaf32 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -95,6 +95,7 @@ func (p *AdventurePlugin) sendMorningDMs() { if err := saveAdvCharacter(&char); err != nil { slog.Error("babysit: failed to save after daily trickle", "user", char.UserID, "err", err) } + _ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(&char)) continue } @@ -124,6 +125,7 @@ func (p *AdventurePlugin) sendMorningDMs() { if petEvent != "" { char.PetMorningDefense = true _ = saveAdvCharacter(&char) + _ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(&char)) } // Send morning DM with choices diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index 411d54d..d0451fe 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -2,6 +2,7 @@ package plugin import ( "database/sql" + "encoding/json" "log/slog" "gogobee/internal/db" @@ -383,6 +384,223 @@ func backfillPlayerMetaMasterworkDrops() error { 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 // 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 diff --git a/internal/plugin/player_meta_test.go b/internal/plugin/player_meta_test.go index b3e9105..6121efc 100644 --- a/internal/plugin/player_meta_test.go +++ b/internal/plugin/player_meta_test.go @@ -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) + } +}