diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index bca2b6f..6de2908 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -435,13 +435,21 @@ Each sub-phase is the same shape as L4a–L4e: column-add → backfill (idempote After L5a–L5h soak (one week post-cut-of-AdvCharacter-writes per §11): -1. **Three remaining `CombatLevel` reader fixes** (post-L5g the legacy fallback is the only source still feeding them): - - `adventure_babysit.go::babysitDailyCost(char.CombatLevel)` → read D&D level. - - `dnd_sheet.go:168` legacy stat-block line displaying `adv.CombatLevel` → drop or swap to D&D level. - - `dnd_onboarding.go:27,38` gate on `advChar.CombatLevel < dndLegacyMinLevel` → re-evaluate purpose now that L5g guarantees every active user has a D&D row. -2. Drop `adventure_character` table behind `GOGOBEE_LEGACY_PURGE=1` env gate (`dnd_l5_cleanup.go` migration script, default off). -3. Drop `player_meta.combat_level` column (introduced as a transitional column in L5a; redundant once readers no longer consult it). -4. Final `go vet ./... && go test ./...` clean. +1. **Three remaining `CombatLevel` reader fixes** — SHIPPED 2026-05-09 (see L5 close-out commits 978a621 / ece838e). Babysit cost reads `dndLevelForUser`, dnd_sheet legacy line removed, dnd_onboarding deleted entirely. +2. **Loader rewire** — SHIPPED 2026-05-09. Without this step the table drop in #3 would break every login. + - `loadAdvCharacter` (adventure_character.go:432) now `SELECT user_id FROM player_meta` + default-init struct + `applyPlayerMetaOverlay`. The giant 70-column SELECT against adventure_characters is gone. + - `loadAllAdvCharacters` enumerates `player_meta` and applies the overlay per row. + - `createAdvCharacter` (adventure_character.go:445) inserts `player_meta` only; the `INSERT INTO adventure_characters` line is removed. + - `npcMidnightReset` and `resetAllAdvDailyActions` (now deleted) had their legacy UPDATEs cut; the player_meta versions are canonical. + - `stats.go::loadAdvSnapshot` SELECT swapped to `player_meta`. + - All `achievements.go` streak reads swapped to `player_meta`. + - `loadArenaLeaderboard` LEFT JOIN swapped to `player_meta`. + - Every `loadXxxState` helper in `player_meta.go` had its "fallback to adventure_characters during soak" branch deleted; player_meta is the single source of truth. + - All `backfillPlayerMeta*` and `backfillDnDCharactersFromAdv` one-shots removed (already-completed migrations; their Init wiring is gone). + - `db.go::snapshotPreDnD` gates on a `sqlite_master` lookup so it skips silently after the purge. +3. **Purge script** — SHIPPED. `internal/plugin/cleanup_l5.go::purgeLegacyAdvCharacterTable` runs on every Init, gated on `GOGOBEE_LEGACY_PURGE=1`. Idempotent (`sqlite_master` existence check). Drops the `adventure_characters` table only; the `adventure_characters_pre_dnd` rollback snapshot is preserved (separate `GOGOBEE_PRE_DND_PURGE` pass when its 30-day grace window expires). The `CREATE TABLE adventure_characters` clause has been removed from the schema string and the columnMigrations runner now also tolerates `no such table: adventure_characters`, so the table won't be re-instated on next boot. +4. **`player_meta.combat_level` column drop — STILL DEFERRED.** `combat_stats.go::DerivePlayerStats` (HP/Attack/Defense/Speed scaling) and `combat_stats.go::applyMistyHealAmt` and `adventure_activities.go::adv power calculations` all read `char.CombatLevel` via the player_meta overlay. Per §7.4's reconciliation, those files are shared infrastructure that stays — so `combat_level` continues to drive combat power scaling and is not safe to drop. Revisit only if/when D&D-derived stats subsume those formulas. +5. Final `go vet ./... && go test ./...` — clean (2026-05-09). **What stays:** - `adventure_character.go` — `AdventureCharacter` struct continues as the loaded-view shape; `loadAdvCharacter` SELECTs from the (now read-only) `adventure_characters` seed row and overlays `player_meta` / `dnd_character` state. A future cosmetic pass may rename it to drop the implication of being legacy, but that is *not* part of this migration. (Per `feedback_avoid_dnd_naming.md`, any rename must avoid "dnd"-prefixed names.) diff --git a/internal/db/db.go b/internal/db/db.go index 87505b3..9c41bea 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -309,10 +309,17 @@ func runMigrations(d *sql.DB) error { } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { + msg := err.Error() // "duplicate column name" means it already exists — safe to ignore. - if !strings.Contains(err.Error(), "duplicate column") { - return fmt.Errorf("migration %q: %w", stmt, err) + // "no such table: adventure_characters" is expected after the + // L5 close-out purge (GOGOBEE_LEGACY_PURGE=1). + if strings.Contains(msg, "duplicate column") { + continue } + if strings.Contains(msg, "no such table: adventure_characters") { + continue + } + return fmt.Errorf("migration %q: %w", stmt, err) } } return nil @@ -321,9 +328,17 @@ func runMigrations(d *sql.DB) error { // snapshotPreDnD copies the current adventure_characters rows into // adventure_characters_pre_dnd as a one-shot rollback safety net for the // D&D-layer migration. Idempotent: only inserts rows for user_ids that -// aren't already snapshotted, so running on a fresh install (no characters) -// or on subsequent boots is a no-op. +// aren't already snapshotted. Skips silently once the source table has +// been dropped by the L5 close-out purge (GOGOBEE_LEGACY_PURGE=1). func snapshotPreDnD(d *sql.DB) error { + var name string + if err := d.QueryRow( + `SELECT name FROM sqlite_master WHERE type='table' AND name='adventure_characters'`, + ).Scan(&name); err == sql.ErrNoRows { + return nil + } else if err != nil { + return err + } _, err := d.Exec(` INSERT OR IGNORE INTO adventure_characters_pre_dnd (user_id, snapshot_json) SELECT user_id, @@ -1184,34 +1199,9 @@ CREATE TABLE IF NOT EXISTS space_groups ( -- ── Adventure Plugin ──────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS adventure_characters ( - user_id TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - combat_level INTEGER NOT NULL DEFAULT 1, - mining_skill INTEGER NOT NULL DEFAULT 1, - foraging_skill INTEGER NOT NULL DEFAULT 1, - fishing_skill INTEGER NOT NULL DEFAULT 1, - combat_xp INTEGER NOT NULL DEFAULT 0, - mining_xp INTEGER NOT NULL DEFAULT 0, - foraging_xp INTEGER NOT NULL DEFAULT 0, - fishing_xp INTEGER NOT NULL DEFAULT 0, - alive INTEGER NOT NULL DEFAULT 1, - dead_until DATETIME, - action_taken_today INTEGER NOT NULL DEFAULT 0, - holiday_action_taken INTEGER NOT NULL DEFAULT 0, - arena_wins INTEGER NOT NULL DEFAULT 0, - arena_losses INTEGER NOT NULL DEFAULT 0, - invasion_score INTEGER NOT NULL DEFAULT 0, - title TEXT NOT NULL DEFAULT '', - current_streak INTEGER NOT NULL DEFAULT 0, - best_streak INTEGER NOT NULL DEFAULT 0, - last_action_date TEXT NOT NULL DEFAULT '', - grudge_location TEXT NOT NULL DEFAULT '', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_active_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - death_reprieve_last DATETIME, - masterwork_drops_received INTEGER NOT NULL DEFAULT 0 -); +-- adventure_characters was retired in the L5 close-out. Per-user state lives +-- in player_meta now; the table is dropped via GOGOBEE_LEGACY_PURGE=1 and +-- the schema CREATE has been removed so it is not re-instated on next boot. CREATE TABLE IF NOT EXISTS adventure_equipment ( user_id TEXT NOT NULL, diff --git a/internal/plugin/achievements.go b/internal/plugin/achievements.go index 4c15656..4fdeb50 100644 --- a/internal/plugin/achievements.go +++ b/internal/plugin/achievements.go @@ -783,7 +783,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef { Check: func(d *sql.DB, u id.UserID) bool { var streak int err := d.QueryRow( - `SELECT best_streak FROM adventure_characters WHERE user_id = ?`, + `SELECT best_streak FROM player_meta WHERE user_id = ?`, string(u), ).Scan(&streak) return err == nil && streak >= 7 @@ -795,7 +795,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef { Check: func(d *sql.DB, u id.UserID) bool { var streak int err := d.QueryRow( - `SELECT best_streak FROM adventure_characters WHERE user_id = ?`, + `SELECT best_streak FROM player_meta WHERE user_id = ?`, string(u), ).Scan(&streak) return err == nil && streak >= 30 @@ -1023,7 +1023,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef { Emoji: "🏃", Check: func(d *sql.DB, u id.UserID) bool { var streak int - _ = d.QueryRow(`SELECT best_streak FROM adventure_characters WHERE user_id = ?`, string(u)).Scan(&streak) + _ = d.QueryRow(`SELECT best_streak FROM player_meta WHERE user_id = ?`, string(u)).Scan(&streak) return streak >= 7 }, }, @@ -1032,7 +1032,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef { Emoji: "🔥", Check: func(d *sql.DB, u id.UserID) bool { var streak int - _ = d.QueryRow(`SELECT best_streak FROM adventure_characters WHERE user_id = ?`, string(u)).Scan(&streak) + _ = d.QueryRow(`SELECT best_streak FROM player_meta WHERE user_id = ?`, string(u)).Scan(&streak) return streak >= 60 }, }, @@ -1041,7 +1041,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef { Emoji: "💯", Check: func(d *sql.DB, u id.UserID) bool { var streak int - _ = d.QueryRow(`SELECT best_streak FROM adventure_characters WHERE user_id = ?`, string(u)).Scan(&streak) + _ = d.QueryRow(`SELECT best_streak FROM player_meta WHERE user_id = ?`, string(u)).Scan(&streak) return streak >= 100 }, }, @@ -1067,7 +1067,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef { Emoji: "🔄", Check: func(d *sql.DB, u id.UserID) bool { var current, best, decayed int - err := d.QueryRow(`SELECT current_streak, best_streak, streak_decayed FROM adventure_characters WHERE user_id = ?`, string(u)).Scan(¤t, &best, &decayed) + err := d.QueryRow(`SELECT current_streak, best_streak, streak_decayed FROM player_meta WHERE user_id = ?`, string(u)).Scan(¤t, &best, &decayed) return err == nil && current >= 14 && decayed > 0 }, }, diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index d9a23c2..761f33a 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -186,86 +186,15 @@ func (p *AdventurePlugin) Init() error { // only touches characters whose last_action_date < today). This handles // the case where the old buggy code marked the midnight job as completed // even though the actual reset failed due to SQLite contention. - if err := resetAllAdvDailyActions(); err != nil { + if err := resetAllPlayerMetaDailyActions(); err != nil { slog.Error("adventure: startup daily reset failed", "err", err) } - // Adv 2.0 Phase L2 step 5 — one-shot arena counter backfill into - // player_meta. Idempotent (INSERT OR IGNORE). - if err := backfillPlayerMetaArena(); err != nil { - slog.Error("player_meta: arena backfill failed", "err", err) - } - // Adv 2.0 Phase L4f-prep — one-shot DisplayName backfill into - // player_meta. Idempotent (only fills empty display_name rows). - if err := backfillPlayerMetaDisplayName(); err != nil { - slog.Error("player_meta: display_name backfill failed", "err", err) - } - // Adv 2.0 Phase L4a — one-shot HospitalVisits backfill into - // player_meta. Idempotent (only fills zero rows). - if err := backfillPlayerMetaHospitalVisits(); err != nil { - slog.Error("player_meta: hospital_visits backfill failed", "err", err) - } - // Adv 2.0 Phase L4b — one-shot rival state backfill into player_meta. - // Idempotent (only fills rows whose rival columns are still zero). - if err := backfillPlayerMetaRivalState(); err != nil { - slog.Error("player_meta: rival state backfill failed", "err", err) - } - // Adv 2.0 Phase L4c — one-shot MasterworkDropsReceived backfill into - // player_meta. Idempotent (only fills zero rows). - if err := backfillPlayerMetaMasterworkDrops(); err != nil { - slog.Error("player_meta: masterwork_drops_received backfill failed", "err", err) - } - // Adv 2.0 Phase L4d — one-shot pet state backfill into player_meta. - // Idempotent (only fills rows whose pet_type is still empty). - if err := backfillPlayerMetaPetState(); err != nil { - slog.Error("player_meta: pet state backfill failed", "err", err) - } - // Adv 2.0 Phase L4e — one-shot house state backfill into player_meta. - // Idempotent (only fills rows whose house_tier and house_loan_balance - // are still both the default zero). - if err := backfillPlayerMetaHouseState(); err != nil { - slog.Error("player_meta: house state backfill failed", "err", err) - } - // Adv 2.0 Phase L5a — one-shot skill state backfill into player_meta. - // Idempotent (only fills rows whose skill columns are all still zero). - if err := backfillPlayerMetaSkillState(); err != nil { - slog.Error("player_meta: skill state backfill failed", "err", err) - } - // Adv 2.0 Phase L5b — one-shot babysit state backfill into player_meta. - // Idempotent (only fills inactive rows whose legacy counterpart is active). - if err := backfillPlayerMetaBabysitState(); err != nil { - slog.Error("player_meta: babysit state backfill failed", "err", err) - } - // Adv 2.0 Phase L5c — one-shot NPC state backfill into player_meta. - // Idempotent (only fills rows where every NPC field is still zero). - 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) - } - // Adv 2.0 Phase L5e — one-shot death state backfill into player_meta. - // Idempotent (only fills rows whose death fields are all default). - if err := backfillPlayerMetaDeathState(); err != nil { - slog.Error("player_meta: death state backfill failed", "err", err) - } - // Adv 2.0 Phase L5f — one-shot misc state backfill into player_meta. - // Idempotent (only fills rows whose misc fields are all default). - if err := backfillPlayerMetaMiscState(); err != nil { - slog.Error("player_meta: misc state backfill failed", "err", err) - } - // Adv 2.0 Phase L5g — one-shot DnDCharacter mass-backfill. Every legacy - // player without a D&D row gets an auto-migrated character seeded from - // their CombatLevel, retiring the dndLevelFromCombatLevel fallbacks in - // dndLevelForUser / rivalLevelForUser / hospitalCostsForUser. Idempotent - // (skips users who already have any dnd_character row). - if err := backfillDnDCharactersFromAdv(); err != nil { - slog.Error("dnd: L5g character backfill failed", "err", err) - } // Phase L3 — cancel any open/active legacy coop dungeon runs and // refund member contributions + unsettled bets. Idempotent. closeAndRefundLegacyCoopRuns(p.euro) + // Phase L5 close-out — drop the now-cold adventure_characters table when + // the operator sets GOGOBEE_LEGACY_PURGE=1. Default off; idempotent. + purgeLegacyAdvCharacterTable() // Phase R1 orphan-archive used to run here on every Init, but it // over-archived: it treats any active dnd_zone_run row not linked to // an active expedition as a legacy `!adventure dungeon` orphan, which diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index c0e13a1..aa6b3aa 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -979,7 +979,7 @@ func loadArenaLeaderboard() ([]ArenaLeaderboardEntry, error) { s.total_earnings, s.highest_tier, s.tier5_completions, s.total_runs, s.total_deaths FROM arena_stats s - LEFT JOIN adventure_characters c ON c.user_id = s.user_id + LEFT JOIN player_meta c ON c.user_id = s.user_id ORDER BY s.total_earnings DESC LIMIT 10`) if err != nil { diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index f11bc88..e094946 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -1,7 +1,6 @@ package plugin import ( - "database/sql" "time" "gogobee/internal/db" @@ -431,119 +430,14 @@ func checkAdvLevelUp(char *AdventureCharacter, skill string) (bool, int) { func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { d := db.Get() - c := &AdventureCharacter{} - var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int - var deadUntil, reprieveLast, babysitExp, pardonUsed sql.NullTime - var mistyLastSeen, arinaLastSeen, mistyBuffExp, mistyDebuffExp, arinaBuffExp sql.NullTime - - var houseFrozen, houseAutopay int - var petChasedAway, petReactivated, petArrived, thomAnimalLine, petSupplyUnlocked, petMorningDef int - var autoBabysit, streakDecayed, treasuresLocked int - - err := d.QueryRow(` - SELECT user_id, display_name, - combat_level, mining_skill, foraging_skill, fishing_skill, - combat_xp, mining_xp, foraging_xp, fishing_xp, - alive, dead_until, action_taken_today, holiday_action_taken, - arena_wins, arena_losses, invasion_score, title, - current_streak, best_streak, last_action_date, grudge_location, - created_at, last_active_at, death_reprieve_last, - masterwork_drops_received, - rival_pool, rival_unlocked_notified, - babysit_active, babysit_expires_at, babysit_skill_focus, - hospital_visits, robbie_visit_count, last_death_date, - combat_actions_used, harvest_actions_used, - last_pardon_used, - misty_last_seen, arina_last_seen, - misty_buff_expires, misty_debuff_expires, arina_buff_expires, - npc_msg_count, npc_msg_count_date, - misty_roll_target, arina_roll_target, - house_tier, house_loan_balance, house_loan_frozen, house_missed_payments, - house_autopay, house_current_rate, - pet_type, pet_name, pet_xp, pet_level, pet_armor_tier, - pet_chased_away, pet_reactivated, pet_arrived, - misty_encounter_count, misty_donated_count, - thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date, - pet_morning_defense, auto_babysit, streak_decayed, crafts_succeeded, - death_source, death_location, auto_babysit_focus, treasures_locked - FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan( - &c.UserID, &c.DisplayName, - &c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill, - &c.CombatXP, &c.MiningXP, &c.ForagingXP, &c.FishingXP, - &alive, &deadUntil, &actionTaken, &holidayTaken, - &c.ArenaWins, &c.ArenaLosses, &c.InvasionScore, &c.Title, - &c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation, - &c.CreatedAt, &c.LastActiveAt, &reprieveLast, - &c.MasterworkDropsReceived, - &c.RivalPool, &rivalUnlocked, - &babysitAct, &babysitExp, &c.BabysitSkillFocus, - &c.HospitalVisits, &c.RobbieVisitCount, &c.LastDeathDate, - &c.CombatActionsUsed, &c.HarvestActionsUsed, - &pardonUsed, - &mistyLastSeen, &arinaLastSeen, - &mistyBuffExp, &mistyDebuffExp, &arinaBuffExp, - &c.NPCMsgCount, &c.NPCMsgCountDate, - &c.MistyRollTarget, &c.ArinaRollTarget, - &c.HouseTier, &c.HouseLoanBalance, &houseFrozen, &c.HouseMissedPayments, - &houseAutopay, &c.HouseCurrentRate, - &c.PetType, &c.PetName, &c.PetXP, &c.PetLevel, &c.PetArmorTier, - &petChasedAway, &petReactivated, &petArrived, - &c.MistyEncounterCount, &c.MistyDonatedCount, - &thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date, - &petMorningDef, &autoBabysit, &streakDecayed, &c.CraftsSucceeded, - &c.DeathSource, &c.DeathLocation, &c.AutoBabysitFocus, &treasuresLocked, - ) - if err != nil { + var uid string + if err := d.QueryRow( + `SELECT user_id FROM player_meta WHERE user_id = ?`, + string(userID), + ).Scan(&uid); err != nil { return nil, err } - c.Alive = alive == 1 - c.ActionTakenToday = actionTaken == 1 - c.HolidayActionTaken = holidayTaken == 1 - c.RivalUnlockedNotified = rivalUnlocked == 1 - c.BabysitActive = babysitAct == 1 - c.PetMorningDefense = petMorningDef == 1 - c.AutoBabysit = autoBabysit == 1 - c.TreasuresLocked = treasuresLocked == 1 - c.StreakDecayed = streakDecayed == 1 - if deadUntil.Valid { - c.DeadUntil = &deadUntil.Time - } - if reprieveLast.Valid { - c.DeathReprieveLast = &reprieveLast.Time - } - if babysitExp.Valid { - c.BabysitExpiresAt = &babysitExp.Time - } - if pardonUsed.Valid { - c.LastPardonUsed = &pardonUsed.Time - } - if mistyLastSeen.Valid { - c.MistyLastSeen = &mistyLastSeen.Time - } - if arinaLastSeen.Valid { - c.ArinaLastSeen = &arinaLastSeen.Time - } - if mistyBuffExp.Valid { - c.MistyBuffExpires = &mistyBuffExp.Time - } - if mistyDebuffExp.Valid { - c.MistyDebuffExpires = &mistyDebuffExp.Time - } - if arinaBuffExp.Valid { - c.ArinaBuffExpires = &arinaBuffExp.Time - } - c.HouseLoanFrozen = houseFrozen == 1 - c.HouseAutopay = houseAutopay == 1 - c.PetChasedAway = petChasedAway == 1 - c.PetReactivated = petReactivated == 1 - c.PetArrived = petArrived == 1 - c.ThomAnimalLineFired = thomAnimalLine == 1 - c.PetSupplyShopUnlocked = petSupplyUnlocked == 1 - - // Phase L5h: adventure_characters columns are no longer written by - // saveAdvCharacter, so the SELECT above returns the row's frozen-at-cut - // snapshot. Overlay every player_meta-mirrored subsystem so callers see - // canonical (live) values for migrated fields. + c := &AdventureCharacter{UserID: id.UserID(uid), Alive: true} applyPlayerMetaOverlay(c) return c, nil } @@ -556,20 +450,12 @@ func createAdvCharacter(userID id.UserID, displayName string) error { } defer tx.Rollback() - _, err = tx.Exec(` - INSERT INTO adventure_characters (user_id, display_name) - VALUES (?, ?)`, string(userID), displayName) - if err != nil { - return err - } - - // 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. + // Adv 2.0 Phase L5 close-out: player_meta is the canonical seed row. + // adventure_characters is no longer written; loadAdvCharacter sources + // the user_id from player_meta and overlays every subsystem's state. if _, err = tx.Exec( - `INSERT INTO player_meta (user_id, display_name, created_at, last_active_at) - VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `INSERT INTO player_meta (user_id, display_name, created_at, last_active_at, alive) + VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1) ON CONFLICT(user_id) DO UPDATE SET display_name = excluded.display_name`, string(userID), displayName, ); err != nil { @@ -709,131 +595,31 @@ func advInventoryCount(userID id.UserID) int { func loadAllAdvCharacters() ([]AdventureCharacter, error) { d := db.Get() - rows, err := d.Query(` - SELECT user_id, display_name, - combat_level, mining_skill, foraging_skill, fishing_skill, - combat_xp, mining_xp, foraging_xp, fishing_xp, - alive, dead_until, action_taken_today, holiday_action_taken, - arena_wins, arena_losses, invasion_score, title, - current_streak, best_streak, last_action_date, grudge_location, - created_at, last_active_at, death_reprieve_last, - masterwork_drops_received, - rival_pool, rival_unlocked_notified, - babysit_active, babysit_expires_at, babysit_skill_focus, - hospital_visits, robbie_visit_count, last_death_date, - combat_actions_used, harvest_actions_used, - last_pardon_used, - misty_last_seen, arina_last_seen, - misty_buff_expires, misty_debuff_expires, arina_buff_expires, - npc_msg_count, npc_msg_count_date, - misty_roll_target, arina_roll_target, - house_tier, house_loan_balance, house_loan_frozen, house_missed_payments, - house_autopay, house_current_rate, - pet_type, pet_name, pet_xp, pet_level, pet_armor_tier, - pet_chased_away, pet_reactivated, pet_arrived, - misty_encounter_count, misty_donated_count, - thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date, - pet_morning_defense, auto_babysit, streak_decayed, crafts_succeeded, - death_source, death_location, auto_babysit_focus, treasures_locked - FROM adventure_characters`) + rows, err := d.Query(`SELECT user_id FROM player_meta`) if err != nil { return nil, err } - defer rows.Close() - - var chars []AdventureCharacter + var uids []string for rows.Next() { - c := AdventureCharacter{} - var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int - var deadUntil, reprieveLast, babysitExp, pardonUsed sql.NullTime - var mistyLastSeen, arinaLastSeen, mistyBuffExp, mistyDebuffExp, arinaBuffExp sql.NullTime - var houseFrozen, houseAutopay int - var petChasedAway, petReactivated, petArrived, thomAnimalLine, petSupplyUnlocked, petMorningDef int - var autoBabysit, streakDecayed, treasuresLocked int - if err := rows.Scan( - &c.UserID, &c.DisplayName, - &c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill, - &c.CombatXP, &c.MiningXP, &c.ForagingXP, &c.FishingXP, - &alive, &deadUntil, &actionTaken, &holidayTaken, - &c.ArenaWins, &c.ArenaLosses, &c.InvasionScore, &c.Title, - &c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation, - &c.CreatedAt, &c.LastActiveAt, &reprieveLast, - &c.MasterworkDropsReceived, - &c.RivalPool, &rivalUnlocked, - &babysitAct, &babysitExp, &c.BabysitSkillFocus, - &c.HospitalVisits, &c.RobbieVisitCount, &c.LastDeathDate, - &c.CombatActionsUsed, &c.HarvestActionsUsed, - &pardonUsed, - &mistyLastSeen, &arinaLastSeen, - &mistyBuffExp, &mistyDebuffExp, &arinaBuffExp, - &c.NPCMsgCount, &c.NPCMsgCountDate, - &c.MistyRollTarget, &c.ArinaRollTarget, - &c.HouseTier, &c.HouseLoanBalance, &houseFrozen, &c.HouseMissedPayments, - &houseAutopay, &c.HouseCurrentRate, - &c.PetType, &c.PetName, &c.PetXP, &c.PetLevel, &c.PetArmorTier, - &petChasedAway, &petReactivated, &petArrived, - &c.MistyEncounterCount, &c.MistyDonatedCount, - &thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date, - &petMorningDef, &autoBabysit, &streakDecayed, &c.CraftsSucceeded, - &c.DeathSource, &c.DeathLocation, &c.AutoBabysitFocus, &treasuresLocked, - ); err != nil { + var uid string + if err := rows.Scan(&uid); err != nil { + rows.Close() return nil, err } - c.Alive = alive == 1 - c.ActionTakenToday = actionTaken == 1 - c.HolidayActionTaken = holidayTaken == 1 - c.RivalUnlockedNotified = rivalUnlocked == 1 - c.BabysitActive = babysitAct == 1 - c.PetMorningDefense = petMorningDef == 1 - c.AutoBabysit = autoBabysit == 1 - c.TreasuresLocked = treasuresLocked == 1 - c.StreakDecayed = streakDecayed == 1 - c.HouseLoanFrozen = houseFrozen == 1 - c.HouseAutopay = houseAutopay == 1 - c.PetChasedAway = petChasedAway == 1 - c.PetReactivated = petReactivated == 1 - c.PetArrived = petArrived == 1 - c.ThomAnimalLineFired = thomAnimalLine == 1 - c.PetSupplyShopUnlocked = petSupplyUnlocked == 1 - if deadUntil.Valid { - c.DeadUntil = &deadUntil.Time - } - if reprieveLast.Valid { - c.DeathReprieveLast = &reprieveLast.Time - } - if babysitExp.Valid { - c.BabysitExpiresAt = &babysitExp.Time - } - if pardonUsed.Valid { - c.LastPardonUsed = &pardonUsed.Time - } - if mistyLastSeen.Valid { - c.MistyLastSeen = &mistyLastSeen.Time - } - if arinaLastSeen.Valid { - c.ArinaLastSeen = &arinaLastSeen.Time - } - if mistyBuffExp.Valid { - c.MistyBuffExpires = &mistyBuffExp.Time - } - if mistyDebuffExp.Valid { - c.MistyDebuffExpires = &mistyDebuffExp.Time - } - if arinaBuffExp.Valid { - c.ArinaBuffExpires = &arinaBuffExp.Time - } + uids = append(uids, uid) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + + chars := make([]AdventureCharacter, 0, len(uids)) + for _, uid := range uids { + c := AdventureCharacter{UserID: id.UserID(uid), Alive: true} + applyPlayerMetaOverlay(&c) chars = append(chars, c) } - return chars, rows.Err() -} - -func resetAllAdvDailyActions() error { - d := db.Get() - // Only reset actions taken before today — protects against race if a player - // resolves their action at exactly midnight. - today := time.Now().UTC().Format("2006-01-02") - _, err := d.Exec(`UPDATE adventure_characters SET action_taken_today = 0, holiday_action_taken = 0, combat_actions_used = 0, harvest_actions_used = 0, pet_morning_defense = 0 WHERE last_action_date < ? OR last_action_date IS NULL`, today) - return err + return chars, nil } func logAdvActivity(userID id.UserID, activityType, location, outcome string, lootValue int64, xpGained int, flavorKey string) { diff --git a/internal/plugin/adventure_npcs.go b/internal/plugin/adventure_npcs.go index 2a3db17..68946a1 100644 --- a/internal/plugin/adventure_npcs.go +++ b/internal/plugin/adventure_npcs.go @@ -428,9 +428,7 @@ func npcRepairMostDamaged(userID id.UserID, equip map[EquipmentSlot]*AdvEquipmen // npcMidnightReset resets message counts and regenerates roll targets. // Called from midnightReset. Uses a direct DB update to avoid loading/saving -// every character individually. Phase L5h: writes player_meta as the -// canonical store; the legacy adventure_characters update is preserved -// only as a defensive sweep for rows that pre-date the L5c backfill. +// every character individually. func npcMidnightReset() { d := db.Get() today := time.Now().UTC().Format("2006-01-02") @@ -441,15 +439,6 @@ func npcMidnightReset() { misty_roll_target = ABS(RANDOM()) % 6 + 5, arina_roll_target = ABS(RANDOM()) % 6 + 5`, today); err != nil { - slog.Error("npc: midnight reset (player_meta) failed", "err", err) - } - if _, err := d.Exec(` - UPDATE adventure_characters - SET npc_msg_count = 0, - npc_msg_count_date = ?, - misty_roll_target = ABS(RANDOM()) % 6 + 5, - arina_roll_target = ABS(RANDOM()) % 6 + 5`, - today); err != nil { - slog.Error("npc: midnight reset (adventure_characters) failed", "err", err) + slog.Error("npc: midnight reset failed", "err", err) } } diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index ce3f113..8332dc3 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -413,7 +413,7 @@ func (p *AdventurePlugin) midnightReset() error { // from concurrent writers (e.g. reminder fire loop). var resetErr error for attempt := 0; attempt < 3; attempt++ { - if resetErr = resetAllAdvDailyActions(); resetErr == nil { + if resetErr = resetAllPlayerMetaDailyActions(); resetErr == nil { break } slog.Warn("adventure: daily action reset failed, retrying", "attempt", attempt+1, "err", resetErr) @@ -422,10 +422,6 @@ 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/cleanup_l5.go b/internal/plugin/cleanup_l5.go new file mode 100644 index 0000000..21f917e --- /dev/null +++ b/internal/plugin/cleanup_l5.go @@ -0,0 +1,40 @@ +package plugin + +import ( + "log/slog" + "os" + + "gogobee/internal/db" +) + +// purgeLegacyAdvCharacterTable drops the `adventure_characters` table when +// the operator opts in via GOGOBEE_LEGACY_PURGE=1. Phase L5 close-out: the +// table has been frozen since L5h (saveAdvCharacter fans out into player_meta +// only), and the loader rewire moved every read off it. This is the final +// step. +// +// The pre-D&D rollback snapshot table (`adventure_characters_pre_dnd`) is +// preserved — it's a separate insurance copy and a future +// `GOGOBEE_PRE_DND_PURGE=1` pass can drop it once the soak window expires. +// +// Idempotent: skips silently if the table is already gone (or never existed +// on a fresh install). +func purgeLegacyAdvCharacterTable() { + if os.Getenv("GOGOBEE_LEGACY_PURGE") != "1" { + return + } + d := db.Get() + var name string + err := d.QueryRow( + `SELECT name FROM sqlite_master WHERE type='table' AND name='adventure_characters'`, + ).Scan(&name) + if err != nil { + // sql.ErrNoRows or any scan error → nothing to drop. + return + } + if _, err := d.Exec(`DROP TABLE adventure_characters`); err != nil { + slog.Error("L5 purge: drop adventure_characters failed", "err", err) + return + } + slog.Warn("L5 purge: adventure_characters table dropped (GOGOBEE_LEGACY_PURGE=1)") +} diff --git a/internal/plugin/dnd_combat.go b/internal/plugin/dnd_combat.go index 3c17417..6ecff88 100644 --- a/internal/plugin/dnd_combat.go +++ b/internal/plugin/dnd_combat.go @@ -282,58 +282,6 @@ func autoBuildCharacter(userID id.UserID, char *AdventureCharacter) *DnDCharacte return c } -// backfillDnDCharactersFromAdv is the L5g one-shot mass-backfill: for every -// adventure_characters row that has no corresponding dnd_character row, build -// an auto-migrated D&D character (same shape as ensureDnDCharacterForCombat's -// fresh path) and persist it. After this lands, every legacy player has a -// D&D row, so dndLevelForUser / rivalLevelForUser / hospitalCostsForUser can -// drop their dndLevelFromCombatLevel fallbacks. -// -// Idempotent: skips users who already have any dnd_character row (including -// pending-setup drafts — those finish via !setup confirm and shouldn't be -// overwritten by the backfill). -func backfillDnDCharactersFromAdv() error { - rows, err := db.Get().Query(` - SELECT a.user_id - FROM adventure_characters a - LEFT JOIN dnd_character d ON d.user_id = a.user_id - WHERE d.user_id IS NULL - `) - if err != nil { - return err - } - var userIDs []id.UserID - for rows.Next() { - var uid string - if err := rows.Scan(&uid); err != nil { - rows.Close() - return err - } - userIDs = append(userIDs, id.UserID(uid)) - } - rows.Close() - - created := 0 - for _, uid := range userIDs { - char, err := loadAdvCharacter(uid) - if err != nil || char == nil { - continue - } - c := autoBuildCharacter(uid, char) - c.AutoMigrated = true - c.PendingSetup = false - if err := SaveDnDCharacter(c); err != nil { - slog.Error("dnd: L5g backfill save failed", "user", uid, "err", err) - continue - } - _ = initResources(uid, c.Class) - _ = ensureSpellsForCharacter(c) - created++ - } - slog.Info("dnd: L5g backfill complete", "rows", created) - return nil -} - // ── Roll summary line ──────────────────────────────────────────────────────── // dndRollSummaryLine scans a CombatResult's events for d20 rolls and returns diff --git a/internal/plugin/dnd_l5g_test.go b/internal/plugin/dnd_l5g_test.go deleted file mode 100644 index 0129f9d..0000000 --- a/internal/plugin/dnd_l5g_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package plugin - -import ( - "testing" - - "gogobee/internal/db" - "maunium.net/go/mautrix/id" -) - -// TestL5gBackfillDnDCharacters_Idempotent verifies that the L5g mass-backfill: -// - creates a DnDCharacter for an adventure_characters row that lacks one, -// - seeds Level from CombatLevel via dndLevelFromCombatLevel, -// - is idempotent (re-running does not clobber an already-populated row), -// - does not touch users who already have a confirmed (or pending) D&D row. -func TestL5gBackfillDnDCharacters_Idempotent(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@l5g-bf:example") - if err := createAdvCharacter(uid, "Backfiller"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE adventure_characters SET combat_level = ? WHERE user_id = ?`, - 25, string(uid), - ); err != nil { - t.Fatalf("seed combat_level: %v", err) - } - // Ensure no D&D row pre-exists. - if _, err := db.Get().Exec(`DELETE FROM dnd_character WHERE user_id = ?`, string(uid)); err != nil { - t.Fatalf("clear dnd_character: %v", err) - } - - if err := backfillDnDCharactersFromAdv(); err != nil { - t.Fatalf("backfill 1: %v", err) - } - - c, err := LoadDnDCharacter(uid) - if err != nil { - t.Fatalf("load after backfill: %v", err) - } - if c == nil { - t.Fatalf("expected D&D character row after backfill, got nil") - } - wantLevel := dndLevelFromCombatLevel(25) - if c.Level != wantLevel { - t.Errorf("Level = %d, want %d", c.Level, wantLevel) - } - if c.PendingSetup { - t.Errorf("backfilled row should be PendingSetup=false") - } - if !c.AutoMigrated { - t.Errorf("backfilled row should be AutoMigrated=true") - } - if c.HPMax <= 0 || c.HPCurrent <= 0 { - t.Errorf("backfilled row missing HP: max=%d cur=%d", c.HPMax, c.HPCurrent) - } - - // Mutate the row to prove a second backfill is idempotent (does not - // overwrite the existing row). - c.Level = 12 - c.HPCurrent = 5 - if err := SaveDnDCharacter(c); err != nil { - t.Fatalf("save mutated: %v", err) - } - if err := backfillDnDCharactersFromAdv(); err != nil { - t.Fatalf("backfill 2: %v", err) - } - got, err := LoadDnDCharacter(uid) - if err != nil || got == nil { - t.Fatalf("reload: %v", err) - } - if got.Level != 12 || got.HPCurrent != 5 { - t.Errorf("backfill clobbered live row: Level=%d HPCurrent=%d", got.Level, got.HPCurrent) - } -} - -// TestL5gBackfillDnDCharacters_SkipsPendingSetup ensures users mid-!setup -// (PendingSetup=1, partial draft) are not overwritten. -func TestL5gBackfillDnDCharacters_SkipsPendingSetup(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@l5g-pending:example") - if err := createAdvCharacter(uid, "Drafter"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - draft := &DnDCharacter{ - UserID: uid, - Race: RaceElf, - Class: ClassMage, - Level: 3, - PendingSetup: true, - } - if err := SaveDnDCharacter(draft); err != nil { - t.Fatalf("save draft: %v", err) - } - - if err := backfillDnDCharactersFromAdv(); err != nil { - t.Fatalf("backfill: %v", err) - } - - got, err := LoadDnDCharacter(uid) - if err != nil || got == nil { - t.Fatalf("reload: %v", err) - } - if !got.PendingSetup { - t.Errorf("backfill clobbered pending draft (PendingSetup flipped to false)") - } - if got.Race != RaceElf || got.Class != ClassMage { - t.Errorf("backfill mutated draft race/class: got %s/%s", got.Race, got.Class) - } -} diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index 069bbf3..83570c5 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -3,7 +3,6 @@ package plugin import ( "database/sql" "encoding/json" - "log/slog" "time" "gogobee/internal/db" @@ -64,25 +63,6 @@ func upsertPlayerMetaArena(userID id.UserID, wins, losses, invasionScore int) er return err } -// backfillPlayerMetaArena copies arena_wins / arena_losses / invasion_score -// from adventure_characters into player_meta for any user_id that doesn't -// already have a row. Idempotent: INSERT OR IGNORE skips users that have -// already been backfilled (or that wrote a row through the dual-write -// path post-deploy). Logs the row count touched, matching Phase R1's -// archiveOrphanZoneRuns precedent. -func backfillPlayerMetaArena() error { - res, err := db.Get().Exec(` - INSERT OR IGNORE INTO player_meta (user_id, arena_wins, arena_losses, invasion_score) - SELECT user_id, arena_wins, arena_losses, invasion_score FROM adventure_characters - `) - if err != nil { - return err - } - n, _ := res.RowsAffected() - slog.Info("player_meta: arena counters backfilled", "rows", n) - return nil -} - // upsertPlayerMetaDisplayName writes display_name for a user, leaving other // columns untouched. Used by the dual-write path during the L4f-prep // DisplayName migration: every place that mutates AdvCharacter.DisplayName @@ -97,28 +77,14 @@ func upsertPlayerMetaDisplayName(userID id.UserID, displayName string) error { return err } -// loadDisplayName returns the player's display name from player_meta when -// present, falling back to adventure_characters.display_name during the -// L4f-prep soak window. Empty string if the user has no row in either -// table. Callers should prefer this helper over reading char.DisplayName -// directly so the eventual reader flip is a no-op at the call site. +// loadDisplayName returns the player's display name from player_meta. +// Empty string if the user has no row. func loadDisplayName(userID id.UserID) (string, error) { var name string err := db.Get().QueryRow( `SELECT display_name FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&name) - if err == nil && name != "" { - return name, nil - } - if err != nil && err != sql.ErrNoRows { - return "", err - } - // Fallback during soak. - err = db.Get().QueryRow( - `SELECT display_name FROM adventure_characters WHERE user_id = ?`, - string(userID), - ).Scan(&name) if err == sql.ErrNoRows { return "", nil } @@ -140,71 +106,17 @@ func upsertPlayerMetaHospitalVisits(userID id.UserID, visits int) error { } // loadHospitalVisits returns the player's hospital visit count from -// player_meta when present, falling back to adventure_characters during -// the L4a soak window. Zero if the user has no row in either table. +// player_meta. Zero if the user has no row. func loadHospitalVisits(userID id.UserID) (int, error) { var visits int err := db.Get().QueryRow( `SELECT hospital_visits FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&visits) - if err == nil && visits > 0 { - return visits, nil - } - if err != nil && err != sql.ErrNoRows { - return 0, err - } - // player_meta row missing or zero → fall back to AdvCharacter for the - // soak window. A zero value in player_meta could legitimately mean "no - // visits yet", but the fallback only returns non-zero if the legacy - // column has a higher value, so this is safe either way. - var legacy int - err = db.Get().QueryRow( - `SELECT hospital_visits FROM adventure_characters WHERE user_id = ?`, - string(userID), - ).Scan(&legacy) if err == sql.ErrNoRows { - return visits, nil + return 0, nil } - if err != nil { - return 0, err - } - if legacy > visits { - return legacy, nil - } - return visits, nil -} - -// backfillPlayerMetaHospitalVisits copies adventure_characters.hospital_visits -// into player_meta.hospital_visits for any row whose value is still the -// default zero. Idempotent: only updates rows that haven't been populated -// yet (via backfill or dual-write). -func backfillPlayerMetaHospitalVisits() 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 hospital_visits = ( - SELECT hospital_visits FROM adventure_characters - WHERE adventure_characters.user_id = player_meta.user_id - ) - WHERE hospital_visits = 0 - AND EXISTS ( - SELECT 1 FROM adventure_characters - WHERE adventure_characters.user_id = player_meta.user_id - AND adventure_characters.hospital_visits > 0 - ) - `) - if err != nil { - return err - } - n, _ := res.RowsAffected() - slog.Info("player_meta: hospital_visits backfilled", "rows", n) - return nil + return visits, err } // upsertPlayerMetaRivalState writes rival_pool / rival_unlocked_notified for @@ -227,84 +139,23 @@ func upsertPlayerMetaRivalState(userID id.UserID, pool int, notified bool) error return err } -// loadRivalState returns rival_pool / rival_unlocked_notified for a user from -// player_meta when populated, falling back to adventure_characters during the -// L4b soak window. Treats a missing row as (0, false). The fallback returns -// the AdvCharacter values whenever player_meta has the unmigrated default -// (pool=0, notified=0) — a true "not yet in pool" state and the pre-backfill -// state are indistinguishable at the column level, but the legacy column -// only ever moves toward unlocked, so picking the higher of the two values -// is safe. +// loadRivalState returns rival_pool / rival_unlocked_notified for a user +// from player_meta. (0, false) if the user has no row. func loadRivalState(userID id.UserID) (pool int, notified bool, err error) { var notifiedInt int err = db.Get().QueryRow( `SELECT rival_pool, rival_unlocked_notified FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&pool, ¬ifiedInt) - if err == nil && (pool > 0 || notifiedInt > 0) { - return pool, notifiedInt == 1, nil - } - if err != nil && err != sql.ErrNoRows { - return 0, false, err - } - // Fallback during soak. - var legacyPool, legacyNotified int - err = db.Get().QueryRow( - `SELECT rival_pool, rival_unlocked_notified FROM adventure_characters WHERE user_id = ?`, - string(userID), - ).Scan(&legacyPool, &legacyNotified) if err == sql.ErrNoRows { - return pool, notifiedInt == 1, nil + return 0, false, nil } if err != nil { return 0, false, err } - if legacyPool > pool { - pool = legacyPool - } - if legacyNotified > notifiedInt { - notifiedInt = legacyNotified - } return pool, notifiedInt == 1, nil } -// backfillPlayerMetaRivalState copies rival_pool / rival_unlocked_notified -// from adventure_characters into player_meta for any row whose values are -// still the default zero. Idempotent: only updates rows that haven't been -// populated yet (via backfill or dual-write). -func backfillPlayerMetaRivalState() 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 rival_pool = ( - SELECT rival_pool FROM adventure_characters - WHERE adventure_characters.user_id = player_meta.user_id - ), - rival_unlocked_notified = ( - SELECT rival_unlocked_notified FROM adventure_characters - WHERE adventure_characters.user_id = player_meta.user_id - ) - WHERE rival_pool = 0 AND rival_unlocked_notified = 0 - AND EXISTS ( - SELECT 1 FROM adventure_characters - WHERE adventure_characters.user_id = player_meta.user_id - AND (adventure_characters.rival_pool > 0 - OR adventure_characters.rival_unlocked_notified > 0) - ) - `) - if err != nil { - return err - } - n, _ := res.RowsAffected() - slog.Info("player_meta: rival state backfilled", "rows", n) - return nil -} - // upsertPlayerMetaMasterworkDrops writes masterwork_drops_received for a user, // leaving other columns untouched. Used by the dual-write path during the // L4c Masterwork migration: every increment on AdvCharacter.MasterworkDropsReceived @@ -320,69 +171,17 @@ func upsertPlayerMetaMasterworkDrops(userID id.UserID, drops int) error { } // loadMasterworkDrops returns the player's masterwork drop count from -// player_meta when populated, falling back to adventure_characters during -// the L4c soak window. Zero if the user has no row in either table. Mirrors -// loadHospitalVisits: the legacy column only ever moves up, so taking the -// higher of the two values is safe across the dual-write window. +// player_meta. Zero if the user has no row. func loadMasterworkDrops(userID id.UserID) (int, error) { var drops int err := db.Get().QueryRow( `SELECT masterwork_drops_received FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&drops) - if err == nil && drops > 0 { - return drops, nil - } - if err != nil && err != sql.ErrNoRows { - return 0, err - } - var legacy int - err = db.Get().QueryRow( - `SELECT masterwork_drops_received FROM adventure_characters WHERE user_id = ?`, - string(userID), - ).Scan(&legacy) if err == sql.ErrNoRows { - return drops, nil + return 0, nil } - if err != nil { - return 0, err - } - if legacy > drops { - return legacy, nil - } - return drops, nil -} - -// backfillPlayerMetaMasterworkDrops copies adventure_characters.masterwork_drops_received -// into player_meta.masterwork_drops_received for any row whose value is still -// the default zero. Idempotent: only updates rows that haven't been populated -// yet (via backfill or dual-write). -func backfillPlayerMetaMasterworkDrops() 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 masterwork_drops_received = ( - SELECT masterwork_drops_received FROM adventure_characters - WHERE adventure_characters.user_id = player_meta.user_id - ) - WHERE masterwork_drops_received = 0 - AND EXISTS ( - SELECT 1 FROM adventure_characters - WHERE adventure_characters.user_id = player_meta.user_id - AND adventure_characters.masterwork_drops_received > 0 - ) - `) - if err != nil { - return err - } - n, _ := res.RowsAffected() - slog.Info("player_meta: masterwork_drops_received backfilled", "rows", n) - return nil + return drops, err } // PetState is the in-memory mirror of player_meta's pet_* columns. Phase L4d @@ -458,11 +257,8 @@ func upsertPlayerMetaPetState(userID id.UserID, s PetState) error { return err } -// loadPetState returns the player's pet state from player_meta when populated, -// falling back to adventure_characters during the L4d soak window. A -// player_meta row whose pet_type is empty is treated as unmigrated and falls -// through to AdvCharacter — matching the "type is the canonical -// has-a-pet marker" semantics used by HasPet(). +// loadPetState returns the player's pet state from player_meta. Empty +// PetState{} if the user has no row. func loadPetState(userID id.UserID) (PetState, error) { var ( s PetState @@ -476,117 +272,22 @@ func loadPetState(userID id.UserID) (PetState, error) { string(userID), ).Scan(&s.Type, &s.Name, &s.XP, &s.Level, &s.ArmorTier, &flagsRaw, &supplyInt, &s.Level10Date) - if err == nil && s.Type != "" { - s.SupplyShopUnlocked = supplyInt == 1 - var f petFlagsJSON - if flagsRaw != "" { - _ = json.Unmarshal([]byte(flagsRaw), &f) - } - s.Arrived = f.Arrived - s.ChasedAway = f.ChasedAway - s.Reactivated = f.Reactivated - s.MorningDefense = f.MorningDefense - return s, nil - } - if err != nil && err != sql.ErrNoRows { - return PetState{}, err - } - // Fallback to AdvCharacter during soak. - var ( - legacyType, legacyName, legacyL10Date string - legacyXP, legacyLevel, legacyArmor int - legacySupply int - arrived, chased, reactivated, morningDefense int - ) - err = db.Get().QueryRow( - `SELECT pet_type, pet_name, pet_xp, pet_level, pet_armor_tier, - pet_arrived, pet_chased_away, pet_reactivated, pet_morning_defense, - pet_supply_shop_unlocked, pet_level_10_date - FROM adventure_characters WHERE user_id = ?`, - string(userID), - ).Scan(&legacyType, &legacyName, &legacyXP, &legacyLevel, &legacyArmor, - &arrived, &chased, &reactivated, &morningDefense, - &legacySupply, &legacyL10Date) if err == sql.ErrNoRows { return PetState{}, nil } if err != nil { return PetState{}, err } - return PetState{ - Type: legacyType, - Name: legacyName, - XP: legacyXP, - Level: legacyLevel, - ArmorTier: legacyArmor, - SupplyShopUnlocked: legacySupply == 1, - Level10Date: legacyL10Date, - Arrived: arrived == 1, - ChasedAway: chased == 1, - Reactivated: reactivated == 1, - MorningDefense: morningDefense == 1, - }, nil -} - -// backfillPlayerMetaPetState copies the pet_* columns from adventure_characters -// into player_meta for any row whose pet_type is still the empty default. -// Idempotent: only updates rows that haven't been populated yet (via backfill -// or dual-write). Pet flags are encoded as JSON to match the runtime helper. -func backfillPlayerMetaPetState() error { - if _, err := db.Get().Exec(` - INSERT OR IGNORE INTO player_meta (user_id) - SELECT user_id FROM adventure_characters - `); err != nil { - return err + s.SupplyShopUnlocked = supplyInt == 1 + var f petFlagsJSON + if flagsRaw != "" { + _ = json.Unmarshal([]byte(flagsRaw), &f) } - rows, err := db.Get().Query(` - SELECT ac.user_id, ac.pet_type, ac.pet_name, ac.pet_xp, ac.pet_level, - ac.pet_armor_tier, ac.pet_arrived, ac.pet_chased_away, - ac.pet_reactivated, ac.pet_morning_defense, - ac.pet_supply_shop_unlocked, ac.pet_level_10_date - FROM adventure_characters ac - JOIN player_meta pm ON pm.user_id = ac.user_id - WHERE pm.pet_type = '' - AND ac.pet_type <> '' - `) - if err != nil { - return err - } - defer rows.Close() - type pending struct { - uid id.UserID - s PetState - } - var todo []pending - for rows.Next() { - var ( - uid string - s PetState - arrived, chased, reactivated, morningDefense int - supply int - ) - if err := rows.Scan(&uid, &s.Type, &s.Name, &s.XP, &s.Level, - &s.ArmorTier, &arrived, &chased, &reactivated, &morningDefense, - &supply, &s.Level10Date); err != nil { - return err - } - s.Arrived = arrived == 1 - s.ChasedAway = chased == 1 - s.Reactivated = reactivated == 1 - s.MorningDefense = morningDefense == 1 - s.SupplyShopUnlocked = supply == 1 - todo = append(todo, pending{uid: id.UserID(uid), s: s}) - } - if err := rows.Err(); err != nil { - return err - } - for _, p := range todo { - if err := upsertPlayerMetaPetState(p.uid, p.s); err != nil { - return err - } - } - slog.Info("player_meta: pet state backfilled", "rows", len(todo)) - return nil + s.Arrived = f.Arrived + s.ChasedAway = f.ChasedAway + s.Reactivated = f.Reactivated + s.MorningDefense = f.MorningDefense + return s, nil } // petStateFromAdvChar projects the pet-related fields off an AdventureCharacter @@ -659,11 +360,8 @@ func upsertPlayerMetaHouseState(userID id.UserID, s HouseState) error { return err } -// loadHouseState returns the player's house state from player_meta when -// populated (HasHouse true), falling back to adventure_characters during -// the L4e soak window. A row with tier=0 and loan_balance=0 is treated as -// unmigrated and falls through — matching the canonical "tier > 0 || loan -// > 0" has-a-house marker used elsewhere. +// loadHouseState returns the player's house state from player_meta. Empty +// HouseState{} if the user has no row. func loadHouseState(userID id.UserID) (HouseState, error) { var ( s HouseState @@ -677,77 +375,15 @@ func loadHouseState(userID id.UserID) (HouseState, error) { string(userID), ).Scan(&s.Tier, &s.LoanBalance, &frozenInt, &s.MissedPayments, &autopayInt, &s.CurrentRate) - if err == nil && (s.Tier > 0 || s.LoanBalance > 0) { - s.LoanFrozen = frozenInt == 1 - s.Autopay = autopayInt == 1 - return s, nil - } - if err != nil && err != sql.ErrNoRows { - return HouseState{}, err - } - // Fallback to AdvCharacter during soak. - var ( - legacyTier, legacyBalance, legacyMissed int - legacyFrozen, legacyAutopay int - legacyRate float64 - ) - err = db.Get().QueryRow( - `SELECT house_tier, house_loan_balance, house_loan_frozen, - house_missed_payments, house_autopay, house_current_rate - FROM adventure_characters WHERE user_id = ?`, - string(userID), - ).Scan(&legacyTier, &legacyBalance, &legacyFrozen, - &legacyMissed, &legacyAutopay, &legacyRate) if err == sql.ErrNoRows { return HouseState{}, nil } if err != nil { return HouseState{}, err } - return HouseState{ - Tier: legacyTier, - LoanBalance: legacyBalance, - LoanFrozen: legacyFrozen == 1, - MissedPayments: legacyMissed, - Autopay: legacyAutopay == 1, - CurrentRate: legacyRate, - }, nil -} - -// backfillPlayerMetaHouseState copies the house_* columns from -// adventure_characters into player_meta for any row whose house_tier and -// house_loan_balance are both still the default zero. Idempotent: only -// updates rows that haven't been populated yet (via backfill or -// dual-write). -func backfillPlayerMetaHouseState() 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 house_tier = (SELECT house_tier FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - house_loan_balance = (SELECT house_loan_balance FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - house_loan_frozen = (SELECT house_loan_frozen FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - house_missed_payments = (SELECT house_missed_payments FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - house_autopay = (SELECT house_autopay FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - house_current_rate = (SELECT house_current_rate FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id) - WHERE house_tier = 0 - AND house_loan_balance = 0 - AND EXISTS ( - SELECT 1 FROM adventure_characters ac - WHERE ac.user_id = player_meta.user_id - AND (ac.house_tier > 0 OR ac.house_loan_balance > 0) - ) - `) - if err != nil { - return err - } - n, _ := res.RowsAffected() - slog.Info("player_meta: house state backfilled", "rows", n) - return nil + s.LoanFrozen = frozenInt == 1 + s.Autopay = autopayInt == 1 + return s, nil } // houseStateFromAdvChar projects the housing-related fields off an @@ -840,74 +476,10 @@ func loadSkillState(userID id.UserID) (SkillState, error) { &s.ForagingSkill, &s.ForagingXP, &s.FishingSkill, &s.FishingXP, ) - if err == nil && s.HasSkills() { - return s, nil - } - if err != nil && err != sql.ErrNoRows { - return SkillState{}, err - } - // Fallback to AdvCharacter during soak. - var legacy SkillState - err = db.Get().QueryRow( - `SELECT combat_level, combat_xp, - mining_skill, mining_xp, - foraging_skill, foraging_xp, - fishing_skill, fishing_xp - FROM adventure_characters WHERE user_id = ?`, - string(userID), - ).Scan( - &legacy.CombatLevel, &legacy.CombatXP, - &legacy.MiningSkill, &legacy.MiningXP, - &legacy.ForagingSkill, &legacy.ForagingXP, - &legacy.FishingSkill, &legacy.FishingXP, - ) if err == sql.ErrNoRows { return SkillState{}, nil } - if err != nil { - return SkillState{}, err - } - return legacy, nil -} - -// backfillPlayerMetaSkillState copies the skill columns from -// adventure_characters into player_meta for any row whose skill columns -// are all still zero. Idempotent: only updates rows that haven't been -// populated yet (via backfill or dual-write). -func backfillPlayerMetaSkillState() 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 combat_level = (SELECT combat_level FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - combat_xp = (SELECT combat_xp FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - mining_skill = (SELECT mining_skill FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - mining_xp = (SELECT mining_xp FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - foraging_skill = (SELECT foraging_skill FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - foraging_xp = (SELECT foraging_xp FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - fishing_skill = (SELECT fishing_skill FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - fishing_xp = (SELECT fishing_xp FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id) - WHERE combat_level = 0 AND combat_xp = 0 - AND mining_skill = 0 AND mining_xp = 0 - AND foraging_skill = 0 AND foraging_xp = 0 - AND fishing_skill = 0 AND fishing_xp = 0 - AND EXISTS ( - SELECT 1 FROM adventure_characters ac - WHERE ac.user_id = player_meta.user_id - AND (ac.combat_level > 0 OR ac.mining_skill > 0 OR ac.foraging_skill > 0 OR ac.fishing_skill > 0 - OR ac.combat_xp > 0 OR ac.mining_xp > 0 OR ac.foraging_xp > 0 OR ac.fishing_xp > 0) - ) - `) - if err != nil { - return err - } - n, _ := res.RowsAffected() - slog.Info("player_meta: skill state backfilled", "rows", n) - return nil + return s, err } // skillStateFromAdvChar projects the skill-related fields off an @@ -979,10 +551,8 @@ func upsertPlayerMetaBabysitState(userID id.UserID, s BabysitState) error { return err } -// loadBabysitState returns the player's babysit state from player_meta -// when active, falling back to adventure_characters during the L5b soak -// window. An inactive row falls through to the legacy table since an -// inactive player_meta row is indistinguishable from an unmigrated one. +// loadBabysitState returns the player's babysit state from player_meta. +// Empty BabysitState{} if the user has no row. func loadBabysitState(userID id.UserID) (BabysitState, error) { var ( s BabysitState @@ -996,82 +566,19 @@ func loadBabysitState(userID id.UserID) (BabysitState, error) { FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&activeInt, &expires, &s.SkillFocus, &autoInt, &s.AutoBabysitFocus) - if err == nil && activeInt == 1 { - s.Active = true - s.AutoBabysit = autoInt == 1 - if expires.Valid { - t := expires.Time.UTC() - s.ExpiresAt = &t - } - return s, nil - } - if err != nil && err != sql.ErrNoRows { - return BabysitState{}, err - } - // Fallback to AdvCharacter during soak. - var ( - legacyActive int - legacyExpires sql.NullTime - legacyFocus string - legacyAuto int - legacyAutoFoc string - ) - err = db.Get().QueryRow( - `SELECT babysit_active, babysit_expires_at, babysit_skill_focus, - auto_babysit, auto_babysit_focus - FROM adventure_characters WHERE user_id = ?`, - string(userID), - ).Scan(&legacyActive, &legacyExpires, &legacyFocus, &legacyAuto, &legacyAutoFoc) if err == sql.ErrNoRows { return BabysitState{}, nil } if err != nil { return BabysitState{}, err } - out := BabysitState{ - Active: legacyActive == 1, - SkillFocus: legacyFocus, - AutoBabysit: legacyAuto == 1, - AutoBabysitFocus: legacyAutoFoc, + s.Active = activeInt == 1 + s.AutoBabysit = autoInt == 1 + if expires.Valid { + t := expires.Time.UTC() + s.ExpiresAt = &t } - if legacyExpires.Valid { - t := legacyExpires.Time.UTC() - out.ExpiresAt = &t - } - return out, nil -} - -// backfillPlayerMetaBabysitState copies the babysit columns from -// adventure_characters into player_meta for any row whose babysit_active -// is still 0 AND the legacy row has babysit_active = 1. Idempotent: only -// fills inactive rows whose legacy counterpart is active. -func backfillPlayerMetaBabysitState() 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 babysit_active = (SELECT babysit_active FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - babysit_expires_at = (SELECT babysit_expires_at FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - babysit_skill_focus = (SELECT babysit_skill_focus FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - auto_babysit = (SELECT auto_babysit FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - auto_babysit_focus = (SELECT auto_babysit_focus FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id) - WHERE babysit_active = 0 - AND EXISTS ( - SELECT 1 FROM adventure_characters ac - WHERE ac.user_id = player_meta.user_id - AND ac.babysit_active = 1 - ) - `) - if err != nil { - return err - } - n, _ := res.RowsAffected() - slog.Info("player_meta: babysit state backfilled", "rows", n) - return nil + return s, nil } // babysitStateFromAdvChar projects the babysit-related fields off an @@ -1200,137 +707,34 @@ func loadNPCState(userID id.UserID) (NPCState, error) { &s.MistyEncounterCount, &s.MistyDonatedCount, &thomInt, &s.RobbieVisitCount, ) - if err == nil { - s.ThomAnimalLineFired = thomInt == 1 - if mistyLast.Valid { - t := mistyLast.Time.UTC() - s.MistyLastSeen = &t - } - if arinaLast.Valid { - t := arinaLast.Time.UTC() - s.ArinaLastSeen = &t - } - if mistyBuff.Valid { - t := mistyBuff.Time.UTC() - s.MistyBuffExpires = &t - } - if mistyDebuff.Valid { - t := mistyDebuff.Time.UTC() - s.MistyDebuffExpires = &t - } - if arinaBuff.Valid { - t := arinaBuff.Time.UTC() - s.ArinaBuffExpires = &t - } - if s.HasNPCActivity() { - return s, nil - } - } - if err != nil && err != sql.ErrNoRows { - return NPCState{}, err - } - // Fallback to AdvCharacter during soak. - var ( - legacy NPCState - legacyMisty, legacyArinaLast, legacyMBuff, legacyMDebuff, legacyABuff sql.NullTime - legacyThom int - ) - err = db.Get().QueryRow( - `SELECT misty_last_seen, arina_last_seen, - misty_buff_expires, misty_debuff_expires, arina_buff_expires, - npc_msg_count, npc_msg_count_date, - misty_roll_target, arina_roll_target, - misty_encounter_count, misty_donated_count, - thom_animal_line_fired, robbie_visit_count - FROM adventure_characters WHERE user_id = ?`, - string(userID), - ).Scan( - &legacyMisty, &legacyArinaLast, &legacyMBuff, &legacyMDebuff, &legacyABuff, - &legacy.NPCMsgCount, &legacy.NPCMsgCountDate, - &legacy.MistyRollTarget, &legacy.ArinaRollTarget, - &legacy.MistyEncounterCount, &legacy.MistyDonatedCount, - &legacyThom, &legacy.RobbieVisitCount, - ) if err == sql.ErrNoRows { return NPCState{}, nil } if err != nil { return NPCState{}, err } - legacy.ThomAnimalLineFired = legacyThom == 1 - if legacyMisty.Valid { - t := legacyMisty.Time.UTC() - legacy.MistyLastSeen = &t + s.ThomAnimalLineFired = thomInt == 1 + if mistyLast.Valid { + t := mistyLast.Time.UTC() + s.MistyLastSeen = &t } - if legacyArinaLast.Valid { - t := legacyArinaLast.Time.UTC() - legacy.ArinaLastSeen = &t + if arinaLast.Valid { + t := arinaLast.Time.UTC() + s.ArinaLastSeen = &t } - if legacyMBuff.Valid { - t := legacyMBuff.Time.UTC() - legacy.MistyBuffExpires = &t + if mistyBuff.Valid { + t := mistyBuff.Time.UTC() + s.MistyBuffExpires = &t } - if legacyMDebuff.Valid { - t := legacyMDebuff.Time.UTC() - legacy.MistyDebuffExpires = &t + if mistyDebuff.Valid { + t := mistyDebuff.Time.UTC() + s.MistyDebuffExpires = &t } - if legacyABuff.Valid { - t := legacyABuff.Time.UTC() - legacy.ArinaBuffExpires = &t + if arinaBuff.Valid { + t := arinaBuff.Time.UTC() + s.ArinaBuffExpires = &t } - return legacy, nil -} - -// backfillPlayerMetaNPCState copies NPC columns from adventure_characters -// into player_meta for any row where every NPC field is still zero AND -// the legacy row has any non-zero NPC field. Idempotent. -func backfillPlayerMetaNPCState() 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 misty_last_seen = (SELECT misty_last_seen FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - arina_last_seen = (SELECT arina_last_seen FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - misty_buff_expires = (SELECT misty_buff_expires FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - misty_debuff_expires = (SELECT misty_debuff_expires FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - arina_buff_expires = (SELECT arina_buff_expires FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - npc_msg_count = (SELECT npc_msg_count FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - npc_msg_count_date = (SELECT npc_msg_count_date FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - misty_roll_target = (SELECT misty_roll_target FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - arina_roll_target = (SELECT arina_roll_target FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - misty_encounter_count = (SELECT misty_encounter_count FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - misty_donated_count = (SELECT misty_donated_count FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - thom_animal_line_fired = (SELECT thom_animal_line_fired FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - robbie_visit_count = (SELECT robbie_visit_count FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id) - WHERE misty_last_seen IS NULL AND arina_last_seen IS NULL - AND misty_buff_expires IS NULL AND misty_debuff_expires IS NULL - AND arina_buff_expires IS NULL - AND npc_msg_count = 0 AND npc_msg_count_date = '' - AND misty_roll_target = 0 AND arina_roll_target = 0 - AND misty_encounter_count = 0 AND misty_donated_count = 0 - AND thom_animal_line_fired = 0 AND robbie_visit_count = 0 - AND EXISTS ( - SELECT 1 FROM adventure_characters ac - WHERE ac.user_id = player_meta.user_id - AND (ac.misty_last_seen IS NOT NULL OR ac.arina_last_seen IS NOT NULL - OR ac.misty_buff_expires IS NOT NULL OR ac.misty_debuff_expires IS NOT NULL - OR ac.arina_buff_expires IS NOT NULL - OR ac.npc_msg_count > 0 OR ac.npc_msg_count_date <> '' - OR ac.misty_roll_target > 0 OR ac.arina_roll_target > 0 - OR ac.misty_encounter_count > 0 OR ac.misty_donated_count > 0 - OR ac.thom_animal_line_fired = 1 OR ac.robbie_visit_count > 0) - ) - `) - if err != nil { - return err - } - n, _ := res.RowsAffected() - slog.Info("player_meta: NPC state backfilled", "rows", n) - return nil + return s, nil } // npcStateFromAdvChar projects NPC fields off an AdventureCharacter into @@ -1455,100 +859,24 @@ func loadLifecycleState(userID id.UserID) (LifecycleState, error) { &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 + s.StreakDecayed = streakDecayed == 1 + s.ActionTakenToday = actionTaken == 1 + s.HolidayActionTaken = holidayTaken == 1 + if createdAt.Valid { + t := createdAt.Time.UTC() + s.CreatedAt = &t } - if legacyActive.Valid { - t := legacyActive.Time.UTC() - legacy.LastActiveAt = &t + if lastActiveAt.Valid { + t := lastActiveAt.Time.UTC() + s.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 + return s, nil } // lifecycleStateFromAdvChar projects lifecycle fields off an @@ -1648,14 +976,13 @@ func upsertPlayerMetaDeathState(userID id.UserID, s DeathState) error { return err } -// loadDeathState returns death state from player_meta when populated, -// otherwise falls back to adventure_characters during the L5e soak -// window. The "row is migrated" marker is any non-default field. +// loadDeathState returns death state from player_meta. Default +// DeathState{Alive: true} if the user has no row. func loadDeathState(userID id.UserID) (DeathState, error) { var ( - s DeathState - aliveInt int - deadUntil, reprieve, pardon sql.NullTime + s DeathState + aliveInt int + deadUntil, reprieve, pardon sql.NullTime ) err := db.Get().QueryRow( `SELECT alive, dead_until, death_reprieve_last, @@ -1668,114 +995,26 @@ func loadDeathState(userID id.UserID) (DeathState, error) { &s.LastDeathDate, &pardon, &s.GrudgeLocation, &s.DeathSource, &s.DeathLocation, ) - migrated := false - if err == nil { - s.Alive = aliveInt == 1 - if deadUntil.Valid { - t := deadUntil.Time.UTC() - s.DeadUntil = &t - migrated = true - } - if reprieve.Valid { - t := reprieve.Time.UTC() - s.DeathReprieveLast = &t - migrated = true - } - if pardon.Valid { - t := pardon.Time.UTC() - s.LastPardonUsed = &t - migrated = true - } - if s.LastDeathDate != "" || s.GrudgeLocation != "" || - s.DeathSource != "" || s.DeathLocation != "" || !s.Alive { - migrated = true - } - if migrated { - return s, nil - } - } - if err != nil && err != sql.ErrNoRows { - return DeathState{}, err - } - // Fallback to AdvCharacter during soak. - var ( - legacy DeathState - legacyAlive int - legacyDead, legacyReprieve, legacyPardon sql.NullTime - ) - err = db.Get().QueryRow( - `SELECT alive, dead_until, death_reprieve_last, - last_death_date, last_pardon_used, - grudge_location, death_source, death_location - FROM adventure_characters WHERE user_id = ?`, - string(userID), - ).Scan( - &legacyAlive, &legacyDead, &legacyReprieve, - &legacy.LastDeathDate, &legacyPardon, - &legacy.GrudgeLocation, &legacy.DeathSource, &legacy.DeathLocation, - ) if err == sql.ErrNoRows { return DeathState{Alive: true}, nil } if err != nil { return DeathState{}, err } - legacy.Alive = legacyAlive == 1 - if legacyDead.Valid { - t := legacyDead.Time.UTC() - legacy.DeadUntil = &t + s.Alive = aliveInt == 1 + if deadUntil.Valid { + t := deadUntil.Time.UTC() + s.DeadUntil = &t } - if legacyReprieve.Valid { - t := legacyReprieve.Time.UTC() - legacy.DeathReprieveLast = &t + if reprieve.Valid { + t := reprieve.Time.UTC() + s.DeathReprieveLast = &t } - if legacyPardon.Valid { - t := legacyPardon.Time.UTC() - legacy.LastPardonUsed = &t + if pardon.Valid { + t := pardon.Time.UTC() + s.LastPardonUsed = &t } - return legacy, nil -} - -// backfillPlayerMetaDeathState copies death columns from -// adventure_characters into player_meta for any row that hasn't been -// migrated yet. Idempotent: only fills rows where every death field is -// still the default (alive=1, all timestamps NULL, all strings empty). -func backfillPlayerMetaDeathState() 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 alive = (SELECT alive FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - dead_until = (SELECT dead_until FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - death_reprieve_last = (SELECT death_reprieve_last FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - last_death_date = (SELECT last_death_date FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - last_pardon_used = (SELECT last_pardon_used FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - grudge_location = (SELECT grudge_location FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - death_source = (SELECT death_source FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - death_location = (SELECT death_location FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id) - WHERE alive = 1 AND dead_until IS NULL - AND death_reprieve_last IS NULL AND last_pardon_used IS NULL - AND last_death_date = '' AND grudge_location = '' - AND death_source = '' AND death_location = '' - AND EXISTS ( - SELECT 1 FROM adventure_characters ac - WHERE ac.user_id = player_meta.user_id - AND (ac.alive = 0 OR ac.dead_until IS NOT NULL - OR ac.death_reprieve_last IS NOT NULL OR ac.last_pardon_used IS NOT NULL - OR ac.last_death_date <> '' OR ac.grudge_location <> '' - OR ac.death_source <> '' OR ac.death_location <> '') - ) - `) - if err != nil { - return err - } - n, _ := res.RowsAffected() - slog.Info("player_meta: death state backfilled", "rows", n) - return nil + return s, nil } // deathStateFromAdvChar projects death fields off an AdventureCharacter. @@ -1830,56 +1069,14 @@ func loadMiscState(userID id.UserID) (MiscState, error) { FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&s.Title, &lockedInt, &s.CraftsSucceeded) - if err == nil && (s.Title != "" || lockedInt == 1 || s.CraftsSucceeded > 0) { - s.TreasuresLocked = lockedInt == 1 - return s, nil - } - if err != nil && err != sql.ErrNoRows { - return MiscState{}, err - } - // Fallback to AdvCharacter during soak. - var legacy MiscState - var legacyLocked int - err = db.Get().QueryRow( - `SELECT title, treasures_locked, crafts_succeeded - FROM adventure_characters WHERE user_id = ?`, - string(userID), - ).Scan(&legacy.Title, &legacyLocked, &legacy.CraftsSucceeded) if err == sql.ErrNoRows { return MiscState{}, nil } if err != nil { return MiscState{}, err } - legacy.TreasuresLocked = legacyLocked == 1 - return legacy, nil -} - -func backfillPlayerMetaMiscState() 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 title = (SELECT title FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - treasures_locked = (SELECT treasures_locked FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), - crafts_succeeded = (SELECT crafts_succeeded FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id) - WHERE title = '' AND treasures_locked = 0 AND crafts_succeeded = 0 - AND EXISTS ( - SELECT 1 FROM adventure_characters ac - WHERE ac.user_id = player_meta.user_id - AND (ac.title <> '' OR ac.treasures_locked = 1 OR ac.crafts_succeeded > 0) - ) - `) - if err != nil { - return err - } - n, _ := res.RowsAffected() - slog.Info("player_meta: misc state backfilled", "rows", n) - return nil + s.TreasuresLocked = lockedInt == 1 + return s, nil } func miscStateFromAdvChar(c *AdventureCharacter) MiscState { @@ -1890,41 +1087,6 @@ func miscStateFromAdvChar(c *AdventureCharacter) MiscState { } } -// 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 -// haven't been populated yet (either by backfill or the dual-write path). -func backfillPlayerMetaDisplayName() error { - // Ensure a player_meta row exists for every adventure_characters user - // (arena backfill already does this, but display_name backfill must - // not assume order — re-run cheaply via INSERT OR IGNORE). - 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 display_name = ( - SELECT display_name FROM adventure_characters - WHERE adventure_characters.user_id = player_meta.user_id - ) - WHERE display_name = '' - AND EXISTS ( - SELECT 1 FROM adventure_characters - WHERE adventure_characters.user_id = player_meta.user_id - AND adventure_characters.display_name <> '' - ) - `) - if err != nil { - return err - } - n, _ := res.RowsAffected() - slog.Info("player_meta: display_name backfilled", "rows", n) - return nil -} - // applyPlayerMetaOverlay overlays every player_meta-mirrored field onto an // AdventureCharacter struct that was just SELECTed from adventure_characters. // Phase L5h: saveAdvCharacter no longer writes the adventure_characters diff --git a/internal/plugin/player_meta_test.go b/internal/plugin/player_meta_test.go index 91ea3b8..4b02af8 100644 --- a/internal/plugin/player_meta_test.go +++ b/internal/plugin/player_meta_test.go @@ -12,68 +12,6 @@ import ( // test pattern: run twice, second run is a no-op (INSERT OR IGNORE), and // dual-writes layered after the backfill don't get clobbered. -func TestPlayerMetaArenaBackfill_Idempotent(t *testing.T) { - setupAuditTestDB(t) - - uidA := id.UserID("@meta-bf-a:example") - uidB := id.UserID("@meta-bf-b:example") - if err := createAdvCharacter(uidA, "metaA"); err != nil { - t.Fatalf("createAdvCharacter A: %v", err) - } - if err := createAdvCharacter(uidB, "metaB"); err != nil { - t.Fatalf("createAdvCharacter B: %v", err) - } - // Stamp arena counters directly on adventure_characters so the backfill - // has something interesting to copy. - if _, err := db.Get().Exec( - `UPDATE adventure_characters SET arena_wins = ?, arena_losses = ?, invasion_score = ? WHERE user_id = ?`, - 7, 3, 0, string(uidA), - ); err != nil { - t.Fatalf("seed A: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE adventure_characters SET arena_wins = ?, arena_losses = ?, invasion_score = ? WHERE user_id = ?`, - 1, 1, 5, string(uidB), - ); err != nil { - t.Fatalf("seed B: %v", err) - } - - // Wipe any pre-existing player_meta rows from the prod-DB copy so we're - // testing the backfill from a clean slate for these two users. - if _, err := db.Get().Exec( - `DELETE FROM player_meta WHERE user_id IN (?, ?)`, - string(uidA), string(uidB), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - if err := backfillPlayerMetaArena(); err != nil { - t.Fatalf("backfill 1: %v", err) - } - - mA, _ := loadPlayerMeta(uidA) - if mA.ArenaWins != 7 || mA.ArenaLosses != 3 || mA.InvasionScore != 0 { - t.Errorf("A after backfill: got %+v want wins=7 losses=3 inv=0", mA) - } - mB, _ := loadPlayerMeta(uidB) - if mB.ArenaWins != 1 || mB.ArenaLosses != 1 || mB.InvasionScore != 5 { - t.Errorf("B after backfill: got %+v want wins=1 losses=1 inv=5", mB) - } - - // Layer a dual-write update on top — INSERT OR IGNORE re-runs must not - // clobber post-backfill state. - if err := upsertPlayerMetaArena(uidA, 8, 3, 0); err != nil { - t.Fatalf("dual-write: %v", err) - } - if err := backfillPlayerMetaArena(); err != nil { - t.Fatalf("backfill 2: %v", err) - } - mA2, _ := loadPlayerMeta(uidA) - if mA2.ArenaWins != 8 { - t.Errorf("backfill clobbered dual-write: got wins=%d want 8", mA2.ArenaWins) - } -} - func TestUpsertPlayerMetaArena_RoundTrip(t *testing.T) { setupAuditTestDB(t) @@ -113,168 +51,10 @@ func TestUpsertPlayerMetaArena_RoundTrip(t *testing.T) { // stays consistent across re-runs; loadDisplayName falls back to AdvCharacter // when player_meta hasn't been populated yet. -func TestPlayerMetaDisplayNameBackfill_Idempotent(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-dn-bf:example") - if err := createAdvCharacter(uid, "DisplayedName"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - // Reset player_meta to empty display_name so the backfill has work. - if _, err := db.Get().Exec( - `UPDATE player_meta SET display_name = '' WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - if err := backfillPlayerMetaDisplayName(); err != nil { - t.Fatalf("backfill 1: %v", err) - } - got, err := loadDisplayName(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if got != "DisplayedName" { - t.Errorf("after backfill: got %q want %q", got, "DisplayedName") - } - - // Layer a dual-write rename. Backfill re-run must NOT clobber it back - // (it only touches rows with empty display_name). - if err := upsertPlayerMetaDisplayName(uid, "Renamed"); err != nil { - t.Fatalf("dual-write: %v", err) - } - if err := backfillPlayerMetaDisplayName(); err != nil { - t.Fatalf("backfill 2: %v", err) - } - got2, _ := loadDisplayName(uid) - if got2 != "Renamed" { - t.Errorf("backfill clobbered dual-write: got %q want %q", got2, "Renamed") - } -} - -func TestLoadDisplayName_FallsBackToAdvCharacter(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-dn-fb:example") - if err := createAdvCharacter(uid, "FromAdvChar"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - // Simulate a row that exists in adventure_characters but whose - // player_meta display_name hasn't been populated yet (pre-backfill, - // pre-dual-write soak case). - if _, err := db.Get().Exec( - `UPDATE player_meta SET display_name = '' WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - got, err := loadDisplayName(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if got != "FromAdvChar" { - t.Errorf("fallback: got %q want %q", got, "FromAdvChar") - } - - // Unknown user → empty string, no error. - got2, err := loadDisplayName(id.UserID("@meta-dn-nobody:example")) - if err != nil { - t.Fatalf("load missing: %v", err) - } - if got2 != "" { - t.Errorf("missing user: got %q want empty", got2) - } -} - // Phase L4a — Hospital migration. Backfill is idempotent and only touches // zero-valued rows; the upsert helper round-trips; loadHospitalVisits falls // back to AdvCharacter when player_meta hasn't been populated yet. -func TestPlayerMetaHospitalVisitsBackfill_Idempotent(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-hv-bf:example") - if err := createAdvCharacter(uid, "HVisitor"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE adventure_characters SET hospital_visits = ? WHERE user_id = ?`, - 4, string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET hospital_visits = 0 WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - if err := backfillPlayerMetaHospitalVisits(); err != nil { - t.Fatalf("backfill 1: %v", err) - } - got, err := loadHospitalVisits(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if got != 4 { - t.Errorf("after backfill: got %d want 4", got) - } - - // Layer a dual-write increment. Backfill re-run must NOT clobber it - // back (only touches rows whose hospital_visits is still zero). - if err := upsertPlayerMetaHospitalVisits(uid, 5); err != nil { - t.Fatalf("dual-write: %v", err) - } - if err := backfillPlayerMetaHospitalVisits(); err != nil { - t.Fatalf("backfill 2: %v", err) - } - got2, _ := loadHospitalVisits(uid) - if got2 != 5 { - t.Errorf("backfill clobbered dual-write: got %d want 5", got2) - } -} - -func TestLoadHospitalVisits_FallsBackToAdvCharacter(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-hv-fb:example") - if err := createAdvCharacter(uid, "Fallbacker"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE adventure_characters SET hospital_visits = ? WHERE user_id = ?`, - 3, string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET hospital_visits = 0 WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - got, err := loadHospitalVisits(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if got != 3 { - t.Errorf("fallback: got %d want 3", got) - } - - // Unknown user → 0, no error. - got2, err := loadHospitalVisits(id.UserID("@meta-hv-nobody:example")) - if err != nil { - t.Fatalf("load missing: %v", err) - } - if got2 != 0 { - t.Errorf("missing user: got %d want 0", got2) - } -} - func TestUpsertPlayerMetaHospitalVisits_RoundTrip(t *testing.T) { setupAuditTestDB(t) @@ -303,89 +83,6 @@ func TestUpsertPlayerMetaHospitalVisits_RoundTrip(t *testing.T) { // loadRivalState falls back to AdvCharacter when player_meta hasn't been // populated yet. -func TestPlayerMetaRivalStateBackfill_Idempotent(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-rv-bf:example") - if err := createAdvCharacter(uid, "Rivaler"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE adventure_characters SET rival_pool = ?, rival_unlocked_notified = ? WHERE user_id = ?`, - 1, 1, string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET rival_pool = 0, rival_unlocked_notified = 0 WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - if err := backfillPlayerMetaRivalState(); err != nil { - t.Fatalf("backfill 1: %v", err) - } - pool, notified, err := loadRivalState(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if pool != 1 || !notified { - t.Errorf("after backfill: got pool=%d notified=%v want 1, true", pool, notified) - } - - // Layer a dual-write update. Backfill re-run must NOT clobber it back - // (only touches rows whose rival columns are still both zero). - if err := upsertPlayerMetaRivalState(uid, 1, false); err != nil { - t.Fatalf("dual-write: %v", err) - } - if err := backfillPlayerMetaRivalState(); err != nil { - t.Fatalf("backfill 2: %v", err) - } - pool2, notified2, _ := loadRivalState(uid) - if pool2 != 1 || notified2 { - t.Errorf("backfill clobbered dual-write: got pool=%d notified=%v want 1, false", pool2, notified2) - } -} - -func TestLoadRivalState_FallsBackToAdvCharacter(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-rv-fb:example") - if err := createAdvCharacter(uid, "RivalFallback"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE adventure_characters SET rival_pool = ?, rival_unlocked_notified = ? WHERE user_id = ?`, - 1, 1, string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET rival_pool = 0, rival_unlocked_notified = 0 WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - pool, notified, err := loadRivalState(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if pool != 1 || !notified { - t.Errorf("fallback: got pool=%d notified=%v want 1, true", pool, notified) - } - - // Unknown user → zeros, no error. - pool2, notified2, err := loadRivalState(id.UserID("@meta-rv-nobody:example")) - if err != nil { - t.Fatalf("load missing: %v", err) - } - if pool2 != 0 || notified2 { - t.Errorf("missing user: got pool=%d notified=%v want 0, false", pool2, notified2) - } -} - func TestUpsertPlayerMetaRivalState_RoundTrip(t *testing.T) { setupAuditTestDB(t) @@ -429,89 +126,6 @@ func TestCreateAdvCharacter_DualWritesDisplayName(t *testing.T) { // zero-valued rows; the upsert helper round-trips; loadMasterworkDrops falls // back to AdvCharacter when player_meta hasn't been populated yet. -func TestPlayerMetaMasterworkDropsBackfill_Idempotent(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-mw-bf:example") - if err := createAdvCharacter(uid, "Dropper"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE adventure_characters SET masterwork_drops_received = ? WHERE user_id = ?`, - 3, string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET masterwork_drops_received = 0 WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - if err := backfillPlayerMetaMasterworkDrops(); err != nil { - t.Fatalf("backfill 1: %v", err) - } - got, err := loadMasterworkDrops(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if got != 3 { - t.Errorf("after backfill: got %d want 3", got) - } - - // Layer a dual-write increment. Backfill re-run must NOT clobber it - // back (only touches rows whose masterwork_drops_received is still zero). - if err := upsertPlayerMetaMasterworkDrops(uid, 4); err != nil { - t.Fatalf("dual-write: %v", err) - } - if err := backfillPlayerMetaMasterworkDrops(); err != nil { - t.Fatalf("backfill 2: %v", err) - } - got2, _ := loadMasterworkDrops(uid) - if got2 != 4 { - t.Errorf("backfill clobbered dual-write: got %d want 4", got2) - } -} - -func TestLoadMasterworkDrops_FallsBackToAdvCharacter(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-mw-fb:example") - if err := createAdvCharacter(uid, "MWFallback"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE adventure_characters SET masterwork_drops_received = ? WHERE user_id = ?`, - 2, string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET masterwork_drops_received = 0 WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - got, err := loadMasterworkDrops(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if got != 2 { - t.Errorf("fallback: got %d want 2", got) - } - - // Unknown user → 0, no error. - got2, err := loadMasterworkDrops(id.UserID("@meta-mw-nobody:example")) - if err != nil { - t.Fatalf("load missing: %v", err) - } - if got2 != 0 { - t.Errorf("missing user: got %d want 0", got2) - } -} - func TestUpsertPlayerMetaMasterworkDrops_RoundTrip(t *testing.T) { setupAuditTestDB(t) @@ -540,103 +154,6 @@ func TestUpsertPlayerMetaMasterworkDrops_RoundTrip(t *testing.T) { // (including the four flag bools encoded into pet_flags_json); loadPetState // falls back to AdvCharacter when player_meta hasn't been populated yet. -func TestPlayerMetaPetStateBackfill_Idempotent(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-pet-bf:example") - if err := createAdvCharacter(uid, "PetOwner"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE adventure_characters - SET pet_type = ?, pet_name = ?, pet_xp = ?, pet_level = ?, - pet_armor_tier = ?, pet_arrived = ?, pet_chased_away = ?, - pet_reactivated = ?, pet_morning_defense = ?, - pet_supply_shop_unlocked = ?, pet_level_10_date = ? - WHERE user_id = ?`, - "dog", "Rex", 750, 4, 2, 1, 0, 0, 1, 0, "", string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET pet_type = '' WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - if err := backfillPlayerMetaPetState(); err != nil { - t.Fatalf("backfill 1: %v", err) - } - got, err := loadPetState(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if got.Type != "dog" || got.Name != "Rex" || got.XP != 750 || got.Level != 4 || - got.ArmorTier != 2 || !got.Arrived || got.ChasedAway || got.Reactivated || - !got.MorningDefense { - t.Errorf("after backfill: got %+v", got) - } - - // Layer a dual-write that promotes the pet (level-up + supply shop). - got.Level = 10 - got.XP = 0 - got.Level10Date = "2026-05-09" - got.SupplyShopUnlocked = true - if err := upsertPlayerMetaPetState(uid, got); err != nil { - t.Fatalf("dual-write: %v", err) - } - if err := backfillPlayerMetaPetState(); err != nil { - t.Fatalf("backfill 2: %v", err) - } - got2, _ := loadPetState(uid) - if got2.Level != 10 || !got2.SupplyShopUnlocked || got2.Level10Date != "2026-05-09" { - t.Errorf("backfill clobbered dual-write: got %+v", got2) - } -} - -func TestLoadPetState_FallsBackToAdvCharacter(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-pet-fb:example") - if err := createAdvCharacter(uid, "PetFallback"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE adventure_characters - SET pet_type = ?, pet_name = ?, pet_xp = ?, pet_level = ?, - pet_arrived = ?, pet_chased_away = ?, pet_reactivated = ? - WHERE user_id = ?`, - "cat", "Whiskers", 50, 2, 1, 1, 1, string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET pet_type = '' WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - got, err := loadPetState(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if got.Type != "cat" || got.Name != "Whiskers" || got.Level != 2 || - !got.Arrived || !got.ChasedAway || !got.Reactivated { - t.Errorf("fallback: got %+v", got) - } - - // Unknown user → zero PetState, no error. - got2, err := loadPetState(id.UserID("@meta-pet-nobody:example")) - if err != nil { - t.Fatalf("load missing: %v", err) - } - if got2.Type != "" || got2.Level != 0 { - t.Errorf("missing user: got %+v", got2) - } -} - func TestUpsertPlayerMetaPetState_RoundTrip(t *testing.T) { setupAuditTestDB(t) @@ -683,197 +200,6 @@ func TestUpsertPlayerMetaPetState_RoundTrip(t *testing.T) { // helper round-trips the full HouseState; loadHouseState falls back to // AdvCharacter when player_meta hasn't been populated yet. -func TestPlayerMetaHouseStateBackfill_Idempotent(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-house-bf:example") - if err := createAdvCharacter(uid, "HouseOwner"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE adventure_characters - SET house_tier = ?, house_loan_balance = ?, house_loan_frozen = ?, - house_missed_payments = ?, house_autopay = ?, house_current_rate = ? - WHERE user_id = ?`, - 2, 50000, 0, 1, 1, 6.25, string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET house_tier = 0, house_loan_balance = 0 WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - if err := backfillPlayerMetaHouseState(); err != nil { - t.Fatalf("backfill 1: %v", err) - } - got, err := loadHouseState(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if got.Tier != 2 || got.LoanBalance != 50000 || got.LoanFrozen || - got.MissedPayments != 1 || !got.Autopay || got.CurrentRate != 6.25 { - t.Errorf("after backfill: got %+v", got) - } - - // Layer a dual-write that pays down the loan and toggles autopay off. - got.LoanBalance = 25000 - got.MissedPayments = 0 - got.Autopay = false - if err := upsertPlayerMetaHouseState(uid, got); err != nil { - t.Fatalf("dual-write: %v", err) - } - if err := backfillPlayerMetaHouseState(); err != nil { - t.Fatalf("backfill 2: %v", err) - } - got2, _ := loadHouseState(uid) - if got2.LoanBalance != 25000 || got2.Autopay || got2.MissedPayments != 0 { - t.Errorf("backfill clobbered dual-write: got %+v", got2) - } -} - -func TestLoadHouseState_FallsBackToAdvCharacter(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-house-fb:example") - if err := createAdvCharacter(uid, "HouseFallback"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE adventure_characters - SET house_tier = ?, house_loan_balance = ?, house_loan_frozen = ?, - house_missed_payments = ?, house_autopay = ?, house_current_rate = ? - WHERE user_id = ?`, - 3, 75000, 1, 5, 0, 7.0, string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET house_tier = 0, house_loan_balance = 0 WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - got, err := loadHouseState(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if got.Tier != 3 || got.LoanBalance != 75000 || !got.LoanFrozen || - got.MissedPayments != 5 || got.Autopay || got.CurrentRate != 7.0 { - t.Errorf("fallback: got %+v", got) - } - - // Unknown user → zero HouseState, no error. - got2, err := loadHouseState(id.UserID("@meta-house-nobody:example")) - if err != nil { - t.Fatalf("load missing: %v", err) - } - if got2.Tier != 0 || got2.LoanBalance != 0 { - t.Errorf("missing user: got %+v", got2) - } -} - -func TestPlayerMetaSkillStateBackfill_Idempotent(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-skill-bf:example") - if err := createAdvCharacter(uid, "Skiller"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE adventure_characters - SET combat_level = ?, combat_xp = ?, - mining_skill = ?, mining_xp = ?, - foraging_skill = ?, foraging_xp = ?, - fishing_skill = ?, fishing_xp = ? - WHERE user_id = ?`, - 7, 250, 4, 100, 3, 80, 2, 60, string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET combat_level = 0, combat_xp = 0, - mining_skill = 0, mining_xp = 0, - foraging_skill = 0, foraging_xp = 0, - fishing_skill = 0, fishing_xp = 0 WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - if err := backfillPlayerMetaSkillState(); err != nil { - t.Fatalf("backfill 1: %v", err) - } - got, err := loadSkillState(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - want := SkillState{CombatLevel: 7, CombatXP: 250, MiningSkill: 4, MiningXP: 100, ForagingSkill: 3, ForagingXP: 80, FishingSkill: 2, FishingXP: 60} - if got != want { - t.Errorf("after backfill: got %+v want %+v", got, want) - } - - // Layer a dual-write (level-up bump combat to 8, drain xp). - got.CombatLevel = 8 - got.CombatXP = 0 - if err := upsertPlayerMetaSkillState(uid, got); err != nil { - t.Fatalf("dual-write: %v", err) - } - if err := backfillPlayerMetaSkillState(); err != nil { - t.Fatalf("backfill 2: %v", err) - } - got2, _ := loadSkillState(uid) - if got2.CombatLevel != 8 || got2.CombatXP != 0 { - t.Errorf("backfill clobbered dual-write: got %+v", got2) - } -} - -func TestLoadSkillState_FallsBackToAdvCharacter(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-skill-fb:example") - if err := createAdvCharacter(uid, "Faller"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE adventure_characters - SET combat_level = ?, mining_skill = ?, foraging_skill = ?, fishing_skill = ? - WHERE user_id = ?`, - 5, 3, 2, 1, string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET combat_level = 0, combat_xp = 0, - mining_skill = 0, mining_xp = 0, - foraging_skill = 0, foraging_xp = 0, - fishing_skill = 0, fishing_xp = 0 WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - got, err := loadSkillState(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if got.CombatLevel != 5 || got.MiningSkill != 3 || got.ForagingSkill != 2 || got.FishingSkill != 1 { - t.Errorf("fallback: got %+v", got) - } - - // Unknown user → zero SkillState, no error. - got2, err := loadSkillState(id.UserID("@meta-skill-nobody:example")) - if err != nil { - t.Fatalf("load missing: %v", err) - } - if got2 != (SkillState{}) { - t.Errorf("missing user: got %+v", got2) - } -} - func TestUpsertPlayerMetaSkillState_RoundTrip(t *testing.T) { setupAuditTestDB(t) @@ -901,105 +227,6 @@ func TestUpsertPlayerMetaSkillState_RoundTrip(t *testing.T) { } } -func TestPlayerMetaBabysitStateBackfill_Idempotent(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-baby-bf:example") - if err := createAdvCharacter(uid, "Sitter"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - expires := time.Now().UTC().Add(7 * 24 * time.Hour).Truncate(time.Second) - if _, err := db.Get().Exec( - `UPDATE adventure_characters - SET babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?, - auto_babysit = ?, auto_babysit_focus = ? - WHERE user_id = ?`, - 1, expires, "mining", 1, "fishing", string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET babysit_active = 0, babysit_expires_at = NULL, - babysit_skill_focus = '', auto_babysit = 0, auto_babysit_focus = '' WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - if err := backfillPlayerMetaBabysitState(); err != nil { - t.Fatalf("backfill 1: %v", err) - } - got, err := loadBabysitState(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if !got.Active || got.SkillFocus != "mining" || !got.AutoBabysit || got.AutoBabysitFocus != "fishing" { - t.Errorf("after backfill: got %+v", got) - } - if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expires) { - t.Errorf("expires_at: got %v want %v", got.ExpiresAt, expires) - } - - // Layer a dual-write: cancel. - got.Active = false - got.ExpiresAt = nil - got.SkillFocus = "" - if err := upsertPlayerMetaBabysitState(uid, got); err != nil { - t.Fatalf("dual-write: %v", err) - } - if err := backfillPlayerMetaBabysitState(); err != nil { - t.Fatalf("backfill 2: %v", err) - } - got2, _ := loadBabysitState(uid) - if got2.Active { - t.Errorf("backfill clobbered cancel: got %+v", got2) - } -} - -func TestLoadBabysitState_FallsBackToAdvCharacter(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-baby-fb:example") - if err := createAdvCharacter(uid, "Faller"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - expires := time.Now().UTC().Add(30 * 24 * time.Hour).Truncate(time.Second) - if _, err := db.Get().Exec( - `UPDATE adventure_characters - SET babysit_active = ?, babysit_expires_at = ? - WHERE user_id = ?`, - 1, expires, string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET babysit_active = 0, babysit_expires_at = NULL WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - got, err := loadBabysitState(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if !got.Active { - t.Errorf("fallback should be active: got %+v", got) - } - if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expires) { - t.Errorf("expires_at: got %v want %v", got.ExpiresAt, expires) - } - - // Unknown user → zero state, no error. - got2, err := loadBabysitState(id.UserID("@meta-baby-nobody:example")) - if err != nil { - t.Fatalf("load missing: %v", err) - } - if got2.Active { - t.Errorf("missing user: got %+v", got2) - } -} - func TestUpsertPlayerMetaBabysitState_RoundTrip(t *testing.T) { setupAuditTestDB(t) @@ -1041,105 +268,6 @@ func TestUpsertPlayerMetaBabysitState_RoundTrip(t *testing.T) { } } -func TestPlayerMetaNPCStateBackfill_Idempotent(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-npc-bf:example") - if err := createAdvCharacter(uid, "NPCer"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - mistyLast := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Second) - if _, err := db.Get().Exec( - `UPDATE adventure_characters - SET misty_last_seen = ?, npc_msg_count = ?, npc_msg_count_date = ?, - misty_encounter_count = ?, misty_donated_count = ?, - thom_animal_line_fired = ?, robbie_visit_count = ? - WHERE user_id = ?`, - mistyLast, 4, "2026-05-09", 3, 1, 1, 2, string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET misty_last_seen = NULL, arina_last_seen = NULL, - misty_buff_expires = NULL, misty_debuff_expires = NULL, arina_buff_expires = NULL, - npc_msg_count = 0, npc_msg_count_date = '', - misty_roll_target = 0, arina_roll_target = 0, - misty_encounter_count = 0, misty_donated_count = 0, - thom_animal_line_fired = 0, robbie_visit_count = 0 WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - if err := backfillPlayerMetaNPCState(); err != nil { - t.Fatalf("backfill 1: %v", err) - } - got, err := loadNPCState(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if got.NPCMsgCount != 4 || got.NPCMsgCountDate != "2026-05-09" || - got.MistyEncounterCount != 3 || got.MistyDonatedCount != 1 || - !got.ThomAnimalLineFired || got.RobbieVisitCount != 2 { - t.Errorf("after backfill: got %+v", got) - } - if got.MistyLastSeen == nil || !got.MistyLastSeen.Equal(mistyLast) { - t.Errorf("misty_last_seen: got %v want %v", got.MistyLastSeen, mistyLast) - } - - // Layer a dual-write: bump robbie visits. - got.RobbieVisitCount = 5 - if err := upsertPlayerMetaNPCState(uid, got); err != nil { - t.Fatalf("dual-write: %v", err) - } - if err := backfillPlayerMetaNPCState(); err != nil { - t.Fatalf("backfill 2: %v", err) - } - got2, _ := loadNPCState(uid) - if got2.RobbieVisitCount != 5 { - t.Errorf("backfill clobbered dual-write: got %+v", got2) - } -} - -func TestLoadNPCState_FallsBackToAdvCharacter(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-npc-fb:example") - if err := createAdvCharacter(uid, "Faller"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE adventure_characters - SET misty_encounter_count = ?, robbie_visit_count = ? - WHERE user_id = ?`, - 7, 3, string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET misty_encounter_count = 0, robbie_visit_count = 0 WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - got, err := loadNPCState(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if got.MistyEncounterCount != 7 || got.RobbieVisitCount != 3 { - t.Errorf("fallback: got %+v", got) - } - - got2, err := loadNPCState(id.UserID("@meta-npc-nobody:example")) - if err != nil { - t.Fatalf("load missing: %v", err) - } - if got2.HasNPCActivity() { - t.Errorf("missing user: got %+v", got2) - } -} - func TestUpsertPlayerMetaNPCState_RoundTrip(t *testing.T) { setupAuditTestDB(t) @@ -1174,99 +302,6 @@ 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) @@ -1327,104 +362,6 @@ func TestResetAllPlayerMetaDailyActions(t *testing.T) { } } -func TestPlayerMetaDeathStateBackfill_Idempotent(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-death-bf:example") - if err := createAdvCharacter(uid, "Mortal"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - deadUntil := time.Now().UTC().Add(2 * time.Hour).Truncate(time.Second) - if _, err := db.Get().Exec( - `UPDATE adventure_characters - SET alive = ?, dead_until = ?, last_death_date = ?, - grudge_location = ?, death_source = ?, death_location = ? - WHERE user_id = ?`, - 0, deadUntil, "2026-05-09", "Dark Cave", "adventure", "Dark Cave", string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET alive = 1, dead_until = NULL, - death_reprieve_last = NULL, last_death_date = '', - last_pardon_used = NULL, grudge_location = '', - death_source = '', death_location = '' WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - if err := backfillPlayerMetaDeathState(); err != nil { - t.Fatalf("backfill 1: %v", err) - } - got, err := loadDeathState(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if got.Alive || got.LastDeathDate != "2026-05-09" || got.GrudgeLocation != "Dark Cave" || - got.DeathSource != "adventure" || got.DeathLocation != "Dark Cave" { - t.Errorf("after backfill: got %+v", got) - } - if got.DeadUntil == nil || !got.DeadUntil.Equal(deadUntil) { - t.Errorf("dead_until: got %v want %v", got.DeadUntil, deadUntil) - } - - // Layer a dual-write: revive. - got.Alive = true - got.DeadUntil = nil - if err := upsertPlayerMetaDeathState(uid, got); err != nil { - t.Fatalf("dual-write: %v", err) - } - if err := backfillPlayerMetaDeathState(); err != nil { - t.Fatalf("backfill 2: %v", err) - } - got2, _ := loadDeathState(uid) - if !got2.Alive || got2.DeadUntil != nil { - t.Errorf("backfill clobbered revive: got %+v", got2) - } -} - -func TestLoadDeathState_FallsBackToAdvCharacter(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-death-fb:example") - if err := createAdvCharacter(uid, "Faller"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE adventure_characters SET alive = ?, grudge_location = ? WHERE user_id = ?`, - 0, "Mine", string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET alive = 1, dead_until = NULL, - death_reprieve_last = NULL, last_death_date = '', - last_pardon_used = NULL, grudge_location = '', - death_source = '', death_location = '' WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - got, err := loadDeathState(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if got.Alive || got.GrudgeLocation != "Mine" { - t.Errorf("fallback: got %+v", got) - } - - // Unknown user → zero state defaults to alive. - got2, err := loadDeathState(id.UserID("@meta-death-nobody:example")) - if err != nil { - t.Fatalf("load missing: %v", err) - } - if !got2.Alive { - t.Errorf("missing user should default to alive: got %+v", got2) - } -} - func TestUpsertPlayerMetaDeathState_RoundTrip(t *testing.T) { setupAuditTestDB(t) @@ -1487,80 +424,6 @@ func TestSaveAdvCharacter_DualWritesDeathState(t *testing.T) { } } -func TestPlayerMetaMiscStateBackfill_Idempotent(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-misc-bf:example") - if err := createAdvCharacter(uid, "Miscer"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE adventure_characters SET title = ?, treasures_locked = ?, crafts_succeeded = ? WHERE user_id = ?`, - "Hero", 1, 7, string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET title = '', treasures_locked = 0, crafts_succeeded = 0 WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - if err := backfillPlayerMetaMiscState(); err != nil { - t.Fatalf("backfill 1: %v", err) - } - got, err := loadMiscState(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if got.Title != "Hero" || !got.TreasuresLocked || got.CraftsSucceeded != 7 { - t.Errorf("after backfill: got %+v", got) - } - - // Layer a dual-write: bump crafts. - got.CraftsSucceeded = 9 - if err := upsertPlayerMetaMiscState(uid, got); err != nil { - t.Fatalf("dual-write: %v", err) - } - if err := backfillPlayerMetaMiscState(); err != nil { - t.Fatalf("backfill 2: %v", err) - } - got2, _ := loadMiscState(uid) - if got2.CraftsSucceeded != 9 { - t.Errorf("backfill clobbered dual-write: got %+v", got2) - } -} - -func TestLoadMiscState_FallsBackToAdvCharacter(t *testing.T) { - setupAuditTestDB(t) - - uid := id.UserID("@meta-misc-fb:example") - if err := createAdvCharacter(uid, "Faller"); err != nil { - t.Fatalf("createAdvCharacter: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE adventure_characters SET crafts_succeeded = ? WHERE user_id = ?`, - 3, string(uid), - ); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := db.Get().Exec( - `UPDATE player_meta SET title = '', treasures_locked = 0, crafts_succeeded = 0 WHERE user_id = ?`, - string(uid), - ); err != nil { - t.Fatalf("clear: %v", err) - } - - got, err := loadMiscState(uid) - if err != nil { - t.Fatalf("load: %v", err) - } - if got.CraftsSucceeded != 3 { - t.Errorf("fallback: got %+v", got) - } -} - func TestUpsertPlayerMetaMiscState_RoundTrip(t *testing.T) { setupAuditTestDB(t) diff --git a/internal/plugin/stats.go b/internal/plugin/stats.go index c497176..a41093c 100644 --- a/internal/plugin/stats.go +++ b/internal/plugin/stats.go @@ -394,7 +394,7 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error { `SELECT combat_level, mining_skill, fishing_skill, foraging_skill, combat_xp, mining_xp, fishing_xp, foraging_xp, alive, current_streak, best_streak - FROM adventure_characters WHERE user_id = ?`, uid, + FROM player_meta WHERE user_id = ?`, uid, ).Scan(&combatLv, &miningLv, &fishingLv, &forageLv, &combatXP, &miningXP, &fishingXP, &forageXP, &alive, &streak, &bestStreak) if err == nil {