From df886ab86fa84ebfd70fad02850e1516a1a61804 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 9 May 2026 11:35:41 -0700 Subject: [PATCH] Adv 2.0 L5f: misc fields (Title, TreasuresLocked, CraftsSucceeded) migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three player_meta columns (title TEXT, treasures_locked INT, crafts_succeeded INT). MiscState struct + load/upsert/backfill/ projection helpers. Dual-write rides saveAdvCharacter alongside the L5e death-state hook — Title is dormant, the other two already mutate at sites that save. Tests: TestPlayerMetaMiscStateBackfill_Idempotent, TestLoadMiscState_FallsBackToAdvCharacter, TestUpsertPlayerMetaMiscState_RoundTrip. Co-Authored-By: Claude Opus 4.7 (1M context) --- gogobee_legacy_migration.md | 2 +- internal/db/db.go | 6 ++ internal/plugin/adventure.go | 5 ++ internal/plugin/adventure_character.go | 5 ++ internal/plugin/player_meta.go | 98 ++++++++++++++++++++++++++ internal/plugin/player_meta_test.go | 91 ++++++++++++++++++++++++ 6 files changed, 206 insertions(+), 1 deletion(-) diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index 2e0a8a1..d92f0e9 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -420,7 +420,7 @@ Each sub-phase is the same shape as L4a–L4e: column-add → backfill (idempote | **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.** **Status (2026-05-09):** column + dual-write SHIPPED on `adv-2.0`. Thirteen columns added (5 nullable DATETIME for Misty/Arina last-seen + buff/debuff expiries, 1 int + 1 text for npc_msg counters, 2 ints for roll targets, 4 ints for encounter/donated/thom-fired/robbie counters). `NPCState` + `HasNPCActivity()` + load/upsert/backfill/projection helpers. Dual-writes wired at `processNPCEncounters` (msg-count save), `npcFireEncounter` (last-seen save), `resolveMisty` (insufficient balance, debit failure, buff/donated, declined-debuff), `resolveArina` (buff), `adventure_robbie.go` (visit count), `adventure_housing.go::handleThomGreet` (animal line fired). Backfill idempotent. Tests added. `go vet ./... && go test ./...` clean. **Reader flip deferred** per pattern. | | **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`. **Status (2026-05-09):** column + dual-write SHIPPED on `adv-2.0`. Ten columns added (current_streak, best_streak, last_action_date, streak_decayed, action_taken_today, holiday_action_taken, combat_actions_used, harvest_actions_used, created_at, last_active_at). `LifecycleState` + `HasLifecycle()` + load/upsert/backfill/projection helpers. Mutation surface turned out to be tiny: only `rest` (adventure.go) sets ActionTakenToday — combat/harvest counts are dormant — plus the streak halve + streak update in scheduler, and the bulk daily reset. Dual-writes wired at all four sites; new `resetAllPlayerMetaDailyActions` parallels `resetAllAdvDailyActions`. `createAdvCharacter` now seeds `created_at` + `last_active_at` (CURRENT_TIMESTAMP) so player_meta rows are fully formed at creation. Backfill idempotent (only fills rows whose `created_at` is still NULL). Tests: `TestPlayerMetaLifecycleStateBackfill_Idempotent`, `TestLoadLifecycleState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaLifecycleState_RoundTrip`, `TestResetAllPlayerMetaDailyActions`. `go vet ./... && go test ./...` clean. **Reader flip deferred** per pattern. | | **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. **Status (2026-05-09):** column + dual-write SHIPPED on `adv-2.0`. Eight columns added (alive INTEGER DEFAULT 1, dead_until/death_reprieve_last/last_pardon_used DATETIME, last_death_date/grudge_location/death_source/death_location TEXT). `DeathState` + load/upsert/backfill/projection helpers. **Strategy switch:** dual-write moved inside `saveAdvCharacter` itself (after the existing display_name dual-write). The mutation surface for death state spans ~50 save sites — per-site upserts would be too noisy. Every `saveAdvCharacter` call now propagates `DeathState` to player_meta. Backfill idempotent (only fills rows where every death field is still default). Tests: `TestPlayerMetaDeathStateBackfill_Idempotent`, `TestLoadDeathState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaDeathState_RoundTrip`, `TestSaveAdvCharacter_DualWritesDeathState`. `go vet ./... && go test ./...` clean. | -| **L5f** | Misc (`Title`, `TreasuresLocked`, `CraftsSucceeded`) | 0.5 day | Each is a single-mutation-site field. | +| **L5f** | Misc (`Title`, `TreasuresLocked`, `CraftsSucceeded`) | 0.5 day | Each is a single-mutation-site field. **Status (2026-05-09):** column + dual-write SHIPPED on `adv-2.0`. Three columns (title TEXT, treasures_locked INT, crafts_succeeded INT). `MiscState` + load/upsert/backfill/projection helpers. Dual-write rides `saveAdvCharacter` alongside L5e (Title is dormant; TreasuresLocked + CraftsSucceeded mutate at known sites that already save). Backfill idempotent. Tests: `TestPlayerMetaMiscStateBackfill_Idempotent`, `TestLoadMiscState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaMiscState_RoundTrip`. `go vet ./... && go test ./...` clean. | | **L5g** | DnDCharacter mass-backfill | 0.5 day | One-shot: for every active user without a D&D row, insert a default-class character at `dndLevelFromCombatLevel(adventure_characters.combat_level)`. Run once, idempotent (skip rows that already exist). After this lands, drop the `dndLevelFromCombatLevel` fallback branch in `loadHospitalVisits`/`rivalLevelForUser`/`dndLevelForUser`/zone gates. CombatLevel column in player_meta becomes unused at this point — drop it during the L5h cleanup. | | **L5h** | L4e in-place housing reader flip + cut all AdvCharacter dual-writes | 1 day | Bundled per L4e note. Once writes stop, AdvCharacter rows go cold; dual-write removal is mechanical (delete `saveAdvCharacter` calls and the upserts that follow them in pairs). | diff --git a/internal/db/db.go b/internal/db/db.go index f35a50f..87505b3 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -300,6 +300,12 @@ func runMigrations(d *sql.DB) error { `ALTER TABLE player_meta ADD COLUMN grudge_location TEXT NOT NULL DEFAULT ''`, `ALTER TABLE player_meta ADD COLUMN death_source TEXT NOT NULL DEFAULT ''`, `ALTER TABLE player_meta ADD COLUMN death_location TEXT NOT NULL DEFAULT ''`, + // Adv 2.0 Phase L5f — Misc fields (Title, TreasuresLocked, + // CraftsSucceeded) off AdvCharacter (gogobee_legacy_migration.md + // §7.3 L5f). Dual-write inside saveAdvCharacter (same as L5e). + `ALTER TABLE player_meta ADD COLUMN title TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE player_meta ADD COLUMN treasures_locked INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN crafts_succeeded INTEGER NOT NULL DEFAULT 0`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 8bc7be0..514b1dc 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -250,6 +250,11 @@ func (p *AdventurePlugin) Init() error { if err := backfillPlayerMetaDeathState(); err != nil { slog.Error("player_meta: death state backfill failed", "err", err) } + // Adv 2.0 Phase L5f — one-shot misc state backfill into player_meta. + // Idempotent (only fills rows whose misc fields are all default). + if err := backfillPlayerMetaMiscState(); err != nil { + slog.Error("player_meta: misc 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_character.go b/internal/plugin/adventure_character.go index d0ebbe5..db52dcf 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -724,6 +724,11 @@ func saveAdvCharacter(char *AdventureCharacter) error { if dsErr := upsertPlayerMetaDeathState(char.UserID, deathStateFromAdvChar(char)); dsErr != nil { slog.Error("player_meta: death state dual-write failed", "user", char.UserID, "err", dsErr) } + // Adv 2.0 Phase L5f — dual-write misc state (Title, TreasuresLocked, + // CraftsSucceeded). Same strategy as L5e — rides every save. + if msErr := upsertPlayerMetaMiscState(char.UserID, miscStateFromAdvChar(char)); msErr != nil { + slog.Error("player_meta: misc state dual-write failed", "user", char.UserID, "err", msErr) + } return nil } diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index 3c0bcbb..efdcbd1 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -1792,6 +1792,104 @@ func deathStateFromAdvChar(c *AdventureCharacter) DeathState { } } +// MiscState mirrors player_meta's miscellaneous columns: Title, +// TreasuresLocked, CraftsSucceeded. Phase L5f ports these fields off +// AdvCharacter (gogobee_legacy_migration.md §7.3 L5f). Dual-write rides +// `saveAdvCharacter` alongside the L5e death state, since these fields +// either rarely mutate or already mutate at every save site. +type MiscState struct { + Title string + TreasuresLocked bool + CraftsSucceeded int +} + +func upsertPlayerMetaMiscState(userID id.UserID, s MiscState) error { + locked := 0 + if s.TreasuresLocked { + locked = 1 + } + _, err := db.Get().Exec( + `INSERT INTO player_meta (user_id, title, treasures_locked, crafts_succeeded) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + title = excluded.title, + treasures_locked = excluded.treasures_locked, + crafts_succeeded = excluded.crafts_succeeded`, + string(userID), s.Title, locked, s.CraftsSucceeded, + ) + return err +} + +func loadMiscState(userID id.UserID) (MiscState, error) { + var ( + s MiscState + lockedInt int + ) + err := db.Get().QueryRow( + `SELECT title, treasures_locked, crafts_succeeded + FROM player_meta WHERE user_id = ?`, + string(userID), + ).Scan(&s.Title, &lockedInt, &s.CraftsSucceeded) + if err == nil && (s.Title != "" || lockedInt == 1 || s.CraftsSucceeded > 0) { + s.TreasuresLocked = lockedInt == 1 + return s, nil + } + if err != nil && err != sql.ErrNoRows { + return MiscState{}, err + } + // Fallback to AdvCharacter during soak. + var legacy MiscState + var legacyLocked int + err = db.Get().QueryRow( + `SELECT title, treasures_locked, crafts_succeeded + FROM adventure_characters WHERE user_id = ?`, + string(userID), + ).Scan(&legacy.Title, &legacyLocked, &legacy.CraftsSucceeded) + if err == sql.ErrNoRows { + return MiscState{}, nil + } + if err != nil { + return MiscState{}, err + } + legacy.TreasuresLocked = legacyLocked == 1 + return legacy, nil +} + +func backfillPlayerMetaMiscState() 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 title = (SELECT title FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + treasures_locked = (SELECT treasures_locked FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + crafts_succeeded = (SELECT crafts_succeeded FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id) + WHERE title = '' AND treasures_locked = 0 AND crafts_succeeded = 0 + AND EXISTS ( + SELECT 1 FROM adventure_characters ac + WHERE ac.user_id = player_meta.user_id + AND (ac.title <> '' OR ac.treasures_locked = 1 OR ac.crafts_succeeded > 0) + ) + `) + if err != nil { + return err + } + n, _ := res.RowsAffected() + slog.Info("player_meta: misc state backfilled", "rows", n) + return nil +} + +func miscStateFromAdvChar(c *AdventureCharacter) MiscState { + return MiscState{ + Title: c.Title, + TreasuresLocked: c.TreasuresLocked, + CraftsSucceeded: c.CraftsSucceeded, + } +} + // 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 590bb7c..91ea3b8 100644 --- a/internal/plugin/player_meta_test.go +++ b/internal/plugin/player_meta_test.go @@ -1487,6 +1487,97 @@ func TestSaveAdvCharacter_DualWritesDeathState(t *testing.T) { } } +func TestPlayerMetaMiscStateBackfill_Idempotent(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-misc-bf:example") + if err := createAdvCharacter(uid, "Miscer"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE adventure_characters SET title = ?, treasures_locked = ?, crafts_succeeded = ? WHERE user_id = ?`, + "Hero", 1, 7, string(uid), + ); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE player_meta SET title = '', treasures_locked = 0, crafts_succeeded = 0 WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + if err := backfillPlayerMetaMiscState(); err != nil { + t.Fatalf("backfill 1: %v", err) + } + got, err := loadMiscState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got.Title != "Hero" || !got.TreasuresLocked || got.CraftsSucceeded != 7 { + t.Errorf("after backfill: got %+v", got) + } + + // Layer a dual-write: bump crafts. + got.CraftsSucceeded = 9 + if err := upsertPlayerMetaMiscState(uid, got); err != nil { + t.Fatalf("dual-write: %v", err) + } + if err := backfillPlayerMetaMiscState(); err != nil { + t.Fatalf("backfill 2: %v", err) + } + got2, _ := loadMiscState(uid) + if got2.CraftsSucceeded != 9 { + t.Errorf("backfill clobbered dual-write: got %+v", got2) + } +} + +func TestLoadMiscState_FallsBackToAdvCharacter(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-misc-fb:example") + if err := createAdvCharacter(uid, "Faller"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE adventure_characters SET crafts_succeeded = ? WHERE user_id = ?`, + 3, string(uid), + ); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE player_meta SET title = '', treasures_locked = 0, crafts_succeeded = 0 WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + got, err := loadMiscState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got.CraftsSucceeded != 3 { + t.Errorf("fallback: got %+v", got) + } +} + +func TestUpsertPlayerMetaMiscState_RoundTrip(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-misc-rt:example") + in := MiscState{Title: "Champion", TreasuresLocked: true, CraftsSucceeded: 12} + if err := upsertPlayerMetaMiscState(uid, in); err != nil { + t.Fatalf("upsert insert: %v", err) + } + got, err := loadMiscState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got != in { + t.Errorf("round-trip mismatch: got %+v want %+v", got, in) + } +} + func TestUpsertPlayerMetaHouseState_RoundTrip(t *testing.T) { setupAuditTestDB(t)