mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
The L5 close-out (596b2b7) rewired loadAdvCharacter to read from player_meta and simultaneously deleted the 13 backfill one-shots from Init. That's only safe if every account already has a player_meta row from prior dual-writes — fresh restores from old backups, dev environments cloned from older snapshots, or a prod upgrade that skipped the soak window all silently strand every character (loader returns empty). Adds bootstrapPlayerMetaFromLegacy: a single consolidated migrator that walks adventure_characters, finds rows missing from player_meta, and fans them out via the audited upsertAllPlayerMetaFromAdvChar helper. Idempotent (re-runs touch zero rows once migrated; no-ops post-purge). Wired into AdventurePlugin.Init before loadAllAdvCharacters. proddb_simulation_test.go exercises the full bootstrap + fan-out + overlay round-trip against a copy of data/gogobee.db with 11 subtests covering every migrated subsystem (skills, arena, babysit, streak, death/revive, NPC counters, pet incl. JSON-packed flags, house, save idempotency, last_active advance, fresh createAdvCharacter). dnd_proddb_integration_test.go also calls the bootstrap explicitly to mirror prod startup ordering. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
202 lines
7.3 KiB
Go
202 lines
7.3 KiB
Go
package plugin
|
|
|
|
import (
|
|
"database/sql"
|
|
"log/slog"
|
|
|
|
"gogobee/internal/db"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// bootstrapPlayerMetaFromLegacy migrates rows from adventure_characters into
|
|
// player_meta for any user_id not already present, using the canonical
|
|
// upsertAllPlayerMetaFromAdvChar fan-out. Idempotent — re-runs touch zero
|
|
// rows once every legacy character has been migrated.
|
|
//
|
|
// Required when deploying L5 close-out code onto a database that hasn't gone
|
|
// through the L4-L5h dual-write soak (fresh restores from old backups, dev
|
|
// environments cloned from older snapshots, or — most importantly — a prod
|
|
// upgrade that skipped the soak window). Without this, the rewired
|
|
// loadAdvCharacter (which sources from player_meta) returns zero characters
|
|
// and silently strands every account.
|
|
//
|
|
// No-ops post-purge — the sqlite_master existence check short-circuits once
|
|
// adventure_characters is dropped.
|
|
func bootstrapPlayerMetaFromLegacy() {
|
|
d := db.Get()
|
|
|
|
var legacyExists int
|
|
if err := d.QueryRow(
|
|
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='adventure_characters'`,
|
|
).Scan(&legacyExists); err != nil || legacyExists == 0 {
|
|
return
|
|
}
|
|
|
|
rows, err := d.Query(`
|
|
SELECT user_id FROM adventure_characters
|
|
WHERE user_id NOT IN (SELECT user_id FROM player_meta)`)
|
|
if err != nil {
|
|
slog.Error("bootstrap: legacy enumeration failed", "err", err)
|
|
return
|
|
}
|
|
var todo []id.UserID
|
|
for rows.Next() {
|
|
var uid string
|
|
if err := rows.Scan(&uid); err != nil {
|
|
rows.Close()
|
|
slog.Error("bootstrap: scan failed", "err", err)
|
|
return
|
|
}
|
|
todo = append(todo, id.UserID(uid))
|
|
}
|
|
rows.Close()
|
|
|
|
if len(todo) == 0 {
|
|
return
|
|
}
|
|
|
|
migrated := 0
|
|
for _, uid := range todo {
|
|
char, err := loadAdvCharacterFromLegacyTable(uid)
|
|
if err != nil {
|
|
slog.Error("bootstrap: failed to load legacy AdvCharacter", "user", uid, "err", err)
|
|
continue
|
|
}
|
|
// Seed the player_meta row first so the fan-out's per-subsystem
|
|
// upserts have something to ON CONFLICT update.
|
|
if _, err := d.Exec(
|
|
`INSERT INTO player_meta (user_id, display_name, created_at, last_active_at, alive)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id) DO NOTHING`,
|
|
string(uid), char.DisplayName, char.CreatedAt, char.LastActiveAt,
|
|
boolToInt(char.Alive),
|
|
); err != nil {
|
|
slog.Error("bootstrap: failed to seed player_meta", "user", uid, "err", err)
|
|
continue
|
|
}
|
|
if err := upsertAllPlayerMetaFromAdvChar(char); err != nil {
|
|
slog.Error("bootstrap: fan-out failed", "user", uid, "err", err)
|
|
continue
|
|
}
|
|
migrated++
|
|
}
|
|
slog.Warn("bootstrap: legacy AdvCharacter rows migrated to player_meta",
|
|
"migrated", migrated, "total", len(todo))
|
|
}
|
|
|
|
// loadAdvCharacterFromLegacyTable runs the pre-L5h 70-column SELECT against
|
|
// adventure_characters. Used only by the bootstrap path; the live loader
|
|
// goes through player_meta + applyPlayerMetaOverlay.
|
|
func loadAdvCharacterFromLegacyTable(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 {
|
|
return nil, err
|
|
}
|
|
c.Alive = alive == 1
|
|
c.ActionTakenToday = actionTaken == 1
|
|
c.HolidayActionTaken = holidayTaken == 1
|
|
c.RivalUnlockedNotified = rivalUnlocked == 1
|
|
c.BabysitActive = babysitAct == 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
|
|
c.PetMorningDefense = petMorningDef == 1
|
|
c.AutoBabysit = autoBabysit == 1
|
|
c.StreakDecayed = streakDecayed == 1
|
|
c.TreasuresLocked = treasuresLocked == 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
|
|
}
|
|
return c, nil
|
|
}
|