mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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:
@@ -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.)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
40
internal/plugin/cleanup_l5.go
Normal file
40
internal/plugin/cleanup_l5.go
Normal 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)")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user