From d5b4834d44a76810465a7ee6457e38aa31ec622b Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 9 May 2026 11:20:24 -0700 Subject: [PATCH] Adv 2.0 L5b: babysit state migration off AdvCharacter to player_meta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five player_meta columns (babysit_active, babysit_expires_at, babysit_skill_focus, auto_babysit, auto_babysit_focus). BabysitState struct with IsActive() marker mirrors SkillState/HouseState shape. loadBabysitState / upsertPlayerMetaBabysitState / backfillPlayerMetaBabysitState / babysitStateFromAdvChar helpers. Dual-writes wired at handleBabysitStart, handleBabysitCancel, checkBabysitExpiry. Backfill is idempotent — only fills inactive rows whose legacy counterpart is active. AutoBabysit / AutoBabysitFocus / BabysitSkillFocus are dormant in current code but preserved for the schema migration. Tests: TestPlayerMetaBabysitStateBackfill_Idempotent, TestLoadBabysitState_FallsBackToAdvCharacter, TestUpsertPlayerMetaBabysitState_RoundTrip. Co-Authored-By: Claude Opus 4.7 (1M context) --- gogobee_legacy_migration.md | 2 +- internal/db/db.go | 9 ++ internal/plugin/adventure.go | 5 + internal/plugin/adventure_babysit.go | 9 ++ internal/plugin/player_meta.go | 160 +++++++++++++++++++++++++++ internal/plugin/player_meta_test.go | 141 +++++++++++++++++++++++ 6 files changed, 325 insertions(+), 1 deletion(-) diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index 3b82f00..7965c71 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -416,7 +416,7 @@ Each sub-phase is the same shape as L4a–L4e: column-add → backfill (idempote | Sub-phase | Bucket | Sizing | Notes | |---|---|---|---| | **L5a** | Skills (8 fields) | 1 day | `SkillState` struct mirroring `PetState`/`HouseState`. Mutation surface is small: `checkAdvLevelUp(char, skill)` + the few sites that bump `*XP` directly. Reader flip unblocks dnd_sheet's legacy display row and `babysitDailyCost`. CombatLevel rejoins this struct as a transitional column — dropped at teardown after the DnD mass-backfill. **Status (2026-05-09):** column + dual-write SHIPPED on `adv-2.0`. Eight `combat_*` / `mining_*` / `foraging_*` / `fishing_*` columns added via columnMigration. `SkillState` struct + `HasSkills()` / `loadSkillState` / `upsertPlayerMetaSkillState` / `backfillPlayerMetaSkillState` / `skillStateFromAdvChar` helpers in `player_meta.go`. Dual-writes wired at every mutation site: `adventure_consumables.go` (craft success + craft failure XP grant), `adventure_events.go` (event XP grant across combat/mining/foraging), `adventure_arena.go::resolveArenaDeath` + `arenaCompleteSession` (combat XP + level-up). Backfill `backfillPlayerMetaSkillState()` wired into Init, idempotent (only fills rows where every skill column is still zero AND the legacy row has at least one non-zero skill/XP). Tests: `TestPlayerMetaSkillStateBackfill_Idempotent`, `TestLoadSkillState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaSkillState_RoundTrip` in `player_meta_test.go`. `go vet ./... && go test ./...` clean. **Reader flip deferred** — bundled with cutting AdvCharacter writes (post-soak), per the L4e pattern. | -| **L5b** | Babysit state (5 fields) | 0.5 day | Mutation surface is `runBabysitDailyTrickle` save in scheduler + the babysit toggle handlers. Tightly scoped. | +| **L5b** | Babysit state (5 fields) | 0.5 day | Mutation surface is `runBabysitDailyTrickle` save in scheduler + the babysit toggle handlers. Tightly scoped. **Status (2026-05-09):** column + dual-write SHIPPED on `adv-2.0`. Five columns (`babysit_active`, `babysit_expires_at` DATETIME, `babysit_skill_focus`, `auto_babysit`, `auto_babysit_focus`) added via columnMigration. `BabysitState` + `IsActive()` + load/upsert/backfill/projection helpers in `player_meta.go`. Dual-writes wired at `handleBabysitStart`, `handleBabysitCancel`, `checkBabysitExpiry`. Backfill idempotent — only fills inactive rows whose legacy counterpart is active. AutoBabysit/AutoBabysitFocus + BabysitSkillFocus are dormant fields (no live mutation site) preserved for the schema migration. Tests: `TestPlayerMetaBabysitStateBackfill_Idempotent`, `TestLoadBabysitState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaBabysitState_RoundTrip`. `go vet ./... && go test ./...` clean. **Reader flip deferred** per L4e/L5a pattern. | | **L5c** | NPC counters/debuffs (~13 fields) | 1 day | "Later phase" referenced in L4d note. Pack the four `*Expires` and three `*LastSeen` timestamps as nullable columns; counters as ints. The roll-targets are write-once-per-encounter so dual-write is cheap. **Hidden discovery mechanic — never surface in player-facing output.** | | **L5d** | Streak + action state + lifecycle (10 fields) | 1 day | Action state mutates every action, so the dual-write hook is hot — ensure the upsert is in the same code path that already does `saveAdvCharacter`. | | **L5e** | Death state (8 fields) | 1 day | Decision point: do `DnDCharacter.HPCurrent == 0` semantics replace `Alive`/`DeadUntil`, or do we keep them as `player_meta.alive` / `player_meta.dead_until`? Recommendation: keep them as columns. The hospital revive path (L4a) already restores `DnDCharacter.HPCurrent = HPMax` alongside `Alive=true`, so the two are kept in sync; flipping the readers is the migration, not the death-state semantics. | diff --git a/internal/db/db.go b/internal/db/db.go index 7b7e67c..849b5d8 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -241,6 +241,15 @@ func runMigrations(d *sql.DB) error { `ALTER TABLE player_meta ADD COLUMN foraging_xp INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE player_meta ADD COLUMN fishing_skill INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE player_meta ADD COLUMN fishing_xp INTEGER NOT NULL DEFAULT 0`, + // Adv 2.0 Phase L5b — Babysit state migration off AdvCharacter. + // BabysitActive / BabysitExpiresAt / BabysitSkillFocus / AutoBabysit / + // AutoBabysitFocus move to player_meta; AdvCharacter columns keep + // dual-writing during soak (gogobee_legacy_migration.md §7.3 L5b). + `ALTER TABLE player_meta ADD COLUMN babysit_active INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN babysit_expires_at DATETIME`, + `ALTER TABLE player_meta ADD COLUMN babysit_skill_focus TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE player_meta ADD COLUMN auto_babysit INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN auto_babysit_focus 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 a772dd1..0568296 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -230,6 +230,11 @@ func (p *AdventurePlugin) Init() error { if err := backfillPlayerMetaSkillState(); err != nil { slog.Error("player_meta: skill state backfill failed", "err", err) } + // Adv 2.0 Phase L5b — one-shot babysit state backfill into player_meta. + // Idempotent (only fills inactive rows whose legacy counterpart is active). + if err := backfillPlayerMetaBabysitState(); err != nil { + slog.Error("player_meta: babysit 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_babysit.go b/internal/plugin/adventure_babysit.go index d253e2f..4e1c2af 100644 --- a/internal/plugin/adventure_babysit.go +++ b/internal/plugin/adventure_babysit.go @@ -143,6 +143,9 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund") return p.SendDM(ctx.Sender, "Something went wrong activating the service. Your gold has been refunded.") } + if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil { + slog.Error("player_meta: babysit start dual-write failed", "user", char.UserID, "err", err) + } confirm := pickBabysitFlavor(babysitConfirmLines) durLabel := "1 week" @@ -234,6 +237,9 @@ func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error { if err := saveAdvCharacter(char); err != nil { slog.Error("babysit: failed to save character on cancel", "user", char.UserID, "err", err) } + if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil { + slog.Error("player_meta: babysit cancel dual-write failed", "user", char.UserID, "err", err) + } return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+summary) } @@ -263,6 +269,9 @@ func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) { slog.Error("babysit: failed to save character on expiry", "user", char.UserID, "err", err) continue } + if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(&char)); err != nil { + slog.Error("player_meta: babysit expiry dual-write failed", "user", char.UserID, "err", err) + } if err := p.SendDM(char.UserID, summary); err != nil { slog.Error("babysit: failed to send expiry summary DM", "user", char.UserID, "err", err) diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index 6cd05cf..bb8f16b 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "log/slog" + "time" "gogobee/internal/db" "maunium.net/go/mautrix/id" @@ -927,6 +928,165 @@ func skillStateFromAdvChar(c *AdventureCharacter) SkillState { } } +// BabysitState is the in-memory mirror of player_meta's babysit columns. +// Phase L5b ports babysit state off AdvCharacter (gogobee_legacy_migration.md +// §7.3 L5b). Five fields: the active service (Active + ExpiresAt + +// SkillFocus — legacy slot, no longer mutated) and the auto-babysit +// preference (AutoBabysit + AutoBabysitFocus, dormant in current code but +// preserved for the schema migration). +type BabysitState struct { + Active bool + ExpiresAt *time.Time + SkillFocus string + AutoBabysit bool + AutoBabysitFocus string +} + +// IsActive returns true when the babysit service is on. Used as the "row +// is migrated" marker in loadBabysitState; an idle row is +// indistinguishable from a never-populated row, so it falls through to the +// legacy table during the soak window. +func (s BabysitState) IsActive() bool { return s.Active } + +// upsertPlayerMetaBabysitState writes the full babysit column set for a +// user. Used by the dual-write path during the L5b soak window. +func upsertPlayerMetaBabysitState(userID id.UserID, s BabysitState) error { + active := 0 + if s.Active { + active = 1 + } + auto := 0 + if s.AutoBabysit { + auto = 1 + } + var expires interface{} + if s.ExpiresAt != nil { + expires = s.ExpiresAt.UTC() + } + _, err := db.Get().Exec( + `INSERT INTO player_meta ( + user_id, babysit_active, babysit_expires_at, babysit_skill_focus, + auto_babysit, auto_babysit_focus + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + babysit_active = excluded.babysit_active, + babysit_expires_at = excluded.babysit_expires_at, + babysit_skill_focus = excluded.babysit_skill_focus, + auto_babysit = excluded.auto_babysit, + auto_babysit_focus = excluded.auto_babysit_focus`, + string(userID), active, expires, s.SkillFocus, auto, s.AutoBabysitFocus, + ) + return err +} + +// loadBabysitState returns the player's babysit state from player_meta +// when active, falling back to adventure_characters during the L5b soak +// window. An inactive row falls through to the legacy table since an +// inactive player_meta row is indistinguishable from an unmigrated one. +func loadBabysitState(userID id.UserID) (BabysitState, error) { + var ( + s BabysitState + activeInt int + autoInt int + expires sql.NullTime + ) + err := db.Get().QueryRow( + `SELECT babysit_active, babysit_expires_at, babysit_skill_focus, + auto_babysit, auto_babysit_focus + FROM player_meta WHERE user_id = ?`, + string(userID), + ).Scan(&activeInt, &expires, &s.SkillFocus, &autoInt, &s.AutoBabysitFocus) + if err == nil && activeInt == 1 { + s.Active = true + s.AutoBabysit = autoInt == 1 + if expires.Valid { + t := expires.Time.UTC() + s.ExpiresAt = &t + } + return s, nil + } + if err != nil && err != sql.ErrNoRows { + return BabysitState{}, err + } + // Fallback to AdvCharacter during soak. + var ( + legacyActive int + legacyExpires sql.NullTime + legacyFocus string + legacyAuto int + legacyAutoFoc string + ) + err = db.Get().QueryRow( + `SELECT babysit_active, babysit_expires_at, babysit_skill_focus, + auto_babysit, auto_babysit_focus + FROM adventure_characters WHERE user_id = ?`, + string(userID), + ).Scan(&legacyActive, &legacyExpires, &legacyFocus, &legacyAuto, &legacyAutoFoc) + if err == sql.ErrNoRows { + return BabysitState{}, nil + } + if err != nil { + return BabysitState{}, err + } + out := BabysitState{ + Active: legacyActive == 1, + SkillFocus: legacyFocus, + AutoBabysit: legacyAuto == 1, + AutoBabysitFocus: legacyAutoFoc, + } + if legacyExpires.Valid { + t := legacyExpires.Time.UTC() + out.ExpiresAt = &t + } + return out, nil +} + +// backfillPlayerMetaBabysitState copies the babysit columns from +// adventure_characters into player_meta for any row whose babysit_active +// is still 0 AND the legacy row has babysit_active = 1. Idempotent: only +// fills inactive rows whose legacy counterpart is active. +func backfillPlayerMetaBabysitState() error { + if _, err := db.Get().Exec(` + INSERT OR IGNORE INTO player_meta (user_id) + SELECT user_id FROM adventure_characters + `); err != nil { + return err + } + res, err := db.Get().Exec(` + UPDATE player_meta + SET babysit_active = (SELECT babysit_active FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + babysit_expires_at = (SELECT babysit_expires_at FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + babysit_skill_focus = (SELECT babysit_skill_focus FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + auto_babysit = (SELECT auto_babysit FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + auto_babysit_focus = (SELECT auto_babysit_focus FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id) + WHERE babysit_active = 0 + AND EXISTS ( + SELECT 1 FROM adventure_characters ac + WHERE ac.user_id = player_meta.user_id + AND ac.babysit_active = 1 + ) + `) + if err != nil { + return err + } + n, _ := res.RowsAffected() + slog.Info("player_meta: babysit state backfilled", "rows", n) + return nil +} + +// babysitStateFromAdvChar projects the babysit-related fields off an +// AdventureCharacter into a BabysitState. Used by the dual-write path +// during the L5b soak window. +func babysitStateFromAdvChar(c *AdventureCharacter) BabysitState { + return BabysitState{ + Active: c.BabysitActive, + ExpiresAt: c.BabysitExpiresAt, + SkillFocus: c.BabysitSkillFocus, + AutoBabysit: c.AutoBabysit, + AutoBabysitFocus: c.AutoBabysitFocus, + } +} + // 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 1fb1479..edb1a83 100644 --- a/internal/plugin/player_meta_test.go +++ b/internal/plugin/player_meta_test.go @@ -2,6 +2,7 @@ package plugin import ( "testing" + "time" "gogobee/internal/db" "maunium.net/go/mautrix/id" @@ -900,6 +901,146 @@ func TestUpsertPlayerMetaSkillState_RoundTrip(t *testing.T) { } } +func TestPlayerMetaBabysitStateBackfill_Idempotent(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-baby-bf:example") + if err := createAdvCharacter(uid, "Sitter"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + expires := time.Now().UTC().Add(7 * 24 * time.Hour).Truncate(time.Second) + if _, err := db.Get().Exec( + `UPDATE adventure_characters + SET babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?, + auto_babysit = ?, auto_babysit_focus = ? + WHERE user_id = ?`, + 1, expires, "mining", 1, "fishing", string(uid), + ); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE player_meta SET babysit_active = 0, babysit_expires_at = NULL, + babysit_skill_focus = '', auto_babysit = 0, auto_babysit_focus = '' WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + if err := backfillPlayerMetaBabysitState(); err != nil { + t.Fatalf("backfill 1: %v", err) + } + got, err := loadBabysitState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if !got.Active || got.SkillFocus != "mining" || !got.AutoBabysit || got.AutoBabysitFocus != "fishing" { + t.Errorf("after backfill: got %+v", got) + } + if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expires) { + t.Errorf("expires_at: got %v want %v", got.ExpiresAt, expires) + } + + // Layer a dual-write: cancel. + got.Active = false + got.ExpiresAt = nil + got.SkillFocus = "" + if err := upsertPlayerMetaBabysitState(uid, got); err != nil { + t.Fatalf("dual-write: %v", err) + } + if err := backfillPlayerMetaBabysitState(); err != nil { + t.Fatalf("backfill 2: %v", err) + } + got2, _ := loadBabysitState(uid) + if got2.Active { + t.Errorf("backfill clobbered cancel: got %+v", got2) + } +} + +func TestLoadBabysitState_FallsBackToAdvCharacter(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-baby-fb:example") + if err := createAdvCharacter(uid, "Faller"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + expires := time.Now().UTC().Add(30 * 24 * time.Hour).Truncate(time.Second) + if _, err := db.Get().Exec( + `UPDATE adventure_characters + SET babysit_active = ?, babysit_expires_at = ? + WHERE user_id = ?`, + 1, expires, string(uid), + ); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE player_meta SET babysit_active = 0, babysit_expires_at = NULL WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + got, err := loadBabysitState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if !got.Active { + t.Errorf("fallback should be active: got %+v", got) + } + if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expires) { + t.Errorf("expires_at: got %v want %v", got.ExpiresAt, expires) + } + + // Unknown user → zero state, no error. + got2, err := loadBabysitState(id.UserID("@meta-baby-nobody:example")) + if err != nil { + t.Fatalf("load missing: %v", err) + } + if got2.Active { + t.Errorf("missing user: got %+v", got2) + } +} + +func TestUpsertPlayerMetaBabysitState_RoundTrip(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-baby-rt:example") + expires := time.Now().UTC().Add(14 * 24 * time.Hour).Truncate(time.Second) + in := BabysitState{ + Active: true, + ExpiresAt: &expires, + SkillFocus: "foraging", + AutoBabysit: true, + AutoBabysitFocus: "mining", + } + if err := upsertPlayerMetaBabysitState(uid, in); err != nil { + t.Fatalf("upsert insert: %v", err) + } + got, err := loadBabysitState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if !got.Active || got.SkillFocus != "foraging" || !got.AutoBabysit || got.AutoBabysitFocus != "mining" { + t.Errorf("round-trip mismatch: got %+v", got) + } + if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expires) { + t.Errorf("expires_at: got %v want %v", got.ExpiresAt, expires) + } + + // Cancel → inactive falls through, but verify upsert wrote 0. + in.Active = false + in.ExpiresAt = nil + if err := upsertPlayerMetaBabysitState(uid, in); err != nil { + t.Fatalf("upsert cancel: %v", err) + } + var activeInt int + if err := db.Get().QueryRow(`SELECT babysit_active FROM player_meta WHERE user_id = ?`, string(uid)).Scan(&activeInt); err != nil { + t.Fatalf("verify cancel: %v", err) + } + if activeInt != 0 { + t.Errorf("cancel should write 0: got %d", activeInt) + } +} + func TestUpsertPlayerMetaHouseState_RoundTrip(t *testing.T) { setupAuditTestDB(t)