namespace Aeshnidae.FairPlay;
///
/// Entry point ACE looks for: assembly "Aeshnidae.FairPlay.dll" -> type
/// "Aeshnidae.FairPlay.Mod".
///
public class Mod : IHarmonyMod
{
public static readonly string Name = typeof(Mod).Assembly.GetName().Name!;
public static readonly string HarmonyId = $"mod.{Name.ToLowerInvariant()}";
public static readonly string ModPath = Path.Combine(ModManager.ModPath, Name);
public static ModContainer? Container => ModManager.GetModContainerByPath(ModPath);
public static Settings Settings { get; private set; } = new();
internal static IpRegistry? Logins { get; private set; }
internal static VpnList? Vpn { get; private set; }
/// Counters for /fairplay, so the rules are visible rather than mysterious.
public static long Bounces, PledgesBlocked, LoginsRecorded, LogoutsForced, LinkedTrades, GroundTransfers, VendorHandovers, VpnLogins, AccountsRefused;
private static Harmony? _harmony;
private static System.Timers.Timer? _sweep;
private bool _disposed;
public void Initialize()
{
try
{
Settings = Settings.Load(ModPath);
// Load the VPN lists first: IpRegistry consults them while building the
// household graph, so an empty list there would link on VPN addresses.
if (Settings.UseVpnLists)
{
Vpn = VpnList.Load(Path.Combine(ModPath, "vpnlists"));
ModManager.Log($"[{Name}] VPN lists: {Vpn.Ranges:N0} ranges from {Vpn.Source}");
}
if (Settings.RecordLoginAddresses)
Logins = new IpRegistry(ModPath, Settings);
_harmony = new Harmony(HarmonyId);
_harmony.PatchAllUncategorized(typeof(Mod).Assembly);
// One source of truth for the number: ACE's own property. The mod owns the
// exemption, not the count, so the two can never disagree.
if (Settings.MaxCharsPerAccount > 0)
{
var current = PropertyManager.GetLong("max_chars_per_account").Item;
if (current != Settings.MaxCharsPerAccount)
{
PropertyManager.ModifyLong("max_chars_per_account", Settings.MaxCharsPerAccount);
ModManager.Log($"[{Name}] max_chars_per_account {current} -> {Settings.MaxCharsPerAccount}");
}
}
StartSweep();
ModManager.Log($"[{Name}] active - max {Settings.MaxConcurrentPerAddress} online per address " +
$"(exempt {Settings.ExemptLevel}+), " +
$"marketplace rule {(Settings.OneCharacterOutsideMarketplace ? "on" : "off")}, " +
$"same-IP allegiance {(Settings.BlockSameIpAllegiance ? "blocked" : "allowed")}, " +
$"login history {Logins?.KnownAccounts ?? 0} account(s) / {Logins?.KnownAddresses ?? 0} address(es)");
}
catch (Exception ex)
{
ModManager.Log($"[{Name}] failed to initialize: {ex}", ModManager.LogLevel.Error);
}
}
///
/// Runs the Marketplace sweep on a timer. The patches only fire when someone
/// travels, which cannot catch a character already standing outside when the mod
/// loaded - the first version of this mod missed exactly that case.
///
private static void StartSweep()
{
_sweep?.Stop();
_sweep?.Dispose();
_sweep = null;
if (Settings.SweepSeconds <= 0)
return;
_sweep = new System.Timers.Timer(Math.Max(5.0, Settings.SweepSeconds) * 1000) { AutoReset = true };
_sweep.Elapsed += (_, _) => MarketplaceRule.Sweep();
_sweep.Start();
}
/// The address a player is connected from, or null if it cannot be determined.
internal static string? AddressOf(Player? player)
{
try { return player?.Session?.EndPointC2S?.Address?.ToString(); }
catch { return null; }
}
///
/// The key the fair-play rules group on: the household an account belongs to when
/// linking is on and we know the account, otherwise the live address.
///
/// This is what makes the rules survive a VPN - the household is derived from
/// history, not from where the player happens to be connecting from tonight.
///
internal static string? IdentityOf(Player? player)
{
try
{
var account = player?.Account?.AccountName;
if (!string.IsNullOrEmpty(account) && Settings.LinkAccountsBySharedAddress && Logins is not null)
{
var household = Logins.HouseholdOf(account);
if (!string.IsNullOrEmpty(household))
return "household:" + household;
}
var address = AddressOf(player);
return string.IsNullOrEmpty(address) ? null : "ip:" + address;
}
catch { return null; }
}
///
/// How many accounts already exist that were created from this address.
/// ACE stores CreateIP as the raw address bytes, so the comparison is on bytes.
///
internal static int CountAccountsCreatedFrom(IPAddress address)
{
try
{
var wanted = address.GetAddressBytes();
using var ctx = new ACE.Database.Models.Auth.AuthDbContext();
return ctx.Account
.AsEnumerable()
.Count(a => a.CreateIP is not null && a.CreateIP.SequenceEqual(wanted));
}
catch (Exception ex)
{
ModManager.Log($"[{Name}] could not count accounts for {address}: {ex.Message}",
ModManager.LogLevel.Warn);
return 0; // fail open: never block creation because the count failed
}
}
/// Is this address in a known VPN or datacentre range?
internal static bool IsVpnAddress(string? address)
{
try { return Settings.UseVpnLists && (Vpn?.Contains(address) ?? false); }
catch { return false; }
}
/// Records a login and announces any correlation worth a look.
internal static void RecordLogin(Player? player)
{
try
{
if (Logins is null || player?.Session is null)
return;
var account = player.Account?.AccountName;
var address = AddressOf(player);
if (string.IsNullOrEmpty(account) || string.IsNullOrEmpty(address))
return;
Interlocked.Increment(ref LoginsRecorded);
if (Settings.FlagVpnLogins && IsVpnAddress(address))
{
Interlocked.Increment(ref VpnLogins);
PlayerManager.BroadcastToAuditChannel(null,
$"[FairPlay] {account} ({player.Name}) logged in from {address}, a known VPN/datacentre range");
}
foreach (var note in Logins.Record(account, player.Name, address))
PlayerManager.BroadcastToAuditChannel(null, $"[FairPlay] {note}");
}
catch (Exception ex)
{
ModManager.Log($"[{Name}] could not record login for {player?.Name}: {ex.Message}",
ModManager.LogLevel.Error);
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed)
return;
if (disposing)
{
_sweep?.Stop();
_sweep?.Dispose();
_sweep = null;
_harmony?.UnpatchAll(HarmonyId);
_harmony = null;
Logins?.Dispose();
Logins = null;
Vpn = null;
ModManager.Log($"[{Name}] shut down");
}
_disposed = true;
}
}