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

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