Adv 2.0 L5g: DnDCharacter mass-backfill + drop CombatLevel fallbacks

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) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 11:41:46 -07:00
parent df886ab86f
commit 0d2e554e22
7 changed files with 186 additions and 23 deletions

View File

@@ -421,7 +421,7 @@ Each sub-phase is the same shape as L4aL4e: 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 L5aL5h)

View File

@@ -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)

View File

@@ -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

View File

@@ -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 ────────────────────────────────────────────────────────────────────

View File

@@ -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.

View File

@@ -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

View File

@@ -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)
}
}