mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
111
internal/plugin/dnd_l5g_test.go
Normal file
111
internal/plugin/dnd_l5g_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user