package plugin import ( "database/sql" "log/slog" "gogobee/internal/db" "maunium.net/go/mautrix/id" ) // player_meta is the Adv 2.0 holding pen for non-stat per-user state // migrating off adventure_characters (gogobee_legacy_migration.md §2.1). // Phase L2 step 5 lands the table + arena counters; later phases extend // the schema and add their own helpers here. // PlayerMeta is the in-memory mirror of the player_meta row. Only the // fields a phase has migrated are meaningful; unmigrated fields stay // zero-valued and are sourced from AdvCharacter. type PlayerMeta struct { UserID id.UserID ArenaWins int ArenaLosses int InvasionScore int DisplayName string HospitalVisits int MasterworkDropsReceived int } // loadPlayerMeta reads the player_meta row for a user. Returns a // zero-valued struct (with UserID set) when no row exists — callers // should treat absence as "all fields zero", matching the column // defaults. func loadPlayerMeta(userID id.UserID) (*PlayerMeta, error) { m := &PlayerMeta{UserID: userID} err := db.Get().QueryRow( `SELECT arena_wins, arena_losses, invasion_score, display_name, hospital_visits, masterwork_drops_received FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&m.ArenaWins, &m.ArenaLosses, &m.InvasionScore, &m.DisplayName, &m.HospitalVisits, &m.MasterworkDropsReceived) if err == sql.ErrNoRows { return m, nil } if err != nil { return nil, err } return m, nil } // upsertPlayerMetaArena writes arena_wins / arena_losses / invasion_score // for a user. Creates the row if missing. Used by the dual-write path // during the L1–L4 migration (§11): every arena counter mutation goes // through both AdvCharacter and player_meta until soak completes. func upsertPlayerMetaArena(userID id.UserID, wins, losses, invasionScore int) error { _, err := db.Get().Exec( `INSERT INTO player_meta (user_id, arena_wins, arena_losses, invasion_score) VALUES (?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET arena_wins = excluded.arena_wins, arena_losses = excluded.arena_losses, invasion_score = excluded.invasion_score`, string(userID), wins, losses, invasionScore, ) return err } // backfillPlayerMetaArena copies arena_wins / arena_losses / invasion_score // from adventure_characters into player_meta for any user_id that doesn't // already have a row. Idempotent: INSERT OR IGNORE skips users that have // already been backfilled (or that wrote a row through the dual-write // path post-deploy). Logs the row count touched, matching Phase R1's // archiveOrphanZoneRuns precedent. func backfillPlayerMetaArena() error { res, err := db.Get().Exec(` INSERT OR IGNORE INTO player_meta (user_id, arena_wins, arena_losses, invasion_score) SELECT user_id, arena_wins, arena_losses, invasion_score FROM adventure_characters `) if err != nil { return err } n, _ := res.RowsAffected() 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 } // upsertPlayerMetaHospitalVisits writes hospital_visits for a user, leaving // other columns untouched. Used by the dual-write path during the L4a // Hospital migration: every increment on AdvCharacter.HospitalVisits also // calls this so player_meta.hospital_visits stays in sync until soak // completes (gogobee_legacy_migration.md §6.1, §11). func upsertPlayerMetaHospitalVisits(userID id.UserID, visits int) error { _, err := db.Get().Exec( `INSERT INTO player_meta (user_id, hospital_visits) VALUES (?, ?) ON CONFLICT(user_id) DO UPDATE SET hospital_visits = excluded.hospital_visits`, string(userID), visits, ) return err } // loadHospitalVisits returns the player's hospital visit count from // player_meta when present, falling back to adventure_characters during // the L4a soak window. Zero if the user has no row in either table. func loadHospitalVisits(userID id.UserID) (int, error) { var visits int err := db.Get().QueryRow( `SELECT hospital_visits FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&visits) if err == nil && visits > 0 { return visits, nil } if err != nil && err != sql.ErrNoRows { return 0, err } // player_meta row missing or zero → fall back to AdvCharacter for the // soak window. A zero value in player_meta could legitimately mean "no // visits yet", but the fallback only returns non-zero if the legacy // column has a higher value, so this is safe either way. var legacy int err = db.Get().QueryRow( `SELECT hospital_visits FROM adventure_characters WHERE user_id = ?`, string(userID), ).Scan(&legacy) if err == sql.ErrNoRows { return visits, nil } if err != nil { return 0, err } if legacy > visits { return legacy, nil } return visits, nil } // backfillPlayerMetaHospitalVisits copies adventure_characters.hospital_visits // into player_meta.hospital_visits for any row whose value is still the // default zero. Idempotent: only updates rows that haven't been populated // yet (via backfill or dual-write). func backfillPlayerMetaHospitalVisits() 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 hospital_visits = ( SELECT hospital_visits FROM adventure_characters WHERE adventure_characters.user_id = player_meta.user_id ) WHERE hospital_visits = 0 AND EXISTS ( SELECT 1 FROM adventure_characters WHERE adventure_characters.user_id = player_meta.user_id AND adventure_characters.hospital_visits > 0 ) `) if err != nil { return err } n, _ := res.RowsAffected() slog.Info("player_meta: hospital_visits backfilled", "rows", n) return nil } // upsertPlayerMetaRivalState writes rival_pool / rival_unlocked_notified for // a user, leaving other columns untouched. Used by the dual-write path during // the L4b Rival migration: every place that mutates AdvCharacter.RivalPool / // RivalUnlockedNotified also calls this so player_meta stays in sync until // soak completes (gogobee_legacy_migration.md §6.2, §11). func upsertPlayerMetaRivalState(userID id.UserID, pool int, notified bool) error { notifiedInt := 0 if notified { notifiedInt = 1 } _, err := db.Get().Exec( `INSERT INTO player_meta (user_id, rival_pool, rival_unlocked_notified) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET rival_pool = excluded.rival_pool, rival_unlocked_notified = excluded.rival_unlocked_notified`, string(userID), pool, notifiedInt, ) return err } // loadRivalState returns rival_pool / rival_unlocked_notified for a user from // player_meta when populated, falling back to adventure_characters during the // L4b soak window. Treats a missing row as (0, false). The fallback returns // the AdvCharacter values whenever player_meta has the unmigrated default // (pool=0, notified=0) — a true "not yet in pool" state and the pre-backfill // state are indistinguishable at the column level, but the legacy column // only ever moves toward unlocked, so picking the higher of the two values // is safe. func loadRivalState(userID id.UserID) (pool int, notified bool, err error) { var notifiedInt int err = db.Get().QueryRow( `SELECT rival_pool, rival_unlocked_notified FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&pool, ¬ifiedInt) if err == nil && (pool > 0 || notifiedInt > 0) { return pool, notifiedInt == 1, nil } if err != nil && err != sql.ErrNoRows { return 0, false, err } // Fallback during soak. var legacyPool, legacyNotified int err = db.Get().QueryRow( `SELECT rival_pool, rival_unlocked_notified FROM adventure_characters WHERE user_id = ?`, string(userID), ).Scan(&legacyPool, &legacyNotified) if err == sql.ErrNoRows { return pool, notifiedInt == 1, nil } if err != nil { return 0, false, err } if legacyPool > pool { pool = legacyPool } if legacyNotified > notifiedInt { notifiedInt = legacyNotified } return pool, notifiedInt == 1, nil } // backfillPlayerMetaRivalState copies rival_pool / rival_unlocked_notified // from adventure_characters into player_meta for any row whose values are // still the default zero. Idempotent: only updates rows that haven't been // populated yet (via backfill or dual-write). func backfillPlayerMetaRivalState() 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 rival_pool = ( SELECT rival_pool FROM adventure_characters WHERE adventure_characters.user_id = player_meta.user_id ), rival_unlocked_notified = ( SELECT rival_unlocked_notified FROM adventure_characters WHERE adventure_characters.user_id = player_meta.user_id ) WHERE rival_pool = 0 AND rival_unlocked_notified = 0 AND EXISTS ( SELECT 1 FROM adventure_characters WHERE adventure_characters.user_id = player_meta.user_id AND (adventure_characters.rival_pool > 0 OR adventure_characters.rival_unlocked_notified > 0) ) `) if err != nil { return err } n, _ := res.RowsAffected() slog.Info("player_meta: rival state backfilled", "rows", n) return nil } // upsertPlayerMetaMasterworkDrops writes masterwork_drops_received for a user, // leaving other columns untouched. Used by the dual-write path during the // L4c Masterwork migration: every increment on AdvCharacter.MasterworkDropsReceived // also calls this so player_meta stays in sync until soak completes // (gogobee_legacy_migration.md §6.3, §11). func upsertPlayerMetaMasterworkDrops(userID id.UserID, drops int) error { _, err := db.Get().Exec( `INSERT INTO player_meta (user_id, masterwork_drops_received) VALUES (?, ?) ON CONFLICT(user_id) DO UPDATE SET masterwork_drops_received = excluded.masterwork_drops_received`, string(userID), drops, ) return err } // loadMasterworkDrops returns the player's masterwork drop count from // player_meta when populated, falling back to adventure_characters during // the L4c soak window. Zero if the user has no row in either table. Mirrors // loadHospitalVisits: the legacy column only ever moves up, so taking the // higher of the two values is safe across the dual-write window. func loadMasterworkDrops(userID id.UserID) (int, error) { var drops int err := db.Get().QueryRow( `SELECT masterwork_drops_received FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&drops) if err == nil && drops > 0 { return drops, nil } if err != nil && err != sql.ErrNoRows { return 0, err } var legacy int err = db.Get().QueryRow( `SELECT masterwork_drops_received FROM adventure_characters WHERE user_id = ?`, string(userID), ).Scan(&legacy) if err == sql.ErrNoRows { return drops, nil } if err != nil { return 0, err } if legacy > drops { return legacy, nil } return drops, nil } // backfillPlayerMetaMasterworkDrops copies adventure_characters.masterwork_drops_received // into player_meta.masterwork_drops_received for any row whose value is still // the default zero. Idempotent: only updates rows that haven't been populated // yet (via backfill or dual-write). func backfillPlayerMetaMasterworkDrops() 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 masterwork_drops_received = ( SELECT masterwork_drops_received FROM adventure_characters WHERE adventure_characters.user_id = player_meta.user_id ) WHERE masterwork_drops_received = 0 AND EXISTS ( SELECT 1 FROM adventure_characters WHERE adventure_characters.user_id = player_meta.user_id AND adventure_characters.masterwork_drops_received > 0 ) `) if err != nil { return err } n, _ := res.RowsAffected() slog.Info("player_meta: masterwork_drops_received backfilled", "rows", n) return nil } // 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 }