feat(bridge): Economics on protocol 14 — pay, balances, adjust (PLAN_REDESIGNS §11.2)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 10s
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 10s
Wulf's Economics as an optional integration (D199), as §11.2.2 designs it:
- hello/status `integrations.economics` adds startingBalance and wipesWithMap,
read from Economics.json (cached by mtime) because its API has neither.
- `economics.pay`: coins from an event step, kept once paid (D335). One main-
thread call for the list; each deposit measured as after - before, since
Deposit answers true while rounding and clamping (`at-limit` otherwise).
The key and its reply are kept in data/RunicGateway/payments.json for seven
days, so a retry answers `repeat: true` and pays nobody. The optional
message ({amount} = what landed) is told now if awake, else on wake.
- `economics.balances` (bounded by PermMaxOps) and staff `economics.adjust`
(deposit / withdraw / set; a withdrawal above the balance is refused
`insufficient` with the balance).
- `player.tally` gains `balance`, a value: read at connect (D336) and from
OnEconomicsBalanceUpdated, offline players included.
- `economics.wiped` from OnEconomicsDataWiped(IPlayer), with `newSave`.
Amounts are boxed as double and only Steam ids are sent, per the spike.
Compiled on both rigs (Oxide 2.0.7826, Carbon 2.0.262), then restored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -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
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
# 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,
|
||||
# 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
|
||||
|
||||
# ── Oxide compatibility ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -59,7 +59,10 @@ namespace Oxide.Plugins
|
||||
///
|
||||
/// <para>
|
||||
/// 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>
|
||||
/// </summary>
|
||||
private const int ProtocolVersion = 14;
|
||||
@@ -256,7 +259,8 @@ namespace Oxide.Plugins
|
||||
"OnEnterZone", "OnExitZone", "OnRgDomesReady",
|
||||
"OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged",
|
||||
"OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied",
|
||||
"OnPlayerSleepEnded"
|
||||
"OnPlayerSleepEnded",
|
||||
"OnEconomicsBalanceUpdated", "OnEconomicsDataWiped"
|
||||
};
|
||||
|
||||
private void Init()
|
||||
@@ -1055,6 +1059,21 @@ namespace Oxide.Plugins
|
||||
HandleKitsUses(cmd, reqId);
|
||||
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":
|
||||
HandleChatSay(cmd, reqId);
|
||||
break;
|
||||
@@ -1653,6 +1672,10 @@ namespace Oxide.Plugins
|
||||
frame["steamId"] = SteamIdOf(player);
|
||||
frame["name"] = player.displayName;
|
||||
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)
|
||||
@@ -2850,6 +2873,14 @@ namespace Oxide.Plugins
|
||||
public int Explosives;
|
||||
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)
|
||||
{
|
||||
Add(Gathered, shortname, amount);
|
||||
@@ -2900,7 +2931,8 @@ namespace Oxide.Plugins
|
||||
&& AnimalKills == 0 && WeaponKills.Count == 0 && Headshots == 0
|
||||
&& BestPvpM <= 0f && BestPveM <= 0f && ApcKills == 0 && HeliKills == 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;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Emits one <c>player.tally</c> per player with anything to report, and clears what it
|
||||
/// emitted.
|
||||
@@ -3002,6 +3051,10 @@ namespace Oxide.Plugins
|
||||
if (tally.NpcProfileKills.Count > 0)
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -5103,9 +5156,15 @@ namespace Oxide.Plugins
|
||||
|
||||
// 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).
|
||||
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);
|
||||
|
||||
// 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
|
||||
// forgotten our getter. Registering again is the only way back (§33.1).
|
||||
if (plugin != null && plugin != this && plugin.Name == "BetterChat")
|
||||
@@ -5128,7 +5187,7 @@ namespace Oxide.Plugins
|
||||
if (plugin != null && plugin.Name == "BetterChat")
|
||||
_titlesRegistered = false;
|
||||
|
||||
if (IsZonePlugin(plugin) || IsRunicNpc(plugin))
|
||||
if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin))
|
||||
NextTick(ResendHello);
|
||||
|
||||
ConfigWriteJob job = _configJob;
|
||||
@@ -10496,14 +10555,19 @@ namespace Oxide.Plugins
|
||||
/// </summary>
|
||||
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)
|
||||
popup.Call("CreatePopupNotification", n.Message, player, 0f);
|
||||
popup.Call("CreatePopupNotification", message, player, 0f);
|
||||
else
|
||||
player.ChatMessage(n.Message);
|
||||
|
||||
return _noticesTold.Add(n.Key);
|
||||
player.ChatMessage(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -10514,7 +10578,13 @@ namespace Oxide.Plugins
|
||||
{
|
||||
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;
|
||||
|
||||
string steamId = SteamIdOf(player);
|
||||
@@ -10605,8 +10675,9 @@ namespace Oxide.Plugins
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -11871,6 +11942,724 @@ namespace Oxide.Plugins
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -12308,7 +13097,8 @@ namespace Oxide.Plugins
|
||||
{ "betterChat", IntegrationBody(BetterChatPlugin()) },
|
||||
{ "popupNotifications", IntegrationBody(PopupPlugin()) },
|
||||
{ "zoneDomes", ZoneDomesBody() },
|
||||
{ "runicNpc", RunicNpcBody() }
|
||||
{ "runicNpc", RunicNpcBody() },
|
||||
{ "economics", EconomicsBody() }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user