From 0d2e554e22c1b58744daff31a4f97fad28a710fd Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 9 May 2026 11:41:46 -0700 Subject: [PATCH] Adv 2.0 L5g: DnDCharacter mass-backfill + drop CombatLevel fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backfillDnDCharactersFromAdv walks adventure_characters rows that have no dnd_character row and inserts an auto-migrated character (race/class inferred from archetypes, Level seeded from dndLevelFromCombatLevel). Idempotent via LEFT JOIN — pending-setup drafts and existing rows are skipped. Wired into Init after the L5f backfill. With every legacy player guaranteed a D&D row, the soak-window fallbacks in dndLevelForUser, rivalLevelForUser, and hospitalCostsForUser are retired (they now floor at level 1). dndLevelFromCombatLevel itself stays — still used by autoBuildCharacter and the !setup seed paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- gogobee_legacy_migration.md | 2 +- internal/plugin/adventure.go | 8 ++ internal/plugin/adventure_hospital.go | 11 +-- internal/plugin/adventure_rival.go | 7 +- internal/plugin/dnd.go | 18 ++--- internal/plugin/dnd_combat.go | 52 ++++++++++++ internal/plugin/dnd_l5g_test.go | 111 ++++++++++++++++++++++++++ 7 files changed, 186 insertions(+), 23 deletions(-) create mode 100644 internal/plugin/dnd_l5g_test.go diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index d92f0e9..c19ccbd 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -421,7 +421,7 @@ Each sub-phase is the same shape as L4a–L4e: column-add → backfill (idempote | **L5d** | Streak + action state + lifecycle (10 fields) | 1 day | Action state mutates every action, so the dual-write hook is hot — ensure the upsert is in the same code path that already does `saveAdvCharacter`. **Status (2026-05-09):** column + dual-write SHIPPED on `adv-2.0`. Ten columns added (current_streak, best_streak, last_action_date, streak_decayed, action_taken_today, holiday_action_taken, combat_actions_used, harvest_actions_used, created_at, last_active_at). `LifecycleState` + `HasLifecycle()` + load/upsert/backfill/projection helpers. Mutation surface turned out to be tiny: only `rest` (adventure.go) sets ActionTakenToday — combat/harvest counts are dormant — plus the streak halve + streak update in scheduler, and the bulk daily reset. Dual-writes wired at all four sites; new `resetAllPlayerMetaDailyActions` parallels `resetAllAdvDailyActions`. `createAdvCharacter` now seeds `created_at` + `last_active_at` (CURRENT_TIMESTAMP) so player_meta rows are fully formed at creation. Backfill idempotent (only fills rows whose `created_at` is still NULL). Tests: `TestPlayerMetaLifecycleStateBackfill_Idempotent`, `TestLoadLifecycleState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaLifecycleState_RoundTrip`, `TestResetAllPlayerMetaDailyActions`. `go vet ./... && go test ./...` clean. **Reader flip deferred** per pattern. | | **L5e** | Death state (8 fields) | 1 day | Decision point: do `DnDCharacter.HPCurrent == 0` semantics replace `Alive`/`DeadUntil`, or do we keep them as `player_meta.alive` / `player_meta.dead_until`? Recommendation: keep them as columns. The hospital revive path (L4a) already restores `DnDCharacter.HPCurrent = HPMax` alongside `Alive=true`, so the two are kept in sync; flipping the readers is the migration, not the death-state semantics. **Status (2026-05-09):** column + dual-write SHIPPED on `adv-2.0`. Eight columns added (alive INTEGER DEFAULT 1, dead_until/death_reprieve_last/last_pardon_used DATETIME, last_death_date/grudge_location/death_source/death_location TEXT). `DeathState` + load/upsert/backfill/projection helpers. **Strategy switch:** dual-write moved inside `saveAdvCharacter` itself (after the existing display_name dual-write). The mutation surface for death state spans ~50 save sites — per-site upserts would be too noisy. Every `saveAdvCharacter` call now propagates `DeathState` to player_meta. Backfill idempotent (only fills rows where every death field is still default). Tests: `TestPlayerMetaDeathStateBackfill_Idempotent`, `TestLoadDeathState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaDeathState_RoundTrip`, `TestSaveAdvCharacter_DualWritesDeathState`. `go vet ./... && go test ./...` clean. | | **L5f** | Misc (`Title`, `TreasuresLocked`, `CraftsSucceeded`) | 0.5 day | Each is a single-mutation-site field. **Status (2026-05-09):** column + dual-write SHIPPED on `adv-2.0`. Three columns (title TEXT, treasures_locked INT, crafts_succeeded INT). `MiscState` + load/upsert/backfill/projection helpers. Dual-write rides `saveAdvCharacter` alongside L5e (Title is dormant; TreasuresLocked + CraftsSucceeded mutate at known sites that already save). Backfill idempotent. Tests: `TestPlayerMetaMiscStateBackfill_Idempotent`, `TestLoadMiscState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaMiscState_RoundTrip`. `go vet ./... && go test ./...` clean. | -| **L5g** | DnDCharacter mass-backfill | 0.5 day | One-shot: for every active user without a D&D row, insert a default-class character at `dndLevelFromCombatLevel(adventure_characters.combat_level)`. Run once, idempotent (skip rows that already exist). After this lands, drop the `dndLevelFromCombatLevel` fallback branch in `loadHospitalVisits`/`rivalLevelForUser`/`dndLevelForUser`/zone gates. CombatLevel column in player_meta becomes unused at this point — drop it during the L5h cleanup. | +| **L5g** | DnDCharacter mass-backfill | 0.5 day | One-shot: for every active user without a D&D row, insert a default-class character at `dndLevelFromCombatLevel(adventure_characters.combat_level)`. Run once, idempotent (skip rows that already exist). After this lands, drop the `dndLevelFromCombatLevel` fallback branch in `loadHospitalVisits`/`rivalLevelForUser`/`dndLevelForUser`/zone gates. CombatLevel column in player_meta becomes unused at this point — drop it during the L5h cleanup. **Status (2026-05-09):** SHIPPED on `adv-2.0`. `backfillDnDCharactersFromAdv` in `dnd_combat.go` walks `adventure_characters LEFT JOIN dnd_character WHERE d.user_id IS NULL`, calls `autoBuildCharacter` (race/class inferred from archetypes; HP/AC computed; Level seeded from `dndLevelFromCombatLevel(CombatLevel)`), saves with `AutoMigrated=1`, `PendingSetup=0`. Idempotent — pre-existing rows (including pending-setup drafts) are skipped via the LEFT JOIN. Wired into Init after the L5f backfill. Fallbacks dropped: `dndLevelForUser` (dnd.go), `rivalLevelForUser` (adventure_rival.go), `hospitalCostsForUser` (adventure_hospital.go) — all now floor at level 1 when no D&D row exists. `dndLevelFromCombatLevel` itself is preserved (used by `autoBuildCharacter` for fresh-account level seeding and by `dndSetupConfirm`/preview/stub paths). Tests: `TestL5gBackfillDnDCharacters_Idempotent`, `TestL5gBackfillDnDCharacters_SkipsPendingSetup` in `dnd_l5g_test.go`. `go vet ./... && go test ./...` clean. | | **L5h** | L4e in-place housing reader flip + cut all AdvCharacter dual-writes | 1 day | Bundled per L4e note. Once writes stop, AdvCharacter rows go cold; dual-write removal is mechanical (delete `saveAdvCharacter` calls and the upserts that follow them in pairs). | ### 7.4 Final teardown (after L5a–L5h) diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 514b1dc..d9a23c2 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -255,6 +255,14 @@ func (p *AdventurePlugin) Init() error { if err := backfillPlayerMetaMiscState(); err != nil { slog.Error("player_meta: misc state backfill failed", "err", err) } + // Adv 2.0 Phase L5g — one-shot DnDCharacter mass-backfill. Every legacy + // player without a D&D row gets an auto-migrated character seeded from + // their CombatLevel, retiring the dndLevelFromCombatLevel fallbacks in + // dndLevelForUser / rivalLevelForUser / hospitalCostsForUser. Idempotent + // (skips users who already have any dnd_character row). + if err := backfillDnDCharactersFromAdv(); err != nil { + slog.Error("dnd: L5g character 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_hospital.go b/internal/plugin/adventure_hospital.go index b830ea0..c6f495e 100644 --- a/internal/plugin/adventure_hospital.go +++ b/internal/plugin/adventure_hospital.go @@ -19,16 +19,13 @@ type advPendingHospitalPay struct { // hospitalCostsForUser returns (beforeInsurance, afterInsurance) for the // hospital revival bill. Phase L4a switches the formula off CombatLevel // onto DnDCharacter.Level: `Level × 50_000` after insurance, 5× that -// before. Falls back to the legacy combat-level mapping when no D&D -// character row exists yet (createDnDCharacterFromAdv hasn't run). +// before. Post-L5g every legacy player has a DnDCharacter row, so the +// CombatLevel-derived fallback has been retired. Floors at level 1. func hospitalCostsForUser(char *AdventureCharacter) (int64, int64) { - level := 0 - if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil { + level := 1 + if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil && dnd.Level > 0 { level = dnd.Level } - if level <= 0 { - level = dndLevelFromCombatLevel(char.CombatLevel) - } after := int64(level) * 50_000 before := after * 5 return before, after diff --git a/internal/plugin/adventure_rival.go b/internal/plugin/adventure_rival.go index 08fee9e..cd2573e 100644 --- a/internal/plugin/adventure_rival.go +++ b/internal/plugin/adventure_rival.go @@ -29,13 +29,14 @@ const ( ) // 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. +// math. Post-L5g every legacy player has a DnDCharacter row, so the +// CombatLevel-derived fallback has been retired. Returns 1 as a safe floor +// (filtered out by rivalMinLevel anyway). 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) + return 1 } // ── Types ──────────────────────────────────────────────────────────────────── diff --git a/internal/plugin/dnd.go b/internal/plugin/dnd.go index 9ee235d..9594574 100644 --- a/internal/plugin/dnd.go +++ b/internal/plugin/dnd.go @@ -340,22 +340,16 @@ func dndLevelFromCombatLevel(combatLevel int) int { return lvl } -// dndLevelForUser returns the player's display-facing D&D level: the -// DnDCharacter row's Level when present, else the converted legacy -// adventure_characters.combat_level as a soak-period fallback. Used by -// render/twinbee/scheduler after L4f to keep "Combat Lv.X" in player- -// facing surfaces in sync with the D&D level system without leaking the -// AdventureCharacter type into render code. +// dndLevelForUser returns the player's display-facing D&D level. Post-L5g +// every legacy player has a DnDCharacter row (auto-migrated by the one-shot +// mass-backfill on Init), so the legacy CombatLevel fallback that lived here +// during the soak window has been retired. Returns 1 as a safe default for +// users without any D&D row (e.g. brand-new accounts before first combat). func dndLevelForUser(userID id.UserID) int { if c, err := LoadDnDCharacter(userID); err == nil && c != nil && c.Level > 0 { return c.Level } - row := db.Get().QueryRow(`SELECT combat_level FROM adventure_characters WHERE user_id = ?`, string(userID)) - var legacy int - if err := row.Scan(&legacy); err != nil { - return 1 - } - return dndLevelFromCombatLevel(legacy) + return 1 } // applyRaceMods adds the race's ability modifiers to a base score block. diff --git a/internal/plugin/dnd_combat.go b/internal/plugin/dnd_combat.go index 64320c3..08ace05 100644 --- a/internal/plugin/dnd_combat.go +++ b/internal/plugin/dnd_combat.go @@ -286,6 +286,58 @@ func autoBuildCharacter(userID id.UserID, char *AdventureCharacter) *DnDCharacte return c } +// backfillDnDCharactersFromAdv is the L5g one-shot mass-backfill: for every +// adventure_characters row that has no corresponding dnd_character row, build +// an auto-migrated D&D character (same shape as ensureDnDCharacterForCombat's +// fresh path) and persist it. After this lands, every legacy player has a +// D&D row, so dndLevelForUser / rivalLevelForUser / hospitalCostsForUser can +// drop their dndLevelFromCombatLevel fallbacks. +// +// Idempotent: skips users who already have any dnd_character row (including +// pending-setup drafts — those finish via !setup confirm and shouldn't be +// overwritten by the backfill). +func backfillDnDCharactersFromAdv() error { + rows, err := db.Get().Query(` + SELECT a.user_id + FROM adventure_characters a + LEFT JOIN dnd_character d ON d.user_id = a.user_id + WHERE d.user_id IS NULL + `) + if err != nil { + return err + } + var userIDs []id.UserID + for rows.Next() { + var uid string + if err := rows.Scan(&uid); err != nil { + rows.Close() + return err + } + userIDs = append(userIDs, id.UserID(uid)) + } + rows.Close() + + created := 0 + for _, uid := range userIDs { + char, err := loadAdvCharacter(uid) + if err != nil || char == nil { + continue + } + c := autoBuildCharacter(uid, char) + c.AutoMigrated = true + c.PendingSetup = false + if err := SaveDnDCharacter(c); err != nil { + slog.Error("dnd: L5g backfill save failed", "user", uid, "err", err) + continue + } + _ = initResources(uid, c.Class) + _ = ensureSpellsForCharacter(c) + created++ + } + slog.Info("dnd: L5g backfill complete", "rows", created) + return nil +} + // ── Roll summary line ──────────────────────────────────────────────────────── // dndRollSummaryLine scans a CombatResult's events for d20 rolls and returns diff --git a/internal/plugin/dnd_l5g_test.go b/internal/plugin/dnd_l5g_test.go new file mode 100644 index 0000000..0129f9d --- /dev/null +++ b/internal/plugin/dnd_l5g_test.go @@ -0,0 +1,111 @@ +package plugin + +import ( + "testing" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +// TestL5gBackfillDnDCharacters_Idempotent verifies that the L5g mass-backfill: +// - creates a DnDCharacter for an adventure_characters row that lacks one, +// - seeds Level from CombatLevel via dndLevelFromCombatLevel, +// - is idempotent (re-running does not clobber an already-populated row), +// - does not touch users who already have a confirmed (or pending) D&D row. +func TestL5gBackfillDnDCharacters_Idempotent(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@l5g-bf:example") + if err := createAdvCharacter(uid, "Backfiller"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE adventure_characters SET combat_level = ? WHERE user_id = ?`, + 25, string(uid), + ); err != nil { + t.Fatalf("seed combat_level: %v", err) + } + // Ensure no D&D row pre-exists. + if _, err := db.Get().Exec(`DELETE FROM dnd_character WHERE user_id = ?`, string(uid)); err != nil { + t.Fatalf("clear dnd_character: %v", err) + } + + if err := backfillDnDCharactersFromAdv(); err != nil { + t.Fatalf("backfill 1: %v", err) + } + + c, err := LoadDnDCharacter(uid) + if err != nil { + t.Fatalf("load after backfill: %v", err) + } + if c == nil { + t.Fatalf("expected D&D character row after backfill, got nil") + } + wantLevel := dndLevelFromCombatLevel(25) + if c.Level != wantLevel { + t.Errorf("Level = %d, want %d", c.Level, wantLevel) + } + if c.PendingSetup { + t.Errorf("backfilled row should be PendingSetup=false") + } + if !c.AutoMigrated { + t.Errorf("backfilled row should be AutoMigrated=true") + } + if c.HPMax <= 0 || c.HPCurrent <= 0 { + t.Errorf("backfilled row missing HP: max=%d cur=%d", c.HPMax, c.HPCurrent) + } + + // Mutate the row to prove a second backfill is idempotent (does not + // overwrite the existing row). + c.Level = 12 + c.HPCurrent = 5 + if err := SaveDnDCharacter(c); err != nil { + t.Fatalf("save mutated: %v", err) + } + if err := backfillDnDCharactersFromAdv(); err != nil { + t.Fatalf("backfill 2: %v", err) + } + got, err := LoadDnDCharacter(uid) + if err != nil || got == nil { + t.Fatalf("reload: %v", err) + } + if got.Level != 12 || got.HPCurrent != 5 { + t.Errorf("backfill clobbered live row: Level=%d HPCurrent=%d", got.Level, got.HPCurrent) + } +} + +// TestL5gBackfillDnDCharacters_SkipsPendingSetup ensures users mid-!setup +// (PendingSetup=1, partial draft) are not overwritten. +func TestL5gBackfillDnDCharacters_SkipsPendingSetup(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@l5g-pending:example") + if err := createAdvCharacter(uid, "Drafter"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + draft := &DnDCharacter{ + UserID: uid, + Race: RaceElf, + Class: ClassMage, + Level: 3, + PendingSetup: true, + } + if err := SaveDnDCharacter(draft); err != nil { + t.Fatalf("save draft: %v", err) + } + + if err := backfillDnDCharactersFromAdv(); err != nil { + t.Fatalf("backfill: %v", err) + } + + got, err := LoadDnDCharacter(uid) + if err != nil || got == nil { + t.Fatalf("reload: %v", err) + } + if !got.PendingSetup { + t.Errorf("backfill clobbered pending draft (PendingSetup flipped to false)") + } + if got.Race != RaceElf || got.Class != ClassMage { + t.Errorf("backfill mutated draft race/class: got %s/%s", got.Race, got.Class) + } +}