Files
gogobee/internal/plugin/player_meta.go
2026-05-09 14:25:22 -07:00

420 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 L1L4 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, &notifiedInt)
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
}