mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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. |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user