diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index d5b421f..fed9651 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -310,6 +310,13 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events, **Sizing:** ~0.5 day for the schema/backfill/helper, then 1 day to swap the ~250 read sites in batches per file. Slot it after L2 wraps and before L4f starts. +**Status (2026-05-09):** schema/backfill/helper SHIPPED on `adv-2.0`. +- `player_meta.display_name` column added via column migration in `internal/db/db.go`. +- `backfillPlayerMetaDisplayName` runs on every Init: idempotent (`UPDATE ... WHERE display_name = ''` only touches empty rows, so dual-writes layered on top survive re-runs). +- `upsertPlayerMetaDisplayName(userID, name)` dual-writes from `createAdvCharacter` (inside the same tx, atomic with the AdvCharacter INSERT) and `saveAdvCharacter` (post-commit, error-logged but non-fatal). +- `loadDisplayName(userID)` reads `player_meta` → falls back to `adventure_characters.display_name` → empty string if neither exists. Helper exists but **readers are NOT flipped yet** — soak week begins now; per-call-site flip happens in a follow-up session once we've confirmed dual-write doesn't drift. +- Tests: `TestPlayerMetaDisplayNameBackfill_Idempotent`, `TestLoadDisplayName_FallsBackToAdvCharacter`, `TestCreateAdvCharacter_DualWritesDisplayName`. + **Pre-conditions:** - L1–L4 all shipped. - DisplayName migrated to `player_meta` (see above). diff --git a/internal/db/db.go b/internal/db/db.go index 378b832..1abb583 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -186,6 +186,10 @@ func runMigrations(d *sql.DB) error { // !zone enter sessions that aren't tied to an expedition. Expedition // runs continue to use expedition.region_state for the same data. `ALTER TABLE dnd_zone_run ADD COLUMN harvest_nodes_json TEXT NOT NULL DEFAULT '{}'`, + // Adv 2.0 Phase L4f-prep — DisplayName migration off AdvCharacter. + // player_meta gets the canonical column; AdvCharacter.display_name + // keeps dual-writing during soak (gogobee_legacy_migration.md §7). + `ALTER TABLE player_meta ADD COLUMN display_name 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 b4de0bc..cb68cb1 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -194,6 +194,11 @@ func (p *AdventurePlugin) Init() error { if err := backfillPlayerMetaArena(); err != nil { slog.Error("player_meta: arena backfill failed", "err", err) } + // Adv 2.0 Phase L4f-prep — one-shot DisplayName backfill into + // player_meta. Idempotent (only fills empty display_name rows). + if err := backfillPlayerMetaDisplayName(); err != nil { + slog.Error("player_meta: display_name 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 994b25d..041affe 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -2,6 +2,7 @@ package plugin import ( "database/sql" + "log/slog" "time" "gogobee/internal/db" @@ -557,6 +558,16 @@ func createAdvCharacter(userID id.UserID, displayName string) error { return err } + // Adv 2.0 Phase L4f-prep — dual-write display_name into player_meta. + // Inside the same tx so the two rows are created atomically. + if _, err = tx.Exec( + `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?) + ON CONFLICT(user_id) DO UPDATE SET display_name = excluded.display_name`, + string(userID), displayName, + ); err != nil { + return err + } + // Create tier-0 equipment in all slots for _, slot := range allSlots { def := equipmentTiers[slot][0] @@ -697,7 +708,14 @@ func saveAdvCharacter(char *AdventureCharacter) error { boolToInt(char.TreasuresLocked), string(char.UserID), ) - return err + if err != nil { + return err + } + // Adv 2.0 Phase L4f-prep — dual-write display_name into player_meta. + if dnErr := upsertPlayerMetaDisplayName(char.UserID, char.DisplayName); dnErr != nil { + slog.Error("player_meta: display_name dual-write failed", "user", char.UserID, "err", dnErr) + } + return nil } func boolToInt(b bool) int { diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index 8f2b87e..21154fd 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -21,6 +21,7 @@ type PlayerMeta struct { ArenaWins int ArenaLosses int InvasionScore int + DisplayName string } // loadPlayerMeta reads the player_meta row for a user. Returns a @@ -30,9 +31,9 @@ type PlayerMeta struct { func loadPlayerMeta(userID id.UserID) (*PlayerMeta, error) { m := &PlayerMeta{UserID: userID} err := db.Get().QueryRow( - `SELECT arena_wins, arena_losses, invasion_score FROM player_meta WHERE user_id = ?`, + `SELECT arena_wins, arena_losses, invasion_score, display_name FROM player_meta WHERE user_id = ?`, string(userID), - ).Scan(&m.ArenaWins, &m.ArenaLosses, &m.InvasionScore) + ).Scan(&m.ArenaWins, &m.ArenaLosses, &m.InvasionScore, &m.DisplayName) if err == sql.ErrNoRows { return m, nil } @@ -77,3 +78,80 @@ func backfillPlayerMetaArena() error { slog.Info("player_meta: arena counters backfilled", "rows", n) return nil } + +// upsertPlayerMetaDisplayName writes display_name for a user, leaving other +// columns untouched. Used by the dual-write path during the L4f-prep +// DisplayName migration: every place that mutates AdvCharacter.DisplayName +// also calls this so player_meta.display_name stays in sync until soak +// completes and readers flip over (gogobee_legacy_migration.md §7). +func upsertPlayerMetaDisplayName(userID id.UserID, displayName string) error { + _, err := db.Get().Exec( + `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?) + ON CONFLICT(user_id) DO UPDATE SET display_name = excluded.display_name`, + string(userID), displayName, + ) + return err +} + +// loadDisplayName returns the player's display name from player_meta when +// present, falling back to adventure_characters.display_name during the +// L4f-prep soak window. Empty string if the user has no row in either +// table. Callers should prefer this helper over reading char.DisplayName +// directly so the eventual reader flip is a no-op at the call site. +func loadDisplayName(userID id.UserID) (string, error) { + var name string + err := db.Get().QueryRow( + `SELECT display_name FROM player_meta WHERE user_id = ?`, + string(userID), + ).Scan(&name) + if err == nil && name != "" { + return name, nil + } + if err != nil && err != sql.ErrNoRows { + return "", err + } + // Fallback during soak. + err = db.Get().QueryRow( + `SELECT display_name FROM adventure_characters WHERE user_id = ?`, + string(userID), + ).Scan(&name) + if err == sql.ErrNoRows { + return "", nil + } + return name, err +} + +// 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 +// haven't been populated yet (either by backfill or the dual-write path). +func backfillPlayerMetaDisplayName() error { + // Ensure a player_meta row exists for every adventure_characters user + // (arena backfill already does this, but display_name backfill must + // not assume order — re-run cheaply via INSERT OR IGNORE). + 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 display_name = ( + SELECT display_name FROM adventure_characters + WHERE adventure_characters.user_id = player_meta.user_id + ) + WHERE display_name = '' + AND EXISTS ( + SELECT 1 FROM adventure_characters + WHERE adventure_characters.user_id = player_meta.user_id + AND adventure_characters.display_name <> '' + ) + `) + if err != nil { + return err + } + n, _ := res.RowsAffected() + slog.Info("player_meta: display_name backfilled", "rows", n) + return nil +} diff --git a/internal/plugin/player_meta_test.go b/internal/plugin/player_meta_test.go index f45c126..ea2ba42 100644 --- a/internal/plugin/player_meta_test.go +++ b/internal/plugin/player_meta_test.go @@ -106,3 +106,99 @@ func TestUpsertPlayerMetaArena_RoundTrip(t *testing.T) { t.Errorf("missing user not zero: %+v", mZ) } } + +// Phase L4f-prep — DisplayName migration. Backfill is idempotent and only +// touches empty rows; dual-write through createAdvCharacter / saveAdvCharacter +// stays consistent across re-runs; loadDisplayName falls back to AdvCharacter +// when player_meta hasn't been populated yet. + +func TestPlayerMetaDisplayNameBackfill_Idempotent(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-dn-bf:example") + if err := createAdvCharacter(uid, "DisplayedName"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + // Reset player_meta to empty display_name so the backfill has work. + if _, err := db.Get().Exec( + `UPDATE player_meta SET display_name = '' WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + if err := backfillPlayerMetaDisplayName(); err != nil { + t.Fatalf("backfill 1: %v", err) + } + got, err := loadDisplayName(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got != "DisplayedName" { + t.Errorf("after backfill: got %q want %q", got, "DisplayedName") + } + + // Layer a dual-write rename. Backfill re-run must NOT clobber it back + // (it only touches rows with empty display_name). + if err := upsertPlayerMetaDisplayName(uid, "Renamed"); err != nil { + t.Fatalf("dual-write: %v", err) + } + if err := backfillPlayerMetaDisplayName(); err != nil { + t.Fatalf("backfill 2: %v", err) + } + got2, _ := loadDisplayName(uid) + if got2 != "Renamed" { + t.Errorf("backfill clobbered dual-write: got %q want %q", got2, "Renamed") + } +} + +func TestLoadDisplayName_FallsBackToAdvCharacter(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-dn-fb:example") + if err := createAdvCharacter(uid, "FromAdvChar"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + // Simulate a row that exists in adventure_characters but whose + // player_meta display_name hasn't been populated yet (pre-backfill, + // pre-dual-write soak case). + if _, err := db.Get().Exec( + `UPDATE player_meta SET display_name = '' WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + got, err := loadDisplayName(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got != "FromAdvChar" { + t.Errorf("fallback: got %q want %q", got, "FromAdvChar") + } + + // Unknown user → empty string, no error. + got2, err := loadDisplayName(id.UserID("@meta-dn-nobody:example")) + if err != nil { + t.Fatalf("load missing: %v", err) + } + if got2 != "" { + t.Errorf("missing user: got %q want empty", got2) + } +} + +func TestCreateAdvCharacter_DualWritesDisplayName(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-dn-create:example") + if err := createAdvCharacter(uid, "FreshName"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + got, err := loadDisplayName(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got != "FreshName" { + t.Errorf("create dual-write: got %q want %q", got, "FreshName") + } +}