Merge pull request 'feat(bridge): Economics on protocol 14 — pay, balances, adjust (PLAN_REDESIGNS §11.2)' (#31) from feat/protocol-14-economics into edge

Reviewed-on: #31
This commit is contained in:
2026-10-12 12:40:24 +00:00
3 changed files with 810 additions and 14 deletions

View File

@@ -101,6 +101,10 @@ Clans on the website come from Rust's **own** clan system, not from the uMod `Cl
is optional. The two share no data: a server running the plugin has two unrelated clan systems, and is optional. The two share no data: a server running the plugin has two unrelated clan systems, and
only the game's own clans become Teams on the site. only the game's own clans become Teams on the site.
Wulf's `Economics` is optional too, and not in `overlay.toml`. Where it is loaded the site can pay
coins from an event, show balances and a richest column, and let staff adjust a balance; where it is
not, each of those says "Economics is not installed on this server".
## Diagnosing it ## Diagnosing it
``` ```

View File

@@ -35,7 +35,9 @@
# and the first player walk's fixes (PROTOCOL.md §19) — opened by a configuration # and the first player walk's fixes (PROTOCOL.md §19) — opened by a configuration
# save that answers at once and reports its reload as a `config.outcome` frame, # save that answers at once and reports its reload as a `config.outcome` frame,
# and the step editor (PROTOCOL.md §20): `kits.uses`, and the one-time reward # and the step editor (PROTOCOL.md §20): `kits.uses`, and the one-time reward
# messages a `perm.sync` carries as `notices`. # messages a `perm.sync` carries as `notices`; and Economics (PROTOCOL.md §20.3):
# `economics.pay`, `economics.balances`, `economics.adjust`, the tally's
# `balance` and the `economics.wiped` event.
protocol = 14 protocol = 14
# ── Oxide compatibility ────────────────────────────────────────────────────── # ── Oxide compatibility ──────────────────────────────────────────────────────

View File

@@ -59,7 +59,10 @@ namespace Oxide.Plugins
/// ///
/// <para> /// <para>
/// 14 (PROTOCOL.md §20, the module's PLAN_REDESIGNS.md §2): <c>kits.uses</c>, and the /// 14 (PROTOCOL.md §20, the module's PLAN_REDESIGNS.md §2): <c>kits.uses</c>, and the
/// one-time reward messages <c>perm.sync</c> carries as <c>notices</c> (D333). /// one-time reward messages <c>perm.sync</c> carries as <c>notices</c> (D333); and
/// Economics (§20.3, PLAN_REDESIGNS.md §11.2): <c>economics.pay</c>,
/// <c>economics.balances</c>, <c>economics.adjust</c>, the tally's <c>balance</c> and the
/// <c>economics.wiped</c> event.
/// </para> /// </para>
/// </summary> /// </summary>
private const int ProtocolVersion = 14; private const int ProtocolVersion = 14;
@@ -256,7 +259,8 @@ namespace Oxide.Plugins
"OnEnterZone", "OnExitZone", "OnRgDomesReady", "OnEnterZone", "OnExitZone", "OnRgDomesReady",
"OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged", "OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged",
"OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied", "OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied",
"OnPlayerSleepEnded" "OnPlayerSleepEnded",
"OnEconomicsBalanceUpdated", "OnEconomicsDataWiped"
}; };
private void Init() private void Init()
@@ -1055,6 +1059,21 @@ namespace Oxide.Plugins
HandleKitsUses(cmd, reqId); HandleKitsUses(cmd, reqId);
break; break;
case "economics.pay":
// Protocol 14 (PROTOCOL.md §20.3, the module's PLAN_REDESIGNS.md §11.2). Coins
// from an event step, kept once paid (D335).
HandleEconomicsPay(cmd, reqId);
break;
case "economics.balances":
HandleEconomicsBalances(cmd, reqId);
break;
case "economics.adjust":
// Staff, from a player's admin page: deposit, withdraw or set.
HandleEconomicsAdjust(cmd, reqId);
break;
case "chat.say": case "chat.say":
HandleChatSay(cmd, reqId); HandleChatSay(cmd, reqId);
break; break;
@@ -1653,6 +1672,10 @@ namespace Oxide.Plugins
frame["steamId"] = SteamIdOf(player); frame["steamId"] = SteamIdOf(player);
frame["name"] = player.displayName; frame["name"] = player.displayName;
EmitFrame(frame); EmitFrame(frame);
// A balance carried over from last wipe is known at once, without waiting for it to
// move (D336). Nothing when Economics is not loaded.
NoteBalance(player);
} }
private void OnPlayerDisconnected(BasePlayer player, string reason) private void OnPlayerDisconnected(BasePlayer player, string reason)
@@ -2850,6 +2873,14 @@ namespace Oxide.Plugins
public int Explosives; public int Explosives;
public int Missions; public int Missions;
/// <summary>
/// Protocol 14 (§11.2.2): the player's Economics balance, a value and not a change,
/// like the bests. Sent only when <see cref="HasBalance"/>: it moved, or was read at
/// connect, since the last flush.
/// </summary>
public double Balance;
public bool HasBalance;
public void Gather(string shortname, int amount) public void Gather(string shortname, int amount)
{ {
Add(Gathered, shortname, amount); Add(Gathered, shortname, amount);
@@ -2900,7 +2931,8 @@ namespace Oxide.Plugins
&& AnimalKills == 0 && WeaponKills.Count == 0 && Headshots == 0 && AnimalKills == 0 && WeaponKills.Count == 0 && Headshots == 0
&& BestPvpM <= 0f && BestPveM <= 0f && ApcKills == 0 && HeliKills == 0 && BestPvpM <= 0f && BestPveM <= 0f && ApcKills == 0 && HeliKills == 0
&& Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0 && Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0
&& Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0; && Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0
&& !HasBalance;
} }
} }
@@ -2918,6 +2950,23 @@ namespace Oxide.Plugins
return tally; return tally;
} }
/// <summary>
/// The tally of a player who need not be online — an Economics balance reaches an offline
/// player too. It carries no name, and the frame it flushes has none.
/// </summary>
private Tally TallyForId(ulong userId)
{
Tally tally;
if (!_tallies.TryGetValue(userId, out tally))
{
tally = new Tally();
_tallies[userId] = tally;
}
return tally;
}
/// <summary> /// <summary>
/// Emits one <c>player.tally</c> per player with anything to report, and clears what it /// Emits one <c>player.tally</c> per player with anything to report, and clears what it
/// emitted. /// emitted.
@@ -3002,6 +3051,10 @@ namespace Oxide.Plugins
if (tally.NpcProfileKills.Count > 0) if (tally.NpcProfileKills.Count > 0)
frame["npcProfileKills"] = tally.NpcProfileKills; frame["npcProfileKills"] = tally.NpcProfileKills;
// Protocol 14: the current balance, a value the site stores rather than sums.
if (tally.HasBalance)
frame["balance"] = Math.Round(tally.Balance, 2);
EmitFrame(frame); EmitFrame(frame);
} }
@@ -5103,9 +5156,15 @@ namespace Oxide.Plugins
// Hello carries ZoneManager's flag list and both helpers' states, so a change to any // Hello carries ZoneManager's flag list and both helpers' states, so a change to any
// of the four is said again rather than waiting for the next connect (§3.1, §3.2). // of the four is said again rather than waiting for the next connect (§3.1, §3.2).
if (IsZonePlugin(plugin) || IsRunicNpc(plugin)) // Economics too: hello carries its starting balance, which the richest column needs.
if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin))
NextTick(ResendHello); NextTick(ResendHello);
// Economics loaded after players connected: what they hold now is not otherwise known
// until it moves (D336).
if (IsEconomics(plugin))
NextTick(NoteOnlineBalances);
// BetterChat keeps third-party titles on its INSTANCE, so a reloaded BetterChat has // BetterChat keeps third-party titles on its INSTANCE, so a reloaded BetterChat has
// forgotten our getter. Registering again is the only way back (§33.1). // forgotten our getter. Registering again is the only way back (§33.1).
if (plugin != null && plugin != this && plugin.Name == "BetterChat") if (plugin != null && plugin != this && plugin.Name == "BetterChat")
@@ -5128,7 +5187,7 @@ namespace Oxide.Plugins
if (plugin != null && plugin.Name == "BetterChat") if (plugin != null && plugin.Name == "BetterChat")
_titlesRegistered = false; _titlesRegistered = false;
if (IsZonePlugin(plugin) || IsRunicNpc(plugin)) if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin))
NextTick(ResendHello); NextTick(ResendHello);
ConfigWriteJob job = _configJob; ConfigWriteJob job = _configJob;
@@ -10496,14 +10555,19 @@ namespace Oxide.Plugins
/// </summary> /// </summary>
private bool TellNotice(BasePlayer player, RewardNotice n) private bool TellNotice(BasePlayer player, RewardNotice n)
{ {
Plugin popup = n.Delivery == DeliveryPopup ? PopupPlugin() : null; SayTo(player, n.Message, n.Delivery);
return _noticesTold.Add(n.Key);
}
/// <summary>Main thread. One line to one player, as a popup where one was asked for and can be shown.</summary>
private void SayTo(BasePlayer player, string message, string delivery)
{
Plugin popup = delivery == DeliveryPopup ? PopupPlugin() : null;
if (popup != null) if (popup != null)
popup.Call("CreatePopupNotification", n.Message, player, 0f); popup.Call("CreatePopupNotification", message, player, 0f);
else else
player.ChatMessage(n.Message); player.ChatMessage(message);
return _noticesTold.Add(n.Key);
} }
/// <summary> /// <summary>
@@ -10514,7 +10578,13 @@ namespace Oxide.Plugins
{ {
MarkHook("OnPlayerSleepEnded"); MarkHook("OnPlayerSleepEnded");
if (player == null || _notices.Count == 0) if (player == null)
return;
// Coins paid while they were away (protocol 14, §20.3).
TellPayments(player);
if (_notices.Count == 0)
return; return;
string steamId = SteamIdOf(player); string steamId = SteamIdOf(player);
@@ -10605,8 +10675,9 @@ namespace Oxide.Plugins
} }
LoadNoticesTold(); LoadNoticesTold();
LoadPayments();
Puts($"rewards: {_runTallies.Count} tallies, {_credits.Count} kit credits and {_noticesTold.Count} told notices on record"); Puts($"rewards: {_runTallies.Count} tallies, {_credits.Count} kit credits, {_noticesTold.Count} told notices and {_payments.Count} Economics payments on record");
EnsureTallyTimer(); EnsureTallyTimer();
} }
@@ -11871,6 +11942,724 @@ namespace Oxide.Plugins
Puts($"rewards: Kits wipes use counts at a new save, so {_credits.Count} kit credits start over"); Puts($"rewards: Kits wipes use counts at a new save, so {_credits.Count} kit credits start over");
} }
// ── Economics (protocol 14, PROTOCOL.md §20.3; the module's PLAN_REDESIGNS.md §11.2) ────
//
// Wulf's Economics, optional like the other mods (D199): every verb below answers
// `economics-missing` without it, and nothing else in this plugin needs it. Its API is five
// private methods, each with a `string` and an `object` overload, called by name. What the
// spike found (§11.2.1) shapes every call here:
//
// * an amount is boxed as a `double` — an `int` matches no overload, and `Call` answers
// null having done nothing;
// * only ids parsed as Steam ids are sent, because Economics makes an account of any
// string it is given;
// * what a deposit paid is the balance after less the balance before, because Deposit
// answers `true` while rounding to two places and clamping to its balance limit.
//
// Coins paid are kept (D335): there is no revert. A payment or adjustment is remembered by
// its key for seven days, so a retried step or a resent form is answered from the first
// reply and never pays twice.
private const double EconomicsAmountMin = 0.01;
private const double EconomicsAmountMax = 1000000d;
/// <summary>A deposit that moved the balance by less than this moved nothing: it rounds to two places.</summary>
private const double EconomicsMovedMin = 0.005;
/// <summary>How long a payment's key and reply are kept: seven days, the tally ceiling (D108).</summary>
private const long PaymentMemoryMs = TallyMaxHoldMs;
/// <summary>The longest payment key. The site's are short (<c>runId:stepIndex</c>).</summary>
private const int PaymentKeyMax = 128;
/// <summary>The lowest individual Steam id. Anything below it is not a player.</summary>
private const ulong SteamIdBase = 76561197960265728UL;
private const string EconomicsPay = "pay";
private const string EconomicsDeposit = "deposit";
private const string EconomicsWithdraw = "withdraw";
private const string EconomicsSet = "set";
/// <summary>
/// One payment (<c>economics.pay</c>) or one staff adjustment (<c>economics.adjust</c>),
/// kept with what it did so a repeat of its key is answered the same. Persisted as it is.
/// </summary>
private sealed class EconomicsPayment
{
public string Key;
/// <summary><c>pay</c>, or the adjustment's <c>deposit</c>, <c>withdraw</c> or <c>set</c>.</summary>
public string Op;
public long AtMs;
public double Amount;
/// <summary>A payment's message, <c>{amount}</c> not yet filled in. Blank tells nobody.</summary>
public string Message;
public string Delivery;
public List<EconomicsPaid> Paid = new List<EconomicsPaid>();
public List<EconomicsFailed> Failed = new List<EconomicsFailed>();
}
private sealed class EconomicsPaid
{
public string SteamId;
public double Before;
public double After;
/// <summary>Whether the payment's message has been said to this player (always, when there is none).</summary>
public bool Told;
}
private sealed class EconomicsFailed
{
public string SteamId;
public string Reason;
}
/// <summary>What Economics' own config says, read from its file because its API does not.</summary>
private sealed class EconomicsSettings
{
public double? StartingBalance;
public bool? WipesWithMap;
}
/// <summary>Payments and adjustments of the last seven days, by <see cref="PaymentSlot"/>.</summary>
private Dictionary<string, EconomicsPayment> _payments = new Dictionary<string, EconomicsPayment>(StringComparer.Ordinal);
private EconomicsSettings _economicsSettings;
private DateTime _economicsSettingsAt;
private Plugin EconomicsPlugin()
{
return plugins.Find("Economics");
}
private bool IsEconomics(Plugin plugin)
{
return plugin != null && plugin != this && plugin.Name == "Economics";
}
/// <summary>
/// Hello's <c>integrations.economics</c>: loaded and version like every optional mod, and
/// the two settings the site needs from Economics' config. Each is absent when the file
/// does not say, and the site then assumes Economics' defaults.
/// </summary>
private Dictionary<string, object> EconomicsBody()
{
Plugin eco = EconomicsPlugin();
Dictionary<string, object> body = IntegrationBody(eco);
if (eco == null)
return body;
EconomicsSettings settings = EconomicsConfig();
if (settings != null && settings.StartingBalance.HasValue)
body["startingBalance"] = settings.StartingBalance.Value;
if (settings != null && settings.WipesWithMap.HasValue)
body["wipesWithMap"] = settings.WipesWithMap.Value;
return body;
}
/// <summary>
/// Economics' config, read again only when the file changes: every status frame asks.
/// A file that will not parse is said once per version of it, and answers nothing. On
/// Carbon the file is under <c>carbon/configs</c>, so the directory comes from the
/// framework (R19), as Kits' does.
/// </summary>
private EconomicsSettings EconomicsConfig()
{
string path = Path.Combine(Interface.Oxide.ConfigDirectory, "Economics.json");
DateTime at;
try
{
if (!File.Exists(path))
return null;
at = File.GetLastWriteTimeUtc(path);
}
catch (Exception)
{
return null;
}
if (_economicsSettings != null && at == _economicsSettingsAt)
return _economicsSettings;
var settings = new EconomicsSettings();
try
{
var config = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path));
Newtonsoft.Json.Linq.JToken start = config["Starting account balance (0 or higher)"];
Newtonsoft.Json.Linq.JToken wipe = config["Wipe balances on new save file"];
if (start != null && (start.Type == Newtonsoft.Json.Linq.JTokenType.Integer || start.Type == Newtonsoft.Json.Linq.JTokenType.Float))
settings.StartingBalance = (double)start;
if (wipe != null && wipe.Type == Newtonsoft.Json.Linq.JTokenType.Boolean)
settings.WipesWithMap = (bool)wipe;
}
catch (Exception ex)
{
Puts($"could not read Economics' config: {Flatten(ex.Message)}");
}
_economicsSettings = settings;
_economicsSettingsAt = at;
return settings;
}
/// <summary>Main thread. A balance to two places, or null when Economics did not answer with one.</summary>
private static double? ReadBalance(Plugin eco, string steamId)
{
object value = eco.Call("Balance", steamId);
return value is double ? (double?)Math.Round((double)value, 2) : null;
}
/// <summary>A Steam id as Economics should see it, or false. Economics checks none itself.</summary>
private static bool TryParseSteamId(string raw, out ulong id)
{
return ulong.TryParse(raw, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out id)
&& id >= SteamIdBase
&& raw.Length == 17;
}
private static string Coins(double amount)
{
return amount.ToString("#,0.##", System.Globalization.CultureInfo.InvariantCulture);
}
private static bool AmountIn(double amount, double min)
{
return !double.IsNaN(amount) && !double.IsInfinity(amount) && amount >= min && amount <= EconomicsAmountMax;
}
/// <summary>
/// Where a payment or an adjustment is remembered. The two keep apart, so a site's step
/// key can never be answered with a staff adjustment's reply; the three adjustments share
/// one, so a key is one adjustment whatever its op.
/// </summary>
private static string PaymentSlot(string op, string key)
{
return (op == EconomicsPay ? "pay|" : "adjust|") + key;
}
/// <summary>Main thread. One player's balance into their tally, from their connect or Economics loading.</summary>
private void NoteBalance(BasePlayer player)
{
Plugin eco = EconomicsPlugin();
if (eco == null || player == null)
return;
double? balance = ReadBalance(eco, SteamIdOf(player));
if (!balance.HasValue)
return;
Tally tally = TallyFor(player);
tally.Balance = balance.Value;
tally.HasBalance = true;
}
private void NoteOnlineBalances()
{
foreach (BasePlayer player in BasePlayer.activePlayerList)
NoteBalance(player);
}
/// <summary>
/// Every balance change, deposits and withdrawals included, raised before their own hooks.
/// Folded into the tally as a value (rule 2: aggregates only). The player need not be
/// online: a payment or a staff adjustment reaches an offline player, and their frame
/// then has no name.
/// </summary>
private void OnEconomicsBalanceUpdated(string playerId, double balance)
{
MarkHook("OnEconomicsBalanceUpdated");
ulong id;
if (!TryParseSteamId(playerId, out id))
return;
// Named only while connected: a sleeper is found too, and a name on the frame is what
// tells the site a player was here (it moves their last-seen). A balance is not play.
BasePlayer player = BasePlayer.FindByID(id);
Tally tally = player != null && player.IsConnected ? TallyFor(player) : TallyForId(id);
tally.Balance = Math.Round(balance, 2);
tally.HasBalance = true;
}
/// <summary>
/// Raised with no arguments when a new save wipes the balances (Economics' own setting),
/// and with the caller after <c>ecowipe</c>. One method taking an <c>IPlayer</c> binds
/// both on both frameworks, with null for the first (§11.2.1). A balance not yet flushed
/// is from before the wipe, so it is dropped.
/// </summary>
private void OnEconomicsDataWiped(Oxide.Core.Libraries.Covalence.IPlayer player)
{
MarkHook("OnEconomicsDataWiped");
foreach (Tally tally in _tallies.Values)
tally.HasBalance = false;
Dictionary<string, object> frame = Frame("economics.wiped", "event");
// A new save's wipe opens a new map, whose balances the site has not seen yet; the
// last wipe's are a record of how it ended. `ecowipe` clears the wipe in play.
frame["newSave"] = player == null;
EmitFrame(frame);
Puts($"Economics wiped every balance ({(player == null ? "new save" : "ecowipe")})");
}
/// <summary>
/// Main thread. Coins from an event step to a list of players, kept once paid (D335). One
/// call for the whole list. A key already paid is answered from its first reply with
/// <c>repeat: true</c>, and pays nobody.
/// </summary>
private void HandleEconomicsPay(Dictionary<string, object> cmd, string reqId)
{
Plugin eco = EconomicsPlugin();
if (eco == null)
{
EmitEconomicsError(reqId, "economics-missing", "Economics is not installed on this server", null);
return;
}
if (_config == null || !_config.EventsEnabled)
{
EmitEconomicsError(reqId, "events-disabled",
"events are switched off on this server — set EventsEnabled in the RunicGateway plugin config", null);
return;
}
string key = StringField(cmd, "key");
if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax)
{
EmitEconomicsError(reqId, "malformed", "a payment has a key", null);
return;
}
ForgetOldPayments();
EconomicsPayment done;
if (_payments.TryGetValue(PaymentSlot(EconomicsPay, key), out done))
{
EmitPaid(done, reqId, true);
return;
}
double amount = DoubleField(cmd, "amount") ?? double.NaN;
if (!AmountIn(amount, EconomicsAmountMin))
{
EmitEconomicsError(reqId, "bad-amount",
$"a payment is {Coins(EconomicsAmountMin)} to {Coins(EconomicsAmountMax)} coins", null);
return;
}
var payment = new EconomicsPayment
{
Key = key,
Op = EconomicsPay,
AtMs = NowMs(),
Amount = amount,
Message = Flatten(StringField(cmd, "message") ?? "").Trim(),
Delivery = StringField(cmd, "delivery") == DeliveryPopup ? DeliveryPopup : DeliveryChat
};
var ids = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (string raw in Strings(cmd, "steamIds"))
{
ulong id;
if (!seen.Add(raw))
continue;
if (TryParseSteamId(raw, out id))
ids.Add(raw);
else
payment.Failed.Add(new EconomicsFailed { SteamId = raw, Reason = "bad-id" });
}
if (seen.Count > MaxRecipients())
{
EmitEconomicsError(reqId, "too-large", $"this server pays at most {MaxRecipients()} players in one step, not {seen.Count}", null);
return;
}
foreach (string id in ids)
{
double? before = ReadBalance(eco, id);
object ok = eco.Call("Deposit", id, amount);
double? after = ReadBalance(eco, id);
if (!(ok is bool) || !(bool)ok)
{
payment.Failed.Add(new EconomicsFailed { SteamId = id, Reason = "refused" });
continue;
}
if (!before.HasValue || !after.HasValue)
{
// Deposited, but not measured. Listed as failed so nobody is told an amount,
// and remembered with the key so a retry still pays nothing more.
payment.Failed.Add(new EconomicsFailed { SteamId = id, Reason = "unmeasured" });
continue;
}
if (after.Value - before.Value < EconomicsMovedMin)
{
payment.Failed.Add(new EconomicsFailed { SteamId = id, Reason = "at-limit" });
continue;
}
var paid = new EconomicsPaid { SteamId = id, Before = before.Value, After = after.Value, Told = payment.Message.Length == 0 };
payment.Paid.Add(paid);
BasePlayer awake = AwakePlayer(id);
if (awake != null && !paid.Told)
TellPaid(awake, payment, paid);
}
_payments[PaymentSlot(EconomicsPay, key)] = payment;
SavePayments();
_lastRewardSummary = $"paid {Coins(amount)} coins to {payment.Paid.Count} player(s), {payment.Failed.Count} failed";
EmitPaid(payment, reqId, false);
}
/// <summary>Main thread. Of the given players, each one's balance, for the account and admin pages.</summary>
private void HandleEconomicsBalances(Dictionary<string, object> cmd, string reqId)
{
Plugin eco = EconomicsPlugin();
if (eco == null)
{
EmitEconomicsError(reqId, "economics-missing", "Economics is not installed on this server", null);
return;
}
var ids = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (string raw in Strings(cmd, "steamIds"))
{
ulong id;
if (TryParseSteamId(raw, out id) && seen.Add(raw))
ids.Add(raw);
}
if (ids.Count > PermMaxOps)
{
EmitEconomicsError(reqId, "too-large", $"at most {PermMaxOps} players in one question, not {ids.Count}", null);
return;
}
var rows = new List<object>();
foreach (string id in ids)
{
double? balance = ReadBalance(eco, id);
if (balance.HasValue)
rows.Add(new Dictionary<string, object> { { "steamId", id }, { "balance", balance.Value } });
}
Dictionary<string, object> reply = Frame("economics.balances", "reply");
reply["balances"] = rows;
// A balance at the starting value may be an account Economics has never seen (§11.2.1).
EconomicsSettings settings = EconomicsConfig();
if (settings != null && settings.StartingBalance.HasValue)
reply["startingBalance"] = settings.StartingBalance.Value;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
/// <summary>
/// Main thread. Staff: deposit, withdraw or set one player's balance. A withdrawal above the
/// balance is refused with the balance, because Economics takes all or nothing.
/// </summary>
private void HandleEconomicsAdjust(Dictionary<string, object> cmd, string reqId)
{
Plugin eco = EconomicsPlugin();
if (eco == null)
{
EmitEconomicsError(reqId, "economics-missing", "Economics is not installed on this server", null);
return;
}
string key = StringField(cmd, "key");
if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax)
{
EmitEconomicsError(reqId, "malformed", "an adjustment has a key", null);
return;
}
string op = StringField(cmd, "op");
string method = op == EconomicsDeposit ? "Deposit" : op == EconomicsWithdraw ? "Withdraw" : op == EconomicsSet ? "SetBalance" : null;
if (method == null)
{
EmitEconomicsError(reqId, "malformed", "an adjustment deposits, withdraws or sets", null);
return;
}
ForgetOldPayments();
EconomicsPayment done;
if (_payments.TryGetValue(PaymentSlot(op, key), out done))
{
EmitAdjusted(done, reqId, true);
return;
}
string steamId = StringField(cmd, "steamId");
ulong parsed;
if (!TryParseSteamId(steamId, out parsed))
{
EmitEconomicsError(reqId, "malformed", "an adjustment names one player by Steam id", null);
return;
}
double amount = DoubleField(cmd, "amount") ?? double.NaN;
if (!AmountIn(amount, op == EconomicsSet ? 0d : EconomicsAmountMin))
{
EmitEconomicsError(reqId, "bad-amount",
$"an adjustment is {(op == EconomicsSet ? "0" : Coins(EconomicsAmountMin))} to {Coins(EconomicsAmountMax)} coins", null);
return;
}
double? before = ReadBalance(eco, steamId);
object ok = eco.Call(method, steamId, amount);
double? after = ReadBalance(eco, steamId);
if (!(ok is bool) || !(bool)ok)
{
if (op == EconomicsWithdraw && before.HasValue && amount > before.Value)
EmitEconomicsError(reqId, "insufficient", $"this player has only {Coins(before.Value)} coins", before);
else
EmitEconomicsError(reqId, "refused", $"Economics refused to {op} {Coins(amount)} coins", before);
return;
}
if (!before.HasValue || !after.HasValue)
{
EmitEconomicsError(reqId, "refused", "Economics did not report this player's balance", null);
return;
}
if (op == EconomicsDeposit && after.Value - before.Value < EconomicsMovedMin)
{
EmitEconomicsError(reqId, "at-limit", "this player's balance is at Economics' limit", before);
return;
}
var adjustment = new EconomicsPayment
{
Key = key,
Op = op,
AtMs = NowMs(),
Amount = amount
};
adjustment.Paid.Add(new EconomicsPaid { SteamId = steamId, Before = before.Value, After = after.Value, Told = true });
_payments[PaymentSlot(op, key)] = adjustment;
SavePayments();
_lastRewardSummary = $"{op} {Coins(amount)} coins for {steamId}: {Coins(before.Value)} → {Coins(after.Value)}";
EmitAdjusted(adjustment, reqId, false);
}
private void EmitPaid(EconomicsPayment payment, string reqId, bool repeat)
{
var paid = new List<object>();
var failed = new List<object>();
foreach (EconomicsPaid row in payment.Paid)
paid.Add(new Dictionary<string, object> { { "steamId", row.SteamId }, { "before", row.Before }, { "after", row.After } });
foreach (EconomicsFailed row in payment.Failed)
failed.Add(new Dictionary<string, object> { { "steamId", row.SteamId }, { "reason", row.Reason } });
Dictionary<string, object> reply = Frame("economics.paid", "reply");
reply["key"] = payment.Key;
reply["repeat"] = repeat;
reply["amount"] = payment.Amount;
reply["paid"] = paid;
reply["failed"] = failed;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
private void EmitAdjusted(EconomicsPayment adjustment, string reqId, bool repeat)
{
EconomicsPaid row = adjustment.Paid.Count > 0 ? adjustment.Paid[0] : null;
Dictionary<string, object> reply = Frame("economics.adjusted", "reply");
reply["key"] = adjustment.Key;
reply["repeat"] = repeat;
reply["op"] = adjustment.Op;
reply["amount"] = adjustment.Amount;
if (row != null)
{
reply["steamId"] = row.SteamId;
reply["before"] = row.Before;
reply["after"] = row.After;
}
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
private void EmitEconomicsError(string reqId, string reason, string message, double? balance)
{
Dictionary<string, object> frame = Frame("economics.error", "reply");
frame["reason"] = reason;
frame["message"] = message;
if (balance.HasValue)
frame["balance"] = balance.Value;
if (reqId != null)
frame["reqId"] = reqId;
_lastRewardSummary = $"refused ({reason}): {message}";
EmitFrame(frame);
}
/// <summary>Main thread. Says a payment's message to one player, with what they received, and marks it told.</summary>
private void TellPaid(BasePlayer player, EconomicsPayment payment, EconomicsPaid row)
{
string line = payment.Message.Replace("{amount}", Coins(row.After - row.Before));
if (line.Length > MaxChat())
line = line.Substring(0, MaxChat());
SayTo(player, line, payment.Delivery);
row.Told = true;
}
/// <summary>Main thread, as a player wakes. Every payment they have not been told of yet.</summary>
private void TellPayments(BasePlayer player)
{
if (_payments.Count == 0)
return;
string steamId = SteamIdOf(player);
bool changed = false;
foreach (EconomicsPayment payment in _payments.Values)
{
if (payment.Op != EconomicsPay || string.IsNullOrEmpty(payment.Message))
continue;
foreach (EconomicsPaid row in payment.Paid)
{
if (row.SteamId == steamId && !row.Told)
{
TellPaid(player, payment, row);
changed = true;
}
}
}
if (changed)
SavePayments();
}
/// <summary>Forgets payments seven days old, with any message nobody woke up to hear. True when any went.</summary>
private bool ForgetOldPayments()
{
long now = NowMs();
var stale = new List<string>();
foreach (KeyValuePair<string, EconomicsPayment> kv in _payments)
{
if (now >= kv.Value.AtMs + PaymentMemoryMs)
stale.Add(kv.Key);
}
foreach (string slot in stale)
_payments.Remove(slot);
if (stale.Count > 0)
SavePayments();
return stale.Count > 0;
}
private string PaymentsPath()
{
return Path.Combine(Interface.Oxide.DataDirectory, Name, "payments.json");
}
private void SavePayments()
{
WriteDataFile(PaymentsPath(), new List<EconomicsPayment>(_payments.Values), "Economics payments");
}
private void LoadPayments()
{
_payments = new Dictionary<string, EconomicsPayment>(StringComparer.Ordinal);
List<EconomicsPayment> payments = ReadDataFile<EconomicsPayment>(PaymentsPath(), "Economics payments");
if (payments == null)
return;
foreach (EconomicsPayment p in payments)
{
if (p == null || string.IsNullOrEmpty(p.Key) || string.IsNullOrEmpty(p.Op))
continue;
if (p.Paid == null)
p.Paid = new List<EconomicsPaid>();
if (p.Failed == null)
p.Failed = new List<EconomicsFailed>();
if (p.Message == null)
p.Message = "";
_payments[PaymentSlot(p.Op, p.Key)] = p;
}
ForgetOldPayments();
}
// ── Chat ────────────────────────────────────────────────────────────────── // ── Chat ──────────────────────────────────────────────────────────────────
/// <summary> /// <summary>
@@ -12308,7 +13097,8 @@ namespace Oxide.Plugins
{ "betterChat", IntegrationBody(BetterChatPlugin()) }, { "betterChat", IntegrationBody(BetterChatPlugin()) },
{ "popupNotifications", IntegrationBody(PopupPlugin()) }, { "popupNotifications", IntegrationBody(PopupPlugin()) },
{ "zoneDomes", ZoneDomesBody() }, { "zoneDomes", ZoneDomesBody() },
{ "runicNpc", RunicNpcBody() } { "runicNpc", RunicNpcBody() },
{ "economics", EconomicsBody() }
}; };
} }