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

@@ -171,9 +171,12 @@ type ArenaPersonalStats struct {
Tier5Completions int
}
func renderArenaPersonalStats(char *AdventureCharacter, stats *ArenaPersonalStats) string {
// renderArenaPersonalStats renders the !arena stats DM. Wins/losses come
// from player_meta post-Phase L2 step 5 (the caller is responsible for
// loading them); displayName is sourced from the AdvCharacter.
func renderArenaPersonalStats(displayName string, wins, losses int, stats *ArenaPersonalStats) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("⚔️ **%s's Arena Stats**\n\n", char.DisplayName))
b.WriteString(fmt.Sprintf("⚔️ **%s's Arena Stats**\n\n", displayName))
if stats == nil || stats.TotalRuns == 0 {
b.WriteString("No arena runs yet. Type `!arena` to begin.")
@@ -194,7 +197,7 @@ func renderArenaPersonalStats(char *AdventureCharacter, stats *ArenaPersonalStat
b.WriteString(fmt.Sprintf("Tier 5 completions: %d\n", stats.Tier5Completions))
}
b.WriteString(fmt.Sprintf("\nArena W/L: %d/%d", char.ArenaWins, char.ArenaLosses))
b.WriteString(fmt.Sprintf("\nArena W/L: %d/%d", wins, losses))
return b.String()
}