Adv 2.0 L5c: NPC counters/debuffs migration off AdvCharacter

Thirteen player_meta columns: misty/arina_last_seen,
misty_buff_expires, misty_debuff_expires, arina_buff_expires (all
nullable DATETIME), npc_msg_count + npc_msg_count_date,
misty/arina_roll_target, misty_encounter_count, misty_donated_count,
thom_animal_line_fired, robbie_visit_count.

NPCState struct with HasNPCActivity() marker + load/upsert/backfill/
projection helpers. Dual-writes wired at every NPC mutation site:
processNPCEncounters msg-count save, npcFireEncounter last-seen,
resolveMisty (insufficient balance, debit failure, buff/donated,
declined-debuff), resolveArina buff, adventure_robbie visit count,
adventure_housing Thom animal line.

Hidden discovery mechanic — buffs/debuffs and counters never surface
in player-facing output.

Tests: TestPlayerMetaNPCStateBackfill_Idempotent,
TestLoadNPCState_FallsBackToAdvCharacter,
TestUpsertPlayerMetaNPCState_RoundTrip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 11:24:55 -07:00
parent d5b4834d44
commit c83b73b655
8 changed files with 435 additions and 1 deletions

View File

@@ -417,7 +417,7 @@ Each sub-phase is the same shape as L4aL4e: 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. | | **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. | | **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`. | | **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. | | **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. | | **L5f** | Misc (`Title`, `TreasuresLocked`, `CraftsSucceeded`) | 0.5 day | Each is a single-mutation-site field. |

View File

@@ -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 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 INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE player_meta ADD COLUMN auto_babysit_focus TEXT NOT NULL DEFAULT ''`, `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 { for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil { if _, err := d.Exec(stmt); err != nil {

View File

@@ -235,6 +235,11 @@ func (p *AdventurePlugin) Init() error {
if err := backfillPlayerMetaBabysitState(); err != nil { if err := backfillPlayerMetaBabysitState(); err != nil {
slog.Error("player_meta: babysit state backfill failed", "err", err) 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 // Phase L3 — cancel any open/active legacy coop dungeon runs and
// refund member contributions + unsettled bets. Idempotent. // refund member contributions + unsettled bets. Idempotent.
closeAndRefundLegacyCoopRuns(p.euro) closeAndRefundLegacyCoopRuns(p.euro)

View File

@@ -253,6 +253,7 @@ func (p *AdventurePlugin) handleThomCmd(ctx MessageContext) error {
userMu.Lock() userMu.Lock()
char.ThomAnimalLineFired = true char.ThomAnimalLineFired = true
_ = saveAdvCharacter(char) _ = saveAdvCharacter(char)
_ = upsertPlayerMetaNPCState(char.UserID, npcStateFromAdvChar(char))
userMu.Unlock() userMu.Unlock()
} }
return p.SendDM(ctx.Sender, text) return p.SendDM(ctx.Sender, text)

View File

@@ -86,6 +86,9 @@ func (p *AdventurePlugin) npcTrackMessage(userID id.UserID) {
if err := saveAdvCharacter(char); err != nil { if err := saveAdvCharacter(char); err != nil {
slog.Error("npc: failed to save msg count", "user", userID, "err", err) 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) // Release lock BEFORE firing encounter (npcFireEncounter re-acquires it)
userMu.Unlock() 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) slog.Error("npc: failed to save last_seen", "user", userID, "npc", npc, "err", err)
return 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.) // Don't overwrite an existing pending interaction (shop, treasure, etc.)
if _, occupied := p.pending.Load(string(userID)); occupied { 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 { if err := saveAdvCharacter(char); err != nil {
slog.Error("npc: failed to save misty debuff", "user", ctx.Sender, "err", err) 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+"_") 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 { if err := saveAdvCharacter(char); err != nil {
slog.Error("npc: failed to save misty debuff", "user", ctx.Sender, "err", err) 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+"_") 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 // 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) slog.Error("npc: failed to save misty buff", "user", ctx.Sender, "err", err)
} }
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char)) _ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char))
_ = upsertPlayerMetaNPCState(char.UserID, npcStateFromAdvChar(char))
reply := mistyAcceptLines[rand.IntN(len(mistyAcceptLines))] reply := mistyAcceptLines[rand.IntN(len(mistyAcceptLines))]
@@ -275,6 +284,7 @@ func (p *AdventurePlugin) resolveMisty(ctx MessageContext, char *AdventureCharac
if err := saveAdvCharacter(char); err != nil { if err := saveAdvCharacter(char); err != nil {
slog.Error("npc: failed to save misty debuff", "user", ctx.Sender, "err", err) 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)) 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 { if err := saveAdvCharacter(char); err != nil {
slog.Error("npc: failed to save arina buff", "user", ctx.Sender, "err", err) slog.Error("npc: failed to save arina buff", "user", ctx.Sender, "err", err)
} }
_ = upsertPlayerMetaNPCState(char.UserID, npcStateFromAdvChar(char))
extra := "" extra := ""
if arcanaCheck.Attempted { if arcanaCheck.Attempted {
if arcanaCheck.Succeeded { if arcanaCheck.Succeeded {

View File

@@ -169,6 +169,7 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
if err == nil { if err == nil {
char.RobbieVisitCount++ char.RobbieVisitCount++
_ = saveAdvCharacter(char) _ = saveAdvCharacter(char)
_ = upsertPlayerMetaNPCState(userID, npcStateFromAdvChar(char))
} }
// Send DM // Send DM

View File

@@ -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 // backfillPlayerMetaDisplayName copies adventure_characters.display_name
// into player_meta.display_name for any row whose display_name is still // 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 // the empty default. Idempotent: safe to re-run; only updates rows that

View File

@@ -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) { func TestUpsertPlayerMetaHouseState_RoundTrip(t *testing.T) {
setupAuditTestDB(t) setupAuditTestDB(t)