Adv 2.0 Phase L2 step 5: arena counters → player_meta

Creates the player_meta table (gogobee_legacy_migration.md §2.1) with the
three columns L2 needs: arena_wins, arena_losses, invasion_score. Future
phases ALTER in their own columns. Naming follows the §2.0 callout — no
dnd_ prefix on new tables.

Helpers in internal/plugin/player_meta.go: loadPlayerMeta,
upsertPlayerMetaArena, backfillPlayerMetaArena (INSERT OR IGNORE,
idempotent, logs row count per Phase R1 precedent). Plugin Init runs
the backfill once on startup.

Dual-write per §11: every char.ArenaWins++/ArenaLosses++ in
adventure_arena.go also calls upsertPlayerMetaArena. AdvCharacter
columns stay in place — reads will switch back to AdvCharacter trivially
if a rollback is needed before L5 teardown.

Read swap: handleArenaStats and renderDnDSheet now load player_meta and
prefer its values (falling back to AdvCharacter if the row is missing,
which only matters during the deploy window before backfill runs).
renderArenaPersonalStats's signature changed from (char, stats) to
(displayName, wins, losses, stats) so the render is DB-free.

Tests: TestPlayerMetaArenaBackfill_Idempotent verifies double-run
backfill is a no-op and doesn't clobber post-backfill dual-writes;
TestUpsertPlayerMetaArena_RoundTrip covers insert/update/missing-user
paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 08:11:52 -07:00
parent 693cd9c221
commit 341b1fc8e5
9 changed files with 238 additions and 13 deletions

View File

@@ -0,0 +1,79 @@
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
}
// 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 FROM player_meta WHERE user_id = ?`,
string(userID),
).Scan(&m.ArenaWins, &m.ArenaLosses, &m.InvasionScore)
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
}