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

@@ -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 {