From 283f7adf5fc16b856b51a10a7e921574aff054bb Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 9 May 2026 10:00:19 -0700 Subject: [PATCH] Adv 2.0 L4c: masterwork migrates MasterworkDropsReceived off AdvCharacter Co-Authored-By: Claude Opus 4.7 (1M context) --- gogobee_legacy_migration.md | 12 ++- internal/db/db.go | 4 + internal/plugin/adventure.go | 5 ++ internal/plugin/adventure_masterwork.go | 26 ++++-- internal/plugin/player_meta.go | 97 +++++++++++++++++++-- internal/plugin/player_meta_test.go | 110 ++++++++++++++++++++++++ 6 files changed, 239 insertions(+), 15 deletions(-) diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index 02c63c9..74c18f4 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -298,7 +298,17 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events, - `go vet ./...` + `go test ./...` clean. ### 6.3 L4c — Masterwork -- `adventure_masterwork.go`: no AdvCharacter mutations today; only equipment reads. Port `advMasterworkSkillBonus` to take `*DnDCharacter` (skill fields added in §2.2). MasterworkDropsReceived → `player_meta`. +- `adventure_masterwork.go`: no AdvCharacter mutations today; only equipment reads. ~~Port `advMasterworkSkillBonus` to take `*DnDCharacter` (skill fields added in §2.2).~~ **Correction (2026-05-09):** `advMasterworkSkillBonus` already takes `(equip, activity)` — no char param to port. MasterworkDropsReceived → `player_meta`. + +**Status (2026-05-09):** SHIPPED on `adv-2.0`. +- `player_meta.masterwork_drops_received` column added via columnMigration in `internal/db/db.go`. +- `checkMasterworkDrop` signature dropped its `char *AdventureCharacter` param. First-drop detection now reads via `loadMasterworkDrops(userID)`; counter writes go through `upsertPlayerMetaMasterworkDrops` and dual-write to `AdvCharacter.MasterworkDropsReceived` via `loadAdvCharacter` + `saveAdvCharacter` during soak. +- `loadMasterworkDrops(userID)` reads `player_meta` → falls back to `adventure_characters.masterwork_drops_received` during soak (mirrors `loadHospitalVisits` shape). +- `backfillPlayerMetaMasterworkDrops()` runs on every Init; idempotent (only fills rows whose `masterwork_drops_received` is still zero). +- Note: `checkMasterworkDrop` itself is currently dead code (Phase R deprecated its only caller, the legacy daily-loop). The migration still ports the field so the column moves cleanly when future zone integration re-wires the drop hook. +- Tests: `TestPlayerMetaMasterworkDropsBackfill_Idempotent`, `TestLoadMasterworkDrops_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaMasterworkDrops_RoundTrip`. +- `go vet ./...` + `go test ./...` clean. +- Exit criterion: `grep 'AdventureCharacter\|CombatLevel' internal/plugin/adventure_masterwork.go` is empty. ### 6.4 L4d — Pets - `adventure_pets.go`: pet fields move from AdvCharacter to `player_meta` (see §2.1 `pet_*` columns). Pet combat hooks (PetMorningDefense, pet damage rolls) target DnDCharacter HP. diff --git a/internal/db/db.go b/internal/db/db.go index db47eca..42b7cd4 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -199,6 +199,10 @@ func runMigrations(d *sql.DB) error { // columns keep dual-writing during soak (gogobee_legacy_migration.md §6.2). `ALTER TABLE player_meta ADD COLUMN rival_pool INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE player_meta ADD COLUMN rival_unlocked_notified INTEGER NOT NULL DEFAULT 0`, + // Adv 2.0 Phase L4c — Masterwork migration off AdvCharacter. + // MasterworkDropsReceived moves to player_meta; AdvCharacter column + // keeps dual-writing during soak (gogobee_legacy_migration.md §6.3). + `ALTER TABLE player_meta ADD COLUMN masterwork_drops_received INTEGER NOT NULL DEFAULT 0`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index aa4f435..b81c6c3 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -209,6 +209,11 @@ func (p *AdventurePlugin) Init() error { if err := backfillPlayerMetaRivalState(); err != nil { slog.Error("player_meta: rival state backfill failed", "err", err) } + // Adv 2.0 Phase L4c — one-shot MasterworkDropsReceived backfill into + // player_meta. Idempotent (only fills zero rows). + if err := backfillPlayerMetaMasterworkDrops(); err != nil { + slog.Error("player_meta: masterwork_drops_received backfill failed", "err", err) + } // Phase L3 — cancel any open/active legacy coop dungeon runs and // refund member contributions + unsettled bets. Idempotent. closeAndRefundLegacyCoopRuns(p.euro) diff --git a/internal/plugin/adventure_masterwork.go b/internal/plugin/adventure_masterwork.go index 49c946f..bdb43d4 100644 --- a/internal/plugin/adventure_masterwork.go +++ b/internal/plugin/adventure_masterwork.go @@ -158,7 +158,7 @@ func masterworkDropFlavorText(activity AdvActivityType, tier int) string { // ── Drop Logic ───────────────────────────────────────────────────────────── -func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, outcome AdvOutcomeType) { +func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, outcome AdvOutcomeType) { // Only full-success outcomes drop masterwork (success or exceptional) if outcome != AdvOutcomeSuccess && outcome != AdvOutcomeExceptional { return @@ -193,11 +193,25 @@ func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, char *AdventureC autoEquip = true // genuinely better than what they have } - // First-drop detection - isFirstDrop := char.MasterworkDropsReceived == 0 - char.MasterworkDropsReceived++ - if err := saveAdvCharacter(char); err != nil { - slog.Error("adventure: failed to save masterwork counter", "user", userID, "err", err) + // First-drop detection. Read via player_meta (L4c migration); during + // soak it falls back to AdvCharacter for users whose row hasn't been + // dual-written yet. + priorDrops, err := loadMasterworkDrops(userID) + if err != nil { + slog.Error("adventure: failed to load masterwork counter", "user", userID, "err", err) + } + isFirstDrop := priorDrops == 0 + newDrops := priorDrops + 1 + if err := upsertPlayerMetaMasterworkDrops(userID, newDrops); err != nil { + slog.Error("adventure: failed to save masterwork counter (player_meta)", "user", userID, "err", err) + } + // Dual-write to AdvCharacter during soak. Best-effort: if the row is + // missing the player_meta write above is canonical. + if char, lerr := loadAdvCharacter(userID); lerr == nil { + char.MasterworkDropsReceived = newDrops + if err := saveAdvCharacter(char); err != nil { + slog.Error("adventure: failed to save masterwork counter (legacy)", "user", userID, "err", err) + } } if autoEquip { diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index b702d84..411d54d 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -17,12 +17,13 @@ import ( // 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 + 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 @@ -32,9 +33,9 @@ type PlayerMeta struct { 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 FROM player_meta WHERE user_id = ?`, + `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) + ).Scan(&m.ArenaWins, &m.ArenaLosses, &m.InvasionScore, &m.DisplayName, &m.HospitalVisits, &m.MasterworkDropsReceived) if err == sql.ErrNoRows { return m, nil } @@ -302,6 +303,86 @@ func backfillPlayerMetaRivalState() error { 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 diff --git a/internal/plugin/player_meta_test.go b/internal/plugin/player_meta_test.go index 175a0f3..b3e9105 100644 --- a/internal/plugin/player_meta_test.go +++ b/internal/plugin/player_meta_test.go @@ -423,3 +423,113 @@ func TestCreateAdvCharacter_DualWritesDisplayName(t *testing.T) { t.Errorf("create dual-write: got %q want %q", got, "FreshName") } } + +// Phase L4c — Masterwork migration. Backfill is idempotent and only touches +// zero-valued rows; the upsert helper round-trips; loadMasterworkDrops falls +// back to AdvCharacter when player_meta hasn't been populated yet. + +func TestPlayerMetaMasterworkDropsBackfill_Idempotent(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-mw-bf:example") + if err := createAdvCharacter(uid, "Dropper"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE adventure_characters SET masterwork_drops_received = ? WHERE user_id = ?`, + 3, string(uid), + ); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE player_meta SET masterwork_drops_received = 0 WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + if err := backfillPlayerMetaMasterworkDrops(); err != nil { + t.Fatalf("backfill 1: %v", err) + } + got, err := loadMasterworkDrops(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got != 3 { + t.Errorf("after backfill: got %d want 3", got) + } + + // Layer a dual-write increment. Backfill re-run must NOT clobber it + // back (only touches rows whose masterwork_drops_received is still zero). + if err := upsertPlayerMetaMasterworkDrops(uid, 4); err != nil { + t.Fatalf("dual-write: %v", err) + } + if err := backfillPlayerMetaMasterworkDrops(); err != nil { + t.Fatalf("backfill 2: %v", err) + } + got2, _ := loadMasterworkDrops(uid) + if got2 != 4 { + t.Errorf("backfill clobbered dual-write: got %d want 4", got2) + } +} + +func TestLoadMasterworkDrops_FallsBackToAdvCharacter(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-mw-fb:example") + if err := createAdvCharacter(uid, "MWFallback"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE adventure_characters SET masterwork_drops_received = ? WHERE user_id = ?`, + 2, string(uid), + ); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE player_meta SET masterwork_drops_received = 0 WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + got, err := loadMasterworkDrops(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got != 2 { + t.Errorf("fallback: got %d want 2", got) + } + + // Unknown user → 0, no error. + got2, err := loadMasterworkDrops(id.UserID("@meta-mw-nobody:example")) + if err != nil { + t.Fatalf("load missing: %v", err) + } + if got2 != 0 { + t.Errorf("missing user: got %d want 0", got2) + } +} + +func TestUpsertPlayerMetaMasterworkDrops_RoundTrip(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-mw-rt:example") + if err := upsertPlayerMetaMasterworkDrops(uid, 1); err != nil { + t.Fatalf("upsert insert: %v", err) + } + got, err := loadMasterworkDrops(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got != 1 { + t.Errorf("round-trip: got %d want 1", got) + } + if err := upsertPlayerMetaMasterworkDrops(uid, 9); err != nil { + t.Fatalf("upsert update: %v", err) + } + got2, _ := loadMasterworkDrops(uid) + if got2 != 9 { + t.Errorf("update: got %d want 9", got2) + } +}