mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
Fix NPC audit findings: race conditions, debit checks, sniper mechanics
Concurrency: - npcTrackMessage now acquires advUserLock to prevent lost updates on message count and double encounter triggers from rapid messages - Lock released before spawning encounter goroutine (avoids deadlock) - Only one NPC encounter fires per message Security: - Debit() return value checked for both Misty and Arina payments - Buff only applied on successful debit; failed debit falls through to decline/debuff path - NPC encounter won't overwrite existing pending interactions (shop, treasure discard, etc.) Arena sniper rework: - Sniper checked independently before combat roll, not after - If sniper fires, combat roll is skipped entirely (not a death save) - Sniper kill renders as standalone narrative with no combat log - Sniper flavor text rewritten: reads as random arena chaos with no hint of Arina's involvement Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,8 +41,14 @@ const (
|
||||
// It increments their daily message count and fires NPC encounter rolls
|
||||
// when thresholds are hit.
|
||||
func (p *AdventurePlugin) npcTrackMessage(userID id.UserID) {
|
||||
// Acquire user lock to prevent lost updates on message count and
|
||||
// double encounter triggers from rapid messages.
|
||||
userMu := p.advUserLock(userID)
|
||||
userMu.Lock()
|
||||
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || !char.Alive {
|
||||
userMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -58,39 +64,36 @@ func (p *AdventurePlugin) npcTrackMessage(userID id.UserID) {
|
||||
|
||||
char.NPCMsgCount++
|
||||
|
||||
var fireNPC string
|
||||
|
||||
// Check Misty threshold
|
||||
if char.MistyRollTarget > 0 && char.NPCMsgCount == char.MistyRollTarget {
|
||||
if p.npcShouldEncounter(char, "misty") {
|
||||
// Zero the target so we don't re-trigger
|
||||
char.MistyRollTarget = 0
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("npc: failed to save char after misty trigger", "user", userID, "err", err)
|
||||
}
|
||||
safeGo("npc-misty-encounter", func() {
|
||||
p.npcFireEncounter(userID, "misty")
|
||||
})
|
||||
return
|
||||
fireNPC = "misty"
|
||||
}
|
||||
}
|
||||
|
||||
// Check Arina threshold
|
||||
if char.ArinaRollTarget > 0 && char.NPCMsgCount == char.ArinaRollTarget {
|
||||
// Check Arina threshold (only if Misty didn't fire — one encounter per message)
|
||||
if fireNPC == "" && char.ArinaRollTarget > 0 && char.NPCMsgCount == char.ArinaRollTarget {
|
||||
if p.npcShouldEncounter(char, "arina") {
|
||||
char.ArinaRollTarget = 0
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("npc: failed to save char after arina trigger", "user", userID, "err", err)
|
||||
}
|
||||
safeGo("npc-arina-encounter", func() {
|
||||
p.npcFireEncounter(userID, "arina")
|
||||
})
|
||||
return
|
||||
fireNPC = "arina"
|
||||
}
|
||||
}
|
||||
|
||||
// Save updated count
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("npc: failed to save msg count", "user", userID, "err", err)
|
||||
}
|
||||
|
||||
// Release lock BEFORE firing encounter (npcFireEncounter re-acquires it)
|
||||
userMu.Unlock()
|
||||
|
||||
if fireNPC != "" {
|
||||
safeGo("npc-"+fireNPC+"-encounter", func() {
|
||||
p.npcFireEncounter(userID, fireNPC)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// npcShouldEncounter checks cooldown and rolls the 7.5% chance.
|
||||
@@ -145,6 +148,11 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't overwrite an existing pending interaction (shop, treasure, etc.)
|
||||
if _, occupied := p.pending.Load(string(userID)); occupied {
|
||||
return
|
||||
}
|
||||
|
||||
// Set pending interaction
|
||||
p.pending.Store(string(userID), &advPendingInteraction{
|
||||
Type: "npc_encounter",
|
||||
@@ -206,7 +214,13 @@ func (p *AdventurePlugin) resolveMisty(ctx MessageContext, char *AdventureCharac
|
||||
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+"_")
|
||||
}
|
||||
|
||||
p.euro.Debit(ctx.Sender, float64(mistyCost), "misty_donation")
|
||||
if !p.euro.Debit(ctx.Sender, float64(mistyCost), "misty_donation") {
|
||||
char.MistyDebuffExpires = expires
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("npc: failed to save misty debuff", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
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+"_")
|
||||
}
|
||||
char.MistyBuffExpires = expires
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("npc: failed to save misty buff", "user", ctx.Sender, "err", err)
|
||||
@@ -232,7 +246,10 @@ func (p *AdventurePlugin) resolveArina(ctx MessageContext, char *AdventureCharac
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You can't afford it. She noticed before you did.\n\n_%s_", reply))
|
||||
}
|
||||
|
||||
p.euro.Debit(ctx.Sender, float64(arinaCost), "arina_investment")
|
||||
if !p.euro.Debit(ctx.Sender, float64(arinaCost), "arina_investment") {
|
||||
reply := arinaDeclineLines[rand.IntN(len(arinaDeclineLines))]
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You can't afford it. She noticed before you did.\n\n_%s_", reply))
|
||||
}
|
||||
char.ArinaBuffExpires = expires
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("npc: failed to save arina buff", "user", ctx.Sender, "err", err)
|
||||
|
||||
Reference in New Issue
Block a user