mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +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:
@@ -139,24 +139,24 @@ func (p *AdventurePlugin) handleArenaFight(ctx MessageContext) error {
|
|||||||
return p.SendDM(ctx.Sender, "Arena data error. This shouldn't happen.")
|
return p.SendDM(ctx.Sender, "Arena data error. This shouldn't happen.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve combat
|
// NPC arena effects — sniper checked first (independent of combat roll)
|
||||||
|
npcResult := npcCheckArenaEffects(char, monster.Name)
|
||||||
|
|
||||||
|
if npcResult != nil && npcResult.SniperKill {
|
||||||
|
// Sniper fired — enemy dies, skip combat roll entirely
|
||||||
|
combatLog := &ArenaCombatLog{PlayerHP: 100, EnemyHP: 0, PlayerWon: true}
|
||||||
|
return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog, npcResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal combat roll
|
||||||
deathChance := arenaDeathChance(monster, char, equip)
|
deathChance := arenaDeathChance(monster, char, equip)
|
||||||
roll := rand.Float64()
|
roll := rand.Float64()
|
||||||
died := roll < deathChance
|
died := roll < deathChance
|
||||||
|
|
||||||
// Generate combat log (cosmetic — outcome already determined)
|
|
||||||
closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance)
|
closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance)
|
||||||
combatLog := generateArenaCombatLog(!died, closeness)
|
combatLog := generateArenaCombatLog(!died, closeness)
|
||||||
|
|
||||||
// NPC arena effects (Misty/Arina) — checked after combat roll
|
|
||||||
npcResult := npcCheckArenaEffects(char, monster.Name)
|
|
||||||
if npcResult != nil && npcResult.SniperKill {
|
|
||||||
// Sniper overrides death — enemy is killed instantly
|
|
||||||
died = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if died {
|
if died {
|
||||||
// Apply crowd revenge text to death message if applicable
|
|
||||||
if npcResult != nil && npcResult.Text != "" {
|
if npcResult != nil && npcResult.Text != "" {
|
||||||
combatLog.NPCText = npcResult.Text
|
combatLog.NPCText = npcResult.Text
|
||||||
}
|
}
|
||||||
@@ -205,6 +205,13 @@ func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext) error {
|
|||||||
equip, _ := loadAdvEquipment(ctx.Sender)
|
equip, _ := loadAdvEquipment(ctx.Sender)
|
||||||
monster := arenaGetMonster(1, 1)
|
monster := arenaGetMonster(1, 1)
|
||||||
|
|
||||||
|
npcResult := npcCheckArenaEffects(char, monster.Name)
|
||||||
|
|
||||||
|
if npcResult != nil && npcResult.SniperKill {
|
||||||
|
combatLog := &ArenaCombatLog{PlayerHP: 100, EnemyHP: 0, PlayerWon: true}
|
||||||
|
return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog, npcResult)
|
||||||
|
}
|
||||||
|
|
||||||
deathChance := arenaDeathChance(monster, char, equip)
|
deathChance := arenaDeathChance(monster, char, equip)
|
||||||
roll := rand.Float64()
|
roll := rand.Float64()
|
||||||
died := roll < deathChance
|
died := roll < deathChance
|
||||||
@@ -212,11 +219,6 @@ func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext) error {
|
|||||||
closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance)
|
closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance)
|
||||||
combatLog := generateArenaCombatLog(!died, closeness)
|
combatLog := generateArenaCombatLog(!died, closeness)
|
||||||
|
|
||||||
npcResult := npcCheckArenaEffects(char, monster.Name)
|
|
||||||
if npcResult != nil && npcResult.SniperKill {
|
|
||||||
died = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if died {
|
if died {
|
||||||
if npcResult != nil && npcResult.Text != "" {
|
if npcResult != nil && npcResult.Text != "" {
|
||||||
combatLog.NPCText = npcResult.Text
|
combatLog.NPCText = npcResult.Text
|
||||||
@@ -321,18 +323,24 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
|
|||||||
}
|
}
|
||||||
run.XPAccumulated += battleXP
|
run.XPAccumulated += battleXP
|
||||||
|
|
||||||
// Build survival message with combat log
|
// Build survival message
|
||||||
|
var text string
|
||||||
|
if npcResult != nil && npcResult.SniperKill {
|
||||||
|
// Sniper killed the enemy — no combat, just the sniper line
|
||||||
|
text = npcResult.Text + "\n\n"
|
||||||
|
text += fmt.Sprintf("🏆 +%d XP | €%d earned\n", battleXP, reward)
|
||||||
|
} else {
|
||||||
closer := arenaWinCloser(monster.Name, len(combatLog.Rounds))
|
closer := arenaWinCloser(monster.Name, len(combatLog.Rounds))
|
||||||
text := renderArenaCombatLog(combatLog, monster, true, reward, battleXP, closer)
|
text = renderArenaCombatLog(combatLog, monster, true, reward, battleXP, closer)
|
||||||
|
|
||||||
// Append NPC effects (Misty food/crowd, Arina sniper)
|
// Append NPC effects (Misty food/crowd)
|
||||||
if npcResult != nil && npcResult.Text != "" {
|
if npcResult != nil && npcResult.Text != "" {
|
||||||
text += npcResult.Text
|
text += npcResult.Text
|
||||||
// Apply equipment condition repair from gourmet food
|
|
||||||
if npcResult.CondRepair > 0 {
|
if npcResult.CondRepair > 0 {
|
||||||
npcRepairMostDamaged(ctx.Sender, equip, npcResult.CondRepair)
|
npcRepairMostDamaged(ctx.Sender, equip, npcResult.CondRepair)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
text += fmt.Sprintf("\nTier earnings: €%d | Session total: €%d (at risk)\n",
|
text += fmt.Sprintf("\nTier earnings: €%d | Session total: €%d (at risk)\n",
|
||||||
run.TierEarnings, run.Earnings+run.TierEarnings)
|
run.TierEarnings, run.Earnings+run.TierEarnings)
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ var mistyCrowdRevengeLines = []string{
|
|||||||
// ── Sniper Log Lines (Arina buff — arena) ──────────────────────────────────
|
// ── Sniper Log Lines (Arina buff — arena) ──────────────────────────────────
|
||||||
|
|
||||||
var arinaSniperLines = []string{
|
var arinaSniperLines = []string{
|
||||||
"Something arrives from the upper tier. {enemy} doesn't finish their round.",
|
"Just as the match was about to begin, something whizzes inches from the side of your head into {enemy}. They look confused because they've been shot with an arrow, yet you're holding a sword. Oh well. The Arena can be brutal sometimes. YOU WIN!",
|
||||||
"A bolt. One bolt. {enemy} is down. Nobody in the crowd reacts. They saw nothing.",
|
"You step forward to fight. {enemy} steps forward to meet you. Then {enemy} stumbles. There's an arrow in their shoulder that wasn't there a second ago. Neither of you saw where it came from. {enemy} goes down. You didn't do that. YOU WIN!",
|
||||||
"The shot comes from somewhere in the stands. {enemy} had a bad week. It ended here.",
|
"The bell rings. You raise your weapon. {enemy} raises theirs. Then something whistles past your ear and {enemy} drops. You stand there for a moment. The crowd cheers like this is normal. Maybe it is. YOU WIN!",
|
||||||
"From the upper tier: one shot. {enemy} goes down. The fight continues without them.",
|
"Before you can swing, {enemy} jerks sideways. There's a bolt sticking out of them that absolutely did not come from you. The crowd doesn't seem surprised. {enemy} is down. You'll take it. YOU WIN!",
|
||||||
"The arena lights caught it briefly. Just briefly. {enemy} is down.",
|
"You blink. {enemy} is on the ground. There's an arrow in their leg and a confused expression on their face. You definitely didn't do that. The Arena official shrugs and waves you through. YOU WIN!",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,14 @@ const (
|
|||||||
// It increments their daily message count and fires NPC encounter rolls
|
// It increments their daily message count and fires NPC encounter rolls
|
||||||
// when thresholds are hit.
|
// when thresholds are hit.
|
||||||
func (p *AdventurePlugin) npcTrackMessage(userID id.UserID) {
|
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)
|
char, err := loadAdvCharacter(userID)
|
||||||
if err != nil || !char.Alive {
|
if err != nil || !char.Alive {
|
||||||
|
userMu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,39 +64,36 @@ func (p *AdventurePlugin) npcTrackMessage(userID id.UserID) {
|
|||||||
|
|
||||||
char.NPCMsgCount++
|
char.NPCMsgCount++
|
||||||
|
|
||||||
|
var fireNPC string
|
||||||
|
|
||||||
// Check Misty threshold
|
// Check Misty threshold
|
||||||
if char.MistyRollTarget > 0 && char.NPCMsgCount == char.MistyRollTarget {
|
if char.MistyRollTarget > 0 && char.NPCMsgCount == char.MistyRollTarget {
|
||||||
if p.npcShouldEncounter(char, "misty") {
|
if p.npcShouldEncounter(char, "misty") {
|
||||||
// Zero the target so we don't re-trigger
|
|
||||||
char.MistyRollTarget = 0
|
char.MistyRollTarget = 0
|
||||||
if err := saveAdvCharacter(char); err != nil {
|
fireNPC = "misty"
|
||||||
slog.Error("npc: failed to save char after misty trigger", "user", userID, "err", err)
|
|
||||||
}
|
|
||||||
safeGo("npc-misty-encounter", func() {
|
|
||||||
p.npcFireEncounter(userID, "misty")
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Arina threshold
|
// Check Arina threshold (only if Misty didn't fire — one encounter per message)
|
||||||
if char.ArinaRollTarget > 0 && char.NPCMsgCount == char.ArinaRollTarget {
|
if fireNPC == "" && char.ArinaRollTarget > 0 && char.NPCMsgCount == char.ArinaRollTarget {
|
||||||
if p.npcShouldEncounter(char, "arina") {
|
if p.npcShouldEncounter(char, "arina") {
|
||||||
char.ArinaRollTarget = 0
|
char.ArinaRollTarget = 0
|
||||||
if err := saveAdvCharacter(char); err != nil {
|
fireNPC = "arina"
|
||||||
slog.Error("npc: failed to save char after arina trigger", "user", userID, "err", err)
|
|
||||||
}
|
|
||||||
safeGo("npc-arina-encounter", func() {
|
|
||||||
p.npcFireEncounter(userID, "arina")
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save updated count
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// npcShouldEncounter checks cooldown and rolls the 7.5% chance.
|
||||||
@@ -145,6 +148,11 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't overwrite an existing pending interaction (shop, treasure, etc.)
|
||||||
|
if _, occupied := p.pending.Load(string(userID)); occupied {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Set pending interaction
|
// Set pending interaction
|
||||||
p.pending.Store(string(userID), &advPendingInteraction{
|
p.pending.Store(string(userID), &advPendingInteraction{
|
||||||
Type: "npc_encounter",
|
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+"_")
|
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
|
char.MistyBuffExpires = expires
|
||||||
if err := saveAdvCharacter(char); err != nil {
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
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)
|
||||||
@@ -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))
|
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
|
char.ArinaBuffExpires = expires
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user