diff --git a/overlay.toml b/overlay.toml
index 76fff64..7b6c852 100644
--- a/overlay.toml
+++ b/overlay.toml
@@ -26,11 +26,11 @@
# the same change that alters the emitters, exactly as the sidecar bumps
# PROTOCOL_VERSION and the module bumps its own constant.
#
-# Current: 9 — the transport, the read path, identity, the permission mirror,
+# Current: 10 — the transport, the read path, identity, the permission mirror,
# configuration from the site, first-party clans, a raid frame that names who
-# lives there, the event leases, and the world verbs events place and give back
-# (docs/rust-link/PROTOCOL.md §15).
-protocol = 9
+# lives there, the event leases, the world verbs events place and give back, and
+# the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16).
+protocol = 10
# ── Oxide compatibility ──────────────────────────────────────────────────────
#
diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs
index 70b44af..da708ad 100644
--- a/overlay/oxide/plugins/RunicGateway.cs
+++ b/overlay/oxide/plugins/RunicGateway.cs
@@ -57,7 +57,7 @@ namespace Oxide.Plugins
/// overlay's overlay.toml carries this same number so the installer can refuse
/// to pair a sidecar and an overlay that disagree.
///
- private const int ProtocolVersion = 9;
+ private const int ProtocolVersion = 10;
///
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
@@ -238,7 +238,8 @@ namespace Oxide.Plugins
"OnGroupPermissionGranted", "OnGroupPermissionRevoked",
"OnPluginLoaded", "OnPluginUnloaded",
"OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded",
- "OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged"
+ "OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged",
+ "OnKitRedeemed"
};
private void Init()
@@ -321,6 +322,14 @@ namespace Oxide.Plugins
// unloads — and they are NOT forgotten: the next load re-creates them (D92).
StopWorldTimer();
+ // A tally is kept across a reload — it records who was there, and the next load
+ // carries on counting — so what it has counted since the last minute's save is written
+ // now rather than lost.
+ StopTallyTimer();
+
+ if (_talliesDirty)
+ SaveTallies();
+
_running = false;
_stopping.Set();
Wake.Set();
@@ -386,6 +395,18 @@ namespace Oxide.Plugins
/// The most NPCs one event step may place (D95). Lowerable, like the above.
public int EventsMaxNpcs = WorldHardMaxNpcs;
+
+ ///
+ /// The most people one reward step may name (protocol 10, §29.5). The website reads
+ /// it and refuses a step over it; it is never trimmed to fit.
+ ///
+ public int EventsMaxRecipients = RecipientsHardMax;
+
+ /// The most tallies that may count at once on this server (§29.5).
+ public int EventsMaxTallies = TallyHardMax;
+
+ /// The longest line an event or a news post may say in chat, in characters.
+ public int EventsMaxChat = ChatHardMax;
}
protected override void LoadDefaultConfig()
@@ -419,8 +440,12 @@ namespace Oxide.Plugins
// what the website mirrors (D95).
_config.EventsMaxCrates = Math.Max(0, Math.Min(_config.EventsMaxCrates, WorldHardMaxCrates));
_config.EventsMaxNpcs = Math.Max(0, Math.Min(_config.EventsMaxNpcs, WorldHardMaxNpcs));
+ _config.EventsMaxRecipients = Math.Max(0, Math.Min(_config.EventsMaxRecipients, RecipientsHardMax));
+ _config.EventsMaxTallies = Math.Max(0, Math.Min(_config.EventsMaxTallies, TallyHardMax));
+ _config.EventsMaxChat = Math.Max(0, Math.Min(_config.EventsMaxChat, ChatHardMax));
- if (Config["EventsEnabled"] == null || Config["EventsMaxCrates"] == null || Config["EventsMaxNpcs"] == null)
+ if (Config["EventsEnabled"] == null || Config["EventsMaxCrates"] == null || Config["EventsMaxNpcs"] == null ||
+ Config["EventsMaxRecipients"] == null || Config["EventsMaxTallies"] == null || Config["EventsMaxChat"] == null)
Config.WriteObject(_config, true);
}
@@ -434,6 +459,7 @@ namespace Oxide.Plugins
// the holds already in place rather than have them loaded over it afterwards.
LoadLeases();
LoadWorld();
+ LoadRewards();
}
// ---- the outbound queue ----
@@ -893,6 +919,27 @@ namespace Oxide.Plugins
HandleWorldOwned(cmd, reqId);
break;
+ case "tally.open":
+ // Protocol 10 (PLAN.md §29). Who takes part in a run, counted by the game.
+ HandleTallyOpen(cmd, reqId);
+ break;
+
+ case "tally.snapshot":
+ HandleTallySnapshot(cmd, reqId);
+ break;
+
+ case "tally.close":
+ HandleTallyClose(cmd, reqId);
+ break;
+
+ case "kits.list":
+ HandleKitsList(reqId);
+ break;
+
+ case "chat.say":
+ HandleChatSay(cmd, reqId);
+ break;
+
default:
Puts($"unknown command '{verb}'");
break;
@@ -1328,6 +1375,9 @@ namespace Oxide.Plugins
{
MarkHook("OnServerShutdown");
FlushTallies();
+
+ if (_talliesDirty)
+ SaveTallies();
EmitFrame(Frame("server.shutdown", "event"));
}
@@ -1361,6 +1411,11 @@ namespace Oxide.Plugins
// Nor is anything an event placed: the old map, and everything on it, is gone.
DropWorldFromOtherWipes();
+ // A run's tally is NOT dropped: it records people, not the map, and a zone it counted
+ // in simply stops gaining anybody. Kit credits are kept too — unless Kits is about to
+ // clear its use counts, when the ones moved into them go with it (§29.3).
+ ResetCreditsForWipe();
+
Puts($"new save; wipe is now {(_wipeId ?? "unknown")}");
}
@@ -1435,6 +1490,12 @@ namespace Oxide.Plugins
if (player == null || player.IsNpc)
return;
+ // A tally's player kill (D98): the last hit, never your own.
+ BasePlayer killer = info != null ? info.InitiatorPlayer : null;
+
+ if (killer != null && !killer.IsNpc && (ulong)killer.userID != (ulong)player.userID)
+ CreditKill(killer, true);
+
Dictionary frame = Frame("player.death", "event");
frame["steamId"] = player.UserIDString;
frame["name"] = player.displayName;
@@ -1517,14 +1578,20 @@ namespace Oxide.Plugins
// A real player's death is OnPlayerDeath's, not ours — emitting here too would
// double every kill in the feed. Only the NPC kind is ours to count.
if (victimPlayer.IsNpc)
+ {
TallyFor(attacker).NpcKills++;
+ CreditKill(attacker, false);
+ }
return;
}
+ // An animal is an NPC to a run's tally exactly as it is to the leaderboard (§29.5):
+ // two meanings of one word would be two answers to "how many NPCs did I kill".
if (entity is BaseNpc)
{
TallyFor(attacker).NpcKills++;
+ CreditKill(attacker, false);
return;
}
@@ -2865,6 +2932,17 @@ namespace Oxide.Plugins
public readonly Dictionary> DesiredGroupPermissions =
new Dictionary>();
+
+ ///
+ /// The site's kit credits for this server (protocol 10, D103), or null when the sync
+ /// carried none — an older website, whose silence must not withdraw anybody's credit.
+ ///
+ public List Credits;
+
+ public int CreditsApplied;
+ public int CreditsWithdrawn;
+ public bool CreditsWaiting;
+ public readonly List CreditsUnresolved = new List();
}
private PermSyncJob _permJob;
@@ -3106,6 +3184,27 @@ namespace Oxide.Plugins
foreach (string group in job.DesiredMembers.Keys)
job.Ops.Add(new PermOp { Kind = PermOpKind.ScanGroup, Subject = group });
+ // ── The kit credits (protocol 10) ──────────────────────────────────
+ //
+ // Not store operations, so not drained with them: they are settled once, after the
+ // grants have landed, and counted in the same report.
+ if (cmd.ContainsKey("credits"))
+ {
+ job.Credits = new List();
+
+ foreach (Dictionary row in Rows(cmd, "credits"))
+ {
+ string steamId = StringField(row, "steamId");
+ string kit = StringField(row, "kit");
+ int count = IntField(row, "count");
+
+ if (string.IsNullOrEmpty(steamId) || string.IsNullOrEmpty(kit) || count <= 0)
+ continue;
+
+ job.Credits.Add(new KitCredit { SteamId = steamId, Kit = kit, Earned = count });
+ }
+ }
+
if (job.Ops.Count > PermMaxOps)
{
EmitPermError(reqId, "too-large");
@@ -3167,6 +3266,21 @@ namespace Oxide.Plugins
_permJob = null;
StopPermTimer();
+
+ if (job.Credits != null)
+ {
+ try
+ {
+ ReconcileCredits(job);
+ }
+ catch (Exception ex)
+ {
+ // The grants have landed and must still be reported; a credit that could not be
+ // settled is settled by the next sync, or by the player's next redemption.
+ Puts($"could not settle kit credits: {Flatten(ex.Message)}");
+ }
+ }
+
SendPermReport(job);
}
@@ -3564,6 +3678,16 @@ namespace Oxide.Plugins
report["notLanded"] = job.NotLanded;
report["operations"] = job.Ops.Count;
+ if (job.Credits != null)
+ {
+ // Counted the way `notLanded` is counted for grants: what actually changed in Kits,
+ // read back, never what was asked for.
+ report["creditsApplied"] = job.CreditsApplied;
+ report["creditsWithdrawn"] = job.CreditsWithdrawn;
+ report["creditsUnresolved"] = job.CreditsUnresolved;
+ report["creditsWaiting"] = job.CreditsWaiting;
+ }
+
if (!string.IsNullOrEmpty(job.SetId))
report["setId"] = job.SetId;
@@ -7061,6 +7185,1108 @@ namespace Oxide.Plugins
$"last: {_lastWorldSummary}");
}
+ // ---- the rewards (protocol 10, PLAN.md §29) ----
+ //
+ // Three things, and the website counts none of them:
+ //
+ // * **The tally** — who took part in a run. It is counted here because only the game
+ // sees a player stand in a zone or land the last hit (D81), and it is persisted,
+ // because a restart in the middle of an event must not forget who was there.
+ // * **The credits** — each reward is one more use of a kit (D103). The site authors how
+ // many a player has earned and pushes them with `perm.sync`; this plugin spends them
+ // LAST, from `OnKitRedeemed`, because Kits cannot be told in advance: its
+ // `SetPlayerKitUses` does nothing for a player with no usage record of the kit.
+ // * **The chat line** — `chat.say`, for a run's `rust.announce` and for the news leg.
+ //
+ // The zone a tally counts in is asked of ZoneManager on every tick (`GetPlayersInZone`,
+ // `IsPlayerInZone`) rather than mirrored from `OnEnterZone`/`OnExitZone`. ZoneManager
+ // already holds each zone's members, and a mirror would have to be reseeded every time a
+ // zone is re-created (D92) — the tick asks at most `EventsMaxTallies` questions every five
+ // seconds, and no chatty hook is subscribed at all (R17).
+
+ /// The most tallies that may count at once. EventsMaxTallies may lower it.
+ private const int TallyHardMax = 8;
+
+ ///
+ /// The most people one reward step may name. The plugin publishes it and the website
+ /// refuses a step over it (§29.5): a reward is priced before its tally is read, so this
+ /// is the price of every mode that is not a count.
+ ///
+ private const int RecipientsHardMax = 100;
+
+ /// The longest chat line, in characters. EventsMaxChat may lower it.
+ private const int ChatHardMax = 256;
+
+ private const float TallyTickSeconds = 5f;
+
+ ///
+ /// The longest a tally counts, and the longest it is kept at all: seven days from
+ /// opening, the lease and zone ceiling (D108). A tally that stopped counting earlier is
+ /// kept for the steps that read it after — collect and the reward — until then, and a
+ /// teardown closes it sooner. One ceiling for everything, so a website that went away
+ /// never leaves a tally on disk for ever.
+ ///
+ private const long TallyMaxHoldMs = 7L * 24L * 60L * 60L * 1000L;
+
+ /// Core files at most 5 000 participants per step; a tally holds no more.
+ private const int TallyMaxPeople = 5000;
+
+ ///
+ /// The most one tick may credit. A tick that arrives late accrues the time that really
+ /// passed, so a longer interval never rewrites history — but a clock that jumps is not
+ /// time anybody spent standing in a zone.
+ ///
+ private const long TallyMaxStepMs = 60000L;
+
+ /// Every twelfth tick (a minute) writes the file, when anything changed.
+ private const int TallySaveEveryTicks = 12;
+
+ private const long ChatKeyMemoryMs = 10L * 60L * 1000L;
+
+ private const string ScoreSeconds = "seconds";
+ private const string ScoreKills = "kills";
+ private const string ScoreBoth = "both";
+
+ /// One person in one tally. Persisted as it is.
+ private sealed class Participant
+ {
+ public string SteamId;
+ public string Name;
+ public double Seconds;
+ public int Kills;
+ public long JoinedMs;
+ }
+
+ /// One run's tally on this server. There is at most one per run (§29.5).
+ private sealed class RunTally
+ {
+ public string RunId;
+
+ /// The idempotency key of the step that opened it; a repeat is answered, not refused.
+ public string Key;
+
+ /// The zone step's name, as the step gave it, or null for the whole server.
+ public string Zone;
+
+ /// What that name resolved to when the tally opened (D100).
+ public string ZoneId;
+
+ public string Score;
+ public string KillsOf;
+ public double KillWeight;
+
+ public long OpenedMs;
+
+ /// When counting stops. What was counted is kept until seven days after opening (D108).
+ public long DeadlineMs;
+
+ /// People who took part after the tally was full, counted so a result can say so.
+ public int Overflow;
+
+ public Dictionary People = new Dictionary();
+
+ /// Not persisted: a load starts accruing from the load, never from before a restart.
+ [JsonIgnore]
+ public long LastAccrueMs;
+ }
+
+ ///
+ /// One player's credits for one kit (D103). Earned is the site's; the other two
+ /// are this plugin's own record of what it did with them.
+ ///
+ private sealed class KitCredit
+ {
+ public string SteamId;
+ public string Kit;
+
+ /// How many the site says this player has earned. Authored there, pushed here.
+ public int Earned;
+
+ /// How many have been moved into Kits' counter — each one a use count lowered by one.
+ public int Applied;
+
+ /// How many of those moved uses have since been redeemed.
+ public int Consumed;
+ }
+
+ private Dictionary _runTallies = new Dictionary();
+ private Dictionary _credits = new Dictionary();
+
+ /// Recently said chat keys, and when. Main thread only.
+ private readonly Dictionary _chatKeys = new Dictionary();
+
+ private Timer _tallyTimer;
+ private int _tallyTicks;
+ private bool _talliesDirty;
+ private string _lastRewardSummary = "none yet";
+
+ private int MaxTallies()
+ {
+ return _config != null ? _config.EventsMaxTallies : TallyHardMax;
+ }
+
+ private int MaxRecipients()
+ {
+ return _config != null ? _config.EventsMaxRecipients : RecipientsHardMax;
+ }
+
+ private int MaxChat()
+ {
+ return _config != null ? _config.EventsMaxChat : ChatHardMax;
+ }
+
+ private static bool Counting(RunTally t, long now)
+ {
+ return now < t.DeadlineMs;
+ }
+
+ /// Whether a player is somebody a tally counts: a real, connected, living, awake person.
+ private static bool CountsAsPresent(BasePlayer p)
+ {
+ return p != null && !p.IsNpc && p.IsConnected && !p.IsDead() && !p.IsSleeping();
+ }
+
+ private static Participant PersonIn(RunTally t, BasePlayer player, long now)
+ {
+ string id = ((ulong)player.userID).ToString(System.Globalization.CultureInfo.InvariantCulture);
+ Participant person;
+
+ if (t.People.TryGetValue(id, out person))
+ {
+ person.Name = player.displayName;
+ return person;
+ }
+
+ if (t.People.Count >= TallyMaxPeople)
+ {
+ t.Overflow++;
+ return null;
+ }
+
+ person = new Participant { SteamId = id, Name = player.displayName, JoinedMs = now };
+ t.People[id] = person;
+ return person;
+ }
+
+ /// The D99 score: seconds, kills, or minutes present plus the weight times kills.
+ private static double ScoreOf(RunTally t, Participant p)
+ {
+ switch (t.Score)
+ {
+ case ScoreSeconds:
+ return Math.Floor(p.Seconds);
+ case ScoreKills:
+ return p.Kills;
+ default:
+ return Math.Round(p.Seconds / 60.0 + t.KillWeight * p.Kills, 2);
+ }
+ }
+
+ /// Main thread. Start counting who takes part in a run.
+ private void HandleTallyOpen(Dictionary cmd, string reqId)
+ {
+ string runId = StringField(cmd, "runId");
+ string key = StringField(cmd, "key");
+
+ if (string.IsNullOrEmpty(runId))
+ {
+ EmitRewardError("tally.error", reqId, "malformed", "a tally is opened for a run");
+ return;
+ }
+
+ RunTally existing;
+
+ if (_runTallies.TryGetValue(runId, out existing))
+ {
+ // A retry whose first answer was lost repeats the key; anything else is a second
+ // tally for one run, which would split its people across two answers (§29.5).
+ if (!string.IsNullOrEmpty(key) && existing.Key == key)
+ EmitTallyOk(reqId, existing, true);
+ else
+ EmitRewardError("tally.error", reqId, "already-open",
+ "this run already has a tally on this server, and a run holds one");
+
+ return;
+ }
+
+ // The same consent as the world verbs (D94): a tally is an event doing something on
+ // this server unattended. Reading and closing one are never behind it.
+ if (_config == null || !_config.EventsEnabled)
+ {
+ EmitRewardError("tally.error", reqId, "events-disabled",
+ "events are switched off on this server — set EventsEnabled in the RunicGateway plugin config");
+ return;
+ }
+
+ string score = Lower(StringField(cmd, "score"));
+
+ if (score != ScoreSeconds && score != ScoreKills && score != ScoreBoth)
+ {
+ EmitRewardError("tally.error", reqId, "malformed", "a tally scores seconds, kills or both");
+ return;
+ }
+
+ string killsOf = Lower(StringField(cmd, "killsOf"));
+
+ if (score == ScoreSeconds)
+ {
+ killsOf = null;
+ }
+ else if (killsOf != "players" && killsOf != "npcs" && killsOf != "both")
+ {
+ EmitRewardError("tally.error", reqId, "malformed", "a tally that counts kills says whose: players, npcs or both");
+ return;
+ }
+
+ double weight = score == ScoreBoth ? (DoubleField(cmd, "killWeight") ?? 5.0) : 0.0;
+
+ if (weight < 0 || weight > 1000)
+ {
+ EmitRewardError("tally.error", reqId, "out-of-range", $"a kill is worth 0 to 1000 minutes, not {weight}");
+ return;
+ }
+
+ long holdMs = LongField(cmd, "holdMs");
+
+ if (holdMs == 0)
+ holdMs = TallyMaxHoldMs;
+
+ if (holdMs < 60000L || holdMs > TallyMaxHoldMs)
+ {
+ EmitRewardError("tally.error", reqId, "out-of-range",
+ $"a tally counts for 1 minute to {TallyMaxHoldMs / 60000L} minutes, not {holdMs / 60000L}");
+ return;
+ }
+
+ long now = NowMs();
+ int live = 0;
+
+ foreach (RunTally t in _runTallies.Values)
+ {
+ if (Counting(t, now))
+ live++;
+ }
+
+ if (live >= MaxTallies())
+ {
+ EmitRewardError("tally.error", reqId, "too-many",
+ $"this server counts at most {MaxTallies()} tallies at once, and {live} are counting");
+ return;
+ }
+
+ string zone = StringField(cmd, "zone");
+ string zoneId = null;
+
+ if (!string.IsNullOrEmpty(zone))
+ {
+ // Only a zone THIS run opened (D100) — never an operator's arena, never another
+ // run's. Matched on the name its step gave it, the way the step's author wrote it.
+ var matches = new List();
+
+ foreach (WorldEntry e in _world.Values)
+ {
+ if (e.Kind == WorldKindZone && e.RunId == runId &&
+ string.Equals((e.Name ?? "").Trim(), zone.Trim(), StringComparison.OrdinalIgnoreCase))
+ matches.Add(e);
+ }
+
+ if (matches.Count == 0)
+ {
+ EmitRewardError("tally.error", reqId, "no-zone",
+ $"this run has opened no zone named '{zone}' on this server — open it in an earlier step");
+ return;
+ }
+
+ if (matches.Count > 1)
+ {
+ EmitRewardError("tally.error", reqId, "ambiguous-zone",
+ $"this run has opened {matches.Count} zones named '{zone}' — give each its own name");
+ return;
+ }
+
+ zoneId = matches[0].Id;
+ }
+
+ var tally = new RunTally
+ {
+ RunId = runId,
+ Key = key,
+ Zone = zoneId != null ? zone.Trim() : null,
+ ZoneId = zoneId,
+ Score = score,
+ KillsOf = killsOf,
+ KillWeight = weight,
+ OpenedMs = now,
+ DeadlineMs = now + holdMs,
+ LastAccrueMs = now
+ };
+
+ _runTallies[runId] = tally;
+ SaveTallies();
+ EnsureTallyTimer();
+
+ _lastRewardSummary = $"tally opened for run {runId} ({score}, {(zoneId ?? "whole server")}) for {holdMs / 60000L} min";
+ Puts($"rewards: {_lastRewardSummary}");
+ EmitTallyOk(reqId, tally, false);
+ }
+
+ /// Main thread. Who has taken part in a run so far. Never behind EventsEnabled.
+ private void HandleTallySnapshot(Dictionary cmd, string reqId)
+ {
+ string runId = StringField(cmd, "runId");
+ RunTally t;
+
+ if (string.IsNullOrEmpty(runId) || !_runTallies.TryGetValue(runId, out t))
+ {
+ EmitRewardError("tally.error", reqId, "no-tally", "this run has no tally on this server");
+ return;
+ }
+
+ // Brought up to this moment first, so a reward read at the end of an event includes
+ // its last few seconds.
+ Accrue(t, NowMs(), null);
+
+ var people = new List