namespace Aeshnidae.FairPlay;
///
/// The four rules. Every patch is a postfix that either observes or flips a boolean -
/// none of them replace ACE's logic, so when this mod is disabled the server behaves
/// exactly as stock.
///
/// Enforcement is announced through PlayerManager.BroadcastToAuditChannel
/// rather than written to a private log. Aeshnidae.AdminAudit already hooks that
/// channel, so every bounce and refusal lands in the audit JSONL and the Discord
/// audit feed for free - and because PlayerManager is a host type, this works without
/// the two mods referencing each other across assembly load contexts.
///
[HarmonyPatch]
public static class Patches
{
// ------------------------------------------------------------ character cap
///
/// Staff are never "at max characters".
///
/// The numeric cap itself stays ACE's max_chars_per_account property -
/// Mod.Initialize sets it - so there is one source of truth for the number. All
/// this does is exempt privileged accounts, which a global property cannot express.
///
[HarmonyPostfix]
[HarmonyPatch(typeof(PlayerManager), nameof(PlayerManager.IsAccountAtMaxCharacterSlots))]
public static void PostIsAccountAtMaxCharacterSlots(string accountName, ref bool __result)
{
try
{
if (!Mod.Settings.Enabled || !__result || string.IsNullOrEmpty(accountName))
return;
var account = DatabaseManager.Authentication.GetAccountByName(accountName);
if (account is null)
return;
if ((AccessLevel)account.AccessLevel >= Mod.Settings.ExemptLevel)
{
__result = false;
ModManager.Log($"[{Mod.Name}] character cap waived for {accountName} " +
$"({(AccessLevel)account.AccessLevel})");
}
}
catch (Exception ex)
{
// Never block character creation because this mod had a bad day.
ModManager.Log($"[{Mod.Name}] character-cap check failed for {accountName}: {ex.Message}",
ModManager.LogLevel.Error);
}
}
// --------------------------------------------------------- account creation
///
/// Caps how many accounts one address may create.
///
/// Auto-creation is on, so without this anyone who can reach the login port can
/// mint accounts endlessly - a spam vector, and a way to bury the household graph
/// in noise. ACE records CreateIP on every account, so the count is simply a query.
///
/// A prefix returning false skips creation and yields null, which ACE's login path
/// turns into a failed login. The player sees a generic failure rather than a
/// tailored message; the real reason goes to the audit channel so staff can see it.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(AuthenticationDatabase), nameof(AuthenticationDatabase.CreateAccount))]
public static bool PreCreateAccount(string name, IPAddress address, ref Account __result)
{
try
{
if (!Mod.Settings.Enabled || Mod.Settings.MaxAccountsPerAddress <= 0 || address is null)
return true;
var ip = address.ToString();
if (Mod.Settings.IsAddressExempt(ip))
return true;
var existing = Mod.CountAccountsCreatedFrom(address);
if (existing < Mod.Settings.MaxAccountsPerAddress)
return true;
Interlocked.Increment(ref Mod.AccountsRefused);
PlayerManager.BroadcastToAuditChannel(null,
$"[FairPlay] refused new account \"{name}\" from {ip} - " +
$"{existing} accounts already created there, limit {Mod.Settings.MaxAccountsPerAddress}");
__result = null!;
return false; // skip ACE's creation
}
catch (Exception ex)
{
// Never stop legitimate account creation because this check failed.
ModManager.Log($"[{Mod.Name}] account-cap check failed: {ex.Message}", ModManager.LogLevel.Error);
return true;
}
}
// -------------------------------------------------------------- marketplace
///
/// Fires after every teleport lands - portal, recall, summon, admin teleport - so
/// it catches every way out of the Marketplace without needing a patch per route.
///
[HarmonyPostfix]
[HarmonyPatch(typeof(Player), nameof(Player.OnTeleportComplete))]
public static void PostOnTeleportComplete(Player __instance) => MarketplaceRule.Enforce(__instance, "teleport");
/// Catches logging in outside the Marketplace while another character is already out.
[HarmonyPostfix]
[HarmonyPatch(typeof(Player), nameof(Player.PlayerEnterWorld))]
public static void PostPlayerEnterWorld(Player __instance)
{
try
{
Mod.RecordLogin(__instance);
// Give the client a moment to finish entering before acting on it.
var chain = new ActionChain();
chain.AddDelaySeconds(3.0);
chain.AddAction(__instance, () =>
{
// Concurrency first: no point bouncing someone to the Marketplace if
// they are about to be logged out for being the third one on.
if (!ConcurrencyRule.Enforce(__instance))
MarketplaceRule.Enforce(__instance, "login");
});
chain.EnqueueChain();
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] login handling failed for {__instance?.Name}: {ex.Message}",
ModManager.LogLevel.Error);
}
}
// ------------------------------------------------------------------- ground
///
/// Records what goes on the ground. A prefix, because once the drop completes the
/// item has left the inventory and the guid no longer resolves for a description.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(Player), nameof(Player.HandleActionDropItem))]
public static void PreDropItemWatch(Player __instance, uint itemGuid)
{
try
{
if (!Mod.Settings.Enabled || !Mod.Settings.FlagGroundTransfers)
return;
var item = __instance.FindObject(itemGuid,
Player.SearchLocations.MyInventory | Player.SearchLocations.MyEquippedItems);
var what = item is null
? $"0x{itemGuid:X8}"
: $"{item.Name}{(item.StackSize > 1 ? $" x{item.StackSize}" : "")} (wcid {item.WeenieClassId})";
GroundWatch.OnDrop(__instance, itemGuid, what);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] drop capture failed: {ex.Message}", ModManager.LogLevel.Error);
}
}
///
/// Both halves of a custody transfer, from the one place that knows where an item
/// came from and where it went.
///
/// This method is private in ACE, but it is the only seam carrying both
/// itemRootOwner and containerRootOwner - which is exactly what
/// separates "moved something between my own packs" from "put something in a
/// housing chest". The public entry point does not have that, and without it a
/// chest deposit is indistinguishable from tidying your backpack.
///
/// A housing chest matters more than the ground, because the item sits there safely
/// until the other character logs in - no timing needed, no risk of a passer-by
/// taking it.
///
[HarmonyPostfix]
[HarmonyPatch(typeof(Player), "DoHandleActionPutItemInContainer")]
public static void PostDoPutItemInContainer(Player __instance, WorldObject item,
Container itemRootOwner, Container containerRootOwner,
bool __result)
{
try
{
if (!__result || item is null || !Mod.Settings.Enabled || !Mod.Settings.FlagGroundTransfers)
return;
var intoMine = ReferenceEquals(containerRootOwner, __instance);
var fromMine = ReferenceEquals(itemRootOwner, __instance);
// Shuffling between your own packs is not a custody change.
if (intoMine && fromMine)
return;
var what = $"{item.Name}{(item.StackSize > 1 ? $" x{item.StackSize}" : "")} (wcid {item.WeenieClassId})";
if (!intoMine)
{
// Leaving my possession into something another character can open.
var where = containerRootOwner is null
? (item.CurrentLandblock is not null ? "the ground" : "a container")
: $"{containerRootOwner.Name}";
GroundWatch.OnRelease(__instance, item.Guid.Full, what, where);
}
else if (!fromMine)
{
// Coming into my possession from outside it - ground, chest, corpse.
GroundWatch.OnPickup(__instance, item.Guid.Full);
}
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] container-move watch failed: {ex.Message}", ModManager.LogLevel.Error);
}
}
// ------------------------------------------------------------------- vendor
///
/// What the vendor kept. ProcessItemsForPurchase decides per item whether to resell
/// it or destroy it, so the ones still in UniqueItemsForSale afterwards are the ones
/// another character can buy.
///
[HarmonyPostfix]
[HarmonyPatch(typeof(Vendor), nameof(Vendor.ProcessItemsForPurchase))]
public static void PostProcessItemsForPurchase(Vendor __instance, Player player, Dictionary items)
{
try
{
if (!Mod.Settings.Enabled || !Mod.Settings.FlagVendorHandovers || items is null)
return;
foreach (var item in items.Values)
{
if (item is not null && __instance.UniqueItemsForSale.ContainsKey(item.Guid))
VendorWatch.OnSell(player, item, __instance.Name);
}
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] vendor sell capture failed: {ex.Message}", ModManager.LogLevel.Error);
}
}
///
/// Unique items reaching a buyer. A prefix, taking the guids before the purchase
/// completes - FinalizeBuyTransaction removes each one from the vendor as it lands.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(Player), nameof(Player.FinalizeBuyTransaction))]
public static void PreFinalizeBuyTransaction(Player __instance, List uniqueItems)
{
try
{
if (!Mod.Settings.Enabled || !Mod.Settings.FlagVendorHandovers || uniqueItems is null)
return;
foreach (var item in uniqueItems)
{
if (item is not null)
VendorWatch.OnBuy(__instance, item.Guid.Full);
}
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] vendor buy capture failed: {ex.Message}", ModManager.LogLevel.Error);
}
}
// ------------------------------------------------------------------ trading
///
/// Flags - does not block - an item handed from one character to another in the
/// same household.
///
/// Moving your own gear to your own mule is ordinary play, so refusing it would be
/// obnoxious. What is worth seeing is the pattern: AdminAudit already records every
/// give, and tagging the linked ones turns that into "this mule received fourteen
/// items from its own main tonight", which is the evidence you would actually act on.
///
/// A prefix, because by the time the handover completes the item may have moved and
/// the guid no longer resolves.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(Player), nameof(Player.HandleActionGiveObjectRequest))]
public static void PreGiveObjectRequest(Player __instance, uint targetGuid, uint itemGuid, int amount)
{
try
{
if (!Mod.Settings.Enabled || !Mod.Settings.FlagLinkedTrades)
return;
var target = PlayerManager.GetOnlinePlayer(new ObjectGuid(targetGuid));
if (target is null || target.Guid.Full == __instance.Guid.Full)
return; // giving to an NPC or to yourself is not interesting
// Audits cover staff. They are precisely who a trail exists to cover.
var giver = Mod.IdentityOf(__instance);
var receiver = Mod.IdentityOf(target);
if (giver is null || receiver is null || giver != receiver)
return;
var item = __instance.FindObject(itemGuid,
Player.SearchLocations.MyInventory | Player.SearchLocations.MyEquippedItems);
var what = item is null
? $"0x{itemGuid:X8}"
: $"{item.Name}{(amount > 1 ? $" x{amount}" : "")} (wcid {item.WeenieClassId})";
Interlocked.Increment(ref Mod.LinkedTrades);
PlayerManager.BroadcastToAuditChannel(null,
$"[FairPlay] linked trade: {__instance.Name} -> {target.Name}, {what} ({giver})");
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] linked-trade flag failed: {ex.Message}", ModManager.LogLevel.Error);
}
}
// --------------------------------------------------------------- allegiance
///
/// Refuses a pledge between two characters on the same address.
///
/// IsPledgable is where ACE already collects every reason a pledge cannot
/// happen and messages the player, so adding one more reason here behaves exactly
/// like the built-in ones. __instance is the would-be vassal.
///
[HarmonyPostfix]
[HarmonyPatch(typeof(Player), nameof(Player.IsPledgable))]
public static void PostIsPledgable(Player __instance, Player target, ref bool __result)
{
try
{
if (!Mod.Settings.Enabled || !Mod.Settings.BlockSameIpAllegiance || !__result)
return;
// No staff exemption: a pledge between two characters on one household is
// the same thing whoever is holding the accounts.
// Household rather than live address, so moving one client onto a VPN no
// longer unlinks the pair.
var vassal = Mod.IdentityOf(__instance);
var patron = Mod.IdentityOf(target);
if (vassal is null || patron is null || vassal != patron)
return;
__result = false;
Interlocked.Increment(ref Mod.PledgesBlocked);
__instance.SendMessage(Mod.Settings.AllegianceBlockedMessage, ChatMessageType.Broadcast);
PlayerManager.BroadcastToAuditChannel(null,
$"[FairPlay] refused allegiance: {__instance.Name} -> {target.Name}, same identity ({vassal})");
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] allegiance check failed: {ex.Message}", ModManager.LogLevel.Error);
}
}
}
///
/// "At most N characters may be logged in at once from one address."
///
/// This is the limit that actually matters on this server: players may create as many
/// characters and hold as many accounts as they like, but simultaneous presence is
/// rationed. Keyed on address for the same reason as the Marketplace rule - a second
/// account on the same machine is the same person.
///
/// The newest arrival is the one logged out, because they are the one that broke the
/// rule, and because logging out someone mid-fight to make room for a fresh login
/// would be worse.
///
internal static class ConcurrencyRule
{
/// Returns true if the player was logged out for exceeding the limit.
public static bool Enforce(Player? player)
{
try
{
if (player?.Session is null || !Mod.Settings.Enabled || Mod.Settings.MaxConcurrentPerAddress <= 0)
return false;
if (Mod.Settings.IsLimitExempt(player))
return false;
var address = Mod.AddressOf(player);
if (string.IsNullOrEmpty(address) || Mod.Settings.IsAddressExempt(address))
return false;
var identity = Mod.IdentityOf(player);
if (string.IsNullOrEmpty(identity))
return false;
var online = PlayerManager.GetAllOnline()
.Where(p => p is not null
&& !Mod.Settings.IsLimitExempt(p)
&& string.Equals(Mod.IdentityOf(p), identity, StringComparison.Ordinal))
.ToList();
if (online.Count <= Mod.Settings.MaxConcurrentPerAddress)
return false;
var message = string.Format(Mod.Settings.TooManyOnlineMessage, Mod.Settings.MaxConcurrentPerAddress);
player.SendMessage(message, ChatMessageType.Broadcast);
Interlocked.Increment(ref Mod.LogoutsForced);
PlayerManager.BroadcastToAuditChannel(null,
$"[FairPlay] logged out {player.Name} - {online.Count} characters online for {identity}, " +
$"limit {Mod.Settings.MaxConcurrentPerAddress}");
// A short delay so the message actually reaches the client before the
// session ends. LogOffPlayer rather than Terminate: it saves the character.
var chain = new ActionChain();
chain.AddDelaySeconds(2.0);
chain.AddAction(player, () =>
{
try { player.Session?.LogOffPlayer(); }
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not log off {player.Name}: {ex.Message}",
ModManager.LogLevel.Error);
}
});
chain.EnqueueChain();
return true;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] concurrency check failed for {player?.Name}: {ex.Message}",
ModManager.LogLevel.Error);
return false;
}
}
}
///
/// "At most one character per IP address may be outside the Marketplace."
///
/// Keyed on address rather than account, because the thing being limited is a person,
/// and a second account on the same machine is the same person.
///
/// Deliberately stateless: nothing marks a character as the mule. The rule is worked
/// out live from who is online and where they are, which means swapping happens by
/// itself - bring both into the Marketplace, walk either one out, and that one is now
/// the character that is out. Whoever is already outside keeps their place; the one
/// who tries to join them is the one sent back.
///
/// The cost of an address-based rule: people who genuinely share a connection - a
/// household, a shared flat - look like one person. ExemptAddresses is the escape
/// hatch for those.
///
internal static class MarketplaceRule
{
public static void Enforce(Player? player, string via)
{
try
{
if (player?.Session is null || !Mod.Settings.Enabled || !Mod.Settings.OneCharacterOutsideMarketplace)
return;
if (Mod.Settings.MarketplaceExemptsStaff && Mod.Settings.IsLimitExempt(player))
return;
var mp = Mod.Settings.Marketplace;
// Already in the Marketplace: nothing to do. This is also what stops the
// bounce from recursing, since the bounce lands them here.
if (LandblockOf(player) == mp.LandblockId)
return;
var address = Mod.AddressOf(player);
if (string.IsNullOrEmpty(address) || Mod.Settings.IsAddressExempt(address))
return;
var identity = Mod.IdentityOf(player);
if (string.IsNullOrEmpty(identity))
return;
// Is another character from the same household already outside? Keyed on
// household rather than account or live address: the point is one *person*
// out at a time, and neither a second account nor a VPN changes the person.
var otherOutside = PlayerManager.GetAllOnline()
.FirstOrDefault(p => p is not null
&& p.Guid.Full != player.Guid.Full
&& string.Equals(Mod.IdentityOf(p), identity, StringComparison.Ordinal)
&& LandblockOf(p) != mp.LandblockId);
if (otherOutside is null)
return; // this one is the single character out - allowed
Bounce(player, via, $"{otherOutside.Name} is already out for {identity}");
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] marketplace rule failed for {player?.Name}: {ex.Message}",
ModManager.LogLevel.Error);
}
}
///
/// Checks everyone online, not just whoever just moved.
///
/// The event hooks only fire on travel, so they cannot see a character who was
/// already standing outside when the mod loaded, or who left by a route with no
/// hook. This makes the rule continuously true rather than true-at-the-moment-of-
/// travel, which is the difference between a rule and a tripwire.
///
public static void Sweep()
{
try
{
if (!Mod.Settings.Enabled || !Mod.Settings.OneCharacterOutsideMarketplace)
return;
var mp = Mod.Settings.Marketplace;
var keepNewest = !string.Equals(Mod.Settings.KeepOutside, "OldestLogin", StringComparison.OrdinalIgnoreCase);
var offenders = PlayerManager.GetAllOnline()
.Where(p => p is not null
&& !(Mod.Settings.MarketplaceExemptsStaff && Mod.Settings.IsLimitExempt(p))
&& !string.IsNullOrEmpty(Mod.IdentityOf(p))
&& !Mod.Settings.IsAddressExempt(Mod.AddressOf(p))
&& LandblockOf(p) != mp.LandblockId)
.GroupBy(p => Mod.IdentityOf(p)!, StringComparer.Ordinal)
.Where(g => g.Count() > 1);
foreach (var group in offenders)
{
// LoginTimestamp is when this session began, so the smallest value is
// the character that has been logged in longest.
var ordered = group.OrderBy(p => p.LoginTimestamp ?? 0d).ToList();
// Keep exactly one outside; everyone else goes back.
var keeper = keepNewest ? ordered[^1] : ordered[0];
foreach (var p in ordered.Where(p => p.Guid.Full != keeper.Guid.Full))
Bounce(p, "sweep", $"{keeper.Name} keeps the slot for {group.Key}");
}
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] marketplace sweep failed: {ex.Message}", ModManager.LogLevel.Error);
}
}
private static void Bounce(Player player, string via, string because)
{
var mp = Mod.Settings.Marketplace;
var destination = new Position(mp.CellId, mp.X, mp.Y, mp.Z,
mp.RotationX, mp.RotationY, mp.RotationZ, mp.RotationW);
// Always go through an ActionChain. The teleport hook runs on the world thread
// already, but Sweep() runs on a timer thread, and moving a player between
// landblocks off-thread is a good way to corrupt landblock state. Enqueueing
// costs nothing on the path that was already safe.
var chain = new ActionChain();
chain.AddAction(player, () =>
{
try
{
player.SendMessage(mp.BounceMessage, ChatMessageType.Broadcast);
player.Teleport(destination);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] bounce of {player.Name} failed: {ex.Message}",
ModManager.LogLevel.Error);
}
});
chain.EnqueueChain();
Interlocked.Increment(ref Mod.Bounces);
PlayerManager.BroadcastToAuditChannel(null,
$"[FairPlay] returned {player.Name} to the Marketplace ({via}); {because}");
}
/// Landblock id (high 16 bits of the cell), or 0 when the player has no position yet.
private static ushort LandblockOf(Player? player)
{
try { return (ushort)((player?.Location?.Cell ?? 0u) >> 16); }
catch { return 0; }
}
}