Adv 2.0 L5 close-out: loader rewire + adventure_characters purge gate

Rewires loadAdvCharacter / createAdvCharacter to source from player_meta,
drops every legacy-table fallback in the loadXxxState helpers, and adds a
GOGOBEE_LEGACY_PURGE=1 gate to drop the now-cold adventure_characters
table. Schema CREATE removed and column migrations tolerate the missing
table so the purge stays sticky across reboots.

§7.4 of the migration plan is reconciled to document that
player_meta.combat_level stays — combat_stats.go consumes it via the
overlay and is shared infrastructure, not legacy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 12:43:25 -07:00
parent 36e14c6084
commit c0f03a41b5
14 changed files with 201 additions and 2601 deletions

View File

@@ -435,13 +435,21 @@ Each sub-phase is the same shape as L4aL4e: column-add → backfill (idempote
After L5aL5h 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.)

View File

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

View File

@@ -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(&current, &best, &decayed)
err := d.QueryRow(`SELECT current_streak, best_streak, streak_decayed FROM player_meta WHERE user_id = ?`, string(u)).Scan(&current, &best, &decayed)
return err == nil && current >= 14 && decayed > 0
},
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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