Adv 2.0 L5d: streak/action/lifecycle migration off AdvCharacter

Ten player_meta columns: 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 struct with HasLifecycle() marker + load/upsert/backfill/
projection helpers. New resetAllPlayerMetaDailyActions parallels the
existing resetAllAdvDailyActions for the bulk midnight reset.

createAdvCharacter now seeds created_at/last_active_at (CURRENT_TIMESTAMP)
so player_meta rows are fully formed at creation.

Mutation surface turned out to be small: only `rest` writes
ActionTakenToday (combat/harvest counters are dormant in current code);
plus streak halve + streak update in scheduler. Dual-writes wired at all
four sites + the bulk reset.

Tests: TestPlayerMetaLifecycleStateBackfill_Idempotent,
TestLoadLifecycleState_FallsBackToAdvCharacter,
TestUpsertPlayerMetaLifecycleState_RoundTrip,
TestResetAllPlayerMetaDailyActions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 11:29:50 -07:00
parent c83b73b655
commit 8fc8941cc9
7 changed files with 426 additions and 2 deletions

View File

@@ -418,7 +418,7 @@ Each sub-phase is the same shape as L4aL4e: 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. |

View File

@@ -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 {

View File

@@ -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, "")

View File

@@ -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 {

View File

@@ -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 {

View File

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

View File

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