diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index cbe2b4f..41a149e 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -418,7 +418,7 @@ Each sub-phase is the same shape as L4a–L4e: column-add → backfill (idempote | **L5a** | Skills (8 fields) | 1 day | `SkillState` struct mirroring `PetState`/`HouseState`. Mutation surface is small: `checkAdvLevelUp(char, skill)` + the few sites that bump `*XP` directly. Reader flip unblocks dnd_sheet's legacy display row and `babysitDailyCost`. CombatLevel rejoins this struct as a transitional column — dropped at teardown after the DnD mass-backfill. **Status (2026-05-09):** column + dual-write SHIPPED on `adv-2.0`. Eight `combat_*` / `mining_*` / `foraging_*` / `fishing_*` columns added via columnMigration. `SkillState` struct + `HasSkills()` / `loadSkillState` / `upsertPlayerMetaSkillState` / `backfillPlayerMetaSkillState` / `skillStateFromAdvChar` helpers in `player_meta.go`. Dual-writes wired at every mutation site: `adventure_consumables.go` (craft success + craft failure XP grant), `adventure_events.go` (event XP grant across combat/mining/foraging), `adventure_arena.go::resolveArenaDeath` + `arenaCompleteSession` (combat XP + level-up). Backfill `backfillPlayerMetaSkillState()` wired into Init, idempotent (only fills rows where every skill column is still zero AND the legacy row has at least one non-zero skill/XP). Tests: `TestPlayerMetaSkillStateBackfill_Idempotent`, `TestLoadSkillState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaSkillState_RoundTrip` in `player_meta_test.go`. `go vet ./... && go test ./...` clean. **Reader flip deferred** — bundled with cutting AdvCharacter writes (post-soak), per the L4e pattern. | | **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`. | +| **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. | | **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. | diff --git a/internal/db/db.go b/internal/db/db.go index ad6ef8e..b58436b 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -267,6 +267,22 @@ func runMigrations(d *sql.DB) error { `ALTER TABLE player_meta ADD COLUMN misty_donated_count INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE player_meta ADD COLUMN thom_animal_line_fired INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE player_meta ADD COLUMN robbie_visit_count INTEGER NOT NULL DEFAULT 0`, + // Adv 2.0 Phase L5d — Streak/action/lifecycle migration off AdvCharacter. + // Streak (current/best/decayed/last_action_date), per-day action flags + // (action_taken_today/holiday_action_taken/combat_actions_used/ + // harvest_actions_used), and lifecycle timestamps (created_at / + // last_active_at) move to player_meta (gogobee_legacy_migration.md + // §7.3 L5d). + `ALTER TABLE player_meta ADD COLUMN current_streak INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN best_streak INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN last_action_date TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE player_meta ADD COLUMN streak_decayed INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN action_taken_today INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN holiday_action_taken INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN combat_actions_used INTEGER NOT NULL DEFAULT 0`, + `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`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index d540f06..cf01ffb 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -240,6 +240,11 @@ func (p *AdventurePlugin) Init() error { if err := backfillPlayerMetaNPCState(); err != nil { slog.Error("player_meta: NPC state backfill failed", "err", err) } + // Adv 2.0 Phase L5d — one-shot lifecycle state backfill into player_meta. + // Idempotent (only fills rows whose created_at is still NULL). + if err := backfillPlayerMetaLifecycleState(); err != nil { + slog.Error("player_meta: lifecycle 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) @@ -1032,6 +1037,7 @@ func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharact if err := saveAdvCharacter(char); err != nil { return p.SendDM(ctx.Sender, "Failed to save. Even resting is broken.") } + _ = upsertPlayerMetaLifecycleState(char.UserID, lifecycleStateFromAdvChar(char)) logAdvActivity(char.UserID, string(AdvActivityRest), "", "rest", 0, 0, "") diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index 041affe..872c764 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -559,9 +559,12 @@ func createAdvCharacter(userID id.UserID, displayName string) error { } // Adv 2.0 Phase L4f-prep — dual-write display_name into player_meta. + // Adv 2.0 Phase L5d — also seed created_at + last_active_at lifecycle + // timestamps so the player_meta row is fully formed at creation. // Inside the same tx so the two rows are created atomically. if _, err = tx.Exec( - `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?) + `INSERT INTO player_meta (user_id, display_name, created_at, last_active_at) + VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET display_name = excluded.display_name`, string(userID), displayName, ); err != nil { diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index c9971df..ce3f113 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -388,6 +388,7 @@ func (p *AdventurePlugin) midnightReset() error { text += " — not all is lost." } _ = saveAdvCharacter(&char) + _ = upsertPlayerMetaLifecycleState(char.UserID, lifecycleStateFromAdvChar(&char)) } if err := p.SendDM(char.UserID, text); err != nil { slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err) @@ -404,6 +405,7 @@ func (p *AdventurePlugin) midnightReset() error { char.BestStreak = char.CurrentStreak } _ = saveAdvCharacter(&char) + _ = upsertPlayerMetaLifecycleState(char.UserID, lifecycleStateFromAdvChar(&char)) } } @@ -420,6 +422,10 @@ func (p *AdventurePlugin) midnightReset() error { if resetErr != nil { return fmt.Errorf("reset daily actions after 3 attempts: %w", resetErr) } + // Adv 2.0 Phase L5d — parallel reset on player_meta during dual-write soak. + if err := resetAllPlayerMetaDailyActions(); err != nil { + slog.Error("player_meta: daily action reset failed", "err", err) + } // Prune expired buffs if err := pruneAdvExpiredBuffs(); err != nil { diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index 08b4ea4..c34cc7a 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -1353,6 +1353,246 @@ func npcStateFromAdvChar(c *AdventureCharacter) NPCState { } } +// LifecycleState mirrors player_meta's streak/action/lifecycle columns. +// Phase L5d ports these fields off AdvCharacter (gogobee_legacy_migration.md +// §7.3 L5d). Streak fields update on the daily morning DM tick; action +// flags reset by `resetAllAdvDailyActions` and only flip true when the +// player rests; lifecycle timestamps are set at character creation and +// auto-bumped by every saveAdvCharacter via CURRENT_TIMESTAMP. +type LifecycleState struct { + CurrentStreak int + BestStreak int + LastActionDate string + StreakDecayed bool + ActionTakenToday bool + HolidayActionTaken bool + CombatActionsUsed int + HarvestActionsUsed int + CreatedAt *time.Time + LastActiveAt *time.Time +} + +// HasLifecycle returns true when any lifecycle field is non-zero — used +// as the "row is migrated" marker in loadLifecycleState. CreatedAt is set +// at character creation, so any migrated row will have it. +func (s LifecycleState) HasLifecycle() bool { + return s.CreatedAt != nil || s.LastActiveAt != nil || + s.CurrentStreak > 0 || s.BestStreak > 0 || + s.LastActionDate != "" || s.StreakDecayed || + s.ActionTakenToday || s.HolidayActionTaken || + s.CombatActionsUsed > 0 || s.HarvestActionsUsed > 0 +} + +// upsertPlayerMetaLifecycleState writes the full lifecycle column set for +// a user. Used by the dual-write path during the L5d soak window. +// CreatedAt and LastActiveAt are written when non-nil; a nil value leaves +// the column NULL. +func upsertPlayerMetaLifecycleState(userID id.UserID, s LifecycleState) error { + streakDecayed := 0 + if s.StreakDecayed { + streakDecayed = 1 + } + actionTaken := 0 + if s.ActionTakenToday { + actionTaken = 1 + } + holidayTaken := 0 + if s.HolidayActionTaken { + holidayTaken = 1 + } + var createdAt, lastActiveAt interface{} + if s.CreatedAt != nil { + createdAt = s.CreatedAt.UTC() + } + if s.LastActiveAt != nil { + lastActiveAt = s.LastActiveAt.UTC() + } + _, err := db.Get().Exec( + `INSERT INTO player_meta ( + user_id, 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 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + current_streak = excluded.current_streak, + best_streak = excluded.best_streak, + last_action_date = excluded.last_action_date, + streak_decayed = excluded.streak_decayed, + action_taken_today = excluded.action_taken_today, + holiday_action_taken = excluded.holiday_action_taken, + combat_actions_used = excluded.combat_actions_used, + harvest_actions_used = excluded.harvest_actions_used, + created_at = excluded.created_at, + last_active_at = excluded.last_active_at`, + string(userID), s.CurrentStreak, s.BestStreak, s.LastActionDate, streakDecayed, + actionTaken, holidayTaken, + s.CombatActionsUsed, s.HarvestActionsUsed, + createdAt, lastActiveAt, + ) + return err +} + +// loadLifecycleState returns lifecycle state from player_meta when +// populated, otherwise falls back to adventure_characters during the L5d +// soak window. +func loadLifecycleState(userID id.UserID) (LifecycleState, error) { + var ( + s LifecycleState + streakDecayed, actionTaken, holidayTaken int + createdAt, lastActiveAt sql.NullTime + ) + err := db.Get().QueryRow( + `SELECT 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 + FROM player_meta WHERE user_id = ?`, + string(userID), + ).Scan( + &s.CurrentStreak, &s.BestStreak, &s.LastActionDate, &streakDecayed, + &actionTaken, &holidayTaken, + &s.CombatActionsUsed, &s.HarvestActionsUsed, + &createdAt, &lastActiveAt, + ) + if err == nil { + s.StreakDecayed = streakDecayed == 1 + s.ActionTakenToday = actionTaken == 1 + s.HolidayActionTaken = holidayTaken == 1 + if createdAt.Valid { + t := createdAt.Time.UTC() + s.CreatedAt = &t + } + if lastActiveAt.Valid { + t := lastActiveAt.Time.UTC() + s.LastActiveAt = &t + } + if s.HasLifecycle() { + return s, nil + } + } + if err != nil && err != sql.ErrNoRows { + return LifecycleState{}, err + } + // Fallback to AdvCharacter during soak. + var ( + legacy LifecycleState + legacyDecayed, legacyAction, legacyHoliday int + legacyCreated, legacyActive sql.NullTime + ) + err = db.Get().QueryRow( + `SELECT 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 + FROM adventure_characters WHERE user_id = ?`, + string(userID), + ).Scan( + &legacy.CurrentStreak, &legacy.BestStreak, &legacy.LastActionDate, &legacyDecayed, + &legacyAction, &legacyHoliday, + &legacy.CombatActionsUsed, &legacy.HarvestActionsUsed, + &legacyCreated, &legacyActive, + ) + if err == sql.ErrNoRows { + return LifecycleState{}, nil + } + if err != nil { + return LifecycleState{}, err + } + legacy.StreakDecayed = legacyDecayed == 1 + legacy.ActionTakenToday = legacyAction == 1 + legacy.HolidayActionTaken = legacyHoliday == 1 + if legacyCreated.Valid { + t := legacyCreated.Time.UTC() + legacy.CreatedAt = &t + } + if legacyActive.Valid { + t := legacyActive.Time.UTC() + legacy.LastActiveAt = &t + } + return legacy, nil +} + +// backfillPlayerMetaLifecycleState copies lifecycle columns from +// adventure_characters into player_meta for any row whose lifecycle is +// still empty (no CreatedAt) AND the legacy row has lifecycle data. +// Idempotent. +func backfillPlayerMetaLifecycleState() 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 current_streak = (SELECT current_streak FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + best_streak = (SELECT best_streak FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + last_action_date = (SELECT last_action_date FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + streak_decayed = (SELECT streak_decayed FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + action_taken_today = (SELECT action_taken_today FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + holiday_action_taken = (SELECT holiday_action_taken FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + combat_actions_used = (SELECT combat_actions_used FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + harvest_actions_used = (SELECT harvest_actions_used FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + created_at = (SELECT created_at FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + last_active_at = (SELECT last_active_at FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id) + WHERE created_at IS NULL + AND EXISTS ( + SELECT 1 FROM adventure_characters ac + WHERE ac.user_id = player_meta.user_id + AND ac.created_at IS NOT NULL + ) + `) + if err != nil { + return err + } + n, _ := res.RowsAffected() + slog.Info("player_meta: lifecycle state backfilled", "rows", n) + return nil +} + +// lifecycleStateFromAdvChar projects lifecycle fields off an +// AdventureCharacter into a LifecycleState. Used by the dual-write path +// during the L5d soak window. +func lifecycleStateFromAdvChar(c *AdventureCharacter) LifecycleState { + out := LifecycleState{ + CurrentStreak: c.CurrentStreak, + BestStreak: c.BestStreak, + LastActionDate: c.LastActionDate, + StreakDecayed: c.StreakDecayed, + ActionTakenToday: c.ActionTakenToday, + HolidayActionTaken: c.HolidayActionTaken, + CombatActionsUsed: c.CombatActionsUsed, + HarvestActionsUsed: c.HarvestActionsUsed, + } + if !c.CreatedAt.IsZero() { + t := c.CreatedAt.UTC() + out.CreatedAt = &t + } + if !c.LastActiveAt.IsZero() { + t := c.LastActiveAt.UTC() + out.LastActiveAt = &t + } + return out +} + +// resetAllPlayerMetaDailyActions parallels resetAllAdvDailyActions: +// resets the action_taken_today / holiday_action_taken / +// combat_actions_used / harvest_actions_used columns in player_meta for +// any row whose last_action_date is older than today (or NULL/empty). +// Used by the daily reset tick during the L5d soak window. +func resetAllPlayerMetaDailyActions() error { + today := time.Now().UTC().Format("2006-01-02") + _, err := db.Get().Exec(` + UPDATE player_meta + SET action_taken_today = 0, + holiday_action_taken = 0, + combat_actions_used = 0, + harvest_actions_used = 0 + WHERE last_action_date < ? OR last_action_date = ''`, today) + return err +} + // 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 0257333..1dc6c92 100644 --- a/internal/plugin/player_meta_test.go +++ b/internal/plugin/player_meta_test.go @@ -1174,6 +1174,159 @@ func TestUpsertPlayerMetaNPCState_RoundTrip(t *testing.T) { } } +func TestPlayerMetaLifecycleStateBackfill_Idempotent(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-life-bf:example") + if err := createAdvCharacter(uid, "Lifer"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + created := time.Now().UTC().Add(-30 * 24 * time.Hour).Truncate(time.Second) + if _, err := db.Get().Exec( + `UPDATE adventure_characters + SET current_streak = ?, best_streak = ?, last_action_date = ?, streak_decayed = ?, + action_taken_today = ?, holiday_action_taken = ?, + combat_actions_used = ?, harvest_actions_used = ?, + created_at = ? + WHERE user_id = ?`, + 7, 12, "2026-05-08", 0, 1, 0, 1, 2, created, string(uid), + ); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE player_meta SET current_streak = 0, best_streak = 0, last_action_date = '', + streak_decayed = 0, action_taken_today = 0, holiday_action_taken = 0, + combat_actions_used = 0, harvest_actions_used = 0, + created_at = NULL, last_active_at = NULL WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + if err := backfillPlayerMetaLifecycleState(); err != nil { + t.Fatalf("backfill 1: %v", err) + } + got, err := loadLifecycleState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got.CurrentStreak != 7 || got.BestStreak != 12 || got.LastActionDate != "2026-05-08" || + !got.ActionTakenToday || got.CombatActionsUsed != 1 || got.HarvestActionsUsed != 2 { + t.Errorf("after backfill: got %+v", got) + } + if got.CreatedAt == nil || !got.CreatedAt.Equal(created) { + t.Errorf("created_at: got %v want %v", got.CreatedAt, created) + } + + // Layer a dual-write: streak halve. + got.CurrentStreak = 3 + got.StreakDecayed = true + if err := upsertPlayerMetaLifecycleState(uid, got); err != nil { + t.Fatalf("dual-write: %v", err) + } + if err := backfillPlayerMetaLifecycleState(); err != nil { + t.Fatalf("backfill 2: %v", err) + } + got2, _ := loadLifecycleState(uid) + if got2.CurrentStreak != 3 || !got2.StreakDecayed { + t.Errorf("backfill clobbered dual-write: got %+v", got2) + } +} + +func TestLoadLifecycleState_FallsBackToAdvCharacter(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-life-fb:example") + if err := createAdvCharacter(uid, "Faller"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + // createAdvCharacter now seeds player_meta.created_at, so to test + // fallback, force the player_meta row to have empty lifecycle. + if _, err := db.Get().Exec( + `UPDATE player_meta SET current_streak = 0, best_streak = 0, last_action_date = '', + streak_decayed = 0, action_taken_today = 0, holiday_action_taken = 0, + combat_actions_used = 0, harvest_actions_used = 0, + created_at = NULL, last_active_at = NULL WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE adventure_characters SET current_streak = ?, best_streak = ? WHERE user_id = ?`, + 5, 8, string(uid), + ); err != nil { + t.Fatalf("seed: %v", err) + } + + got, err := loadLifecycleState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got.CurrentStreak != 5 || got.BestStreak != 8 { + t.Errorf("fallback: got %+v", got) + } +} + +func TestUpsertPlayerMetaLifecycleState_RoundTrip(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-life-rt:example") + created := time.Now().UTC().Add(-7 * 24 * time.Hour).Truncate(time.Second) + in := LifecycleState{ + CurrentStreak: 4, + BestStreak: 10, + LastActionDate: "2026-05-09", + StreakDecayed: false, + ActionTakenToday: true, + HolidayActionTaken: false, + CombatActionsUsed: 2, + HarvestActionsUsed: 1, + CreatedAt: &created, + } + if err := upsertPlayerMetaLifecycleState(uid, in); err != nil { + t.Fatalf("upsert insert: %v", err) + } + got, err := loadLifecycleState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got.CurrentStreak != 4 || got.BestStreak != 10 || got.LastActionDate != "2026-05-09" || + !got.ActionTakenToday || got.CombatActionsUsed != 2 || got.HarvestActionsUsed != 1 { + t.Errorf("round-trip mismatch: got %+v", got) + } + if got.CreatedAt == nil || !got.CreatedAt.Equal(created) { + t.Errorf("created_at: got %v want %v", got.CreatedAt, created) + } +} + +func TestResetAllPlayerMetaDailyActions(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-life-reset:example") + if err := createAdvCharacter(uid, "Resetter"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + in := LifecycleState{ + LastActionDate: "2020-01-01", // way in the past so the reset matches + ActionTakenToday: true, + HolidayActionTaken: true, + CombatActionsUsed: 3, + HarvestActionsUsed: 2, + } + if err := upsertPlayerMetaLifecycleState(uid, in); err != nil { + t.Fatalf("upsert: %v", err) + } + + if err := resetAllPlayerMetaDailyActions(); err != nil { + t.Fatalf("reset: %v", err) + } + got, _ := loadLifecycleState(uid) + if got.ActionTakenToday || got.HolidayActionTaken || + got.CombatActionsUsed != 0 || got.HarvestActionsUsed != 0 { + t.Errorf("after reset: got %+v", got) + } +} + func TestUpsertPlayerMetaHouseState_RoundTrip(t *testing.T) { setupAuditTestDB(t)