diff --git a/internal/db/db.go b/internal/db/db.go index 32ad278..378b832 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1698,6 +1698,19 @@ CREATE TABLE IF NOT EXISTS dnd_known_recipe ( discovered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 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. diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 846d05c..165b67a 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -189,6 +189,11 @@ func (p *AdventurePlugin) Init() error { if err := resetAllAdvDailyActions(); err != nil { 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 { slog.Error("adventure: startup coop combat lock failed", "err", err) } diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index 16fdb27..dee715f 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -317,7 +317,13 @@ func (p *AdventurePlugin) handleArenaStats(ctx MessageContext) error { } 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 { @@ -516,6 +522,10 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c if err := saveAdvCharacter(char); err != nil { 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() run.Status = "dead" @@ -599,6 +609,10 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun, if err := saveAdvCharacter(char); err != nil { 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 if run.Status != "completed" && run.Status != "cashed_out" { diff --git a/internal/plugin/adventure_arena_render.go b/internal/plugin/adventure_arena_render.go index f8efbf4..38bedc0 100644 --- a/internal/plugin/adventure_arena_render.go +++ b/internal/plugin/adventure_arena_render.go @@ -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() } diff --git a/internal/plugin/adventure_arena_test.go b/internal/plugin/adventure_arena_test.go index e169d3d..6eac8a3 100644 --- a/internal/plugin/adventure_arena_test.go +++ b/internal/plugin/adventure_arena_test.go @@ -730,20 +730,18 @@ func TestRenderArenaDeathReprieve(t *testing.T) { } func TestRenderArenaPersonalStats_NoStats(t *testing.T) { - char := &AdventureCharacter{DisplayName: "TestPlayer"} - text := renderArenaPersonalStats(char, nil) + text := renderArenaPersonalStats("TestPlayer", 0, 0, nil) if !strings.Contains(text, "No arena runs") { t.Error("empty stats should say no runs") } } func TestRenderArenaPersonalStats_WithStats(t *testing.T) { - char := &AdventureCharacter{DisplayName: "Alice", ArenaWins: 8, ArenaLosses: 3} stats := &ArenaPersonalStats{ TotalRuns: 11, TotalEarnings: 150000, TotalDeaths: 3, HighestTier: 5, Tier5Completions: 2, } - text := renderArenaPersonalStats(char, stats) + text := renderArenaPersonalStats("Alice", 8, 3, stats) if !strings.Contains(text, "Alice") { t.Error("stats should show player name") } diff --git a/internal/plugin/dnd_sheet.go b/internal/plugin/dnd_sheet.go index 39352a3..2c785cd 100644 --- a/internal/plugin/dnd_sheet.go +++ b/internal/plugin/dnd_sheet.go @@ -62,11 +62,12 @@ func (p *AdventurePlugin) handleDnDSheetCmd(ctx MessageContext) error { advChar, _ := loadAdvCharacter(ctx.Sender) equip, _ := loadAdvEquipment(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) ci, _ := classInfo(c.Class) 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(fmt.Sprintf(" Mining %d Foraging %d Fishing %d Combat (legacy) %d\n", 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", - adv.ArenaWins, adv.ArenaLosses, adv.CurrentStreak, adv.BestStreak)) + wins, losses, adv.CurrentStreak, adv.BestStreak)) if adv.PetName != "" { b.WriteString(fmt.Sprintf(" Pet: %s the %s (lv %d)\n", adv.PetName, adv.PetType, adv.PetLevel)) } diff --git a/internal/plugin/dnd_subclass_test.go b/internal/plugin/dnd_subclass_test.go index 4d50800..173ca3c 100644 --- a/internal/plugin/dnd_subclass_test.go +++ b/internal/plugin/dnd_subclass_test.go @@ -308,7 +308,7 @@ func TestSheet_ShowsSubclassWhenChosen(t *testing.T) { STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10, Subclass: SubclassBattleMaster, } - out := renderDnDSheet(c, nil, nil, nil) + out := renderDnDSheet(c, nil, nil, nil, nil) if !strings.Contains(out, "Battle Master") { 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, 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") { t.Errorf("sheet should nudge `!subclass` when unchosen at L5:\n%s", out) } diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go new file mode 100644 index 0000000..8f2b87e --- /dev/null +++ b/internal/plugin/player_meta.go @@ -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 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 +} diff --git a/internal/plugin/player_meta_test.go b/internal/plugin/player_meta_test.go new file mode 100644 index 0000000..f45c126 --- /dev/null +++ b/internal/plugin/player_meta_test.go @@ -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) + } +}