namespace Aeshnidae.FairPlay;
///
/// Where the mule is sent back to. Defaults to the destination of ACE's own
/// "portalmarketplace" weenie (wcid 23032), read out of the world database, so the
/// landing spot is exactly where the normal Marketplace portal drops you.
///
public class MarketplaceSettings
{
/// Landblock that counts as "inside the Marketplace". 0x016C.
public string Landblock { get; set; } = "016C";
/// Cell to land in when bounced back.
public string Cell { get; set; } = "016C01BC";
public float X { get; set; } = 49.206f;
public float Y { get; set; } = -31.935f;
public float Z { get; set; } = 0.005f;
public float RotationX { get; set; } = 0f;
public float RotationY { get; set; } = 0f;
public float RotationZ { get; set; } = -0.707f;
public float RotationW { get; set; } = 0.7071f;
public string BounceMessage { get; set; } =
"Only one of your characters may be outside the Marketplace at a time.";
public ushort LandblockId =>
ushort.TryParse(Landblock, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var v) ? v : (ushort)0x016C;
public uint CellId =>
uint.TryParse(Cell, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var v) ? v : 0x016C01BCu;
}
public class Settings
{
public const string FileName = "Settings.json";
public bool Enabled { get; set; } = true;
// ---------------------------------------------------------------- characters
///
/// How many characters may be *logged in at once* from one address.
///
/// This is the real limit: players may create as many characters and hold as many
/// accounts as they like. What is rationed is simultaneous presence. The third
/// character to enter the world from an address is logged straight back out.
///
public int MaxConcurrentPerAddress { get; set; } = 2;
public string TooManyOnlineMessage { get; set; } =
"Only {0} of your characters may be logged in at once.";
///
/// ACE's own creation cap, applied at startup. 11 is ACE's default and means "no
/// meaningful limit" - characters are not what we are rationing. Set it lower only
/// if you actually want to stop people *making* characters.
///
public int MaxCharsPerAccount { get; set; } = 11;
///
/// Accounts at or above this access level are exempt from the *limits* - the
/// number of characters they may have online at once.
///
/// They are NOT exempt from anything that records or flags. Staff are exactly who
/// an audit trail exists to cover, and an audit with a hole shaped like the people
/// holding the most power is not much of an audit.
///
public string ExemptAtOrAbove { get; set; } = nameof(AccessLevel.Developer);
///
/// Whether staff also skip the Marketplace rule.
///
/// False means an admin running two clients gets one of them sent to the
/// Marketplace like anybody else. Set true if that gets in the way of running the
/// server - it is a limit rather than an audit, so exempting staff here costs no
/// visibility.
///
public bool MarketplaceExemptsStaff { get; set; } = false;
///
/// Accounts that may be created from one address. Auto-creation is on, so without
/// a cap anyone reaching the login port can mint accounts without limit - which is
/// both a spam vector and a way to pollute the household graph with noise.
/// 0 disables the cap.
///
public int MaxAccountsPerAddress { get; set; } = 3;
// -------------------------------------------------------------- marketplace
///
/// At most one character per IP address may be outside the Marketplace. Whoever is
/// already out keeps their place; the one who tries to join them is sent back.
/// Nothing is stored - the rule is evaluated live from who is online and where
/// they are, so swapping is automatic.
///
/// Keyed on address, not account: the limit is meant to be one person at a time,
/// and a second account on the same machine is the same person.
///
public bool OneCharacterOutsideMarketplace { get; set; } = true;
///
/// Addresses the Marketplace rule ignores.
///
/// An address-based rule cannot tell two people sharing a house from one person
/// running two clients. This is the escape hatch for the former - siblings, a
/// shared flat, a LAN party. Add their address and both play normally.
///
public string[] ExemptAddresses { get; set; } = Array.Empty();
public MarketplaceSettings Marketplace { get; set; } = new();
///
/// How often to sweep everyone online, in seconds. 0 disables the sweep.
///
/// The event hooks (teleport, login) only fire when someone moves, so a character
/// already standing outside the Marketplace when this mod loads - or who got out
/// by a route with no hook - would never be noticed. The sweep is what makes the
/// rule true continuously rather than only at the moment of travel.
///
public double SweepSeconds { get; set; } = 15.0;
///
/// When the sweep finds two of an account's characters already outside, which one
/// keeps its place. "NewestLogin" sends the character that has been logged in
/// longest back to the Marketplace; "OldestLogin" does the reverse.
///
/// This only decides the already-broken case. When someone actively travels out,
/// the traveller is the one bounced, because that is the character that just tried
/// to break the rule.
///
public string KeepOutside { get; set; } = "NewestLogin";
// --------------------------------------------------------------- allegiance
///
/// Refuse an allegiance pledge when patron and vassal are connected from the
/// same IP address - the usual shape of someone swearing their own alt to
/// themselves to farm passup XP.
///
public bool BlockSameIpAllegiance { get; set; } = true;
public string AllegianceBlockedMessage { get; set; } =
"You cannot swear allegiance to a character connected from your own address.";
// ---------------------------------------------------------------------- IPs
///
/// Treat accounts that have ever shared an address as one household, and enforce
/// against the household rather than the live address.
///
/// Without this, every rule here is defeated by a VPN: a second client on a
/// different address simply is not seen as the same person. History does not
/// evaporate, so once two accounts are observed together they stay linked.
///
/// Links are built only from addresses NOT in ExemptAddresses - otherwise a
/// genuinely shared connection would weld strangers together permanently.
///
public bool LinkAccountsBySharedAddress { get; set; } = true;
///
/// Accounts that never join a household, whatever they share an address with.
/// The escape hatch for someone caught by an internet cafe, student halls, or a
/// one-off login from a friend's house.
///
public string[] NeverLink { get; set; } = Array.Empty();
///
/// Announce when one character hands an item to another in the same household.
/// A flag, not a block - moving your own gear to your own mule is normal; doing it
/// fourteen times in an evening is the pattern worth seeing.
///
public bool FlagLinkedTrades { get; set; } = true;
///
/// Flag when an item one character drops is picked up by a different character.
///
/// Dropping on the ground is how you move an item without a trade, which makes it
/// the obvious way around trade logging and the linked-trade flag. A drop on its
/// own is noise - people discard junk constantly - so what is recorded is the
/// *handover*: dropped by one person, collected by another.
///
public bool FlagGroundTransfers { get; set; } = true;
///
/// How long after a drop a pickup still counts as a handover, in seconds. Long
/// enough to cover walking a mule over; short enough that genuinely abandoned
/// loot someone stumbles on an hour later is not reported.
///
public double GroundTransferWindowSeconds { get; set; } = 600;
///
/// Flag every drop, not just the ones someone else collects. Very noisy - off by
/// default - but useful for a short window when investigating one player.
///
public bool FlagAllDrops { get; set; } = false;
///
/// Flag when an item one character sells to a vendor is bought by a different
/// character.
///
/// The third dead drop, after the ground and a housing chest: ACE keeps a
/// player-sold item on the vendor until somebody buys it or it rots, so selling it
/// and having the other character buy it back moves it with no trade, give or drop
/// to see. Ordinary selling is never reported - only the completed handover.
///
public bool FlagVendorHandovers { get; set; } = true;
///
/// How long after a sale a purchase by someone else still counts as a handover, in
/// seconds. Longer than the ground window because the item is in no danger on the
/// vendor, so there is no hurry to collect it.
///
public double VendorHandoverWindowSeconds { get; set; } = 1800;
///
/// Match logins against downloaded VPN / datacentre CIDR lists.
///
/// Not used to block. Blocking VPNs punishes the legitimate - corporate networks,
/// countries where a VPN is simply how people use the internet, the merely
/// privacy-minded - while an adversary just rents a residential proxy instead.
/// What it is good for is the two things below.
///
public bool UseVpnLists { get; set; } = true;
/// Folder of one-CIDR-per-line .txt files. Empty means <mod folder>pnlists.
public string VpnListDirectory { get; set; } = "";
/// Announce logins arriving from a listed VPN or datacentre range.
public bool FlagVpnLogins { get; set; } = true;
///
/// Do not let a VPN address link accounts together, or count as evidence of a
/// separate identity.
///
/// This is the useful half. Two strangers who happen to share an exit node are not
/// a household, and someone switching to a VPN should not thereby appear to be a
/// new person. Excluding these addresses from the graph makes the linking both
/// fairer and harder to game.
///
public bool ExcludeVpnFromLinking { get; set; } = true;
/// Record the source address of every login, for correlation.
public bool RecordLoginAddresses { get; set; } = true;
/// Announce when one address is seen using more than this many accounts.
public int FlagAccountsPerAddress { get; set; } = 3;
/// Announce when one account is seen from more than this many addresses.
public int FlagAddressesPerAccount { get; set; } = 5;
/// Days of login history to keep. 0 keeps everything.
public int HistoryDays { get; set; } = 90;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
public AccessLevel ExemptLevel =>
Enum.TryParse(ExemptAtOrAbove, true, out var lvl) ? lvl : AccessLevel.Developer;
/// Accounts pinned to a household of one.
public bool IsNeverLinked(string? account) =>
!string.IsNullOrEmpty(account) &&
(NeverLink ?? Array.Empty()).Any(a => string.Equals(a, account, StringComparison.OrdinalIgnoreCase));
/// Addresses that genuinely host more than one person.
public bool IsAddressExempt(string? address) =>
!string.IsNullOrEmpty(address) &&
(ExemptAddresses ?? Array.Empty()).Any(a => string.Equals(a, address, StringComparison.OrdinalIgnoreCase));
///
/// Staff are outside the *limits* only. Deliberately not called by anything that
/// records or flags - see ExemptAtOrAbove.
///
public bool IsLimitExempt(Player? player) =>
player?.Session is { } s && s.AccessLevel >= ExemptLevel;
public static Settings Load(string modPath)
{
var path = Path.Combine(modPath, FileName);
try
{
if (File.Exists(path))
{
var loaded = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) ?? new Settings();
loaded.Marketplace ??= new MarketplaceSettings();
loaded.ExemptAddresses ??= Array.Empty();
loaded.NeverLink ??= Array.Empty();
return loaded;
}
var defaults = new Settings();
defaults.Save(modPath);
ModManager.Log($"[{Mod.Name}] wrote default settings to {path}");
return defaults;
}
catch (Exception ex)
{
// Defaults enforce MORE than a broken file would. For a fairness rule the
// safe direction is on, not off.
ModManager.Log($"[{Mod.Name}] could not read {path}, using defaults: {ex.Message}", ModManager.LogLevel.Warn);
return new Settings();
}
}
public void Save(string modPath)
{
try
{
File.WriteAllText(Path.Combine(modPath, FileName), JsonSerializer.Serialize(this, JsonOptions));
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not save settings: {ex.Message}", ModManager.LogLevel.Error);
}
}
}