mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Adv 2.0 L5f: misc fields (Title, TreasuresLocked, CraftsSucceeded) migration
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user