diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index 41a149e..2e0a8a1 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -419,7 +419,7 @@ Each sub-phase is the same shape as L4a–L4e: column-add → backfill (idempote | **L5b** | Babysit state (5 fields) | 0.5 day | Mutation surface is `runBabysitDailyTrickle` save in scheduler + the babysit toggle handlers. Tightly scoped. **Status (2026-05-09):** column + dual-write SHIPPED on `adv-2.0`. Five columns (`babysit_active`, `babysit_expires_at` DATETIME, `babysit_skill_focus`, `auto_babysit`, `auto_babysit_focus`) added via columnMigration. `BabysitState` + `IsActive()` + load/upsert/backfill/projection helpers in `player_meta.go`. Dual-writes wired at `handleBabysitStart`, `handleBabysitCancel`, `checkBabysitExpiry`. Backfill idempotent — only fills inactive rows whose legacy counterpart is active. AutoBabysit/AutoBabysitFocus + BabysitSkillFocus are dormant fields (no live mutation site) preserved for the schema migration. Tests: `TestPlayerMetaBabysitStateBackfill_Idempotent`, `TestLoadBabysitState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaBabysitState_RoundTrip`. `go vet ./... && go test ./...` clean. **Reader flip deferred** per L4e/L5a pattern. | | **L5c** | NPC counters/debuffs (~13 fields) | 1 day | "Later phase" referenced in L4d note. Pack the four `*Expires` and three `*LastSeen` timestamps as nullable columns; counters as ints. The roll-targets are write-once-per-encounter so dual-write is cheap. **Hidden discovery mechanic — never surface in player-facing output.** **Status (2026-05-09):** column + dual-write SHIPPED on `adv-2.0`. Thirteen columns added (5 nullable DATETIME for Misty/Arina last-seen + buff/debuff expiries, 1 int + 1 text for npc_msg counters, 2 ints for roll targets, 4 ints for encounter/donated/thom-fired/robbie counters). `NPCState` + `HasNPCActivity()` + load/upsert/backfill/projection helpers. Dual-writes wired at `processNPCEncounters` (msg-count save), `npcFireEncounter` (last-seen save), `resolveMisty` (insufficient balance, debit failure, buff/donated, declined-debuff), `resolveArina` (buff), `adventure_robbie.go` (visit count), `adventure_housing.go::handleThomGreet` (animal line fired). Backfill idempotent. Tests added. `go vet ./... && go test ./...` clean. **Reader flip deferred** per pattern. | | **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. | +| **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. | | **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. | | **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). | diff --git a/internal/db/db.go b/internal/db/db.go index b58436b..f35a50f 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -283,6 +283,23 @@ func runMigrations(d *sql.DB) error { `ALTER TABLE player_meta ADD COLUMN harvest_actions_used INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE player_meta ADD COLUMN created_at DATETIME`, `ALTER TABLE player_meta ADD COLUMN last_active_at DATETIME`, + // Adv 2.0 Phase L5e — Death state migration off AdvCharacter. + // Alive / DeadUntil / DeathReprieveLast / LastDeathDate / + // LastPardonUsed / GrudgeLocation / DeathSource / DeathLocation move + // to player_meta. Dual-write strategy switches to inside + // saveAdvCharacter (death state mutates at ~50 save sites; per-site + // upserts would be too noisy — gogobee_legacy_migration.md §7.3 + // L5e). `Alive` defaults to 1 since legacy created characters are + // alive, and a never-migrated row should fall through to the legacy + // table via loadDeathState. + `ALTER TABLE player_meta ADD COLUMN alive INTEGER NOT NULL DEFAULT 1`, + `ALTER TABLE player_meta ADD COLUMN dead_until DATETIME`, + `ALTER TABLE player_meta ADD COLUMN death_reprieve_last DATETIME`, + `ALTER TABLE player_meta ADD COLUMN last_death_date TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE player_meta ADD COLUMN last_pardon_used DATETIME`, + `ALTER TABLE player_meta ADD COLUMN grudge_location TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE player_meta ADD COLUMN death_source TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE player_meta ADD COLUMN death_location TEXT NOT NULL DEFAULT ''`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index cf01ffb..8bc7be0 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -245,6 +245,11 @@ func (p *AdventurePlugin) Init() error { if err := backfillPlayerMetaLifecycleState(); err != nil { slog.Error("player_meta: lifecycle state backfill failed", "err", err) } + // Adv 2.0 Phase L5e — one-shot death state backfill into player_meta. + // Idempotent (only fills rows whose death fields are all default). + if err := backfillPlayerMetaDeathState(); err != nil { + slog.Error("player_meta: death state 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_character.go b/internal/plugin/adventure_character.go index 872c764..d0ebbe5 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -718,6 +718,12 @@ func saveAdvCharacter(char *AdventureCharacter) error { if dnErr := upsertPlayerMetaDisplayName(char.UserID, char.DisplayName); dnErr != nil { slog.Error("player_meta: display_name dual-write failed", "user", char.UserID, "err", dnErr) } + // Adv 2.0 Phase L5e — dual-write death state into player_meta. Mutation + // surface is too wide for per-site upserts (~50 save sites), so the + // dual-write rides every saveAdvCharacter call. + if dsErr := upsertPlayerMetaDeathState(char.UserID, deathStateFromAdvChar(char)); dsErr != nil { + slog.Error("player_meta: death state dual-write failed", "user", char.UserID, "err", dsErr) + } return nil } diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index c34cc7a..3c0bcbb 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -1593,6 +1593,205 @@ func resetAllPlayerMetaDailyActions() error { return err } +// DeathState mirrors player_meta's death-state columns. Phase L5e ports +// these fields off AdvCharacter (gogobee_legacy_migration.md §7.3 L5e). +// Mutation surface is large (~50 saveAdvCharacter sites touch death +// state), so the dual-write pattern switches to inside saveAdvCharacter +// itself rather than per-site upserts. +type DeathState struct { + Alive bool + DeadUntil *time.Time + DeathReprieveLast *time.Time + LastDeathDate string + LastPardonUsed *time.Time + GrudgeLocation string + DeathSource string + DeathLocation string +} + +// upsertPlayerMetaDeathState writes the full death column set for a user. +// Called from inside saveAdvCharacter during the L5e soak window. +func upsertPlayerMetaDeathState(userID id.UserID, s DeathState) error { + alive := 0 + if s.Alive { + alive = 1 + } + var deadUntil, reprieve, pardon interface{} + if s.DeadUntil != nil { + deadUntil = s.DeadUntil.UTC() + } + if s.DeathReprieveLast != nil { + reprieve = s.DeathReprieveLast.UTC() + } + if s.LastPardonUsed != nil { + pardon = s.LastPardonUsed.UTC() + } + _, err := db.Get().Exec( + `INSERT INTO player_meta ( + user_id, alive, dead_until, death_reprieve_last, + last_death_date, last_pardon_used, + grudge_location, death_source, death_location + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + alive = excluded.alive, + dead_until = excluded.dead_until, + death_reprieve_last = excluded.death_reprieve_last, + last_death_date = excluded.last_death_date, + last_pardon_used = excluded.last_pardon_used, + grudge_location = excluded.grudge_location, + death_source = excluded.death_source, + death_location = excluded.death_location`, + string(userID), alive, deadUntil, reprieve, + s.LastDeathDate, pardon, + s.GrudgeLocation, s.DeathSource, s.DeathLocation, + ) + return err +} + +// loadDeathState returns death state from player_meta when populated, +// otherwise falls back to adventure_characters during the L5e soak +// window. The "row is migrated" marker is any non-default field. +func loadDeathState(userID id.UserID) (DeathState, error) { + var ( + s DeathState + aliveInt int + deadUntil, reprieve, pardon sql.NullTime + ) + err := db.Get().QueryRow( + `SELECT alive, dead_until, death_reprieve_last, + last_death_date, last_pardon_used, + grudge_location, death_source, death_location + FROM player_meta WHERE user_id = ?`, + string(userID), + ).Scan( + &aliveInt, &deadUntil, &reprieve, + &s.LastDeathDate, &pardon, + &s.GrudgeLocation, &s.DeathSource, &s.DeathLocation, + ) + migrated := false + if err == nil { + s.Alive = aliveInt == 1 + if deadUntil.Valid { + t := deadUntil.Time.UTC() + s.DeadUntil = &t + migrated = true + } + if reprieve.Valid { + t := reprieve.Time.UTC() + s.DeathReprieveLast = &t + migrated = true + } + if pardon.Valid { + t := pardon.Time.UTC() + s.LastPardonUsed = &t + migrated = true + } + if s.LastDeathDate != "" || s.GrudgeLocation != "" || + s.DeathSource != "" || s.DeathLocation != "" || !s.Alive { + migrated = true + } + if migrated { + return s, nil + } + } + if err != nil && err != sql.ErrNoRows { + return DeathState{}, err + } + // Fallback to AdvCharacter during soak. + var ( + legacy DeathState + legacyAlive int + legacyDead, legacyReprieve, legacyPardon sql.NullTime + ) + err = db.Get().QueryRow( + `SELECT alive, dead_until, death_reprieve_last, + last_death_date, last_pardon_used, + grudge_location, death_source, death_location + FROM adventure_characters WHERE user_id = ?`, + string(userID), + ).Scan( + &legacyAlive, &legacyDead, &legacyReprieve, + &legacy.LastDeathDate, &legacyPardon, + &legacy.GrudgeLocation, &legacy.DeathSource, &legacy.DeathLocation, + ) + if err == sql.ErrNoRows { + return DeathState{Alive: true}, nil + } + if err != nil { + return DeathState{}, err + } + legacy.Alive = legacyAlive == 1 + if legacyDead.Valid { + t := legacyDead.Time.UTC() + legacy.DeadUntil = &t + } + if legacyReprieve.Valid { + t := legacyReprieve.Time.UTC() + legacy.DeathReprieveLast = &t + } + if legacyPardon.Valid { + t := legacyPardon.Time.UTC() + legacy.LastPardonUsed = &t + } + return legacy, nil +} + +// backfillPlayerMetaDeathState copies death columns from +// adventure_characters into player_meta for any row that hasn't been +// migrated yet. Idempotent: only fills rows where every death field is +// still the default (alive=1, all timestamps NULL, all strings empty). +func backfillPlayerMetaDeathState() error { + if _, err := db.Get().Exec(` + INSERT OR IGNORE INTO player_meta (user_id) + SELECT user_id FROM adventure_characters + `); err != nil { + return err + } + res, err := db.Get().Exec(` + UPDATE player_meta + SET alive = (SELECT alive FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + dead_until = (SELECT dead_until FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + death_reprieve_last = (SELECT death_reprieve_last FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + last_death_date = (SELECT last_death_date FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + last_pardon_used = (SELECT last_pardon_used FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + grudge_location = (SELECT grudge_location FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + death_source = (SELECT death_source FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + death_location = (SELECT death_location FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id) + WHERE alive = 1 AND dead_until IS NULL + AND death_reprieve_last IS NULL AND last_pardon_used IS NULL + AND last_death_date = '' AND grudge_location = '' + AND death_source = '' AND death_location = '' + AND EXISTS ( + SELECT 1 FROM adventure_characters ac + WHERE ac.user_id = player_meta.user_id + AND (ac.alive = 0 OR ac.dead_until IS NOT NULL + OR ac.death_reprieve_last IS NOT NULL OR ac.last_pardon_used IS NOT NULL + OR ac.last_death_date <> '' OR ac.grudge_location <> '' + OR ac.death_source <> '' OR ac.death_location <> '') + ) + `) + if err != nil { + return err + } + n, _ := res.RowsAffected() + slog.Info("player_meta: death state backfilled", "rows", n) + return nil +} + +// deathStateFromAdvChar projects death fields off an AdventureCharacter. +func deathStateFromAdvChar(c *AdventureCharacter) DeathState { + return DeathState{ + Alive: c.Alive, + DeadUntil: c.DeadUntil, + DeathReprieveLast: c.DeathReprieveLast, + LastDeathDate: c.LastDeathDate, + LastPardonUsed: c.LastPardonUsed, + GrudgeLocation: c.GrudgeLocation, + DeathSource: c.DeathSource, + DeathLocation: c.DeathLocation, + } +} + // backfillPlayerMetaDisplayName copies adventure_characters.display_name // into player_meta.display_name for any row whose display_name is still // the empty default. Idempotent: safe to re-run; only updates rows that diff --git a/internal/plugin/player_meta_test.go b/internal/plugin/player_meta_test.go index 1dc6c92..590bb7c 100644 --- a/internal/plugin/player_meta_test.go +++ b/internal/plugin/player_meta_test.go @@ -1327,6 +1327,166 @@ func TestResetAllPlayerMetaDailyActions(t *testing.T) { } } +func TestPlayerMetaDeathStateBackfill_Idempotent(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-death-bf:example") + if err := createAdvCharacter(uid, "Mortal"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + deadUntil := time.Now().UTC().Add(2 * time.Hour).Truncate(time.Second) + if _, err := db.Get().Exec( + `UPDATE adventure_characters + SET alive = ?, dead_until = ?, last_death_date = ?, + grudge_location = ?, death_source = ?, death_location = ? + WHERE user_id = ?`, + 0, deadUntil, "2026-05-09", "Dark Cave", "adventure", "Dark Cave", string(uid), + ); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE player_meta SET alive = 1, dead_until = NULL, + death_reprieve_last = NULL, last_death_date = '', + last_pardon_used = NULL, grudge_location = '', + death_source = '', death_location = '' WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + if err := backfillPlayerMetaDeathState(); err != nil { + t.Fatalf("backfill 1: %v", err) + } + got, err := loadDeathState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got.Alive || got.LastDeathDate != "2026-05-09" || got.GrudgeLocation != "Dark Cave" || + got.DeathSource != "adventure" || got.DeathLocation != "Dark Cave" { + t.Errorf("after backfill: got %+v", got) + } + if got.DeadUntil == nil || !got.DeadUntil.Equal(deadUntil) { + t.Errorf("dead_until: got %v want %v", got.DeadUntil, deadUntil) + } + + // Layer a dual-write: revive. + got.Alive = true + got.DeadUntil = nil + if err := upsertPlayerMetaDeathState(uid, got); err != nil { + t.Fatalf("dual-write: %v", err) + } + if err := backfillPlayerMetaDeathState(); err != nil { + t.Fatalf("backfill 2: %v", err) + } + got2, _ := loadDeathState(uid) + if !got2.Alive || got2.DeadUntil != nil { + t.Errorf("backfill clobbered revive: got %+v", got2) + } +} + +func TestLoadDeathState_FallsBackToAdvCharacter(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-death-fb:example") + if err := createAdvCharacter(uid, "Faller"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE adventure_characters SET alive = ?, grudge_location = ? WHERE user_id = ?`, + 0, "Mine", string(uid), + ); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE player_meta SET alive = 1, dead_until = NULL, + death_reprieve_last = NULL, last_death_date = '', + last_pardon_used = NULL, grudge_location = '', + death_source = '', death_location = '' WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + got, err := loadDeathState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got.Alive || got.GrudgeLocation != "Mine" { + t.Errorf("fallback: got %+v", got) + } + + // Unknown user → zero state defaults to alive. + got2, err := loadDeathState(id.UserID("@meta-death-nobody:example")) + if err != nil { + t.Fatalf("load missing: %v", err) + } + if !got2.Alive { + t.Errorf("missing user should default to alive: got %+v", got2) + } +} + +func TestUpsertPlayerMetaDeathState_RoundTrip(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-death-rt:example") + deadUntil := time.Now().UTC().Add(time.Hour).Truncate(time.Second) + pardon := time.Now().UTC().Add(-24 * time.Hour).Truncate(time.Second) + in := DeathState{ + Alive: false, + DeadUntil: &deadUntil, + LastDeathDate: "2026-05-09", + LastPardonUsed: &pardon, + GrudgeLocation: "the Abyss", + DeathSource: "arena", + DeathLocation: "the Arena", + } + if err := upsertPlayerMetaDeathState(uid, in); err != nil { + t.Fatalf("upsert insert: %v", err) + } + got, err := loadDeathState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got.Alive || got.LastDeathDate != "2026-05-09" || got.GrudgeLocation != "the Abyss" || + got.DeathSource != "arena" || got.DeathLocation != "the Arena" { + t.Errorf("round-trip mismatch: got %+v", got) + } + if got.DeadUntil == nil || !got.DeadUntil.Equal(deadUntil) { + t.Errorf("dead_until: got %v want %v", got.DeadUntil, deadUntil) + } + if got.LastPardonUsed == nil || !got.LastPardonUsed.Equal(pardon) { + t.Errorf("last_pardon_used: got %v want %v", got.LastPardonUsed, pardon) + } +} + +func TestSaveAdvCharacter_DualWritesDeathState(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-death-sw:example") + if err := createAdvCharacter(uid, "Saver"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + char, err := loadAdvCharacter(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + deadUntil := time.Now().UTC().Add(time.Hour).Truncate(time.Second) + char.Alive = false + char.DeadUntil = &deadUntil + char.GrudgeLocation = "the Pit" + if err := saveAdvCharacter(char); err != nil { + t.Fatalf("save: %v", err) + } + + got, err := loadDeathState(uid) + if err != nil { + t.Fatalf("loadDeathState: %v", err) + } + if got.Alive || got.GrudgeLocation != "the Pit" { + t.Errorf("save did not propagate to player_meta: got %+v", got) + } +} + func TestUpsertPlayerMetaHouseState_RoundTrip(t *testing.T) { setupAuditTestDB(t)