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