diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index 068e7e4..02c63c9 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -284,9 +284,19 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events, - Tests: `TestPlayerMetaHospitalVisitsBackfill_Idempotent`, `TestLoadHospitalVisits_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaHospitalVisits_RoundTrip` (skip without prod DB, matching the L2/L4f-prep pattern). ### 6.2 L4b — Rival -- `adventure_rival.go`: unlock at Level ≥ 3 (was CombatLevel ≥ 5). RivalPool → `player_meta`. Duel combat already uses `combat_engine` — port to `simulateCombat` here too (small extra step). +- `adventure_rival.go`: unlock at Level ≥ 3 (was CombatLevel ≥ 5). RivalPool → `player_meta`. ~~Duel combat already uses `combat_engine` — port to `simulateCombat` here too (small extra step).~~ **Correction (2026-05-09):** rival duel combat is RPS, not `combat_engine` — no combat-engine port needed. - Move rival flavor into existing `adventure_flavor_rival.go` — no new file. +**Status (2026-05-09):** SHIPPED on `adv-2.0`. +- `player_meta.rival_pool` + `player_meta.rival_unlocked_notified` columns added via columnMigration in `internal/db/db.go`. +- New `rivalMinLevel = 3` (was `rivalMinCombatLevel = 5`). Stake formula `(level / 3) * 1000` (was `(combatLevel / 5) * 1000`) — same magnitude at the unlock threshold (Level 3 → €1000 mirrors legacy CL5 → €1000), tops out around €6000 at Level 20. Tunable later. +- `rivalLevelForUser(char)` reads `DnDCharacter.Level` with `dndLevelFromCombatLevel(char.CombatLevel)` fallback when no D&D row exists yet — mirrors `hospitalCostsForUser` shape. +- `checkRivalPoolUnlock` now reads D&D level, dual-writes `RivalPool` / `RivalUnlockedNotified` to player_meta via `upsertPlayerMetaRivalState`. Read sites flipped to `loadRivalState(userID)` in `selectRivalPair`, `handleRivalsCmd`, and the morning-DM rival status in `adventure_render.go`. +- `loadRivalState(userID)` reads `player_meta` → falls back to `adventure_characters.rival_pool / rival_unlocked_notified` during soak. +- `backfillPlayerMetaRivalState()` runs on every Init; idempotent (only fills rows whose rival columns are still zero, so dual-writes survive re-runs). +- Tests: `TestPlayerMetaRivalStateBackfill_Idempotent`, `TestLoadRivalState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaRivalState_RoundTrip` (skip without prod DB, matching the L4a/L4f-prep pattern). +- `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`. diff --git a/internal/db/db.go b/internal/db/db.go index d145c19..db47eca 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -194,6 +194,11 @@ func runMigrations(d *sql.DB) error { // HospitalVisits moves to player_meta; AdvCharacter.hospital_visits // keeps dual-writing during soak (gogobee_legacy_migration.md §6.1). `ALTER TABLE player_meta ADD COLUMN hospital_visits INTEGER NOT NULL DEFAULT 0`, + // Adv 2.0 Phase L4b — Rival migration off AdvCharacter. + // RivalPool / RivalUnlockedNotified move to player_meta; AdvCharacter + // 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`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index f1a7791..aa4f435 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -204,6 +204,11 @@ func (p *AdventurePlugin) Init() error { if err := backfillPlayerMetaHospitalVisits(); err != nil { slog.Error("player_meta: hospital_visits backfill failed", "err", err) } + // Adv 2.0 Phase L4b — one-shot rival state backfill into player_meta. + // Idempotent (only fills rows whose rival columns are still zero). + if err := backfillPlayerMetaRivalState(); err != nil { + slog.Error("player_meta: rival state 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_render.go b/internal/plugin/adventure_render.go index fbb8101..2464f5b 100644 --- a/internal/plugin/adventure_render.go +++ b/internal/plugin/adventure_render.go @@ -194,8 +194,9 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]* sb.WriteString(fmt.Sprintf("\n🍼 Babysitting: %s (focus: %s)\n", remaining, char.BabysitSkillFocus)) } - // Rival status - if char.RivalPool == 1 { + // Rival status — read from player_meta (Adv 2.0 Phase L4b). + rivalPoolFlag, _, _ := loadRivalState(char.UserID) + if rivalPoolFlag == 1 { records, _ := loadAllRivalRecords(char.UserID) sb.WriteString("\n⚔️ Rivals: Unlocked") if len(records) > 0 { diff --git a/internal/plugin/adventure_rival.go b/internal/plugin/adventure_rival.go index c36d1a3..08fee9e 100644 --- a/internal/plugin/adventure_rival.go +++ b/internal/plugin/adventure_rival.go @@ -18,13 +18,26 @@ import ( // ── Constants ──────────────────────────────────────────────────────────────── const ( - rivalMinCombatLevel = 5 + // Adv 2.0 Phase L4b — gate switched off CombatLevel onto DnDCharacter.Level. + // Legacy gate was CombatLevel >= 5; new gate is Level >= 3 + // (gogobee_legacy_migration.md §2.3). + rivalMinLevel = 3 rivalChallengeWindow = 24 * time.Hour rivalSamePairCooldown = 7 * 24 * time.Hour rivalMinIntervalHours = 3 * 24 // 3 days in hours rivalMaxIntervalHours = 4 * 24 // 4 days in hours ) +// rivalLevelForUser returns the player's D&D level for rival gating + stake +// math, falling back to a CombatLevel-derived level when no D&D row exists +// yet. Mirrors hospitalCostsForUser's fallback pattern. +func rivalLevelForUser(char *AdventureCharacter) int { + if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil && dnd.Level > 0 { + return dnd.Level + } + return dndLevelFromCombatLevel(char.CombatLevel) +} + // ── Types ──────────────────────────────────────────────────────────────────── type advRivalChallenge struct { @@ -52,19 +65,38 @@ type advPendingRivalRPS struct { // ── Stake Calculation ──────────────────────────────────────────────────────── -func rivalStake(combatLevel int) int { - return (combatLevel / 5) * 1000 +// rivalStake returns the duel stake in € for a given D&D Level. +// Adv 2.0 Phase L4b — previously `(combatLevel / 5) * 1000`. New formula +// `(level / 3) * 1000` keeps the same magnitude at the unlock threshold +// (Level 3 = €1000, matching legacy CombatLevel 5 = €1000) and tops out +// around €6000 at Level 20. Players below `rivalMinLevel` produce stake 0, +// which selectRivalPair already filters out. +func rivalStake(level int) int { + return (level / 3) * 1000 } // ── Rival Pool Unlock ──────────────────────────────────────────────────────── func (p *AdventurePlugin) checkRivalPoolUnlock(char *AdventureCharacter) { - if char.CombatLevel >= rivalMinCombatLevel && char.RivalPool == 0 { - char.RivalPool = 1 - if !char.RivalUnlockedNotified { - char.RivalUnlockedNotified = true - p.SendDM(char.UserID, rivalUnlockDM) - } + level := rivalLevelForUser(char) + if level < rivalMinLevel { + return + } + pool, notified, _ := loadRivalState(char.UserID) + if pool > 0 && notified { + // Already unlocked + notified; nothing to do. + return + } + // Promote to pool + send unlock DM if not yet announced. + char.RivalPool = 1 + pool = 1 + if !notified { + char.RivalUnlockedNotified = true + notified = true + p.SendDM(char.UserID, rivalUnlockDM) + } + if err := upsertPlayerMetaRivalState(char.UserID, pool, notified); err != nil { + slog.Error("player_meta: rival unlock dual-write failed", "user", char.UserID, "err", err) } } @@ -303,13 +335,17 @@ func (p *AdventurePlugin) selectRivalPair() (*AdventureCharacter, *AdventureChar // Filter to eligible pool members. var pool []AdventureCharacter for _, c := range chars { - if c.RivalPool == 0 || !c.Alive || c.BabysitActive { + if !c.Alive || c.BabysitActive { + continue + } + poolFlag, _, _ := loadRivalState(c.UserID) + if poolFlag == 0 { continue } if hasActiveChallenge(c.UserID) { continue } - stake := rivalStake(c.CombatLevel) + stake := rivalStake(rivalLevelForUser(&c)) if stake <= 0 { continue } @@ -349,7 +385,7 @@ func (p *AdventurePlugin) selectRivalPair() (*AdventureCharacter, *AdventureChar // ── Challenge Issuance ─────────────────────────────────────────────────────── func (p *AdventurePlugin) issueRivalChallenge(challenger, challenged *AdventureCharacter) { - stake := rivalStake(challenged.CombatLevel) + stake := rivalStake(rivalLevelForUser(challenged)) challenge := &advRivalChallenge{ ChallengeID: uuid.New().String()[:12], ChallengerID: challenger.UserID, @@ -828,10 +864,11 @@ func (p *AdventurePlugin) handleRivalsCmd(ctx MessageContext) error { return err } - if char.RivalPool == 0 { + pool, _, _ := loadRivalState(char.UserID) + if pool == 0 { return p.SendReply(ctx.RoomID, ctx.EventID, - fmt.Sprintf("You need Combat Level %d to enter the rival pool. Currently: %d.", - rivalMinCombatLevel, char.CombatLevel)) + fmt.Sprintf("You need Level %d to enter the rival pool. Currently: %d.", + rivalMinLevel, rivalLevelForUser(char))) } records, err := loadAllRivalRecords(char.UserID) diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index e4bdc44..b702d84 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -204,6 +204,104 @@ func backfillPlayerMetaHospitalVisits() error { return nil } +// upsertPlayerMetaRivalState writes rival_pool / rival_unlocked_notified for +// a user, leaving other columns untouched. Used by the dual-write path during +// the L4b Rival migration: every place that mutates AdvCharacter.RivalPool / +// RivalUnlockedNotified also calls this so player_meta stays in sync until +// soak completes (gogobee_legacy_migration.md §6.2, §11). +func upsertPlayerMetaRivalState(userID id.UserID, pool int, notified bool) error { + notifiedInt := 0 + if notified { + notifiedInt = 1 + } + _, err := db.Get().Exec( + `INSERT INTO player_meta (user_id, rival_pool, rival_unlocked_notified) VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + rival_pool = excluded.rival_pool, + rival_unlocked_notified = excluded.rival_unlocked_notified`, + string(userID), pool, notifiedInt, + ) + return err +} + +// loadRivalState returns rival_pool / rival_unlocked_notified for a user from +// player_meta when populated, falling back to adventure_characters during the +// L4b soak window. Treats a missing row as (0, false). The fallback returns +// the AdvCharacter values whenever player_meta has the unmigrated default +// (pool=0, notified=0) — a true "not yet in pool" state and the pre-backfill +// state are indistinguishable at the column level, but the legacy column +// only ever moves toward unlocked, so picking the higher of the two values +// is safe. +func loadRivalState(userID id.UserID) (pool int, notified bool, err error) { + var notifiedInt int + err = db.Get().QueryRow( + `SELECT rival_pool, rival_unlocked_notified FROM player_meta WHERE user_id = ?`, + string(userID), + ).Scan(&pool, ¬ifiedInt) + if err == nil && (pool > 0 || notifiedInt > 0) { + return pool, notifiedInt == 1, nil + } + if err != nil && err != sql.ErrNoRows { + return 0, false, err + } + // Fallback during soak. + var legacyPool, legacyNotified int + err = db.Get().QueryRow( + `SELECT rival_pool, rival_unlocked_notified FROM adventure_characters WHERE user_id = ?`, + string(userID), + ).Scan(&legacyPool, &legacyNotified) + if err == sql.ErrNoRows { + return pool, notifiedInt == 1, nil + } + if err != nil { + return 0, false, err + } + if legacyPool > pool { + pool = legacyPool + } + if legacyNotified > notifiedInt { + notifiedInt = legacyNotified + } + return pool, notifiedInt == 1, nil +} + +// backfillPlayerMetaRivalState copies rival_pool / rival_unlocked_notified +// from adventure_characters into player_meta for any row whose values are +// still the default zero. Idempotent: only updates rows that haven't been +// populated yet (via backfill or dual-write). +func backfillPlayerMetaRivalState() 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 rival_pool = ( + SELECT rival_pool FROM adventure_characters + WHERE adventure_characters.user_id = player_meta.user_id + ), + rival_unlocked_notified = ( + SELECT rival_unlocked_notified FROM adventure_characters + WHERE adventure_characters.user_id = player_meta.user_id + ) + WHERE rival_pool = 0 AND rival_unlocked_notified = 0 + AND EXISTS ( + SELECT 1 FROM adventure_characters + WHERE adventure_characters.user_id = player_meta.user_id + AND (adventure_characters.rival_pool > 0 + OR adventure_characters.rival_unlocked_notified > 0) + ) + `) + if err != nil { + return err + } + n, _ := res.RowsAffected() + slog.Info("player_meta: rival state 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 5ac9ccd..175a0f3 100644 --- a/internal/plugin/player_meta_test.go +++ b/internal/plugin/player_meta_test.go @@ -297,6 +297,117 @@ func TestUpsertPlayerMetaHospitalVisits_RoundTrip(t *testing.T) { } } +// Phase L4b — Rival migration. Backfill is idempotent and only fills rows +// whose rival columns are still default zero; the upsert helper round-trips; +// loadRivalState falls back to AdvCharacter when player_meta hasn't been +// populated yet. + +func TestPlayerMetaRivalStateBackfill_Idempotent(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-rv-bf:example") + if err := createAdvCharacter(uid, "Rivaler"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE adventure_characters SET rival_pool = ?, rival_unlocked_notified = ? WHERE user_id = ?`, + 1, 1, string(uid), + ); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE player_meta SET rival_pool = 0, rival_unlocked_notified = 0 WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + if err := backfillPlayerMetaRivalState(); err != nil { + t.Fatalf("backfill 1: %v", err) + } + pool, notified, err := loadRivalState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if pool != 1 || !notified { + t.Errorf("after backfill: got pool=%d notified=%v want 1, true", pool, notified) + } + + // Layer a dual-write update. Backfill re-run must NOT clobber it back + // (only touches rows whose rival columns are still both zero). + if err := upsertPlayerMetaRivalState(uid, 1, false); err != nil { + t.Fatalf("dual-write: %v", err) + } + if err := backfillPlayerMetaRivalState(); err != nil { + t.Fatalf("backfill 2: %v", err) + } + pool2, notified2, _ := loadRivalState(uid) + if pool2 != 1 || notified2 { + t.Errorf("backfill clobbered dual-write: got pool=%d notified=%v want 1, false", pool2, notified2) + } +} + +func TestLoadRivalState_FallsBackToAdvCharacter(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-rv-fb:example") + if err := createAdvCharacter(uid, "RivalFallback"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE adventure_characters SET rival_pool = ?, rival_unlocked_notified = ? WHERE user_id = ?`, + 1, 1, string(uid), + ); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE player_meta SET rival_pool = 0, rival_unlocked_notified = 0 WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + pool, notified, err := loadRivalState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if pool != 1 || !notified { + t.Errorf("fallback: got pool=%d notified=%v want 1, true", pool, notified) + } + + // Unknown user → zeros, no error. + pool2, notified2, err := loadRivalState(id.UserID("@meta-rv-nobody:example")) + if err != nil { + t.Fatalf("load missing: %v", err) + } + if pool2 != 0 || notified2 { + t.Errorf("missing user: got pool=%d notified=%v want 0, false", pool2, notified2) + } +} + +func TestUpsertPlayerMetaRivalState_RoundTrip(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-rv-rt:example") + if err := upsertPlayerMetaRivalState(uid, 1, false); err != nil { + t.Fatalf("upsert insert: %v", err) + } + pool, notified, err := loadRivalState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if pool != 1 || notified { + t.Errorf("round-trip 1: got pool=%d notified=%v want 1, false", pool, notified) + } + if err := upsertPlayerMetaRivalState(uid, 1, true); err != nil { + t.Fatalf("upsert update: %v", err) + } + pool2, notified2, _ := loadRivalState(uid) + if pool2 != 1 || !notified2 { + t.Errorf("round-trip 2: got pool=%d notified=%v want 1, true", pool2, notified2) + } +} + func TestCreateAdvCharacter_DualWritesDisplayName(t *testing.T) { setupAuditTestDB(t)