namespace Aeshnidae.FairPlay;
///
/// Watches items that go onto the ground and who picks them up.
///
/// Dropping is the obvious way around trade logging: hand nothing to anyone, just put
/// it down and walk away while your mule collects it. No trade event, no give event,
/// nothing for the linked-trade flag to see. A housing chest is the same move with
/// less walking - and better for the launderer, because the item sits there safely
/// until the other character logs in.
///
/// Both are the same shape, so both go through here: an item leaves your possession
/// into somewhere another character can reach, and later a different character takes
/// it out.
///
/// A drop on its own is not evidence - players discard junk constantly - so nothing is
/// announced for a drop alone unless FlagAllDrops is set. What gets reported is the
/// completed handover: dropped by one character, collected by another, inside a time
/// window. When both belong to the same household that is worth reading closely; when
/// they do not, it is still worth knowing, because that is what selling outside the
/// trade window looks like.
///
internal static class GroundWatch
{
private sealed record Dropped(string Identity, string Character, string Item, string Where, DateTime When);
/// item guid -> who put it down. Small and short-lived by construction.
private static readonly ConcurrentDictionary _onGround = new();
private static DateTime _lastPrune = DateTime.UtcNow;
public static void OnDrop(Player? player, uint itemGuid, string itemDescription)
=> OnRelease(player, itemGuid, itemDescription, "the ground");
///
/// An item leaving the player's possession into somewhere another player can reach
/// it - the ground, a housing chest, any external container.
///
public static void OnRelease(Player? player, uint itemGuid, string itemDescription, string where)
{
try
{
if (player is null || !Mod.Settings.Enabled || !Mod.Settings.FlagGroundTransfers)
return;
// No staff exemption - this is a record, not a restriction.
var identity = Mod.IdentityOf(player);
if (identity is null)
return;
_onGround[itemGuid] = new Dropped(identity, player.Name, itemDescription, where, DateTime.UtcNow);
if (Mod.Settings.FlagAllDrops)
PlayerManager.BroadcastToAuditChannel(null,
$"[FairPlay] {player.Name} placed {itemDescription} in {where}");
Prune();
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] drop watch failed: {ex.Message}", ModManager.LogLevel.Error);
}
}
public static void OnPickup(Player? player, uint itemGuid)
{
try
{
if (player is null || !Mod.Settings.Enabled || !Mod.Settings.FlagGroundTransfers)
return;
// Only interesting if this exact item was recently put down. Everything
// else moving through inventory is ordinary and never reaches here.
if (!_onGround.TryRemove(itemGuid, out var drop))
return;
var age = DateTime.UtcNow - drop.When;
if (age.TotalSeconds > Math.Max(1, Mod.Settings.GroundTransferWindowSeconds))
return;
// Picking your own item back up is not a handover.
if (string.Equals(player.Name, drop.Character, StringComparison.OrdinalIgnoreCase))
return;
var identity = Mod.IdentityOf(player);
var sameHousehold = identity is not null && identity == drop.Identity;
Interlocked.Increment(ref Mod.GroundTransfers);
PlayerManager.BroadcastToAuditChannel(null,
$"[FairPlay] ground transfer{(sameHousehold ? " (SAME HOUSEHOLD)" : "")}: " +
$"{drop.Character} left {drop.Item} in {drop.Where}, {player.Name} collected it " +
$"{age.TotalSeconds:0}s later");
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] pickup watch failed: {ex.Message}", ModManager.LogLevel.Error);
}
}
/// Drops nobody collected 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.GroundTransferWindowSeconds));
foreach (var kv in _onGround)
if (kv.Value.When < cutoff)
_onGround.TryRemove(kv.Key, out _);
}
public static void Clear() => _onGround.Clear();
public static int Watching => _onGround.Count;
}