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
- `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.

View File

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

View File

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

View File

@@ -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))]

View File

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

View File

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

View File

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

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