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

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