mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 L4f-prep: DisplayName migration scaffold (schema + dual-write + helper)
Adds player_meta.display_name with idempotent backfill, dual-writes from createAdvCharacter (in-tx) and saveAdvCharacter (post-commit), and a loadDisplayName helper with AdvCharacter fallback. Readers are not flipped yet — soak begins now; per-call-site swap follows in a later session. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user