mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user