diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index 0513ae1..e0daa79 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -139,24 +139,24 @@ func (p *AdventurePlugin) handleArenaFight(ctx MessageContext) error { 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) roll := rand.Float64() died := roll < deathChance - // Generate combat log (cosmetic — outcome already determined) closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance) 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 { - // Apply crowd revenge text to death message if applicable if npcResult != nil && npcResult.Text != "" { combatLog.NPCText = npcResult.Text } @@ -205,6 +205,13 @@ func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext) error { equip, _ := loadAdvEquipment(ctx.Sender) 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) roll := rand.Float64() 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) combatLog := generateArenaCombatLog(!died, closeness) - npcResult := npcCheckArenaEffects(char, monster.Name) - if npcResult != nil && npcResult.SniperKill { - died = false - } - if died { if npcResult != nil && npcResult.Text != "" { combatLog.NPCText = npcResult.Text @@ -321,16 +323,22 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun } run.XPAccumulated += battleXP - // Build survival message with combat log - closer := arenaWinCloser(monster.Name, len(combatLog.Rounds)) - text := renderArenaCombatLog(combatLog, monster, true, reward, battleXP, closer) + // 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)) + text = renderArenaCombatLog(combatLog, monster, true, reward, battleXP, closer) - // Append NPC effects (Misty food/crowd, Arina sniper) - if npcResult != nil && npcResult.Text != "" { - text += npcResult.Text - // Apply equipment condition repair from gourmet food - if npcResult.CondRepair > 0 { - npcRepairMostDamaged(ctx.Sender, equip, npcResult.CondRepair) + // Append NPC effects (Misty food/crowd) + if npcResult != nil && npcResult.Text != "" { + text += npcResult.Text + if npcResult.CondRepair > 0 { + npcRepairMostDamaged(ctx.Sender, equip, npcResult.CondRepair) + } } } diff --git a/internal/plugin/adventure_flavor_npcs.go b/internal/plugin/adventure_flavor_npcs.go index 2231f61..c937189 100644 --- a/internal/plugin/adventure_flavor_npcs.go +++ b/internal/plugin/adventure_flavor_npcs.go @@ -77,9 +77,9 @@ var mistyCrowdRevengeLines = []string{ // ── Sniper Log Lines (Arina buff — arena) ────────────────────────────────── var arinaSniperLines = []string{ - "Something arrives from the upper tier. {enemy} doesn't finish their round.", - "A bolt. One bolt. {enemy} is down. Nobody in the crowd reacts. They saw nothing.", - "The shot comes from somewhere in the stands. {enemy} had a bad week. It ended here.", - "From the upper tier: one shot. {enemy} goes down. The fight continues without them.", - "The arena lights caught it briefly. Just briefly. {enemy} is down.", + "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!", + "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 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!", + "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!", + "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!", } diff --git a/internal/plugin/adventure_npcs.go b/internal/plugin/adventure_npcs.go index 2b7bfbe..2ade20a 100644 --- a/internal/plugin/adventure_npcs.go +++ b/internal/plugin/adventure_npcs.go @@ -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)