diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index 7965c71..cbe2b4f 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -417,7 +417,7 @@ Each sub-phase is the same shape as L4a–L4e: column-add → backfill (idempote |---|---|---|---| | **L5a** | Skills (8 fields) | 1 day | `SkillState` struct mirroring `PetState`/`HouseState`. Mutation surface is small: `checkAdvLevelUp(char, skill)` + the few sites that bump `*XP` directly. Reader flip unblocks dnd_sheet's legacy display row and `babysitDailyCost`. CombatLevel rejoins this struct as a transitional column — dropped at teardown after the DnD mass-backfill. **Status (2026-05-09):** column + dual-write SHIPPED on `adv-2.0`. Eight `combat_*` / `mining_*` / `foraging_*` / `fishing_*` columns added via columnMigration. `SkillState` struct + `HasSkills()` / `loadSkillState` / `upsertPlayerMetaSkillState` / `backfillPlayerMetaSkillState` / `skillStateFromAdvChar` helpers in `player_meta.go`. Dual-writes wired at every mutation site: `adventure_consumables.go` (craft success + craft failure XP grant), `adventure_events.go` (event XP grant across combat/mining/foraging), `adventure_arena.go::resolveArenaDeath` + `arenaCompleteSession` (combat XP + level-up). Backfill `backfillPlayerMetaSkillState()` wired into Init, idempotent (only fills rows where every skill column is still zero AND the legacy row has at least one non-zero skill/XP). Tests: `TestPlayerMetaSkillStateBackfill_Idempotent`, `TestLoadSkillState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaSkillState_RoundTrip` in `player_meta_test.go`. `go vet ./... && go test ./...` clean. **Reader flip deferred** — bundled with cutting AdvCharacter writes (post-soak), per the L4e pattern. | | **L5b** | Babysit state (5 fields) | 0.5 day | Mutation surface is `runBabysitDailyTrickle` save in scheduler + the babysit toggle handlers. Tightly scoped. **Status (2026-05-09):** column + dual-write SHIPPED on `adv-2.0`. Five columns (`babysit_active`, `babysit_expires_at` DATETIME, `babysit_skill_focus`, `auto_babysit`, `auto_babysit_focus`) added via columnMigration. `BabysitState` + `IsActive()` + load/upsert/backfill/projection helpers in `player_meta.go`. Dual-writes wired at `handleBabysitStart`, `handleBabysitCancel`, `checkBabysitExpiry`. Backfill idempotent — only fills inactive rows whose legacy counterpart is active. AutoBabysit/AutoBabysitFocus + BabysitSkillFocus are dormant fields (no live mutation site) preserved for the schema migration. Tests: `TestPlayerMetaBabysitStateBackfill_Idempotent`, `TestLoadBabysitState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaBabysitState_RoundTrip`. `go vet ./... && go test ./...` clean. **Reader flip deferred** per L4e/L5a pattern. | -| **L5c** | NPC counters/debuffs (~13 fields) | 1 day | "Later phase" referenced in L4d note. Pack the four `*Expires` and three `*LastSeen` timestamps as nullable columns; counters as ints. The roll-targets are write-once-per-encounter so dual-write is cheap. **Hidden discovery mechanic — never surface in player-facing output.** | +| **L5c** | NPC counters/debuffs (~13 fields) | 1 day | "Later phase" referenced in L4d note. Pack the four `*Expires` and three `*LastSeen` timestamps as nullable columns; counters as ints. The roll-targets are write-once-per-encounter so dual-write is cheap. **Hidden discovery mechanic — never surface in player-facing output.** **Status (2026-05-09):** column + dual-write SHIPPED on `adv-2.0`. Thirteen columns added (5 nullable DATETIME for Misty/Arina last-seen + buff/debuff expiries, 1 int + 1 text for npc_msg counters, 2 ints for roll targets, 4 ints for encounter/donated/thom-fired/robbie counters). `NPCState` + `HasNPCActivity()` + load/upsert/backfill/projection helpers. Dual-writes wired at `processNPCEncounters` (msg-count save), `npcFireEncounter` (last-seen save), `resolveMisty` (insufficient balance, debit failure, buff/donated, declined-debuff), `resolveArina` (buff), `adventure_robbie.go` (visit count), `adventure_housing.go::handleThomGreet` (animal line fired). Backfill idempotent. Tests added. `go vet ./... && go test ./...` clean. **Reader flip deferred** per pattern. | | **L5d** | Streak + action state + lifecycle (10 fields) | 1 day | Action state mutates every action, so the dual-write hook is hot — ensure the upsert is in the same code path that already does `saveAdvCharacter`. | | **L5e** | Death state (8 fields) | 1 day | Decision point: do `DnDCharacter.HPCurrent == 0` semantics replace `Alive`/`DeadUntil`, or do we keep them as `player_meta.alive` / `player_meta.dead_until`? Recommendation: keep them as columns. The hospital revive path (L4a) already restores `DnDCharacter.HPCurrent = HPMax` alongside `Alive=true`, so the two are kept in sync; flipping the readers is the migration, not the death-state semantics. | | **L5f** | Misc (`Title`, `TreasuresLocked`, `CraftsSucceeded`) | 0.5 day | Each is a single-mutation-site field. | diff --git a/internal/db/db.go b/internal/db/db.go index 849b5d8..ad6ef8e 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -250,6 +250,23 @@ func runMigrations(d *sql.DB) error { `ALTER TABLE player_meta ADD COLUMN babysit_skill_focus TEXT NOT NULL DEFAULT ''`, `ALTER TABLE player_meta ADD COLUMN auto_babysit INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE player_meta ADD COLUMN auto_babysit_focus TEXT NOT NULL DEFAULT ''`, + // Adv 2.0 Phase L5c — NPC counters & debuff timestamps off AdvCharacter. + // Misty/Arina/Robbie/Thom counters and buff/debuff expiry times move + // to player_meta (gogobee_legacy_migration.md §7.3 L5c). Hidden + // discovery mechanics — never surface in player-facing output. + `ALTER TABLE player_meta ADD COLUMN misty_last_seen DATETIME`, + `ALTER TABLE player_meta ADD COLUMN arina_last_seen DATETIME`, + `ALTER TABLE player_meta ADD COLUMN misty_buff_expires DATETIME`, + `ALTER TABLE player_meta ADD COLUMN misty_debuff_expires DATETIME`, + `ALTER TABLE player_meta ADD COLUMN arina_buff_expires DATETIME`, + `ALTER TABLE player_meta ADD COLUMN npc_msg_count INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN npc_msg_count_date TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE player_meta ADD COLUMN misty_roll_target INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN arina_roll_target INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN misty_encounter_count INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN misty_donated_count INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN thom_animal_line_fired INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN robbie_visit_count INTEGER NOT NULL DEFAULT 0`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 0568296..d540f06 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -235,6 +235,11 @@ func (p *AdventurePlugin) Init() error { 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) + } // Phase L3 — cancel any open/active legacy coop dungeon runs and // refund member contributions + unsettled bets. Idempotent. closeAndRefundLegacyCoopRuns(p.euro) diff --git a/internal/plugin/adventure_housing.go b/internal/plugin/adventure_housing.go index 31e6bd2..c58556b 100644 --- a/internal/plugin/adventure_housing.go +++ b/internal/plugin/adventure_housing.go @@ -253,6 +253,7 @@ func (p *AdventurePlugin) handleThomCmd(ctx MessageContext) error { userMu.Lock() char.ThomAnimalLineFired = true _ = saveAdvCharacter(char) + _ = upsertPlayerMetaNPCState(char.UserID, npcStateFromAdvChar(char)) userMu.Unlock() } return p.SendDM(ctx.Sender, text) diff --git a/internal/plugin/adventure_npcs.go b/internal/plugin/adventure_npcs.go index 1f25ed3..2246002 100644 --- a/internal/plugin/adventure_npcs.go +++ b/internal/plugin/adventure_npcs.go @@ -86,6 +86,9 @@ func (p *AdventurePlugin) npcTrackMessage(userID id.UserID) { if err := saveAdvCharacter(char); err != nil { slog.Error("npc: failed to save msg count", "user", userID, "err", err) } + if err := upsertPlayerMetaNPCState(userID, npcStateFromAdvChar(char)); err != nil { + slog.Error("player_meta: npc msg count dual-write failed", "user", userID, "err", err) + } // Release lock BEFORE firing encounter (npcFireEncounter re-acquires it) userMu.Unlock() @@ -152,6 +155,9 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) { slog.Error("npc: failed to save last_seen", "user", userID, "npc", npc, "err", err) return } + if err := upsertPlayerMetaNPCState(userID, npcStateFromAdvChar(char)); err != nil { + slog.Error("player_meta: npc last_seen dual-write failed", "user", userID, "npc", npc, "err", err) + } // Don't overwrite an existing pending interaction (shop, treasure, etc.) if _, occupied := p.pending.Load(string(userID)); occupied { @@ -217,6 +223,7 @@ func (p *AdventurePlugin) resolveMisty(ctx MessageContext, char *AdventureCharac if err := saveAdvCharacter(char); err != nil { slog.Error("npc: failed to save misty debuff", "user", ctx.Sender, "err", err) } + _ = upsertPlayerMetaNPCState(char.UserID, npcStateFromAdvChar(char)) return p.SendDM(ctx.Sender, "You reach for your gold but there isn't enough. She sees this. She doesn't say anything.\n\n_"+mistyDeclineLine+"_") } @@ -225,6 +232,7 @@ func (p *AdventurePlugin) resolveMisty(ctx MessageContext, char *AdventureCharac if err := saveAdvCharacter(char); err != nil { slog.Error("npc: failed to save misty debuff", "user", ctx.Sender, "err", err) } + _ = upsertPlayerMetaNPCState(char.UserID, npcStateFromAdvChar(char)) return p.SendDM(ctx.Sender, "You reach for your gold but there isn't enough. She sees this. She doesn't say anything.\n\n_"+mistyDeclineLine+"_") } // D&D Insight check — passing refunds the donation. Renders flavor on @@ -243,6 +251,7 @@ func (p *AdventurePlugin) resolveMisty(ctx MessageContext, char *AdventureCharac slog.Error("npc: failed to save misty buff", "user", ctx.Sender, "err", err) } _ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char)) + _ = upsertPlayerMetaNPCState(char.UserID, npcStateFromAdvChar(char)) reply := mistyAcceptLines[rand.IntN(len(mistyAcceptLines))] @@ -275,6 +284,7 @@ func (p *AdventurePlugin) resolveMisty(ctx MessageContext, char *AdventureCharac if err := saveAdvCharacter(char); err != nil { slog.Error("npc: failed to save misty debuff", "user", ctx.Sender, "err", err) } + _ = upsertPlayerMetaNPCState(char.UserID, npcStateFromAdvChar(char)) return p.SendDM(ctx.Sender, fmt.Sprintf("_%s_", mistyDeclineLine)) } @@ -296,6 +306,7 @@ func (p *AdventurePlugin) resolveArina(ctx MessageContext, char *AdventureCharac if err := saveAdvCharacter(char); err != nil { slog.Error("npc: failed to save arina buff", "user", ctx.Sender, "err", err) } + _ = upsertPlayerMetaNPCState(char.UserID, npcStateFromAdvChar(char)) extra := "" if arcanaCheck.Attempted { if arcanaCheck.Succeeded { diff --git a/internal/plugin/adventure_robbie.go b/internal/plugin/adventure_robbie.go index b8248ad..4287425 100644 --- a/internal/plugin/adventure_robbie.go +++ b/internal/plugin/adventure_robbie.go @@ -169,6 +169,7 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string if err == nil { char.RobbieVisitCount++ _ = saveAdvCharacter(char) + _ = upsertPlayerMetaNPCState(userID, npcStateFromAdvChar(char)) } // Send DM diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index bb8f16b..08b4ea4 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -1087,6 +1087,272 @@ func babysitStateFromAdvChar(c *AdventureCharacter) BabysitState { } } +// NPCState mirrors player_meta's NPC counter/timestamp columns. Phase +// L5c ports Misty/Arina/Robbie/Thom counters + buff/debuff expiries off +// AdvCharacter (gogobee_legacy_migration.md §7.3 L5c). Hidden discovery +// mechanics — never surface in player-facing output. +type NPCState struct { + MistyLastSeen *time.Time + ArinaLastSeen *time.Time + MistyBuffExpires *time.Time + MistyDebuffExpires *time.Time + ArinaBuffExpires *time.Time + NPCMsgCount int + NPCMsgCountDate string + MistyRollTarget int + ArinaRollTarget int + MistyEncounterCount int + MistyDonatedCount int + ThomAnimalLineFired bool + RobbieVisitCount int +} + +// HasNPCActivity returns true when any counter or timestamp is non-zero — +// the "row is migrated" marker for the loadNPCState fallback path. +func (s NPCState) HasNPCActivity() bool { + return s.MistyLastSeen != nil || s.ArinaLastSeen != nil || + s.MistyBuffExpires != nil || s.MistyDebuffExpires != nil || + s.ArinaBuffExpires != nil || + s.NPCMsgCount > 0 || s.NPCMsgCountDate != "" || + s.MistyRollTarget > 0 || s.ArinaRollTarget > 0 || + s.MistyEncounterCount > 0 || s.MistyDonatedCount > 0 || + s.ThomAnimalLineFired || s.RobbieVisitCount > 0 +} + +// upsertPlayerMetaNPCState writes the full NPC column set for a user. +// Used by the dual-write path during the L5c soak window. +func upsertPlayerMetaNPCState(userID id.UserID, s NPCState) error { + thom := 0 + if s.ThomAnimalLineFired { + thom = 1 + } + var ( + mistyLast, arinaLast, mistyBuff, mistyDebuff, arinaBuff interface{} + ) + if s.MistyLastSeen != nil { + mistyLast = s.MistyLastSeen.UTC() + } + if s.ArinaLastSeen != nil { + arinaLast = s.ArinaLastSeen.UTC() + } + if s.MistyBuffExpires != nil { + mistyBuff = s.MistyBuffExpires.UTC() + } + if s.MistyDebuffExpires != nil { + mistyDebuff = s.MistyDebuffExpires.UTC() + } + if s.ArinaBuffExpires != nil { + arinaBuff = s.ArinaBuffExpires.UTC() + } + _, err := db.Get().Exec( + `INSERT INTO player_meta ( + user_id, 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 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + misty_last_seen = excluded.misty_last_seen, + arina_last_seen = excluded.arina_last_seen, + misty_buff_expires = excluded.misty_buff_expires, + misty_debuff_expires = excluded.misty_debuff_expires, + arina_buff_expires = excluded.arina_buff_expires, + npc_msg_count = excluded.npc_msg_count, + npc_msg_count_date = excluded.npc_msg_count_date, + misty_roll_target = excluded.misty_roll_target, + arina_roll_target = excluded.arina_roll_target, + misty_encounter_count = excluded.misty_encounter_count, + misty_donated_count = excluded.misty_donated_count, + thom_animal_line_fired = excluded.thom_animal_line_fired, + robbie_visit_count = excluded.robbie_visit_count`, + string(userID), mistyLast, arinaLast, mistyBuff, mistyDebuff, arinaBuff, + s.NPCMsgCount, s.NPCMsgCountDate, + s.MistyRollTarget, s.ArinaRollTarget, + s.MistyEncounterCount, s.MistyDonatedCount, + thom, s.RobbieVisitCount, + ) + return err +} + +// loadNPCState returns the NPC state from player_meta when populated, +// otherwise falls back to adventure_characters during the L5c soak window. +func loadNPCState(userID id.UserID) (NPCState, error) { + var ( + s NPCState + mistyLast, arinaLast, mistyBuff, mistyDebuff, arinaBuff sql.NullTime + thomInt 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 player_meta WHERE user_id = ?`, + string(userID), + ).Scan( + &mistyLast, &arinaLast, &mistyBuff, &mistyDebuff, &arinaBuff, + &s.NPCMsgCount, &s.NPCMsgCountDate, + &s.MistyRollTarget, &s.ArinaRollTarget, + &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 + } + if legacyArinaLast.Valid { + t := legacyArinaLast.Time.UTC() + legacy.ArinaLastSeen = &t + } + if legacyMBuff.Valid { + t := legacyMBuff.Time.UTC() + legacy.MistyBuffExpires = &t + } + if legacyMDebuff.Valid { + t := legacyMDebuff.Time.UTC() + legacy.MistyDebuffExpires = &t + } + if legacyABuff.Valid { + t := legacyABuff.Time.UTC() + legacy.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 +} + +// npcStateFromAdvChar projects NPC fields off an AdventureCharacter into +// an NPCState. Used by the dual-write path during L5c soak. +func npcStateFromAdvChar(c *AdventureCharacter) NPCState { + return NPCState{ + MistyLastSeen: c.MistyLastSeen, + ArinaLastSeen: c.ArinaLastSeen, + MistyBuffExpires: c.MistyBuffExpires, + MistyDebuffExpires: c.MistyDebuffExpires, + ArinaBuffExpires: c.ArinaBuffExpires, + NPCMsgCount: c.NPCMsgCount, + NPCMsgCountDate: c.NPCMsgCountDate, + MistyRollTarget: c.MistyRollTarget, + ArinaRollTarget: c.ArinaRollTarget, + MistyEncounterCount: c.MistyEncounterCount, + MistyDonatedCount: c.MistyDonatedCount, + ThomAnimalLineFired: c.ThomAnimalLineFired, + RobbieVisitCount: c.RobbieVisitCount, + } +} + // 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 diff --git a/internal/plugin/player_meta_test.go b/internal/plugin/player_meta_test.go index edb1a83..0257333 100644 --- a/internal/plugin/player_meta_test.go +++ b/internal/plugin/player_meta_test.go @@ -1041,6 +1041,139 @@ 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) + + uid := id.UserID("@meta-npc-rt:example") + mistyBuff := time.Now().UTC().Add(48 * time.Hour).Truncate(time.Second) + in := NPCState{ + MistyBuffExpires: &mistyBuff, + NPCMsgCount: 12, + NPCMsgCountDate: "2026-05-09", + MistyRollTarget: 8, + ArinaRollTarget: 6, + MistyEncounterCount: 2, + MistyDonatedCount: 1, + ThomAnimalLineFired: true, + RobbieVisitCount: 4, + } + if err := upsertPlayerMetaNPCState(uid, in); err != nil { + t.Fatalf("upsert insert: %v", err) + } + got, err := loadNPCState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got.NPCMsgCount != 12 || got.NPCMsgCountDate != "2026-05-09" || + got.MistyRollTarget != 8 || got.ArinaRollTarget != 6 || + got.MistyEncounterCount != 2 || got.MistyDonatedCount != 1 || + !got.ThomAnimalLineFired || got.RobbieVisitCount != 4 { + t.Errorf("round-trip mismatch: got %+v", got) + } + if got.MistyBuffExpires == nil || !got.MistyBuffExpires.Equal(mistyBuff) { + t.Errorf("misty_buff_expires: got %v want %v", got.MistyBuffExpires, mistyBuff) + } +} + func TestUpsertPlayerMetaHouseState_RoundTrip(t *testing.T) { setupAuditTestDB(t)