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

@@ -1698,6 +1698,19 @@ CREATE TABLE IF NOT EXISTS dnd_known_recipe (
discovered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, discovered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, recipe_id) PRIMARY KEY (user_id, recipe_id)
); );
-- ── Adv 2.0 Phase L2 step 5 — player_meta ──────────────────────────────────
-- Holding pen for non-stat per-user state migrating off adventure_characters
-- (gogobee_legacy_migration.md §2.1). Each L-phase ALTER TABLEs in the
-- columns it needs; this initial CREATE only covers L2's three arena
-- counters. The dual-write rule (§11) applies: writes go to both this
-- table and adventure_characters until a phase soaks clean for one week.
CREATE TABLE IF NOT EXISTS player_meta (
user_id TEXT PRIMARY KEY,
arena_wins INTEGER NOT NULL DEFAULT 0,
arena_losses INTEGER NOT NULL DEFAULT 0,
invasion_score INTEGER NOT NULL DEFAULT 0
);
` `
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist. // SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.

View File

@@ -189,6 +189,11 @@ func (p *AdventurePlugin) Init() error {
if err := resetAllAdvDailyActions(); err != nil { if err := resetAllAdvDailyActions(); err != nil {
slog.Error("adventure: startup daily reset failed", "err", err) slog.Error("adventure: startup daily reset failed", "err", err)
} }
// Adv 2.0 Phase L2 step 5 — one-shot arena counter backfill into
// player_meta. Idempotent (INSERT OR IGNORE).
if err := backfillPlayerMetaArena(); err != nil {
slog.Error("player_meta: arena backfill failed", "err", err)
}
if err := lockCoopCombatActions(); err != nil { if err := lockCoopCombatActions(); err != nil {
slog.Error("adventure: startup coop combat lock failed", "err", err) slog.Error("adventure: startup coop combat lock failed", "err", err)
} }

View File

@@ -317,7 +317,13 @@ func (p *AdventurePlugin) handleArenaStats(ctx MessageContext) error {
} }
stats := loadArenaPersonalStats(ctx.Sender) stats := loadArenaPersonalStats(ctx.Sender)
return p.SendDM(ctx.Sender, renderArenaPersonalStats(char, stats)) wins, losses := char.ArenaWins, char.ArenaLosses
if meta, err := loadPlayerMeta(ctx.Sender); err == nil {
wins, losses = meta.ArenaWins, meta.ArenaLosses
} else {
slog.Error("player_meta: arena stats read failed, falling back to AdvCharacter", "user", ctx.Sender, "err", err)
}
return p.SendDM(ctx.Sender, renderArenaPersonalStats(char.DisplayName, wins, losses, stats))
} }
func (p *AdventurePlugin) handleArenaLeaderboard(ctx MessageContext) error { func (p *AdventurePlugin) handleArenaLeaderboard(ctx MessageContext) error {
@@ -516,6 +522,10 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
if err := saveAdvCharacter(char); err != nil { if err := saveAdvCharacter(char); err != nil {
slog.Error("arena: failed to save character after death", "user", ctx.Sender, "err", err) slog.Error("arena: failed to save character after death", "user", ctx.Sender, "err", err)
} }
// Dual-write to player_meta (Adv 2.0 Phase L2 step 5).
if err := upsertPlayerMetaArena(ctx.Sender, char.ArenaWins, char.ArenaLosses, char.InvasionScore); err != nil {
slog.Error("player_meta: arena loss dual-write failed", "user", ctx.Sender, "err", err)
}
now := time.Now().UTC() now := time.Now().UTC()
run.Status = "dead" run.Status = "dead"
@@ -599,6 +609,10 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
if err := saveAdvCharacter(char); err != nil { if err := saveAdvCharacter(char); err != nil {
slog.Error("arena: failed to save character after session complete", "user", userID, "err", err) slog.Error("arena: failed to save character after session complete", "user", userID, "err", err)
} }
// Dual-write to player_meta (Adv 2.0 Phase L2 step 5).
if err := upsertPlayerMetaArena(userID, char.ArenaWins, char.ArenaLosses, char.InvasionScore); err != nil {
slog.Error("player_meta: arena win dual-write failed", "user", userID, "err", err)
}
// End run if not already ended // End run if not already ended
if run.Status != "completed" && run.Status != "cashed_out" { if run.Status != "completed" && run.Status != "cashed_out" {

View File

@@ -171,9 +171,12 @@ type ArenaPersonalStats struct {
Tier5Completions int 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 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 { if stats == nil || stats.TotalRuns == 0 {
b.WriteString("No arena runs yet. Type `!arena` to begin.") 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("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() return b.String()
} }

View File

@@ -730,20 +730,18 @@ func TestRenderArenaDeathReprieve(t *testing.T) {
} }
func TestRenderArenaPersonalStats_NoStats(t *testing.T) { func TestRenderArenaPersonalStats_NoStats(t *testing.T) {
char := &AdventureCharacter{DisplayName: "TestPlayer"} text := renderArenaPersonalStats("TestPlayer", 0, 0, nil)
text := renderArenaPersonalStats(char, nil)
if !strings.Contains(text, "No arena runs") { if !strings.Contains(text, "No arena runs") {
t.Error("empty stats should say no runs") t.Error("empty stats should say no runs")
} }
} }
func TestRenderArenaPersonalStats_WithStats(t *testing.T) { func TestRenderArenaPersonalStats_WithStats(t *testing.T) {
char := &AdventureCharacter{DisplayName: "Alice", ArenaWins: 8, ArenaLosses: 3}
stats := &ArenaPersonalStats{ stats := &ArenaPersonalStats{
TotalRuns: 11, TotalEarnings: 150000, TotalDeaths: 3, TotalRuns: 11, TotalEarnings: 150000, TotalDeaths: 3,
HighestTier: 5, Tier5Completions: 2, HighestTier: 5, Tier5Completions: 2,
} }
text := renderArenaPersonalStats(char, stats) text := renderArenaPersonalStats("Alice", 8, 3, stats)
if !strings.Contains(text, "Alice") { if !strings.Contains(text, "Alice") {
t.Error("stats should show player name") t.Error("stats should show player name")
} }

View File

@@ -62,11 +62,12 @@ func (p *AdventurePlugin) handleDnDSheetCmd(ctx MessageContext) error {
advChar, _ := loadAdvCharacter(ctx.Sender) advChar, _ := loadAdvCharacter(ctx.Sender)
equip, _ := loadAdvEquipment(ctx.Sender) equip, _ := loadAdvEquipment(ctx.Sender)
treasures, _ := loadAdvTreasureBonuses(ctx.Sender) treasures, _ := loadAdvTreasureBonuses(ctx.Sender)
meta, _ := loadPlayerMeta(ctx.Sender)
return p.SendDM(ctx.Sender, renderDnDSheet(c, advChar, equip, treasures)) return p.SendDM(ctx.Sender, renderDnDSheet(c, advChar, meta, equip, treasures))
} }
func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, treasures []AdvTreasureBonus) string { func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta, equip map[EquipmentSlot]*AdvEquipment, treasures []AdvTreasureBonus) string {
ri, _ := raceInfo(c.Race) ri, _ := raceInfo(c.Race)
ci, _ := classInfo(c.Class) ci, _ := classInfo(c.Class)
mods := c.Modifiers() mods := c.Modifiers()
@@ -164,8 +165,12 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, equip map[Equipmen
b.WriteString("\n**Adventure progress** _(preserved)_\n") b.WriteString("\n**Adventure progress** _(preserved)_\n")
b.WriteString(fmt.Sprintf(" Mining %d Foraging %d Fishing %d Combat (legacy) %d\n", b.WriteString(fmt.Sprintf(" Mining %d Foraging %d Fishing %d Combat (legacy) %d\n",
adv.MiningSkill, adv.ForagingSkill, adv.FishingSkill, adv.CombatLevel)) adv.MiningSkill, adv.ForagingSkill, adv.FishingSkill, adv.CombatLevel))
wins, losses := adv.ArenaWins, adv.ArenaLosses
if meta != nil {
wins, losses = meta.ArenaWins, meta.ArenaLosses
}
b.WriteString(fmt.Sprintf(" Arena %dW/%dL Streak %d (best %d)\n", b.WriteString(fmt.Sprintf(" Arena %dW/%dL Streak %d (best %d)\n",
adv.ArenaWins, adv.ArenaLosses, adv.CurrentStreak, adv.BestStreak)) wins, losses, adv.CurrentStreak, adv.BestStreak))
if adv.PetName != "" { if adv.PetName != "" {
b.WriteString(fmt.Sprintf(" Pet: %s the %s (lv %d)\n", adv.PetName, adv.PetType, adv.PetLevel)) b.WriteString(fmt.Sprintf(" Pet: %s the %s (lv %d)\n", adv.PetName, adv.PetType, adv.PetLevel))
} }

View File

@@ -308,7 +308,7 @@ func TestSheet_ShowsSubclassWhenChosen(t *testing.T) {
STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10, STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10,
Subclass: SubclassBattleMaster, Subclass: SubclassBattleMaster,
} }
out := renderDnDSheet(c, nil, nil, nil) out := renderDnDSheet(c, nil, nil, nil, nil)
if !strings.Contains(out, "Battle Master") { if !strings.Contains(out, "Battle Master") {
t.Errorf("sheet missing subclass name:\n%s", out) t.Errorf("sheet missing subclass name:\n%s", out)
} }
@@ -320,7 +320,7 @@ func TestSheet_PromptsWhenUnchosenAtL5(t *testing.T) {
Level: 5, HPMax: 40, HPCurrent: 40, ArmorClass: 16, Level: 5, HPMax: 40, HPCurrent: 40, ArmorClass: 16,
STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10, STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10,
} }
out := renderDnDSheet(c, nil, nil, nil) out := renderDnDSheet(c, nil, nil, nil, nil)
if !strings.Contains(out, "!subclass") { if !strings.Contains(out, "!subclass") {
t.Errorf("sheet should nudge `!subclass` when unchosen at L5:\n%s", out) t.Errorf("sheet should nudge `!subclass` when unchosen at L5:\n%s", out)
} }

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
}

View File

@@ -0,0 +1,108 @@
package plugin
import (
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// Phase L2 step 5 — backfill idempotency. Mirrors the R1 archiveOrphanZoneRuns
// test pattern: run twice, second run is a no-op (INSERT OR IGNORE), and
// dual-writes layered after the backfill don't get clobbered.
func TestPlayerMetaArenaBackfill_Idempotent(t *testing.T) {
setupAuditTestDB(t)
uidA := id.UserID("@meta-bf-a:example")
uidB := id.UserID("@meta-bf-b:example")
if err := createAdvCharacter(uidA, "metaA"); err != nil {
t.Fatalf("createAdvCharacter A: %v", err)
}
if err := createAdvCharacter(uidB, "metaB"); err != nil {
t.Fatalf("createAdvCharacter B: %v", err)
}
// Stamp arena counters directly on adventure_characters so the backfill
// has something interesting to copy.
if _, err := db.Get().Exec(
`UPDATE adventure_characters SET arena_wins = ?, arena_losses = ?, invasion_score = ? WHERE user_id = ?`,
7, 3, 0, string(uidA),
); err != nil {
t.Fatalf("seed A: %v", err)
}
if _, err := db.Get().Exec(
`UPDATE adventure_characters SET arena_wins = ?, arena_losses = ?, invasion_score = ? WHERE user_id = ?`,
1, 1, 5, string(uidB),
); err != nil {
t.Fatalf("seed B: %v", err)
}
// Wipe any pre-existing player_meta rows from the prod-DB copy so we're
// testing the backfill from a clean slate for these two users.
if _, err := db.Get().Exec(
`DELETE FROM player_meta WHERE user_id IN (?, ?)`,
string(uidA), string(uidB),
); err != nil {
t.Fatalf("clear: %v", err)
}
if err := backfillPlayerMetaArena(); err != nil {
t.Fatalf("backfill 1: %v", err)
}
mA, _ := loadPlayerMeta(uidA)
if mA.ArenaWins != 7 || mA.ArenaLosses != 3 || mA.InvasionScore != 0 {
t.Errorf("A after backfill: got %+v want wins=7 losses=3 inv=0", mA)
}
mB, _ := loadPlayerMeta(uidB)
if mB.ArenaWins != 1 || mB.ArenaLosses != 1 || mB.InvasionScore != 5 {
t.Errorf("B after backfill: got %+v want wins=1 losses=1 inv=5", mB)
}
// Layer a dual-write update on top — INSERT OR IGNORE re-runs must not
// clobber post-backfill state.
if err := upsertPlayerMetaArena(uidA, 8, 3, 0); err != nil {
t.Fatalf("dual-write: %v", err)
}
if err := backfillPlayerMetaArena(); err != nil {
t.Fatalf("backfill 2: %v", err)
}
mA2, _ := loadPlayerMeta(uidA)
if mA2.ArenaWins != 8 {
t.Errorf("backfill clobbered dual-write: got wins=%d want 8", mA2.ArenaWins)
}
}
func TestUpsertPlayerMetaArena_RoundTrip(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@meta-rt:example")
if err := upsertPlayerMetaArena(uid, 4, 2, 0); err != nil {
t.Fatalf("upsert insert: %v", err)
}
m, err := loadPlayerMeta(uid)
if err != nil {
t.Fatalf("load: %v", err)
}
if m.ArenaWins != 4 || m.ArenaLosses != 2 {
t.Errorf("round-trip 1: got %+v want wins=4 losses=2", m)
}
// Update path
if err := upsertPlayerMetaArena(uid, 5, 2, 0); err != nil {
t.Fatalf("upsert update: %v", err)
}
m2, _ := loadPlayerMeta(uid)
if m2.ArenaWins != 5 {
t.Errorf("round-trip 2: got wins=%d want 5", m2.ArenaWins)
}
// Missing user → zero-valued struct, no error
mZ, err := loadPlayerMeta(id.UserID("@meta-rt-nobody:example"))
if err != nil {
t.Fatalf("load missing: %v", err)
}
if mZ.ArenaWins != 0 || mZ.ArenaLosses != 0 {
t.Errorf("missing user not zero: %+v", mZ)
}
}