namespace Aeshnidae.FairPlay; /// /// Watches items sold to a vendor and who buys them back. /// /// The third way to move an item without a trade, after the ground and a housing /// chest: sell it to any vendor, walk the other character over, buy it. ACE keeps a /// player-sold item on the vendor as a unique item until somebody buys it or it rots, /// so it is a dead drop with a shopkeeper standing guard - no trade event, no give, no /// drop, and it even looks like ordinary commerce in the vendor's own logs. /// /// Same shape as GroundWatch, for the same reason: a sale on its own is nothing - /// people sell loot all day - so what is reported is the completed handover, sold by /// one character and bought by a different one inside a window. SAME HOUSEHOLD marks /// the ones worth reading closely. /// internal static class VendorWatch { private sealed record Sold(string Identity, string Character, string Item, string Vendor, DateTime When); /// item guid -> who sold it. Small and short-lived by construction. private static readonly ConcurrentDictionary _onVendor = new(); private static DateTime _lastPrune = DateTime.UtcNow; /// An item that the vendor kept for resale, and who it came from. public static void OnSell(Player? player, WorldObject? item, string vendorName) { try { if (player is null || item is null || !Mod.Settings.Enabled || !Mod.Settings.FlagVendorHandovers) return; // No staff exemption - this is a record, not a restriction. var identity = Mod.IdentityOf(player); if (identity is null) return; var what = $"{item.Name} (wcid {item.WeenieClassId})"; _onVendor[item.Guid.Full] = new Sold(identity, player.Name, what, vendorName, DateTime.UtcNow); Prune(); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] vendor sell watch failed: {ex.Message}", ModManager.LogLevel.Error); } } /// A unique item leaving the vendor for a buyer. public static void OnBuy(Player? player, uint itemGuid) { try { if (player is null || !Mod.Settings.Enabled || !Mod.Settings.FlagVendorHandovers) return; // Only interesting if a player sold this exact item recently. Stock items // never reach here. if (!_onVendor.TryRemove(itemGuid, out var sale)) return; var age = DateTime.UtcNow - sale.When; if (age.TotalSeconds > Math.Max(1, Mod.Settings.VendorHandoverWindowSeconds)) return; // Buying your own item back is a change of mind, not a handover. if (string.Equals(player.Name, sale.Character, StringComparison.OrdinalIgnoreCase)) return; var identity = Mod.IdentityOf(player); var sameHousehold = identity is not null && identity == sale.Identity; Interlocked.Increment(ref Mod.VendorHandovers); PlayerManager.BroadcastToAuditChannel(null, $"[FairPlay] vendor handover{(sameHousehold ? " (SAME HOUSEHOLD)" : "")}: " + $"{sale.Character} sold {sale.Item} to {sale.Vendor}, {player.Name} bought it " + $"{age.TotalSeconds:0}s later"); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] vendor buy watch failed: {ex.Message}", ModManager.LogLevel.Error); } } /// Sales nobody bought inside the window stop being interesting. private static void Prune() { if ((DateTime.UtcNow - _lastPrune).TotalSeconds < 60) return; _lastPrune = DateTime.UtcNow; var cutoff = DateTime.UtcNow.AddSeconds(-Math.Max(1, Mod.Settings.VendorHandoverWindowSeconds)); foreach (var kv in _onVendor) if (kv.Value.When < cutoff) _onVendor.TryRemove(kv.Key, out _); } public static void Clear() => _onVendor.Clear(); public static int Watching => _onVendor.Count; }