From 684a11504a0ea19e59784a589cfcebf7754e2bf4 Mon Sep 17 00:00:00 2001 From: Rathio12 <146424297+Rathio12@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:02:55 +0200 Subject: [PATCH 1/6] Add team chat commands (!shop, !small, !large, !pos) + join/leave/AFK alert - !shop : search map vending machines; single chat reply (anti-flood, reads cached shop data, no extra API polling) - !small / !large: individual oil rig crate timers - !pos : report a teammate's grid - Announce team join / leave and AFK-after-5min (gated by Chat Announce toggle) - Vanilla emoji shortcodes: :skull: death, :wave: join/leave, :vending.machine: shop alerts - Fix: emoji-tolerant chat delivery confirmation (no failed-confirm waits / duplicate sends) - Instant command responses (removed artificial pre-send delay) - New renameable command rows in the Chat Commands panel; alerts editable in Custom Alerts --- CHANGELOG.md | 31 +++ RustPlusDesktop/Models/ServerProfile.cs | 35 +++ RustPlusDesktop/Properties/Resources.resx | 86 ++++++- .../lang/en-US/Resources.en-US.resx | 86 ++++++- .../MainWindow/Map/ChatCommandsOverlay.xaml | 46 +++- .../MainWindow/Map/MainWindow.Map.Chat.cs | 20 +- .../Map/MainWindow.Map.ChatCommands.cs | 221 +++++++++++++++--- .../MainWindow/Team/MainWindow.Team.Core.cs | 133 ++++++++++- .../Views/Windows/CustomAlertsWindow.xaml.cs | 4 + 9 files changed, 611 insertions(+), 51 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..0467ccaf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog — Team chat commands & presence alerts + +This PR adds a few team-chat commands and presence announcements, and fixes a couple of chat-reliability issues. Everything is built to be **API-friendly** (single message per command, throttled, no extra polling) — directly addressing the concern that shop-style chat commands tend to flood the API and get players kicked. + +## Added +- **`!shop `** — searches the map's vending machines and replies with grid, price and stock. + - Reads the **already-cached** shop list (`GetLastShopsList()`) — **no extra map/marker polling**. + - Sends **exactly one** chat message: best matches (in-stock first, then cheapest) packed into a single ~128-char line with a `+N` overflow indicator. +- **`!small` / `!large`** — individual Small/Large Oil Rig crate timers (the existing `!oilrig` was refactored into a shared `BuildSingleRigStatus` helper). +- **`!pos `** — reports a teammate's current grid (exact-then-partial name match). +- **Team presence announcements** (gated by the existing "Chat Announce" master toggle): + - Teammate **joined** / **left** the team. + - Teammate went **AFK** — message now includes how long they've been idle (e.g. "X is now AFK (idle 5m)"). + - Teammate **back from AFK** — new announcement reporting how long they were AFK (e.g. "X is back (was AFK for 12m)"). + - Teammate **came online** — message now appends how long they were offline (e.g. "X came online @ G12 (was offline 2h 5m)"). + - Smart handling of **your own** team switches: + - You join someone else's team → announces **only you** once (not every existing member). + - People join **your** team (you're leader) → announces each. + - You leave / the team dissolves (roster collapses to just you) → **suppressed** (no spam). + - Multiple announcements are **spaced 2.5s apart** so they never burst the API. +- **Configurable AFK threshold** — new per-server setting (default 5 min, range 1–60) replacing the hardcoded value, with a selector in the Chat Commands panel. +- Config rows for the new commands in the Chat Commands panel; join/leave/AFK (and back-from-AFK) templates are editable in the Custom Alerts window. +- In-game emoji shortcodes on alerts: `:skull:` (death), `:wave:` (join/leave), `:vending.machine:` (shop alerts). + +## Fixed +- **Chat delivery confirmation** — Rust rewrites `:shortcode:` emoji into glyphs in its echo, which broke the exact-text match used to confirm a sent message, causing wait-timeouts and **duplicate re-sends**. Both sides are now normalized before matching. +- **Instant command responses** — removed an artificial pre-send delay so single replies go out immediately (the delay setting still spaces multi-line replies). + +## Notes +- New strings added to `Resources.resx` and `Resources.en-US.resx`. +- No changes to polling cadence or existing features; all additions are opt-in via the chat-commands toggle. diff --git a/RustPlusDesktop/Models/ServerProfile.cs b/RustPlusDesktop/Models/ServerProfile.cs index 849646d3..f3d27514 100644 --- a/RustPlusDesktop/Models/ServerProfile.cs +++ b/RustPlusDesktop/Models/ServerProfile.cs @@ -259,6 +259,34 @@ public string CmdUpkeepDetail set { _cmdUpkeepDetail = ValidateCommand(value, "upkeepdetail"); OnProp(); } } + private string _cmdShop = "shop"; + public string CmdShop + { + get => _cmdShop; + set { _cmdShop = ValidateCommand(value, "shop"); OnProp(); } + } + + private string _cmdSmall = "small"; + public string CmdSmall + { + get => _cmdSmall; + set { _cmdSmall = ValidateCommand(value, "small"); OnProp(); } + } + + private string _cmdLarge = "large"; + public string CmdLarge + { + get => _cmdLarge; + set { _cmdLarge = ValidateCommand(value, "large"); OnProp(); } + } + + private string _cmdPos = "pos"; + public string CmdPos + { + get => _cmdPos; + set { _cmdPos = ValidateCommand(value, "pos"); OnProp(); } + } + private int _chatCommandDelaySeconds = 2; public int ChatCommandDelaySeconds { @@ -273,6 +301,13 @@ public double ChatResponseDelaySeconds set { if (value >= 0.0 && value <= 5.0) { _chatResponseDelaySeconds = value; OnProp(); } } } + private int _afkThresholdMinutes = 5; + public int AfkThresholdMinutes + { + get => _afkThresholdMinutes; + set { if (value >= 1 && value <= 60) { _afkThresholdMinutes = value; OnProp(); } } + } + private ObservableCollection _switchCommandMappings = new(); public ObservableCollection SwitchCommandMappings { diff --git a/RustPlusDesktop/Properties/Resources.resx b/RustPlusDesktop/Properties/Resources.resx index b368f66c..1ecdef69 100644 --- a/RustPlusDesktop/Properties/Resources.resx +++ b/RustPlusDesktop/Properties/Resources.resx @@ -1186,6 +1186,18 @@ Download and install now? Oil Rig: + + Shop Search: + + + Small Oil Rig: + + + Large Oil Rig: + + + Player Position: + Patrol Heli: @@ -1198,6 +1210,9 @@ Download and install now? Command Delay: + + AFK after (min): + Language @@ -1502,10 +1517,10 @@ Download and install now? Deep Sea is up - New shop {0} [{1}]: {2} + :vending.machine: New shop {0} [{1}]: {2} - Suspicious shop {0} [{1}] was online {2}s, sold {3} + :vending.machine: Suspicious shop {0} [{1}] was online {2}s, sold {3} Cargo Ship spawned {0} @@ -1541,11 +1556,35 @@ Download and install now? {0} went offline - {0} died @ {1} + :skull: {0} died @ {1} {0} respawned @ {1} + + :wave: {0} joined the team + + + :wave: {0} left the team + + + {0} is now AFK (idle {1}) + + + {0} is back (was AFK for {1}) + + + Player Back From AFK + + + Sent when a team member returns after being AFK. + + + {0}: Player Name, {1}: AFK duration + + + (was offline {0}) + [Tracking] {0}{1} is now ONLINE @@ -1565,7 +1604,7 @@ Download and install now? Shop - {0} [{1}] {2} x{3} {4} (Stock {5}) for {6} {7} + :vending.machine: {0} [{1}] {2} x{3} {4} (Stock {5}) for {6} {7} List Chat Commands @@ -2583,6 +2622,27 @@ Are you sure you want to continue? No one is AFK. + + Usage: {0} <item name> — searches the map's shops for an item. + + + No shop data loaded yet. Enable shop polling on the map first. + + + No shops are selling '{0}'. + + + Usage: {0} <player name> + + + No teammate matching '{0}'. + + + {0} is at {1} + + + No location known for {0}. + Player AFK @@ -2592,4 +2652,22 @@ Are you sure you want to continue? {0}: Player Name, {1}: Time + + Player Joined Team + + + Sent when a new player joins your team. + + + {0}: Player Name + + + Player Left Team + + + Sent when a player leaves your team. + + + {0}: Player Name + diff --git a/RustPlusDesktop/Properties/lang/en-US/Resources.en-US.resx b/RustPlusDesktop/Properties/lang/en-US/Resources.en-US.resx index 6a17207f..a3f5fe2f 100644 --- a/RustPlusDesktop/Properties/lang/en-US/Resources.en-US.resx +++ b/RustPlusDesktop/Properties/lang/en-US/Resources.en-US.resx @@ -1183,6 +1183,18 @@ Download and install now? Oil Rig: + + Shop Search: + + + Small Oil Rig: + + + Large Oil Rig: + + + Player Position: + Patrol Heli: @@ -1195,6 +1207,9 @@ Download and install now? Command Delay: + + AFK after (min): + Language @@ -1499,10 +1514,10 @@ Download and install now? Deep Sea is up - New shop {0} [{1}]: {2} + :vending.machine: New shop {0} [{1}]: {2} - Suspicious shop {0} [{1}] was online {2}s, sold {3} + :vending.machine: Suspicious shop {0} [{1}] was online {2}s, sold {3} Cargo Ship spawned {0} @@ -1538,11 +1553,35 @@ Download and install now? {0} went offline - {0} died @ {1} + :skull: {0} died @ {1} {0} respawned @ {1} + + :wave: {0} joined the team + + + :wave: {0} left the team + + + {0} is now AFK (idle {1}) + + + {0} is back (was AFK for {1}) + + + Player Back From AFK + + + Sent when a team member returns after being AFK. + + + {0}: Player Name, {1}: AFK duration + + + (was offline {0}) + [Tracking] {0}{1} is now ONLINE @@ -1562,7 +1601,7 @@ Download and install now? Shop - {0} [{1}] {2} x{3} {4} (Stock {5}) for {6} {7} + :vending.machine: {0} [{1}] {2} x{3} {4} (Stock {5}) for {6} {7} List Chat Commands @@ -2424,6 +2463,27 @@ Are you sure you want to continue? No one is AFK. + + Usage: {0} <item name> — searches the map's shops for an item. + + + No shop data loaded yet. Enable shop polling on the map first. + + + No shops are selling '{0}'. + + + Usage: {0} <player name> + + + No teammate matching '{0}'. + + + {0} is at {1} + + + No location known for {0}. + Player AFK @@ -2433,4 +2493,22 @@ Are you sure you want to continue? {0}: Player Name, {1}: Time + + Player Joined Team + + + Sent when a new player joins your team. + + + {0}: Player Name + + + Player Left Team + + + Sent when a player leaves your team. + + + {0}: Player Name + diff --git a/RustPlusDesktop/Views/MainWindow/Map/ChatCommandsOverlay.xaml b/RustPlusDesktop/Views/MainWindow/Map/ChatCommandsOverlay.xaml index 4275b967..aa45951a 100644 --- a/RustPlusDesktop/Views/MainWindow/Map/ChatCommandsOverlay.xaml +++ b/RustPlusDesktop/Views/MainWindow/Map/ChatCommandsOverlay.xaml @@ -66,6 +66,22 @@ + + + + + + + + + + + + + + + + + + + + @@ -154,10 +174,30 @@ - - + + - + + + + + + + + + + + + + + + + + + + + + diff --git a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Chat.cs b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Chat.cs index f869fd42..c93f575a 100644 --- a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Chat.cs +++ b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Chat.cs @@ -94,9 +94,20 @@ private void ScrollChatToBottom() } // ====== CORE SENDING ====== - + private readonly HashSet _recentAutomatedMessages = new(); + // strip leading emoji/shortcodes so Rust's emoji-converted echo still confirms + private static readonly System.Text.RegularExpressions.Regex _leadingEmojiOrShortcode = + new(@"^(?:\s|:[A-Za-z0-9_+\-]+:|[\p{So}\p{Sk}\p{Cs}\uFE0F\u200D])+", + System.Text.RegularExpressions.RegexOptions.Compiled); + + private static string NormalizeChatTextForConfirm(string text) + { + if (string.IsNullOrEmpty(text)) return string.Empty; + return _leadingEmojiOrShortcode.Replace(text.Trim(), "").Trim(); + } + private async Task SendTeamChatSafeAsync(string text, bool bypassChatAlertMasterBlock = false, bool skipDiscordChatForwarding = false) { if (skipDiscordChatForwarding) @@ -149,8 +160,8 @@ private async Task SendTeamChatReliableAsync(string text) AppendLog($"[Chat] Sending: {text}"); - // Füge die Nachricht zu unseren ausstehenden Bestätigungen hinzu - string trackKey = $"{text.Trim()}_{DateTime.UtcNow:HHmmss}"; + // Füge die Nachricht zu unseren ausstehenden Bestätigungen hinzu (normalized for emoji echo) + string trackKey = $"{NormalizeChatTextForConfirm(text)}_{DateTime.UtcNow:HHmmss}"; lock (_pendingChatConfirms) { _pendingChatConfirms.Add(trackKey); } try @@ -221,7 +232,8 @@ private void Real_TeamChatReceived(object? sender, TeamChatMessage m) { lock (_pendingChatConfirms) { - var match = _pendingChatConfirms.FirstOrDefault(k => k.StartsWith(m.Text.Trim() + "_")); + var echoKey = NormalizeChatTextForConfirm(m.Text); + var match = _pendingChatConfirms.FirstOrDefault(k => k.StartsWith(echoKey + "_")); if (match != null) { _pendingChatConfirms.Remove(match); diff --git a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.ChatCommands.cs b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.ChatCommands.cs index c1926bf8..fe43e17e 100644 --- a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.ChatCommands.cs +++ b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.ChatCommands.cs @@ -42,15 +42,7 @@ private void ChatCommandsOverlay_CommandsEnabledChanged(object sender, System.Wi private async Task SendChatCommandResponseAsync(string text) { - var profile = _vm.Selected; - if (profile != null) - { - int delayMs = (int)(profile.ChatResponseDelaySeconds * 1000); - if (delayMs > 0) - { - await Task.Delay(delayMs); - } - } + // respond instantly (no pre-send delay) await SendTeamChatSafeAsync(text, bypassChatAlertMasterBlock: true); } @@ -94,8 +86,12 @@ private async Task ProcessChatCommands(TeamChatMessage m) if (!string.IsNullOrWhiteSpace(profile.CmdDeepSea)) standardCmds.Add(prefix + profile.CmdDeepSea); if (!string.IsNullOrWhiteSpace(profile.CmdCargo)) standardCmds.Add(prefix + profile.CmdCargo); if (!string.IsNullOrWhiteSpace(profile.CmdOilRig)) standardCmds.Add(prefix + profile.CmdOilRig); + if (!string.IsNullOrWhiteSpace(profile.CmdSmall)) standardCmds.Add(prefix + profile.CmdSmall); + if (!string.IsNullOrWhiteSpace(profile.CmdLarge)) standardCmds.Add(prefix + profile.CmdLarge); + if (!string.IsNullOrWhiteSpace(profile.CmdPos)) standardCmds.Add(prefix + profile.CmdPos); if (!string.IsNullOrWhiteSpace(profile.CmdHeli)) standardCmds.Add(prefix + profile.CmdHeli); if (!string.IsNullOrWhiteSpace(profile.CmdVendor)) standardCmds.Add(prefix + profile.CmdVendor); + if (!string.IsNullOrWhiteSpace(profile.CmdShop)) standardCmds.Add(prefix + profile.CmdShop); if (!string.IsNullOrWhiteSpace(profile.CmdUpkeepDetail)) standardCmds.Add(prefix + profile.CmdUpkeepDetail); if (!string.IsNullOrWhiteSpace(profile.CmdAfk)) standardCmds.Add(prefix + profile.CmdAfk); @@ -171,6 +167,26 @@ private async Task ProcessChatCommands(TeamChatMessage m) return; } + // Command: Shop Search (e.g. "!shop rifle") + if (!string.IsNullOrWhiteSpace(profile.CmdShop)) + { + var shopCmd = profile.CmdShop.ToLowerInvariant(); + if (cmd == shopCmd) + { + var usage = Properties.Resources.ResourceManager.GetString("ChatCmdShopUsage") + ?? "Usage: {0} — searches the map's shops for an item."; + _ = SendChatCommandResponseAsync(string.Format(usage, prefix + shopCmd)); + AppendLog($"[ChatCommand] Shop (no query) executed by {m.Author}"); + return; + } + if (cmd.StartsWith(shopCmd + " ")) + { + var query = cmd.Substring(shopCmd.Length + 1).Trim(); + await ProcessShopSearchCommand(query, m.Author); + return; + } + } + // Command: AFK if (!string.IsNullOrWhiteSpace(profile.CmdAfk) && cmd == profile.CmdAfk.ToLowerInvariant()) { @@ -198,6 +214,8 @@ private async Task ProcessChatCommands(TeamChatMessage m) } // Command: Promote + // No leader check needed: if the app's account isn't the team leader, Rust+ just + // ignores the request (nothing happens), so we keep this simple. if (cmd == profile.CmdPromote.ToLowerInvariant()) { _ = real.PromoteToLeaderAsync(m.SteamId); @@ -293,36 +311,51 @@ private async Task ProcessChatCommands(TeamChatMessage m) return; } - // Command: Oil Rig + // Command: Oil Rig (both rigs) if (cmd == profile.CmdOilRig.ToLowerInvariant()) { - var parts = new List(); - foreach (var rigName in new[] { "Small Oil Rig", "Large Oil Rig" }) - { - var timeLeft = _monumentWatcher.GetActiveEventTimeLeft(rigName); - if (timeLeft.HasValue) - { - parts.Add(string.Format(Properties.Resources.ChatCmdOilRigCrateIn, rigName, (int)timeLeft.Value.TotalMinutes, timeLeft.Value.Seconds)); - } - else - { - var lastTrig = _monumentWatcher.GetLastTriggered(rigName); - if (lastTrig.HasValue) - { - var ago = DateTime.UtcNow - lastTrig.Value; - parts.Add(string.Format(Properties.Resources.ChatCmdOilRigLastCalledAgo, rigName, (int)ago.TotalMinutes)); - } - else - { - parts.Add(string.Format(Properties.Resources.ChatCmdOilRigNotCalled, rigName)); - } - } - } + var parts = new[] { "Small Oil Rig", "Large Oil Rig" }.Select(BuildSingleRigStatus).ToList(); _ = SendChatCommandResponseAsync(string.Join(" | ", parts)); AppendLog($"[ChatCommand] OilRig executed by {m.Author}"); return; } + // Command: Small Oil Rig + if (!string.IsNullOrWhiteSpace(profile.CmdSmall) && cmd == profile.CmdSmall.ToLowerInvariant()) + { + _ = SendChatCommandResponseAsync(BuildSingleRigStatus("Small Oil Rig")); + AppendLog($"[ChatCommand] Small Oil Rig executed by {m.Author}"); + return; + } + + // Command: Large Oil Rig + if (!string.IsNullOrWhiteSpace(profile.CmdLarge) && cmd == profile.CmdLarge.ToLowerInvariant()) + { + _ = SendChatCommandResponseAsync(BuildSingleRigStatus("Large Oil Rig")); + AppendLog($"[ChatCommand] Large Oil Rig executed by {m.Author}"); + return; + } + + // Command: Player Position (e.g. "!pos john") + if (!string.IsNullOrWhiteSpace(profile.CmdPos)) + { + var posCmd = profile.CmdPos.ToLowerInvariant(); + if (cmd == posCmd) + { + var usage = Properties.Resources.ResourceManager.GetString("ChatCmdPosUsage") + ?? "Usage: {0} "; + _ = SendChatCommandResponseAsync(string.Format(usage, prefix + posCmd)); + AppendLog($"[ChatCommand] Pos (no name) executed by {m.Author}"); + return; + } + if (cmd.StartsWith(posCmd + " ")) + { + var who = cmd.Substring(posCmd.Length + 1).Trim(); + ProcessPlayerPosCommand(who, m.Author); + return; + } + } + // Command: Patrol Heli if (cmd == profile.CmdHeli.ToLowerInvariant()) { @@ -695,6 +728,130 @@ void AddSwitches(SmartDevice d) } } + private async Task ProcessShopSearchCommand(string query, string author) + { + if (string.IsNullOrWhiteSpace(query)) return; + + var shops = GetLastShopsList(); + if (shops == null || shops.Count == 0) + { + var noShops = Properties.Resources.ResourceManager.GetString("ChatCmdShopNoShopsLoaded") + ?? "No shop data loaded yet. Enable shop polling on the map first."; + _ = SendChatCommandResponseAsync(noShops); + AppendLog($"[ChatCommand] Shop '{query}' requested by {author} but no shops loaded"); + return; + } + + // collect matching sell orders + var matches = new List<(string Grid, string Line, int Stock, double UnitPrice)>(); + foreach (var s in shops) + { + if (s.Orders == null) continue; + foreach (var o in s.Orders) + { + string itemName = ResolveItemName(o.ItemId, o.ItemShortName); + bool nameHit = itemName.Contains(query, StringComparison.OrdinalIgnoreCase) + || (o.ItemShortName != null && o.ItemShortName.Contains(query, StringComparison.OrdinalIgnoreCase)); + if (!nameHit) continue; + + string grid = GetGridLabel(s); + string currName = ResolveItemName(o.CurrencyItemId, o.CurrencyShortName); + string qtyPart = o.Quantity > 1 ? $"x{o.Quantity} " : ""; + string stockPart = o.Stock <= 0 ? " (out)" : $" ({o.Stock} left)"; + string line = $"{grid}: {qtyPart}{itemName} → {o.CurrencyAmount} {currName}{stockPart}"; + double unitPrice = (double)o.CurrencyAmount / Math.Max(1, o.Quantity); + matches.Add((grid, line, o.Stock, unitPrice)); + } + } + + if (matches.Count == 0) + { + var none = Properties.Resources.ResourceManager.GetString("ChatCmdShopNoMatch") + ?? "No shops are selling '{0}'."; + _ = SendChatCommandResponseAsync(string.Format(none, query)); + AppendLog($"[ChatCommand] Shop '{query}' by {author}: no matches"); + return; + } + + // in-stock first, then cheapest per unit + var ordered = matches + .OrderByDescending(x => x.Stock > 0) + .ThenBy(x => x.UnitPrice) + .Select(x => x.Line) + .ToList(); + + // one message per command to avoid API flooding; pack best results into ~128 chars + var sb = new System.Text.StringBuilder(); + int shown = 0; + foreach (var line in ordered) + { + string piece = sb.Length == 0 ? line : " | " + line; + if (sb.Length + piece.Length > 120 && sb.Length > 0) break; + sb.Append(piece); + shown++; + } + int omitted = matches.Count - shown; + if (omitted > 0) sb.Append($" (+{omitted})"); + + await SendChatCommandResponseAsync(sb.ToString()); + AppendLog($"[ChatCommand] Shop '{query}' by {author}: {matches.Count} match(es), showed {shown} in 1 msg"); + } + + // single oil rig status line + private string BuildSingleRigStatus(string rigName) + { + var timeLeft = _monumentWatcher.GetActiveEventTimeLeft(rigName); + if (timeLeft.HasValue) + return string.Format(Properties.Resources.ChatCmdOilRigCrateIn, rigName, (int)timeLeft.Value.TotalMinutes, timeLeft.Value.Seconds); + + var lastTrig = _monumentWatcher.GetLastTriggered(rigName); + if (lastTrig.HasValue) + { + var ago = DateTime.UtcNow - lastTrig.Value; + return string.Format(Properties.Resources.ChatCmdOilRigLastCalledAgo, rigName, (int)ago.TotalMinutes); + } + return string.Format(Properties.Resources.ChatCmdOilRigNotCalled, rigName); + } + + private void ProcessPlayerPosCommand(string who, string author) + { + if (string.IsNullOrWhiteSpace(who)) return; + + // match teammate by name (exact first, then partial) + var member = TeamMembers.FirstOrDefault(t => string.Equals(t.Name, who, StringComparison.OrdinalIgnoreCase)) + ?? TeamMembers.FirstOrDefault(t => !string.IsNullOrEmpty(t.Name) && t.Name.Contains(who, StringComparison.OrdinalIgnoreCase)); + + if (member == null) + { + var notFound = Properties.Resources.ResourceManager.GetString("ChatCmdPosNotFound") + ?? "No teammate matching '{0}'."; + _ = SendChatCommandResponseAsync(string.Format(notFound, who)); + AppendLog($"[ChatCommand] Pos '{who}' by {author}: no match"); + return; + } + + double? px = member.X, py = member.Y; + if ((!px.HasValue || !py.HasValue) && TryResolvePosFromDynMarkers(member.SteamId, out var dx, out var dy)) + { + px = dx; + py = dy; + } + + var dispName = GetDisplayPlayerName(member.Name); + if (px.HasValue && py.HasValue) + { + var resp = Properties.Resources.ResourceManager.GetString("ChatCmdPosResponse") ?? "{0} is at {1}"; + _ = SendChatCommandResponseAsync(string.Format(resp, dispName, GetGridLabel(px.Value, py.Value))); + AppendLog($"[ChatCommand] Pos '{who}' by {author}: {member.Name} located"); + } + else + { + var noLoc = Properties.Resources.ResourceManager.GetString("ChatCmdPosNoLocation") ?? "No location known for {0}."; + _ = SendChatCommandResponseAsync(string.Format(noLoc, dispName)); + AppendLog($"[ChatCommand] Pos '{who}' by {author}: {member.Name} no location"); + } + } + private async Task ProcessUpkeepCommand(RustPlusClientReal real, uint entityId, string author) { var profile = _vm.Selected; diff --git a/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs b/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs index 6304ff53..9fb51552 100644 --- a/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs +++ b/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs @@ -134,6 +134,11 @@ public string AfkText set { if (_afkText == value) return; _afkText = value; OnChanged(nameof(AfkText)); } } + // Set when the player moves after being AFK (= how long they were idle). Consumed by AfkTimer to announce the return. + public TimeSpan? AfkReturnDuration { get; set; } + // Set when the player goes offline; used to report how long they were gone when they come back. + public DateTime? OfflineSince { get; set; } + public void SetPosition(double? x, double? y) { if (x == null || y == null) @@ -149,6 +154,13 @@ public void SetPosition(double? x, double? y) double dist = Math.Sqrt(dx * dx + dy * dy); if (dist > 0.05) { + if (_isAfk) + { + // they're back — remember how long they were idle so we can announce it + AfkReturnDuration = DateTime.UtcNow - _lastMoveTime; + IsAfk = false; + AfkText = string.Empty; + } _lastMoveTime = DateTime.UtcNow; } } @@ -160,18 +172,19 @@ public void SetPosition(double? x, double? y) Y = y; } - public bool UpdateAfkState(DateTime now) + public bool UpdateAfkState(DateTime now, int thresholdMinutes = 5) { if (!IsOnline || IsDead) { _lastMoveTime = now; IsAfk = false; AfkText = string.Empty; + AfkReturnDuration = null; // don't announce an AFK return if they went offline/died return false; } var elapsed = now - _lastMoveTime; - if (elapsed.TotalMinutes >= 5) + if (elapsed.TotalMinutes >= thresholdMinutes) { bool becameAfk = !_isAfk; IsAfk = true; @@ -203,6 +216,7 @@ public ImageSource? Avatar private readonly Dictionary _steamNames = new(); private DateTime _lastTeamRefresh = DateTime.MinValue; + private bool _teamRosterInitialized; // skip announcing the initial roster as joins private string? _lastCloudPresenceSignature; private DateTime _lastPresenceUploadTime = DateTime.MinValue; private bool _hasCriticalPresenceChange; @@ -210,6 +224,7 @@ public ImageSource? Avatar private void StartTeamPolling() { if (_teamTimer != null) return; + _teamRosterInitialized = false; // re-baseline roster _teamTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(5) @@ -253,9 +268,34 @@ private void StopTeamPolling() private void AfkTimer_Tick(object? sender, EventArgs e) { var now = DateTime.UtcNow; + int afkThreshold = _vm?.Selected?.AfkThresholdMinutes ?? 5; foreach (var m in TeamMembers) { - m.UpdateAfkState(now); + // true only on transition into AFK + bool becameAfk = m.UpdateAfkState(now, afkThreshold); + + if (!_announceSpawns || m.SteamId == _mySteamId) + { + m.AfkReturnDuration = null; // never announce for self / when announce is off + continue; + } + + var dispName = GetDisplayPlayerName(m.Name); + if (becameAfk) + { + // just went AFK — include how long they've been idle (≈ the threshold) + var txt = AlertTemplateService.GetFormattedAlert("AlertPlayerAfk", dispName, FormatAgo(now - m.LastMoveTime)); + if (!string.IsNullOrWhiteSpace(txt)) + _ = SendTeamChatSafeAsync(txt); + } + else if (m.AfkReturnDuration.HasValue && m.IsOnline) + { + // came back — report how long they were AFK + var txt = AlertTemplateService.GetFormattedAlert("AlertPlayerAfkBack", dispName, FormatAgo(m.AfkReturnDuration.Value)); + if (!string.IsNullOrWhiteSpace(txt)) + _ = SendTeamChatSafeAsync(txt); + m.AfkReturnDuration = null; + } } } @@ -328,6 +368,12 @@ private async Task LoadTeamAsync() var leaderId = team.LeaderSteamId; foreach (var m in TeamMembers) m.MissingCount++; + // track this pass's roster delta so we can tell a single teammate change + // apart from YOU switching teams (mass change) + int prevTeammateCount = TeamMembers.Count(t => t.SteamId != _mySteamId); + var joinedThisPass = new List(); + var leftThisPass = new List(); + var avatarTasks = new List(); foreach (var m in team.Members) { @@ -340,6 +386,7 @@ private async Task LoadTeamAsync() vm = new TeamMemberVM { SteamId = sid, Abbreviate = _abbreviateNames }; TeamMembers.Add(vm); _hasCriticalPresenceChange = true; + if (sid != _mySteamId) joinedThisPass.Add(vm); } if (vm.Avatar == null) @@ -379,10 +426,21 @@ private async Task LoadTeamAsync() for (int i = TeamMembers.Count - 1; i >= 0; i--) if (TeamMembers[i].MissingCount > 2) { + var left = TeamMembers[i]; + if (left.SteamId != _mySteamId) leftThisPass.Add(left); TeamMembers.RemoveAt(i); _hasCriticalPresenceChange = true; } + int curTeammateCount = TeamMembers.Count(t => t.SteamId != _mySteamId); + + // Announce join/leave (spaced out to avoid API flood); handle YOUR own team-switches specially. + if (_teamRosterInitialized && _announceSpawns) + _ = AnnounceRosterDelta(joinedThisPass, leftThisPass, prevTeammateCount, curTeammateCount, leaderId); + + // baseline set; later passes announce joins/leaves + _teamRosterInitialized = true; + // Cleanup subscriptions of players who left the team on the UI thread var currentTeamIds = TeamMembers.Select(tm => tm.SteamId).ToHashSet(); await Dispatcher.InvokeAsync(() => @@ -480,6 +538,56 @@ private static string BuildCloudPresenceSignature( return $"{serverKey ?? ""}#{serverName ?? ""}#{team}"; } + // Announces team join/leave, distinguishing YOUR own team-switches from normal teammate changes, + // and spaces messages out so we never burst the Rust+ API (which can get the player kicked). + private async Task AnnounceRosterDelta(List joined, List left, + int prevTeammateCount, int curTeammateCount, ulong leaderId) + { + const int gapMs = 2500; // spacing between consecutive chat messages + + // Build all messages first (sync), then send with delays. + var messages = new List(); + + // ----- JOINS ----- + if (joined.Count > 0) + { + bool iAmLeader = leaderId != 0 && leaderId == _mySteamId; + if (!iAmLeader && prevTeammateCount == 0) + { + // I went from solo to being in someone else's team => I joined them. + // Announce only me once, not every member who was already there. + var meName = GetDisplayPlayerName(TeamMembers.FirstOrDefault(t => t.SteamId == _mySteamId)?.Name ?? ""); + var txt = AlertTemplateService.GetFormattedAlert("AlertPlayerJoinedTeam", meName); + if (!string.IsNullOrWhiteSpace(txt)) messages.Add(txt); + } + else + { + // It's my team (I'm leader) or a teammate joined the team I'm already in => announce each. + foreach (var vm in joined) + { + var txt = AlertTemplateService.GetFormattedAlert("AlertPlayerJoinedTeam", GetDisplayPlayerName(vm.Name)); + if (!string.IsNullOrWhiteSpace(txt)) messages.Add(txt); + } + } + } + + // ----- LEAVES ----- (suppress entirely if the roster collapsed to just me: I left / team dissolved) + if (left.Count > 0 && curTeammateCount >= 1) + { + foreach (var vm in left) + { + var txt = AlertTemplateService.GetFormattedAlert("AlertPlayerLeftTeam", GetDisplayPlayerName(vm.Name)); + if (!string.IsNullOrWhiteSpace(txt)) messages.Add(txt); + } + } + + for (int i = 0; i < messages.Count; i++) + { + if (i > 0) await Task.Delay(gapMs); + await SendTeamChatSafeAsync(messages[i]); + } + } + private async Task AnnouncePresenceChangeAsync(TeamMemberVM vm, (bool online, bool dead) prev, (bool online, bool dead) now) { try @@ -493,9 +601,26 @@ private async Task AnnouncePresenceChangeAsync(TeamMemberVM vm, (bool online, bo { var where = (vm.X.HasValue && vm.Y.HasValue) ? GetGridLabel(vm.X.Value, vm.Y.Value) : Properties.Resources.Unknown; var dispName = GetDisplayPlayerName(vm.Name); - var txt = now.online ? AlertTemplateService.GetFormattedAlert("AlertPlayerOnlineWithPos", dispName, where) : AlertTemplateService.GetFormattedAlert("AlertPlayerOffline", dispName); + string txt; + if (now.online) + { + txt = AlertTemplateService.GetFormattedAlert("AlertPlayerOnlineWithPos", dispName, where); + // tack on how long they were offline, if we tracked it + if (vm.OfflineSince.HasValue) + { + var suffix = AlertTemplateService.GetFormattedAlert("AlertPlayerOfflineDuration", FormatAgo(DateTime.UtcNow - vm.OfflineSince.Value)); + if (!string.IsNullOrWhiteSpace(suffix)) txt += " " + suffix; + } + } + else + { + txt = AlertTemplateService.GetFormattedAlert("AlertPlayerOffline", dispName); + } await SendTeamChatSafeAsync(txt); } + + // remember when they went offline so we can report the gap on return + vm.OfflineSince = now.online ? (DateTime?)null : DateTime.UtcNow; } if (prev.dead != now.dead) diff --git a/RustPlusDesktop/Views/Windows/CustomAlertsWindow.xaml.cs b/RustPlusDesktop/Views/Windows/CustomAlertsWindow.xaml.cs index a317facd..2fbbd4f3 100644 --- a/RustPlusDesktop/Views/Windows/CustomAlertsWindow.xaml.cs +++ b/RustPlusDesktop/Views/Windows/CustomAlertsWindow.xaml.cs @@ -51,6 +51,10 @@ private void LoadAlerts() "AlertPlayerOffline", "AlertPlayerDied", "AlertPlayerRespawned", + "AlertPlayerJoinedTeam", + "AlertPlayerLeftTeam", + "AlertPlayerAfk", + "AlertPlayerAfkBack", "AlertTrackingOnline", "AlertTrackingOffline", "AlertTrackingRenamed" From a8bd89a5e2bd592717e1b2e380eab9ed35fa3ede Mon Sep 17 00:00:00 2001 From: Rathio12 <146424297+Rathio12@users.noreply.github.com> Date: Mon, 15 Jun 2026 04:57:35 +0200 Subject: [PATCH 2/6] Fix AFK/presence alerts, shop search results, and Discord webhook flooding - AFK alerts now announce for any team member (including yourself); the alert was previously hard-skipped for the local account, so going AFK yourself produced no message. - !shop now replies with the top 3 matches as separate, spaced messages and filters out junk barter trades (non-Scrap currency) that ranked as "cheapest" because 1 < 25 across different currencies. Multi-item queries (e.g. "clone") no longer mislabel every grid as the first item. - !afk respects the name-abbreviation setting and caps its reply at the 128-char team-chat limit so long AFK lists no longer fail to send. - Discord webhook forwarding now uses a serialized, rate-limited queue (shared HttpClient, min gap between posts, honors 429 Retry-After) instead of a fresh HttpClient per message with no rate handling. - Offline-duration tracking is no longer coupled to the Chat Alerts toggle, so the "(was offline X)" suffix is correct even if alerts were enabled only after the player went offline. --- CHANGELOG.md | 9 +- .../MainWindow/Map/MainWindow.Map.Chat.cs | 87 +++++++++++++++---- .../Map/MainWindow.Map.ChatCommands.cs | 56 +++++++----- .../MainWindow/Team/MainWindow.Team.Core.cs | 47 +++++----- 4 files changed, 135 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0467ccaf..e778f728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,13 @@ This PR adds a few team-chat commands and presence announcements, and fixes a co ## Added - **`!shop `** — searches the map's vending machines and replies with grid, price and stock. - Reads the **already-cached** shop list (`GetLastShopsList()`) — **no extra map/marker polling**. - - Sends **exactly one** chat message: best matches (in-stock first, then cheapest) packed into a single ~128-char line with a `+N` overflow indicator. + - Replies with the **top 3 matches** (in-stock first, then cheapest) as up to 3 separate messages, spaced by the chat-response delay so the API is never flooded; the last line shows a `(+N more)` overflow indicator. + - **Filters out junk barter trades**: when real Scrap prices exist, odd trades priced in another item (e.g. "Bone Fragments for 1 LR-300") are dropped — they previously ranked as "cheapest" because `1 < 25` across different currencies. Barter is shown only when nothing is priced in Scrap. - **`!small` / `!large`** — individual Small/Large Oil Rig crate timers (the existing `!oilrig` was refactored into a shared `BuildSingleRigStatus` helper). - **`!pos `** — reports a teammate's current grid (exact-then-partial name match). - **Team presence announcements** (gated by the existing "Chat Announce" master toggle): - Teammate **joined** / **left** the team. - - Teammate went **AFK** — message now includes how long they've been idle (e.g. "X is now AFK (idle 5m)"). + - Teammate went **AFK** — message now includes how long they've been idle (e.g. "X is now AFK (idle 5m)"). Announced for **anyone in the team, including yourself**. - Teammate **back from AFK** — new announcement reporting how long they were AFK (e.g. "X is back (was AFK for 12m)"). - Teammate **came online** — message now appends how long they were offline (e.g. "X came online @ G12 (was offline 2h 5m)"). - Smart handling of **your own** team switches: @@ -23,6 +24,10 @@ This PR adds a few team-chat commands and presence announcements, and fixes a co - In-game emoji shortcodes on alerts: `:skull:` (death), `:wave:` (join/leave), `:vending.machine:` (shop alerts). ## Fixed +- **AFK announcements never fired for your own account** — the AFK alert was hard-skipped for the local player (`SteamId == _mySteamId`), so going AFK yourself produced no message. Now any team member (you included) is announced when Chat Alerts is on, matching how death/online alerts already behave. +- **`!afk` command** — now respects the name-abbreviation setting (was printing raw names) and caps its reply at Rust's 128-char team-chat limit, so a long AFK list no longer silently fails to send. +- **Offline-duration tracking** — a member's `OfflineSince` timestamp was only recorded when Chat Alerts happened to be enabled, so the "(was offline X)" suffix could be missing if alerts were turned on only after they went offline. The timing is now tracked independently of the announce toggle. +- **Discord webhook flooding / rate-limits** — webhook forwarding previously created a fresh `HttpClient` per message and ignored HTTP 429s. It now routes through a serialized, rate-limited queue: one shared client, one in-flight POST at a time, a minimum gap between posts (~40/min ceiling), and it honors Discord's `Retry-After` (header and JSON body) with a single retry. - **Chat delivery confirmation** — Rust rewrites `:shortcode:` emoji into glyphs in its echo, which broke the exact-text match used to confirm a sent message, causing wait-timeouts and **duplicate re-sends**. Both sides are now normalized before matching. - **Instant command responses** — removed an artificial pre-send delay so single replies go out immediately (the delay setting still spaces multi-line replies). diff --git a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Chat.cs b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Chat.cs index c93f575a..544fe1c7 100644 --- a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Chat.cs +++ b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Chat.cs @@ -119,25 +119,12 @@ private async Task SendTeamChatSafeAsync(string text, bool bypassChatAlertMaster } if (!bypassChatAlertMasterBlock && !CanSendAutomatedTeamChat()) return; - // Discord Webhook Integration (Free Tier) + // Discord Webhook Integration (Free Tier) — routed through a rate-limited queue + // so bursts of alerts never flood the webhook or trip Discord's 429 limiter. if (!bypassChatAlertMasterBlock && _vm?.Selected?.DiscordWebhookChatAlertsEnabled == true && _vm.IsCloudConnected && !string.IsNullOrWhiteSpace(_vm.Selected.DiscordWebhookChatAlertsUrl)) { - _ = Task.Run(async () => - { - try - { - string serverName = _vm.Selected.Name ?? "Rust Server"; - var payload = new { content = $"**[{serverName}]** {text}", tts = _vm.Selected.DiscordWebhookChatAlertsTts }; - var json = System.Text.Json.JsonSerializer.Serialize(payload); - using var content = new System.Net.Http.StringContent(json, System.Text.Encoding.UTF8, "application/json"); - using var client = new System.Net.Http.HttpClient(); - await client.PostAsync(_vm.Selected.DiscordWebhookChatAlertsUrl, content); - } - catch (Exception ex) - { - AppendLog($"[Discord] Webhook send failed: {ex.Message}"); - } - }); + string serverName = _vm.Selected.Name ?? "Rust Server"; + EnqueueDiscordWebhook(_vm.Selected.DiscordWebhookChatAlertsUrl, serverName, text, _vm.Selected.DiscordWebhookChatAlertsTts); } // Thread-safe wrapper für Hintergrund-Alerts @@ -148,6 +135,72 @@ private async Task SendTeamChatSafeAsync(string text, bool bypassChatAlertMaster catch { /* ignore background errors */ } } + // ====== DISCORD WEBHOOK QUEUE (rate-limited) ====== + // One shared client (avoids socket exhaustion), one in-flight POST at a time, a minimum + // gap between posts, and honoring Discord's 429 Retry-After so we never get rate-limited. + private static readonly System.Net.Http.HttpClient _discordHttp = + new() { Timeout = TimeSpan.FromSeconds(15) }; + private readonly SemaphoreSlim _discordGate = new(1, 1); + private DateTime _discordNextAllowedUtc = DateTime.MinValue; + private const int DiscordMinGapMs = 1500; // ~40/min ceiling, comfortably under Discord's limit + + private void EnqueueDiscordWebhook(string url, string serverName, string text, bool tts) + { + _ = Task.Run(async () => + { + await _discordGate.WaitAsync(); + try + { + // proactive spacing so a burst of alerts never stacks up at Discord + var now = DateTime.UtcNow; + if (now < _discordNextAllowedUtc) + await Task.Delay(_discordNextAllowedUtc - now); + + var payload = new { content = $"**[{serverName}]** {text}", tts }; + var json = JsonSerializer.Serialize(payload); + + // try send; on a 429, wait the requested time and retry once + for (int attempt = 0; attempt < 2; attempt++) + { + using var content = new System.Net.Http.StringContent( + json, System.Text.Encoding.UTF8, "application/json"); + using var resp = await _discordHttp.PostAsync(url, content); + + if ((int)resp.StatusCode == 429) + { + double retrySec = 1.0; + if (resp.Headers.RetryAfter?.Delta is TimeSpan d) retrySec = d.TotalSeconds; + else + { + try + { + var body = await resp.Content.ReadAsStringAsync(); + using var doc = System.Text.Json.JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("retry_after", out var ra)) + retrySec = ra.GetDouble(); + } + catch { /* fall back to 1s */ } + } + AppendLog($"[Discord] Rate limited, retrying after {retrySec:0.##}s"); + await Task.Delay(TimeSpan.FromSeconds(Math.Min(Math.Max(retrySec, 0.5) + 0.25, 15))); + continue; + } + break; + } + + _discordNextAllowedUtc = DateTime.UtcNow.AddMilliseconds(DiscordMinGapMs); + } + catch (Exception ex) + { + AppendLog($"[Discord] Webhook send failed: {ex.Message}"); + } + finally + { + _discordGate.Release(); + } + }); + } + private async Task SendTeamChatReliableAsync(string text) { if (_rust is not RustPlusClientReal real) return false; diff --git a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.ChatCommands.cs b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.ChatCommands.cs index fe43e17e..76cbb712 100644 --- a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.ChatCommands.cs +++ b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.ChatCommands.cs @@ -199,15 +199,17 @@ private async Task ProcessChatCommands(TeamChatMessage m) else { var now = DateTime.UtcNow; - var parts = afkMembers.Select(t => + var parts = afkMembers.Select(t => { var elapsed = now - t.LastMoveTime; int totalSecs = (int)elapsed.TotalSeconds; int mins = totalSecs / 60; int secs = totalSecs % 60; - return $"{t.Name} - {mins}:{secs:D2}"; + return $"{GetDisplayPlayerName(t.Name)} - {mins}:{secs:D2}"; }).ToList(); - _ = SendChatCommandResponseAsync("AFK: " + string.Join(" | ", parts)); + string afkMsg = "AFK: " + string.Join(" | ", parts); + if (afkMsg.Length > 128) afkMsg = afkMsg.Substring(0, 125) + "..."; + _ = SendChatCommandResponseAsync(afkMsg); } AppendLog($"[ChatCommand] AFK executed by {m.Author}"); return; @@ -743,7 +745,7 @@ private async Task ProcessShopSearchCommand(string query, string author) } // collect matching sell orders - var matches = new List<(string Grid, string Line, int Stock, double UnitPrice)>(); + var matches = new List<(string Grid, string ItemName, int Price, string Curr, int Qty, int Stock, double UnitPrice, bool IsScrap)>(); foreach (var s in shops) { if (s.Orders == null) continue; @@ -756,11 +758,10 @@ private async Task ProcessShopSearchCommand(string query, string author) string grid = GetGridLabel(s); string currName = ResolveItemName(o.CurrencyItemId, o.CurrencyShortName); - string qtyPart = o.Quantity > 1 ? $"x{o.Quantity} " : ""; - string stockPart = o.Stock <= 0 ? " (out)" : $" ({o.Stock} left)"; - string line = $"{grid}: {qtyPart}{itemName} → {o.CurrencyAmount} {currName}{stockPart}"; + bool isScrap = string.Equals(o.CurrencyShortName, "scrap", StringComparison.OrdinalIgnoreCase) + || currName.Contains("scrap", StringComparison.OrdinalIgnoreCase); double unitPrice = (double)o.CurrencyAmount / Math.Max(1, o.Quantity); - matches.Add((grid, line, o.Stock, unitPrice)); + matches.Add((grid, itemName, o.CurrencyAmount, currName, o.Quantity, o.Stock, unitPrice, isScrap)); } } @@ -773,28 +774,37 @@ private async Task ProcessShopSearchCommand(string query, string author) return; } - // in-stock first, then cheapest per unit + // Drop odd barter trades (e.g. "Bone Fragments for 1 LR-300 Assault Rifle") when + // real Scrap prices exist — they otherwise rank as "cheapest" since 1 < 25 across + // different currencies. Keep barter only if nothing is priced in Scrap. + if (matches.Any(x => x.IsScrap)) + matches = matches.Where(x => x.IsScrap).ToList(); + + // best first: in-stock, then cheapest per unit var ordered = matches .OrderByDescending(x => x.Stock > 0) .ThenBy(x => x.UnitPrice) - .Select(x => x.Line) .ToList(); - // one message per command to avoid API flooding; pack best results into ~128 chars - var sb = new System.Text.StringBuilder(); - int shown = 0; - foreach (var line in ordered) + // Send the top 3 as separate messages (spaced out so we never flood the API). + const int MaxMessages = 3; + var top = ordered.Take(MaxMessages).ToList(); + int omitted = ordered.Count - top.Count; + int delayMs = (int)(Math.Max(2.0, _vm.Selected?.ChatResponseDelaySeconds ?? 2.0) * 1000); + + for (int i = 0; i < top.Count; i++) { - string piece = sb.Length == 0 ? line : " | " + line; - if (sb.Length + piece.Length > 120 && sb.Length > 0) break; - sb.Append(piece); - shown++; + var x = top[i]; + string qtyPart = x.Qty > 1 ? $"x{x.Qty} " : ""; + string stockPart = x.Stock <= 0 ? " (out)" : $" ({x.Stock} left)"; + string suffix = (i == top.Count - 1 && omitted > 0) ? $" (+{omitted} more)" : ""; + string line = $"{x.Grid}: {qtyPart}{x.ItemName} → {x.Price} {x.Curr}{stockPart}{suffix}"; + if (line.Length > 128) line = line.Substring(0, 128); + + if (i > 0) await Task.Delay(delayMs); + await SendChatCommandResponseAsync(line); } - int omitted = matches.Count - shown; - if (omitted > 0) sb.Append($" (+{omitted})"); - - await SendChatCommandResponseAsync(sb.ToString()); - AppendLog($"[ChatCommand] Shop '{query}' by {author}: {matches.Count} match(es), showed {shown} in 1 msg"); + AppendLog($"[ChatCommand] Shop '{query}' by {author}: {ordered.Count} match(es), sent {top.Count} msg(s)"); } // single oil rig status line diff --git a/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs b/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs index 9fb51552..26f0942a 100644 --- a/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs +++ b/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs @@ -274,9 +274,9 @@ private void AfkTimer_Tick(object? sender, EventArgs e) // true only on transition into AFK bool becameAfk = m.UpdateAfkState(now, afkThreshold); - if (!_announceSpawns || m.SteamId == _mySteamId) + if (!_announceSpawns) { - m.AfkReturnDuration = null; // never announce for self / when announce is off + m.AfkReturnDuration = null; // announcements disabled — nothing to send continue; } @@ -592,34 +592,37 @@ private async Task AnnouncePresenceChangeAsync(TeamMemberVM vm, (bool online, bo { try { - if (prev.online != now.online && _announceSpawns) + if (prev.online != now.online) { - bool isSelf = vm.SteamId == _mySteamId; - bool shouldAnnounce = now.online ? TrackingService.AnnouncePlayerOnline : TrackingService.AnnouncePlayerOffline; - - if (shouldAnnounce) + if (_announceSpawns) { - var where = (vm.X.HasValue && vm.Y.HasValue) ? GetGridLabel(vm.X.Value, vm.Y.Value) : Properties.Resources.Unknown; - var dispName = GetDisplayPlayerName(vm.Name); - string txt; - if (now.online) + bool shouldAnnounce = now.online ? TrackingService.AnnouncePlayerOnline : TrackingService.AnnouncePlayerOffline; + + if (shouldAnnounce) { - txt = AlertTemplateService.GetFormattedAlert("AlertPlayerOnlineWithPos", dispName, where); - // tack on how long they were offline, if we tracked it - if (vm.OfflineSince.HasValue) + var where = (vm.X.HasValue && vm.Y.HasValue) ? GetGridLabel(vm.X.Value, vm.Y.Value) : Properties.Resources.Unknown; + var dispName = GetDisplayPlayerName(vm.Name); + string txt; + if (now.online) + { + txt = AlertTemplateService.GetFormattedAlert("AlertPlayerOnlineWithPos", dispName, where); + // tack on how long they were offline, if we tracked it + if (vm.OfflineSince.HasValue) + { + var suffix = AlertTemplateService.GetFormattedAlert("AlertPlayerOfflineDuration", FormatAgo(DateTime.UtcNow - vm.OfflineSince.Value)); + if (!string.IsNullOrWhiteSpace(suffix)) txt += " " + suffix; + } + } + else { - var suffix = AlertTemplateService.GetFormattedAlert("AlertPlayerOfflineDuration", FormatAgo(DateTime.UtcNow - vm.OfflineSince.Value)); - if (!string.IsNullOrWhiteSpace(suffix)) txt += " " + suffix; + txt = AlertTemplateService.GetFormattedAlert("AlertPlayerOffline", dispName); } + await SendTeamChatSafeAsync(txt); } - else - { - txt = AlertTemplateService.GetFormattedAlert("AlertPlayerOffline", dispName); - } - await SendTeamChatSafeAsync(txt); } - // remember when they went offline so we can report the gap on return + // Track offline timing regardless of the announce setting, so the duration is + // correct even if alerts were toggled on only after the player went offline. vm.OfflineSince = now.online ? (DateTime?)null : DateTime.UtcNow; } From 05aee7f0149c912e3214226fb6130a92f40c9976 Mon Sep 17 00:00:00 2001 From: Rathio12 <146424297+Rathio12@users.noreply.github.com> Date: Mon, 15 Jun 2026 05:20:08 +0200 Subject: [PATCH 3/6] Add "alive for X" to teammate death announcements When a teammate dies, the death message now reports how long they were alive (e.g. ":skull: X died @ G12 was alive for 1h 25m"). - New AliveSince field on TeamMemberVM: set on respawn, seeded when a player is first seen alive, and cleared on death. - Survives going offline/online (sleeping is not dying) and is tracked regardless of the Chat Alerts toggle, so the duration stays accurate. - New "AlertPlayerAliveDuration" string ("was alive for {0}") added to Resources.resx and the en-US resources. --- CHANGELOG.md | 1 + RustPlusDesktop/Properties/Resources.resx | 3 +++ .../lang/en-US/Resources.en-US.resx | 3 +++ .../MainWindow/Team/MainWindow.Team.Core.cs | 25 ++++++++++++++++++- 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e778f728..4e35313e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ This PR adds a few team-chat commands and presence announcements, and fixes a co - Teammate went **AFK** — message now includes how long they've been idle (e.g. "X is now AFK (idle 5m)"). Announced for **anyone in the team, including yourself**. - Teammate **back from AFK** — new announcement reporting how long they were AFK (e.g. "X is back (was AFK for 12m)"). - Teammate **came online** — message now appends how long they were offline (e.g. "X came online @ G12 (was offline 2h 5m)"). + - Teammate **died** — message now appends how long they were alive (e.g. ":skull: X died @ G12 was alive for 1h 25m"). The clock starts on respawn, survives going offline/online (sleeping ≠ dying), and is tracked regardless of the announce toggle. - Smart handling of **your own** team switches: - You join someone else's team → announces **only you** once (not every existing member). - People join **your** team (you're leader) → announces each. diff --git a/RustPlusDesktop/Properties/Resources.resx b/RustPlusDesktop/Properties/Resources.resx index 1ecdef69..c2a30638 100644 --- a/RustPlusDesktop/Properties/Resources.resx +++ b/RustPlusDesktop/Properties/Resources.resx @@ -1585,6 +1585,9 @@ Download and install now? (was offline {0}) + + was alive for {0} + [Tracking] {0}{1} is now ONLINE diff --git a/RustPlusDesktop/Properties/lang/en-US/Resources.en-US.resx b/RustPlusDesktop/Properties/lang/en-US/Resources.en-US.resx index a3f5fe2f..67273310 100644 --- a/RustPlusDesktop/Properties/lang/en-US/Resources.en-US.resx +++ b/RustPlusDesktop/Properties/lang/en-US/Resources.en-US.resx @@ -1582,6 +1582,9 @@ Download and install now? (was offline {0}) + + was alive for {0} + [Tracking] {0}{1} is now ONLINE diff --git a/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs b/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs index 26f0942a..5ec9979c 100644 --- a/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs +++ b/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs @@ -138,6 +138,8 @@ public string AfkText public TimeSpan? AfkReturnDuration { get; set; } // Set when the player goes offline; used to report how long they were gone when they come back. public DateTime? OfflineSince { get; set; } + // Set when the player (re)spawns alive; used to report how long they were alive when they die. + public DateTime? AliveSince { get; set; } public void SetPosition(double? x, double? y) { @@ -406,6 +408,9 @@ private async Task LoadTeamAsync() vm.IsLeader = leaderId != 0 && sid == leaderId; vm.IsOnline = m.Online; vm.IsDead = m.Dead; + // Best-effort seed for players already alive when first seen (no spawn event to + // anchor to). Real respawns reset this accurately in AnnouncePresenceChangeAsync. + if (!m.Dead && !vm.AliveSince.HasValue) vm.AliveSince = DateTime.UtcNow; vm.SetPosition(m.X, m.Y); var now = (m.Online, m.Dead); @@ -650,11 +655,29 @@ private async Task AnnouncePresenceChangeAsync(TeamMemberVM vm, (bool online, bo { var where = (px.HasValue && py.HasValue) ? GetGridLabel(px.Value, py.Value) : Properties.Resources.Unknown; var dispName = GetDisplayPlayerName(vm.Name); - var txt = now.dead ? AlertTemplateService.GetFormattedAlert("AlertPlayerDied", dispName, where) : AlertTemplateService.GetFormattedAlert("AlertPlayerRespawned", dispName, where); + string txt; + if (now.dead) + { + txt = AlertTemplateService.GetFormattedAlert("AlertPlayerDied", dispName, where); + // tack on how long they were alive, if we tracked their spawn + if (vm.AliveSince.HasValue) + { + var suffix = AlertTemplateService.GetFormattedAlert("AlertPlayerAliveDuration", FormatAgo(DateTime.UtcNow - vm.AliveSince.Value)); + if (!string.IsNullOrWhiteSpace(suffix)) txt += " " + suffix; + } + } + else + { + txt = AlertTemplateService.GetFormattedAlert("AlertPlayerRespawned", dispName, where); + } await SendTeamChatSafeAsync(txt); } } + // Track alive time independently of the announce setting: reset the clock on + // respawn, clear it on death (read above before clearing). + vm.AliveSince = now.dead ? (DateTime?)null : DateTime.UtcNow; + if (now.dead && px.HasValue && py.HasValue) { if (_vm?.Selected != null) From 589af8e4835159fa41e713f13fed7e58602ce644 Mon Sep 17 00:00:00 2001 From: Rathio12 <146424297+Rathio12@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:52:38 +0200 Subject: [PATCH 4/6] Add persistent event logs and reword presence alerts Persistence (survives app crashes/restarts), written regardless of the Chat Alerts toggle: - timeline.log: append-only, crash-safe history of every event (offline/ online, AFK/back, death/respawn, oil rig, cargo, heli, vendor, deep sea). - events.json: latest state per event type, keyed per server and overwritten as new events arrive; loaded on startup. On reconnect it rehydrates the "X ago" timers for cargo/heli/vendor/deep sea so those commands still answer correctly after a restart. New EventStateService handles this. Reworded in-game presence messages (defaults; still editable in Custom Alerts): - Death: ":skull: X is dead @ G12, was alive for 1h 25m" - AFK: "X is now AFK for more than 5m" - Back from AFK: "X came back from AFK, was AFK for 12m" --- CHANGELOG.md | 9 +- RustPlusDesktop/MainWindow.xaml.cs | 5 +- RustPlusDesktop/Properties/Resources.resx | 8 +- .../lang/en-US/Resources.en-US.resx | 8 +- RustPlusDesktop/Services/EventStateService.cs | 78 +++++++++++++++ .../MainWindow/Map/MainWindow.Map.Markers.cs | 9 ++ .../MainWindow/Map/MainWindow.Map.Shops.cs | 2 + .../MainWindow/Team/MainWindow.Team.Core.cs | 96 ++++++++++++++++++- 8 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 RustPlusDesktop/Services/EventStateService.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e35313e..2864f581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,10 @@ This PR adds a few team-chat commands and presence announcements, and fixes a co - **`!pos `** — reports a teammate's current grid (exact-then-partial name match). - **Team presence announcements** (gated by the existing "Chat Announce" master toggle): - Teammate **joined** / **left** the team. - - Teammate went **AFK** — message now includes how long they've been idle (e.g. "X is now AFK (idle 5m)"). Announced for **anyone in the team, including yourself**. - - Teammate **back from AFK** — new announcement reporting how long they were AFK (e.g. "X is back (was AFK for 12m)"). + - Teammate went **AFK** — e.g. "X is now AFK for more than 5m". Announced for **anyone in the team, including yourself**. + - Teammate **back from AFK** — e.g. "X came back from AFK, was AFK for 12m". - Teammate **came online** — message now appends how long they were offline (e.g. "X came online @ G12 (was offline 2h 5m)"). - - Teammate **died** — message now appends how long they were alive (e.g. ":skull: X died @ G12 was alive for 1h 25m"). The clock starts on respawn, survives going offline/online (sleeping ≠ dying), and is tracked regardless of the announce toggle. + - Teammate **died** — message now appends how long they were alive (e.g. ":skull: X is dead @ G12, was alive for 1h 25m"). The clock starts on respawn, survives going offline/online (sleeping ≠ dying), and is tracked regardless of the announce toggle. - Smart handling of **your own** team switches: - You join someone else's team → announces **only you** once (not every existing member). - People join **your** team (you're leader) → announces each. @@ -23,6 +23,9 @@ This PR adds a few team-chat commands and presence announcements, and fixes a co - **Configurable AFK threshold** — new per-server setting (default 5 min, range 1–60) replacing the hardcoded value, with a selector in the Chat Commands panel. - Config rows for the new commands in the Chat Commands panel; join/leave/AFK (and back-from-AFK) templates are editable in the Custom Alerts window. - In-game emoji shortcodes on alerts: `:skull:` (death), `:wave:` (join/leave), `:vending.machine:` (shop alerts). +- **Persistent event logs** (under `%LOCALAPPDATA%\RustPlusDesk\logs\`, written regardless of the Chat Alerts toggle so the record survives an app crash): + - `timeline.log` — append-only, crash-safe history of every event (offline/online, AFK/back, death/respawn, oil-rig crate, cargo, heli, vendor, deep sea), one timestamped line each. + - `events.json` — latest state per event type (keyed per server, overwritten as new events arrive) and **loaded on startup**. On reconnect it rehydrates the "X ago" timers for cargo/heli/vendor/deep sea so those `!` commands still answer correctly after a restart. ## Fixed - **AFK announcements never fired for your own account** — the AFK alert was hard-skipped for the local player (`SteamId == _mySteamId`), so going AFK yourself produced no message. Now any team member (you included) is announced when Chat Alerts is on, matching how death/online alerts already behave. diff --git a/RustPlusDesktop/MainWindow.xaml.cs b/RustPlusDesktop/MainWindow.xaml.cs index b01058a5..573293c9 100644 --- a/RustPlusDesktop/MainWindow.xaml.cs +++ b/RustPlusDesktop/MainWindow.xaml.cs @@ -364,6 +364,7 @@ public MainWindow() var baseDir = AppDomain.CurrentDomain.BaseDirectory; _vm.IsInitializing = true; + Services.EventStateService.Load(); // restore persisted event states from a previous run/crash InitializeComponent(); PlayersTab?.SetMainWindow(this); @@ -715,11 +716,13 @@ await Dispatcher.InvokeAsync(() => _monumentWatcher.OnOilRigTriggered += (s, data) => { - if (!TrackingService.AnnounceSpawnsMaster || !TrackingService.AnnounceOilRig) return; string timeStr = data.Duration >= 800 ? "~15m" : "~12:30m"; string rigName = data.Name == "Small Oil Rig" ? Properties.Resources.SmallOilRig : data.Name == "Large Oil Rig" ? Properties.Resources.LargeOilRig : data.Name; + // crash-safe log regardless of the announce toggle + RecordEvent(data.Name, "CRATE-CALLED", $"crate in {timeStr}"); + if (!TrackingService.AnnounceSpawnsMaster || !TrackingService.AnnounceOilRig) return; Dispatcher.InvokeAsync(async () => { var msg = AlertTemplateService.GetFormattedAlert("AlertOilRigTriggered", rigName, timeStr); diff --git a/RustPlusDesktop/Properties/Resources.resx b/RustPlusDesktop/Properties/Resources.resx index c2a30638..08adc733 100644 --- a/RustPlusDesktop/Properties/Resources.resx +++ b/RustPlusDesktop/Properties/Resources.resx @@ -1556,7 +1556,7 @@ Download and install now? {0} went offline - :skull: {0} died @ {1} + :skull: {0} is dead @ {1} {0} respawned @ {1} @@ -1568,10 +1568,10 @@ Download and install now? :wave: {0} left the team - {0} is now AFK (idle {1}) + {0} is now AFK for more than {1} - {0} is back (was AFK for {1}) + {0} came back from AFK, was AFK for {1} Player Back From AFK @@ -1586,7 +1586,7 @@ Download and install now? (was offline {0}) - was alive for {0} + , was alive for {0} [Tracking] {0}{1} is now ONLINE diff --git a/RustPlusDesktop/Properties/lang/en-US/Resources.en-US.resx b/RustPlusDesktop/Properties/lang/en-US/Resources.en-US.resx index 67273310..33ad13d9 100644 --- a/RustPlusDesktop/Properties/lang/en-US/Resources.en-US.resx +++ b/RustPlusDesktop/Properties/lang/en-US/Resources.en-US.resx @@ -1553,7 +1553,7 @@ Download and install now? {0} went offline - :skull: {0} died @ {1} + :skull: {0} is dead @ {1} {0} respawned @ {1} @@ -1565,10 +1565,10 @@ Download and install now? :wave: {0} left the team - {0} is now AFK (idle {1}) + {0} is now AFK for more than {1} - {0} is back (was AFK for {1}) + {0} came back from AFK, was AFK for {1} Player Back From AFK @@ -1583,7 +1583,7 @@ Download and install now? (was offline {0}) - was alive for {0} + , was alive for {0} [Tracking] {0}{1} is now ONLINE diff --git a/RustPlusDesktop/Services/EventStateService.cs b/RustPlusDesktop/Services/EventStateService.cs new file mode 100644 index 00000000..b9f88708 --- /dev/null +++ b/RustPlusDesktop/Services/EventStateService.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; + +namespace RustPlusDesk.Services +{ + /// + /// Persists the latest known state of each in-game event (oil rig, cargo, heli, players, ...) + /// to a single JSON file, overwriting the entry for a key when a newer event of that type + /// arrives. Survives app crashes/restarts and is loaded back on startup. + /// Paired with the append-only timeline.log for full history. + /// + public static class EventStateService + { + public sealed record EventEntry(string State, string Detail, DateTime TimeUtc); + + private static readonly string _path = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "RustPlusDesk", "logs", "events.json"); + + private static readonly object _lock = new(); + private static Dictionary _states = new(StringComparer.OrdinalIgnoreCase); + private static readonly JsonSerializerOptions _json = new() { WriteIndented = true }; + + /// Reads the persisted state into memory. Call once on startup. + public static void Load() + { + try + { + lock (_lock) + { + if (!File.Exists(_path)) return; + var data = JsonSerializer.Deserialize>(File.ReadAllText(_path)); + if (data != null) _states = new Dictionary(data, StringComparer.OrdinalIgnoreCase); + } + } + catch { /* missing/corrupt -> start fresh */ } + } + + /// Overwrites the latest state for and persists immediately. + public static void Record(string key, string state, string detail = "") + { + if (string.IsNullOrWhiteSpace(key)) return; + lock (_lock) + { + _states[key] = new EventEntry(state, detail ?? string.Empty, DateTime.UtcNow); + Save(); + } + } + + public static bool TryGet(string key, out EventEntry entry) + { + lock (_lock) { return _states.TryGetValue(key, out entry!); } + } + + public static IReadOnlyDictionary Snapshot() + { + lock (_lock) { return new Dictionary(_states, StringComparer.OrdinalIgnoreCase); } + } + + private static void Save() + { + try + { + var dir = Path.GetDirectoryName(_path); + if (dir != null) Directory.CreateDirectory(dir); + + // write to a temp file then swap, so a crash mid-write can't corrupt the store + var tmp = _path + ".tmp"; + File.WriteAllText(tmp, JsonSerializer.Serialize(_states, _json)); + File.Copy(tmp, _path, overwrite: true); + File.Delete(tmp); + } + catch { /* best effort */ } + } + } +} diff --git a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Markers.cs b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Markers.cs index 522ec2a1..ddfaf313 100644 --- a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Markers.cs +++ b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Markers.cs @@ -381,6 +381,9 @@ private void ProcessCargoDocking(RustPlusClientReal.DynMarker m, bool isGhost = state.SeenAtEdge = distFromCenter > (half * 0.85); + // crash-safe log regardless of the announce toggle + RecordEvent("Cargo Ship", "SPAWNED", $"@ {GetGridLabel(m.X, m.Y)}"); + if (_announceSpawns && TrackingService.AnnounceCargo) { string grid = GetGridLabel(m.X, m.Y); @@ -571,6 +574,7 @@ private void CleanupCargoDockStates() if ((now - state.LastSeen).TotalSeconds > 60) { _cargoLastDespawnUtc = now; + RecordEvent("Cargo Ship", "LEFT"); AppendLog($"[cargo] Despawn detected – last seen {(now - state.LastSeen).TotalSeconds:F0}s ago."); if (state.FirstSeen.HasValue && state.HarborCount >= 1 && state.SeenAtEdge) @@ -1385,6 +1389,7 @@ private void UpdateDynUI(IReadOnlyList markers) { _heliSpawnTime = DateTime.UtcNow; _heliMidEvent = false; + RecordEvent("Patrol Heli", "SPAWNED", $"@ {GetGridLabel(m.X, m.Y)}"); } } else if (m.Type == 6) // Travelling Vendor @@ -1400,6 +1405,7 @@ private void UpdateDynUI(IReadOnlyList markers) _vendorSpawnTime = DateTime.UtcNow; _vendorMidEvent = false; _vendorDespawnTime = null; + RecordEvent("Travelling Vendor", "SPAWNED", $"@ {GetGridLabel(m.X, m.Y)}"); } } @@ -1672,6 +1678,8 @@ private void UpdateDynUI(IReadOnlyList markers) _heliLastEventWasCrash = crashed; _heliSpawnTime = null; _heliMidEvent = false; + RecordEvent("Patrol Heli", crashed ? "SHOT-DOWN" : "LEFT", + crashed ? $"@ {GetGridLabel(state.LastRealX, state.LastRealY)}" : ""); if (crashed) { @@ -1698,6 +1706,7 @@ private void UpdateDynUI(IReadOnlyList markers) _vendorDespawnTime = DateTime.UtcNow; _vendorSpawnTime = null; _vendorMidEvent = false; + RecordEvent("Travelling Vendor", "LEFT"); AppendLog("[Vendor] Travelling Vendor despawned or left the map area."); } diff --git a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Shops.cs b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Shops.cs index 8f8dd3da..9185c645 100644 --- a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Shops.cs +++ b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Shops.cs @@ -48,6 +48,7 @@ private void CheckDeepSeaEvent(IEnumerable shops) _deepSeaSpawnTime = DateTime.UtcNow; _deepSeaDespawnTime = null; _deepSeaMidEvent = false; + RecordEvent("Deep Sea", "UP"); string dir = GetDeepSeaDirection(deepSeaShop.X, deepSeaShop.Y); if (_announceSpawns && TrackingService.AnnounceDeepSea) { @@ -74,6 +75,7 @@ private void CheckDeepSeaEvent(IEnumerable shops) { // Genuine despawn witnessed while shop tracking was running _deepSeaDespawnTime = DateTime.UtcNow; + RecordEvent("Deep Sea", "ENDED"); AppendLog("[DEEPSEA] Despawn detected."); } else diff --git a/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs b/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs index 5ec9979c..88353e49 100644 --- a/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs +++ b/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs @@ -21,6 +21,77 @@ public partial class MainWindow private System.Windows.Threading.DispatcherTimer? _afkTimer; public ObservableCollection TeamMembers { get; } = new(); + // Crash-safe timeline log: appended (and flushed) immediately on every offline/online/AFK, + // death, and world event (oil rig, etc.) so the history survives an app crash. Lives next to + // the other app data at %LOCALAPPDATA%\RustPlusDesk\logs\timeline.log. + private static readonly string _timelineLogPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "RustPlusDesk", "logs", "timeline.log"); + private static readonly object _timelineLogLock = new(); + + // Records an event to BOTH stores: append-only history (timeline.log) and the latest-state + // snapshot (events.json, overwritten per key and reloaded on startup). + // subject/key = player name or monument/event name; eventType = OFFLINE/ONLINE/AFK/DIED/CRATE-CALLED/... + private void RecordEvent(string subject, string eventType, string detail = "") + { + LogTimelineEvent(subject, eventType, detail); + // latest-state snapshot is keyed per-server so different servers don't clobber each other + string server = _vm?.Selected?.Name ?? "?"; + Services.EventStateService.Record($"{server}{subject}", eventType, detail); + } + + // Rehydrate the world-event "last ended" timestamps for the connected server from the + // persisted snapshot, so !cargo/!heli/!vendor/!deepsea "X ago" still work after a restart. + private void RestoreEventStateFromSnapshot() + { + try + { + string server = _vm?.Selected?.Name ?? "?"; + string prefix = $"{server}"; + foreach (var kv in Services.EventStateService.Snapshot()) + { + if (!kv.Key.StartsWith(prefix, StringComparison.Ordinal)) continue; + string subject = kv.Key.Substring(prefix.Length); + var e = kv.Value; + switch (subject) + { + case "Cargo Ship" when e.State == "LEFT": + _cargoLastDespawnUtc ??= e.TimeUtc; break; + case "Patrol Heli" when e.State is "LEFT" or "SHOT-DOWN": + if (!_heliLastEventUtc.HasValue) { _heliLastEventUtc = e.TimeUtc; _heliLastEventWasCrash = e.State == "SHOT-DOWN"; } + break; + case "Travelling Vendor" when e.State == "LEFT": + _vendorDespawnTime ??= e.TimeUtc; break; + case "Deep Sea" when e.State == "ENDED": + _deepSeaDespawnTime ??= e.TimeUtc; break; + } + } + } + catch (Exception ex) { AppendLog($"[TimelineLog] restore failed: {ex.Message}"); } + } + + private void LogTimelineEvent(string subject, string eventType, string detail = "") + { + try + { + var dir = Path.GetDirectoryName(_timelineLogPath); + if (dir != null) Directory.CreateDirectory(dir); + + string server = _vm?.Selected?.Name ?? "?"; + string detailPart = string.IsNullOrEmpty(detail) ? "" : "\t" + detail; + string line = $"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}Z\t[{server}]\t{subject}\t{eventType}{detailPart}{Environment.NewLine}"; + + lock (_timelineLogLock) + { + File.AppendAllText(_timelineLogPath, line); + } + } + catch (Exception ex) + { + AppendLog($"[TimelineLog] write failed: {ex.Message}"); + } + } + private readonly Dictionary _avatarCache = new(); private RustPlusClientReal? _real => _rust as RustPlusClientReal; @@ -227,6 +298,7 @@ private void StartTeamPolling() { if (_teamTimer != null) return; _teamRosterInitialized = false; // re-baseline roster + RestoreEventStateFromSnapshot(); // rehydrate world-event "last X ago" timers for this server _teamTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(5) @@ -275,6 +347,13 @@ private void AfkTimer_Tick(object? sender, EventArgs e) { // true only on transition into AFK bool becameAfk = m.UpdateAfkState(now, afkThreshold); + bool cameBack = m.AfkReturnDuration.HasValue && m.IsOnline; + + // Crash-safe log first, regardless of whether chat alerts are enabled. + if (becameAfk) + RecordEvent(m.Name, "AFK", $"idle {FormatAgo(now - m.LastMoveTime)}"); + else if (cameBack) + RecordEvent(m.Name, "AFK-BACK", $"was AFK {FormatAgo(m.AfkReturnDuration!.Value)}"); if (!_announceSpawns) { @@ -599,6 +678,13 @@ private async Task AnnouncePresenceChangeAsync(TeamMemberVM vm, (bool online, bo { if (prev.online != now.online) { + // Crash-safe log first, regardless of whether chat alerts are enabled. + string logGrid = (vm.X.HasValue && vm.Y.HasValue) ? GetGridLabel(vm.X.Value, vm.Y.Value) : "?"; + string logDetail = $"@ {logGrid}"; + if (now.online && vm.OfflineSince.HasValue) + logDetail += $", was offline {FormatAgo(DateTime.UtcNow - vm.OfflineSince.Value)}"; + RecordEvent(vm.Name, now.online ? "ONLINE" : "OFFLINE", logDetail); + if (_announceSpawns) { bool shouldAnnounce = now.online ? TrackingService.AnnouncePlayerOnline : TrackingService.AnnouncePlayerOffline; @@ -640,6 +726,13 @@ private async Task AnnouncePresenceChangeAsync(TeamMemberVM vm, (bool online, bo py = dy; } + // Crash-safe log first, regardless of whether chat alerts are enabled. + string deathGrid = (px.HasValue && py.HasValue) ? GetGridLabel(px.Value, py.Value) : "?"; + string deathDetail = $"@ {deathGrid}"; + if (now.dead && vm.AliveSince.HasValue) + deathDetail += $", alive for {FormatAgo(DateTime.UtcNow - vm.AliveSince.Value)}"; + RecordEvent(vm.Name, now.dead ? "DIED" : "RESPAWNED", deathDetail); + if (_announceSpawns) { bool isSelf = vm.SteamId == _mySteamId; @@ -660,10 +753,11 @@ private async Task AnnouncePresenceChangeAsync(TeamMemberVM vm, (bool online, bo { txt = AlertTemplateService.GetFormattedAlert("AlertPlayerDied", dispName, where); // tack on how long they were alive, if we tracked their spawn + // (the template already includes its own leading ", ") if (vm.AliveSince.HasValue) { var suffix = AlertTemplateService.GetFormattedAlert("AlertPlayerAliveDuration", FormatAgo(DateTime.UtcNow - vm.AliveSince.Value)); - if (!string.IsNullOrWhiteSpace(suffix)) txt += " " + suffix; + if (!string.IsNullOrWhiteSpace(suffix)) txt += suffix; } } else From 2ffcf503b7563404d541abc0a45ea1ae1df6e834 Mon Sep 17 00:00:00 2001 From: Rathio12 <146424297+Rathio12@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:32:44 +0200 Subject: [PATCH 5/6] Add send throttle, alarm spam cooldown, and event-restore guards - Global outgoing-send throttle: minimum 1.5s gap between any two team-chat sends so bursts of alerts/commands don't hammer the Rust+ API. Isolated messages still send instantly; only back-to-back ones wait. - Alarm spam cooldown: if a Smart Alarm fires more than 3 times in a row (each within 2 min of the last), its team-chat and Discord raid alerts are muted for 10 minutes (per-alarm). Popup/audio still fire. - Event-state restore guards: ignore persisted states from before the server's last wipe (RustMapsWipeTime) and anything older than ~15h. Past that age, !cargo/!heli/!vendor/!deepsea say "Haven't seen X in a while" instead of a misleading "28h ago". - !deepsea reports "enable Shops to track it" when shop polling is off, instead of blindly claiming it's not up. --- CHANGELOG.md | 4 ++ RustPlusDesktop/MainWindow.xaml.cs | 52 +++++++++++++++++-- .../Properties/Resources.Designer.cs | 2 + RustPlusDesktop/Properties/Resources.resx | 6 +++ .../lang/en-US/Resources.en-US.resx | 6 +++ .../MainWindow/Map/MainWindow.Map.Chat.cs | 19 ++++++- .../Map/MainWindow.Map.ChatCommands.cs | 33 ++++++++++-- .../MainWindow/Team/MainWindow.Team.Core.cs | 6 +++ 8 files changed, 117 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2864f581..ce54a430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ This PR adds a few team-chat commands and presence announcements, and fixes a co - **Persistent event logs** (under `%LOCALAPPDATA%\RustPlusDesk\logs\`, written regardless of the Chat Alerts toggle so the record survives an app crash): - `timeline.log` — append-only, crash-safe history of every event (offline/online, AFK/back, death/respawn, oil-rig crate, cargo, heli, vendor, deep sea), one timestamped line each. - `events.json` — latest state per event type (keyed per server, overwritten as new events arrive) and **loaded on startup**. On reconnect it rehydrates the "X ago" timers for cargo/heli/vendor/deep sea so those `!` commands still answer correctly after a restart. + - **Map-wipe & staleness guards on restore**: persisted states from before the server's last wipe (`RustMapsWipeTime`) are ignored, and anything older than ~15h is dropped. Past that age the `!cargo`/`!heli`/`!vendor`/`!deepsea` replies say "Haven't seen X in a while" instead of a misleading "28h ago". + - `!deepsea` now replies "enable Shops on the map to track it" when shop polling is off, instead of blindly reporting it as not up. +- **Global outgoing-send throttle** — a minimum 1.5s gap between any two team-chat sends so bursts of alerts/commands never hammer the Rust+ API (isolated messages still send instantly; only back-to-back ones wait). +- **Alarm spam cooldown** — if a Smart Alarm fires more than 3 times in a row (each within 2 min of the last), its team-chat **and** Discord raid alerts are muted for 10 minutes (per-alarm). Popup/audio still fire; only the broadcasts are suppressed, so a raid/decay loop can't flood chat. ## Fixed - **AFK announcements never fired for your own account** — the AFK alert was hard-skipped for the local player (`SteamId == _mySteamId`), so going AFK yourself produced no message. Now any team member (you included) is announced when Chat Alerts is on, matching how death/online alerts already behave. diff --git a/RustPlusDesktop/MainWindow.xaml.cs b/RustPlusDesktop/MainWindow.xaml.cs index f72f0788..5c22e21c 100644 --- a/RustPlusDesktop/MainWindow.xaml.cs +++ b/RustPlusDesktop/MainWindow.xaml.cs @@ -2062,6 +2062,38 @@ private sealed record MarkerRef(System.Windows.Shapes.Ellipse Dot, double U_DIP, private readonly ObservableCollection _alarmFeed = new(); private readonly Dictionary _lastAlarmProcessed = new(); private DateTime _lastAnyAlarmTime = DateTime.MinValue; // Globaler Marker für Fuzzy-Dedup + + // Per-alarm spam guard: if an alarm fires more than 3 times in a row (each within the window + // of the last), its alerts are muted for 10 minutes so a raid/decay loop can't flood chat. + private sealed class AlarmSpamState { public DateTime LastTrigger; public int Streak; public DateTime CooldownUntil; } + private readonly Dictionary _alarmSpam = new(); + private const int AlarmSpamThreshold = 3; + private static readonly TimeSpan AlarmSpamWindow = TimeSpan.FromMinutes(2); + private static readonly TimeSpan AlarmSpamCooldown = TimeSpan.FromMinutes(10); + + private bool IsAlarmSpamSuppressed(string key, DateTime now) + { + if (!_alarmSpam.TryGetValue(key, out var st)) { st = new AlarmSpamState(); _alarmSpam[key] = st; } + + // already cooling down -> stay muted + if (now < st.CooldownUntil) return true; + + // count consecutive triggers that arrive within the window of the previous one + if (st.LastTrigger != default && (now - st.LastTrigger) <= AlarmSpamWindow) + st.Streak++; + else + st.Streak = 1; + st.LastTrigger = now; + + if (st.Streak > AlarmSpamThreshold) + { + st.CooldownUntil = now + AlarmSpamCooldown; + st.Streak = 0; + AppendLog($"[alarm] '{key}' fired >{AlarmSpamThreshold}x in a row — muting its alerts for {AlarmSpamCooldown.TotalMinutes:0} min"); + return true; // mute the one that tripped the cooldown too + } + return false; + } private readonly Dictionary _alarmMetadataCache = new(); private readonly Dictionary _lastSeenIdPerServer = new(); private readonly List _alarmHistoryDedup = new(); @@ -2236,15 +2268,25 @@ private void ShowAlarmPopup(AlarmNotification n, string source = "FCM") var raidOwnerSteamId = !string.IsNullOrWhiteSpace(_vm.SteamId64) ? _vm.SteamId64 : alarmProfile?.SteamId64 ?? ""; - _ = DiscordBotListenerService.Instance.SendRaidNotificationAsync( - raidServerKey, - raidOwnerSteamId, - $"\uD83D\uDEA8 **{dev?.PureName ?? n.DeviceName ?? "Smart Alarm"}**: {n.Message}"); + + // Spam guard: if this alarm fires >3 times in a row it gets muted for 10 min so a + // raid/decay loop can't flood team chat + Discord (and burn API tokens). + string alarmSpamKey = n.EntityId.HasValue ? $"ID:{n.EntityId.Value}" : (n.DeviceName ?? "alarm"); + bool alarmSpamMuted = IsAlarmSpamSuppressed(alarmSpamKey, now); + + if (!alarmSpamMuted) + { + _ = DiscordBotListenerService.Instance.SendRaidNotificationAsync( + raidServerKey, + raidOwnerSteamId, + $"\uD83D\uDEA8 **{dev?.PureName ?? n.DeviceName ?? "Smart Alarm"}**: {n.Message}"); + } // Send smart alert to team chat if setting and master switch are enabled if (_vm.Selected?.IsFullConnected == true && TrackingService.AnnounceSmartAlerts - && _announceSpawns) + && _announceSpawns + && !alarmSpamMuted) { string alarmName = dev?.PureName ?? (!string.IsNullOrEmpty(n.DeviceName) ? n.DeviceName : "Smart Alarm"); _ = SendTeamChatSafeAsync(AlertTemplateService.GetFormattedAlert("AlertAlarmTriggered", alarmName), false, true); diff --git a/RustPlusDesktop/Properties/Resources.Designer.cs b/RustPlusDesktop/Properties/Resources.Designer.cs index e1c4c1ff..b865ca8d 100644 --- a/RustPlusDesktop/Properties/Resources.Designer.cs +++ b/RustPlusDesktop/Properties/Resources.Designer.cs @@ -454,6 +454,8 @@ private static string GetString(string key) public static string ChatCmdDeepSeaActiveMidEvent => GetString("ChatCmdDeepSeaActiveMidEvent"); public static string ChatCmdDeepSeaEndedMinutesAgo => GetString("ChatCmdDeepSeaEndedMinutesAgo"); public static string ChatCmdDeepSeaStatusUnknown => GetString("ChatCmdDeepSeaStatusUnknown"); + public static string ChatCmdDeepSeaNeedsShopPolling => GetString("ChatCmdDeepSeaNeedsShopPolling"); + public static string ChatCmdEventNotSeenLong => GetString("ChatCmdEventNotSeenLong"); public static string ChatCmdCargoNotActive => GetString("ChatCmdCargoNotActive"); public static string ChatCmdCargoDockedDeparts => GetString("ChatCmdCargoDockedDeparts"); public static string ChatCmdCargoDockedPreparingDepart => GetString("ChatCmdCargoDockedPreparingDepart"); diff --git a/RustPlusDesktop/Properties/Resources.resx b/RustPlusDesktop/Properties/Resources.resx index 08adc733..12960acb 100644 --- a/RustPlusDesktop/Properties/Resources.resx +++ b/RustPlusDesktop/Properties/Resources.resx @@ -1342,6 +1342,12 @@ Download and install now? Deep Sea event status unknown (not seen this session). + + Deep Sea status unknown — enable Shops on the map to track it. + + + Haven't seen {0} in a while. + Cargo Ship not active. diff --git a/RustPlusDesktop/Properties/lang/en-US/Resources.en-US.resx b/RustPlusDesktop/Properties/lang/en-US/Resources.en-US.resx index 33ad13d9..0b97fc0b 100644 --- a/RustPlusDesktop/Properties/lang/en-US/Resources.en-US.resx +++ b/RustPlusDesktop/Properties/lang/en-US/Resources.en-US.resx @@ -1339,6 +1339,12 @@ Download and install now? Deep Sea event status unknown (not seen this session). + + Deep Sea status unknown — enable Shops on the map to track it. + + + Haven't seen {0} in a while. + Cargo Ship not active. diff --git a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Chat.cs b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Chat.cs index 544fe1c7..c229db86 100644 --- a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Chat.cs +++ b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Chat.cs @@ -201,16 +201,33 @@ private void EnqueueDiscordWebhook(string url, string serverName, string text, b }); } + // Global outgoing-send throttle: a minimum gap between ANY two team-chat sends so bursts of + // alerts/commands never hammer the Rust+ API (which burns its rate-limit tokens and can get the + // player kicked). An isolated message still goes out immediately; only back-to-back sends wait. + private readonly SemaphoreSlim _globalSendGate = new(1, 1); + private DateTime _lastGlobalSendUtc = DateTime.MinValue; + private const int GlobalSendMinGapMs = 1500; + private async Task SendTeamChatReliableAsync(string text) { if (_rust is not RustPlusClientReal real) return false; - + if (text == null) { AppendLog("[Chat] Fail to send: text is null"); return false; } + // enforce the global minimum gap between sends + await _globalSendGate.WaitAsync(); + try + { + int waitGap = GlobalSendMinGapMs - (int)(DateTime.UtcNow - _lastGlobalSendUtc).TotalMilliseconds; + if (waitGap > 0) await Task.Delay(waitGap); + _lastGlobalSendUtc = DateTime.UtcNow; + } + finally { _globalSendGate.Release(); } + AppendLog($"[Chat] Sending: {text}"); // Füge die Nachricht zu unseren ausstehenden Bestätigungen hinzu (normalized for emoji echo) diff --git a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.ChatCommands.cs b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.ChatCommands.cs index 76cbb712..a2cc8d63 100644 --- a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.ChatCommands.cs +++ b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.ChatCommands.cs @@ -40,6 +40,11 @@ private void ChatCommandsOverlay_CommandsEnabledChanged(object sender, System.Wi private DateTime _lastChatCommandTime = DateTime.MinValue; private const int ChatCommandCooldownSeconds = 2; // 2s cooldown for system stability + // Above this age a "last seen X ago" is no longer meaningful (e.g. a state restored from a + // previous session after a long shutdown) — commands show a vague "haven't seen it" instead + // of a misleading "28h ago". + private const double EventStaleHours = 15.0; + private async Task SendChatCommandResponseAsync(string text) { // respond instantly (no pre-send delay) @@ -242,10 +247,17 @@ private async Task ProcessChatCommands(TeamChatMessage m) msg = Properties.Resources.ChatCmdDeepSeaActiveMidEvent; } } + else if (_shopTimer == null) + { + // Deep Sea is only observable via shop polling; without it we can't claim it's gone + msg = Properties.Resources.ChatCmdDeepSeaNeedsShopPolling; + } else if (_deepSeaDespawnTime.HasValue) { var ago = DateTime.UtcNow - _deepSeaDespawnTime.Value; - msg = string.Format(Properties.Resources.ChatCmdDeepSeaEndedMinutesAgo, (int)ago.TotalMinutes); + msg = ago.TotalHours > EventStaleHours + ? string.Format(Properties.Resources.ChatCmdEventNotSeenLong, "Deep Sea") + : string.Format(Properties.Resources.ChatCmdDeepSeaEndedMinutesAgo, (int)ago.TotalMinutes); } else { @@ -306,7 +318,9 @@ private async Task ProcessChatCommands(TeamChatMessage m) else if (_cargoLastDespawnUtc.HasValue) { var ago = DateTime.UtcNow - _cargoLastDespawnUtc.Value; - msg = string.Format(Properties.Resources.ChatCmdCargoDespawnedMinutesAgo, (int)ago.TotalMinutes); + msg = ago.TotalHours > EventStaleHours + ? string.Format(Properties.Resources.ChatCmdEventNotSeenLong, "Cargo") + : string.Format(Properties.Resources.ChatCmdCargoDespawnedMinutesAgo, (int)ago.TotalMinutes); } _ = SendChatCommandResponseAsync(msg); AppendLog($"[ChatCommand] Cargo executed by {m.Author}"); @@ -378,8 +392,15 @@ private async Task ProcessChatCommands(TeamChatMessage m) else if (_heliLastEventUtc.HasValue) { var ago = DateTime.UtcNow - _heliLastEventUtc.Value; - string reason = _heliLastEventWasCrash ? Properties.Resources.ChatCmdHeliReasonShotDown : Properties.Resources.ChatCmdHeliReasonLeftMap; - msg = string.Format(Properties.Resources.ChatCmdHeliNotActiveAgo, reason, FormatAgo(ago)); + if (ago.TotalHours > EventStaleHours) + { + msg = string.Format(Properties.Resources.ChatCmdEventNotSeenLong, "Patrol Heli"); + } + else + { + string reason = _heliLastEventWasCrash ? Properties.Resources.ChatCmdHeliReasonShotDown : Properties.Resources.ChatCmdHeliReasonLeftMap; + msg = string.Format(Properties.Resources.ChatCmdHeliNotActiveAgo, reason, FormatAgo(ago)); + } } else { @@ -410,7 +431,9 @@ private async Task ProcessChatCommands(TeamChatMessage m) else if (_vendorDespawnTime.HasValue) { var ago = DateTime.UtcNow - _vendorDespawnTime.Value; - msg = string.Format(Properties.Resources.ChatCmdVendorDespawnedAgo, FormatAgo(ago)); + msg = ago.TotalHours > EventStaleHours + ? string.Format(Properties.Resources.ChatCmdEventNotSeenLong, "Travelling Vendor") + : string.Format(Properties.Resources.ChatCmdVendorDespawnedAgo, FormatAgo(ago)); } else { diff --git a/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs b/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs index 928e645e..31b52787 100644 --- a/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs +++ b/RustPlusDesktop/Views/MainWindow/Team/MainWindow.Team.Core.cs @@ -53,6 +53,12 @@ private void RestoreEventStateFromSnapshot() if (!kv.Key.StartsWith(prefix, StringComparison.Ordinal)) continue; string subject = kv.Key.Substring(prefix.Length); var e = kv.Value; + // If the map wiped while we were closed, persisted states are from the previous + // wipe and must be ignored (RustMapsWipeTime is our best wipe signal). Also drop + // anything too old to still be meaningful. + var wipe = _vm?.Selected?.RustMapsWipeTime; + if (wipe.HasValue && e.TimeUtc < wipe.Value) continue; + if ((DateTime.UtcNow - e.TimeUtc).TotalHours > 15.0) continue; switch (subject) { case "Cargo Ship" when e.State == "LEFT": From faf60a659be079f56d510b850c1ce2118867bbbe Mon Sep 17 00:00:00 2001 From: Rathio12 <146424297+Rathio12@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:44:36 +0200 Subject: [PATCH 6/6] Auto-detect Deep Sea without the Shops layer, fix its direction - Background Deep Sea poll (every 60s, started on connect) tracks the event without needing the Shops layer enabled. Skips when full shop polling is already active and backs off under API pressure; also refreshes the shop cache so !shop works too. - Deep Sea direction is now reported reliably as West (it always spawns off the west edge; the real direction can't be read from the API) and is shown in the alert, e.g. "Deep Sea event is up (West)". The old edge-based guess tested Y first and could wrongly say North/South. - Removed the obsolete "enable Shops" guard from !deepsea now that it's auto-tracked. --- CHANGELOG.md | 2 +- .../Connection/MainWindow.Connection.Core.cs | 2 + .../Connection/MainWindow.Connection.Reset.cs | 1 + .../Map/MainWindow.Map.ChatCommands.cs | 5 -- .../MainWindow/Map/MainWindow.Map.Shops.cs | 58 +++++++++++++++---- 5 files changed, 52 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce54a430..0e7c66a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ This PR adds a few team-chat commands and presence announcements, and fixes a co - `timeline.log` — append-only, crash-safe history of every event (offline/online, AFK/back, death/respawn, oil-rig crate, cargo, heli, vendor, deep sea), one timestamped line each. - `events.json` — latest state per event type (keyed per server, overwritten as new events arrive) and **loaded on startup**. On reconnect it rehydrates the "X ago" timers for cargo/heli/vendor/deep sea so those `!` commands still answer correctly after a restart. - **Map-wipe & staleness guards on restore**: persisted states from before the server's last wipe (`RustMapsWipeTime`) are ignored, and anything older than ~15h is dropped. Past that age the `!cargo`/`!heli`/`!vendor`/`!deepsea` replies say "Haven't seen X in a while" instead of a misleading "28h ago". - - `!deepsea` now replies "enable Shops on the map to track it" when shop polling is off, instead of blindly reporting it as not up. + - **Deep Sea auto-detection** — a gentle background poll (every 60s, started on connect) tracks the Deep Sea event without needing the Shops layer enabled. It skips when full shop polling is already active and backs off under API pressure, and it keeps the shop cache fresh so `!shop` works too. The Deep Sea direction is now reported reliably as **West** (it always spawns off the west edge; the precise direction can't be read from the API) and is shown in the alert, e.g. "Deep Sea event is up (West)". - **Global outgoing-send throttle** — a minimum 1.5s gap between any two team-chat sends so bursts of alerts/commands never hammer the Rust+ API (isolated messages still send instantly; only back-to-back ones wait). - **Alarm spam cooldown** — if a Smart Alarm fires more than 3 times in a row (each within 2 min of the last), its team-chat **and** Discord raid alerts are muted for 10 minutes (per-alarm). Popup/audio still fire; only the broadcasts are suppressed, so a raid/decay loop can't flood chat. diff --git a/RustPlusDesktop/Views/MainWindow/Connection/MainWindow.Connection.Core.cs b/RustPlusDesktop/Views/MainWindow/Connection/MainWindow.Connection.Core.cs index 743babcb..313f42ed 100644 --- a/RustPlusDesktop/Views/MainWindow/Connection/MainWindow.Connection.Core.cs +++ b/RustPlusDesktop/Views/MainWindow/Connection/MainWindow.Connection.Core.cs @@ -289,6 +289,7 @@ private async Task PerformConnectAsync(bool silent, bool showBusy = true) // Soft connect exists: Reset UI/polling states but do NOT call HardResetAsync (which disconnects) _shopTimer?.Stop(); _shopTimer = null; + StopDeepSeaAutoPoll(); StopDynPolling(); StopTeamPolling(); TeamMembers.Clear(); @@ -507,6 +508,7 @@ await Dispatcher.InvokeAsync(() => _statusTimer.Start(); StartTeamPolling(); + StartDeepSeaAutoPoll(); // auto-detect Deep Sea without needing the Shops layer enabled if (_overlayToolsVisible) { RebuildOverlayTeamBar(); diff --git a/RustPlusDesktop/Views/MainWindow/Connection/MainWindow.Connection.Reset.cs b/RustPlusDesktop/Views/MainWindow/Connection/MainWindow.Connection.Reset.cs index c8b33a61..a06f213f 100644 --- a/RustPlusDesktop/Views/MainWindow/Connection/MainWindow.Connection.Reset.cs +++ b/RustPlusDesktop/Views/MainWindow/Connection/MainWindow.Connection.Reset.cs @@ -32,6 +32,7 @@ private async Task HardResetAsync(bool reconnect = false) // 2) Timer stoppen try { _statusTimer?.Stop(); } catch { } try { _shopTimer?.Stop(); _shopTimer = null; } catch { } + try { StopDeepSeaAutoPoll(); } catch { } try { _storageTimer?.Stop(); _storageTimer = null; } catch { } try { Dispatcher.Invoke(() => ChkShops.IsChecked = false); } catch { } diff --git a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.ChatCommands.cs b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.ChatCommands.cs index a2cc8d63..f9e586a8 100644 --- a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.ChatCommands.cs +++ b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.ChatCommands.cs @@ -247,11 +247,6 @@ private async Task ProcessChatCommands(TeamChatMessage m) msg = Properties.Resources.ChatCmdDeepSeaActiveMidEvent; } } - else if (_shopTimer == null) - { - // Deep Sea is only observable via shop polling; without it we can't claim it's gone - msg = Properties.Resources.ChatCmdDeepSeaNeedsShopPolling; - } else if (_deepSeaDespawnTime.HasValue) { var ago = DateTime.UtcNow - _deepSeaDespawnTime.Value; diff --git a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Shops.cs b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Shops.cs index 9185c645..e8fd3909 100644 --- a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Shops.cs +++ b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Shops.cs @@ -52,7 +52,7 @@ private void CheckDeepSeaEvent(IEnumerable shops) string dir = GetDeepSeaDirection(deepSeaShop.X, deepSeaShop.Y); if (_announceSpawns && TrackingService.AnnounceDeepSea) { - var msg = AlertTemplateService.GetAlertTemplate("AlertDeepSeaUp"); + var msg = $"{AlertTemplateService.GetAlertTemplate("AlertDeepSeaUp")} ({dir})"; _ = SendTeamChatSafeAsync(msg, false, true); _ = RustPlusDesk.Services.DiscordBotListenerService.Instance.SendNotificationAsync("events", $"\uD83D\uDEA2 **Event:** {msg}"); } @@ -91,15 +91,11 @@ private void CheckDeepSeaEvent(IEnumerable shops) private string GetDeepSeaDirection(double x, double y) { - double size = _worldSizeS > 0 ? _worldSizeS : 4500; - double margin = 200; - - if (y < margin) return "North"; - if (y > size - margin) return "South"; - if (x < margin) return "West"; - if (x > size - margin) return "East"; - - return TryGetGridRef(x, y, out var g) ? g : "Unknown"; + // The Deep Sea NPC shop always spawns off the WEST edge of the map (its X is negative, + // outside the playable grid). The real in-game approach direction can't be read from the + // API, and the old edge-based N/S/E/W guess was wrong because it tested Y first and could + // report "North"/"South". So we report the only thing that's actually reliable: West. + return "West"; } private static void PrefetchShopIcons(IEnumerable shops) @@ -258,6 +254,48 @@ private async Task PollShopsOnceAsync(bool force = false) } } + // ===== Background Deep Sea auto-detection ===== + // Deep Sea is only observable via the shop list, but we don't want to force the user to enable + // the (heavier) Shops layer just to track it. This gentle background poll fetches the shop list + // on a slow cadence purely to detect Deep Sea (no marker rendering, no new-shop alerts). It backs + // off entirely while full shop polling is active (that already covers Deep Sea) or under API load. + private DispatcherTimer? _deepSeaPollTimer; + + private void StartDeepSeaAutoPoll() + { + if (_deepSeaPollTimer != null) return; + _deepSeaPollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(60) }; + _deepSeaPollTimer.Tick += async (_, __) => await PollDeepSeaOnceAsync(); + _deepSeaPollTimer.Start(); + _ = PollDeepSeaOnceAsync(); // check soon after connect, don't wait a full minute + } + + private void StopDeepSeaAutoPoll() + { + _deepSeaPollTimer?.Stop(); + _deepSeaPollTimer = null; + } + + private async Task PollDeepSeaOnceAsync() + { + if (_rust is not RustPlusClientReal real) return; + if (_shopTimer != null) return; // full shop polling already tracks Deep Sea + if (Interlocked.CompareExchange(ref _shopPollLock, 1, 0) != 0) return; + try + { + if (IsApiUnderPressure) return; + var shops = await real.GetVendingShopsAsync(); + if (shops == null) return; + _lastShops = shops; // keep cache fresh so !shop works too + // CheckDeepSeaEvent before flipping _firstShopPollDone, so a Deep Sea that's already up + // on our first poll is treated as "connected mid-event", not a fresh spawn to announce. + CheckDeepSeaEvent(shops); + if (shops.Count > 0) _firstShopPollDone = true; + } + catch { } + finally { Interlocked.Exchange(ref _shopPollLock, 0); } + } + private async Task DetectNewShopsAsync(IReadOnlyList shops) { if (_initialShopSnapshotTimeUtc == DateTime.MinValue)