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

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