diff --git a/overlay.toml b/overlay.toml
index 55d57c2..daa699b 100644
--- a/overlay.toml
+++ b/overlay.toml
@@ -26,15 +26,17 @@
# the same change that alters the emitters, exactly as the sidecar bumps
# PROTOCOL_VERSION and the module bumps its own constant.
#
-# Current: 13 — the transport, the read path, identity, the permission mirror,
+# Current: 14 — 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, the world verbs events place and give back, and
# the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16),
# the map: its picture and everything that moves on it (PROTOCOL.md §17), and the
# optional mods: BetterChat titles and group styles, and popups (PROTOCOL.md §18),
# 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.
-protocol = 13
+# 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`.
+protocol = 14
# ── Oxide compatibility ──────────────────────────────────────────────────────
#
diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs
index 4217de3..1f61bd3 100644
--- a/overlay/oxide/plugins/RunicGateway.cs
+++ b/overlay/oxide/plugins/RunicGateway.cs
@@ -56,8 +56,13 @@ namespace Oxide.Plugins
/// PROTOCOL_VERSION in the sidecar, and module.json in Module-Rust. The
/// overlay's overlay.toml carries this same number so the installer can refuse
/// to pair a sidecar and an overlay that disagree.
+ ///
+ ///
+ /// 14 (PROTOCOL.md §20, the module's PLAN_REDESIGNS.md §2): kits.uses, and the
+ /// one-time reward messages perm.sync carries as notices (D333).
+ ///
///
- private const int ProtocolVersion = 13;
+ private const int ProtocolVersion = 14;
///
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
@@ -250,7 +255,8 @@ namespace Oxide.Plugins
"OnExplosiveThrown", "OnExplosiveDropped", "OnMissionSucceeded",
"OnEnterZone", "OnExitZone", "OnRgDomesReady",
"OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged",
- "OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied"
+ "OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied",
+ "OnPlayerSleepEnded"
};
private void Init()
@@ -1043,6 +1049,12 @@ namespace Oxide.Plugins
HandleKitsList(reqId);
break;
+ case "kits.uses":
+ // Protocol 14 (the module's PLAN_REDESIGNS.md §2.6). How many of the given
+ // players have used a kit up, for the warning beside a kit weekend.
+ HandleKitsUses(cmd, reqId);
+ break;
+
case "chat.say":
HandleChatSay(cmd, reqId);
break;
@@ -3609,6 +3621,15 @@ namespace Oxide.Plugins
public bool CreditsWaiting;
public readonly List CreditsUnresolved = new List();
+ ///
+ /// The site's untold reward messages for this server (protocol 14, D333), or null when
+ /// the sync carried none — an older website, whose silence must not cancel anything.
+ ///
+ public List Notices;
+
+ /// The keys of already told, for the report.
+ public readonly List NoticesTold = new List();
+
///
/// Whether BetterChat was loaded when this sync was compiled (protocol 12). When it
/// was not, no style op is compiled and the report says loaded: false and
@@ -4394,6 +4415,38 @@ namespace Oxide.Plugins
}
}
+ // ── The reward messages (protocol 14, D333) ────────────────────────
+ //
+ // "You earned kit vip", said once to each player, now or when they next wake up. The
+ // site's WHOLE untold set, like the credits: a notice it stops sending is one it has
+ // been told about, or one whose reward was withdrawn before anybody heard of it.
+ if (cmd.ContainsKey("notices"))
+ {
+ job.Notices = new List();
+
+ foreach (Dictionary row in Rows(cmd, "notices"))
+ {
+ string key = StringField(row, "key");
+ string steamId = StringField(row, "steamId");
+ string message = Flatten(StringField(row, "message") ?? "");
+ ulong id;
+
+ if (string.IsNullOrEmpty(key) || key.Length > NoticeKeyMax || !ulong.TryParse(steamId, out id) || message.Length == 0)
+ continue;
+
+ if (job.Notices.Count >= NoticesMax)
+ break;
+
+ job.Notices.Add(new RewardNotice
+ {
+ Key = key,
+ SteamId = steamId,
+ Message = message.Length > MaxChat() ? message.Substring(0, MaxChat()) : message,
+ Delivery = StringField(row, "delivery") == DeliveryPopup ? DeliveryPopup : DeliveryChat
+ });
+ }
+ }
+
if (job.Ops.Count > PermMaxOps)
{
EmitPermError(reqId, "too-large");
@@ -4470,6 +4523,20 @@ namespace Oxide.Plugins
}
}
+ if (job.Notices != null)
+ {
+ try
+ {
+ ApplyNotices(job);
+ }
+ catch (Exception ex)
+ {
+ // The grants have landed and must still be reported; an untold notice is told
+ // by the next sync, or when its player wakes.
+ Puts($"could not tell reward notices: {Flatten(ex.Message)}");
+ }
+ }
+
SendPermReport(job);
}
@@ -4817,6 +4884,12 @@ namespace Oxide.Plugins
report["creditsWaiting"] = job.CreditsWaiting;
}
+ // Protocol 14 (D333). Every notice of this sync's set that its player has been told —
+ // at this sync or before it — so the site marks it told and stops sending it. Present
+ // only when the sync carried `notices`, like the credit counts.
+ if (job.Notices != null)
+ report["noticesTold"] = job.NoticesTold;
+
// Protocol 12. Always present, so a site can tell "BetterChat is absent" from "this
// plugin predates styles" — the second has no `chat` at all.
var chat = new Dictionary { { "loaded", job.ChatLoaded } };
@@ -9768,9 +9841,41 @@ namespace Oxide.Plugins
public int Consumed;
}
+ ///
+ /// One "you earned a reward" message for one player (protocol 14, D333). The key names
+ /// the reward — a run and its step — so a message is told once however many syncs carry
+ /// it.
+ ///
+ private sealed class RewardNotice
+ {
+ public string Key;
+ public string SteamId;
+ public string Message;
+ public string Delivery;
+ }
+
+ /// The most notices one sync carries; the rest wait for the next.
+ private const int NoticesMax = 2000;
+
+ /// The longest notice key. The site's are short (run:N:step:N).
+ private const int NoticeKeyMax = 128;
+
private Dictionary _runTallies = new Dictionary();
private Dictionary _credits = new Dictionary();
+ ///
+ /// The site's untold notices, by key, as the last sync carried them. In memory only: the
+ /// site sends its whole untold set on every sync, so a reload loses nothing it will not
+ /// hear again.
+ ///
+ private Dictionary _notices = new Dictionary();
+
+ ///
+ /// The keys already told, kept on disk until the site stops sending them. A reload
+ /// between telling a player and the report that says so would otherwise tell them twice.
+ ///
+ private HashSet _noticesTold = new HashSet(StringComparer.Ordinal);
+
/// Recently said chat keys, and when. Main thread only.
private readonly Dictionary _chatKeys = new Dictionary();
@@ -10303,6 +10408,128 @@ namespace Oxide.Plugins
WriteDataFile(CreditsPath(), new List(_credits.Values), "kit credits");
}
+ // ── Reward notices (protocol 14, PROTOCOL.md §20.2, D333) ──────────────────────────────
+ //
+ // "You earned kit vip — type /kit vip", said once to the player a reward step picked, in
+ // chat or as a popup. The site decides what to say and to whom; this plugin decides only
+ // WHEN: at once if the player is up and about, otherwise the next time they wake on this
+ // server. A sleeping or loading player sees no popup and reads no chat, so "online" is not
+ // enough — awake is.
+
+ private string NoticesPath()
+ {
+ return Path.Combine(Interface.Oxide.DataDirectory, Name, "notices.json");
+ }
+
+ private void SaveNoticesTold()
+ {
+ WriteDataFile(NoticesPath(), new List(_noticesTold), "told reward notices");
+ }
+
+ private void LoadNoticesTold()
+ {
+ List told = ReadDataFile(NoticesPath(), "told reward notices");
+
+ if (told == null)
+ return;
+
+ foreach (string key in told)
+ {
+ if (!string.IsNullOrEmpty(key))
+ _noticesTold.Add(key);
+ }
+ }
+
+ ///
+ /// Main thread, at the end of a sync that carried notices. Takes the site's set as
+ /// the whole truth, forgets told keys the site no longer sends (it has heard, or the
+ /// reward was withdrawn), tells everybody awake now, and lists every told key of the set
+ /// for the report.
+ ///
+ private void ApplyNotices(PermSyncJob job)
+ {
+ var next = new Dictionary(StringComparer.Ordinal);
+
+ foreach (RewardNotice n in job.Notices)
+ next[n.Key] = n;
+
+ _notices = next;
+
+ int before = _noticesTold.Count;
+ _noticesTold.RemoveWhere(k => !next.ContainsKey(k));
+ bool changed = _noticesTold.Count != before;
+
+ foreach (RewardNotice n in next.Values)
+ {
+ if (_noticesTold.Contains(n.Key))
+ continue;
+
+ BasePlayer player = AwakePlayer(n.SteamId);
+
+ if (player != null && TellNotice(player, n))
+ changed = true;
+ }
+
+ if (changed)
+ SaveNoticesTold();
+
+ foreach (string key in _noticesTold)
+ job.NoticesTold.Add(key);
+ }
+
+ /// The player with this Steam id if they are connected and awake, else null.
+ private static BasePlayer AwakePlayer(string steamId)
+ {
+ ulong id;
+
+ if (!ulong.TryParse(steamId, out id))
+ return null;
+
+ BasePlayer player = BasePlayer.FindByID(id);
+ return player != null && player.IsConnected && !player.IsSleeping() ? player : null;
+ }
+
+ ///
+ /// Main thread. Says one notice to one player and records it told. A popup where
+ /// PopupNotifications is loaded and the step chose one, chat otherwise (§18) — the reward
+ /// was granted long before this moment, and the player is told either way.
+ ///
+ private bool TellNotice(BasePlayer player, RewardNotice n)
+ {
+ Plugin popup = n.Delivery == DeliveryPopup ? PopupPlugin() : null;
+
+ if (popup != null)
+ popup.Call("CreatePopupNotification", n.Message, player, 0f);
+ else
+ player.ChatMessage(n.Message);
+
+ return _noticesTold.Add(n.Key);
+ }
+
+ ///
+ /// A player woke — after connecting, or after sleeping in a bag. Their untold notices are
+ /// said now. The site hears at the next sync's report.
+ ///
+ private void OnPlayerSleepEnded(BasePlayer player)
+ {
+ MarkHook("OnPlayerSleepEnded");
+
+ if (player == null || _notices.Count == 0)
+ return;
+
+ string steamId = SteamIdOf(player);
+ bool changed = false;
+
+ foreach (RewardNotice n in _notices.Values)
+ {
+ if (n.SteamId == steamId && !_noticesTold.Contains(n.Key) && TellNotice(player, n))
+ changed = true;
+ }
+
+ if (changed)
+ SaveNoticesTold();
+ }
+
private void WriteDataFile(string path, object rows, string what)
{
try
@@ -10377,7 +10604,9 @@ namespace Oxide.Plugins
}
}
- Puts($"rewards: {_runTallies.Count} tallies and {_credits.Count} kit credits on record");
+ LoadNoticesTold();
+
+ Puts($"rewards: {_runTallies.Count} tallies, {_credits.Count} kit credits and {_noticesTold.Count} told notices on record");
EnsureTallyTimer();
}
@@ -11333,6 +11562,82 @@ namespace Oxide.Plugins
EmitFrame(reply);
}
+ ///
+ /// Main thread. Protocol 14 (PROTOCOL.md §20.1; the module's PLAN_REDESIGNS.md §2.6). Of
+ /// the given players, how many have used kit up and how many are waiting out its
+ /// cooldown — the warning beside a kit weekend ("9 of 41 linked players have used it
+ /// up"). Counts, never a list: the website asked about players it already knows,
+ /// and the answer it needs is how many, not who.
+ ///
+ ///
+ /// Two Kits reads per player, so the list is bounded by the sync's own bound: the site
+ /// sends its linked players for this server, and a sync carries no more than that.
+ ///
+ ///
+ private void HandleKitsUses(Dictionary cmd, string reqId)
+ {
+ Plugin kits = KitsPlugin();
+
+ if (kits == null)
+ {
+ EmitRewardError("kits.error", reqId, "kits-missing", "Kits is not loaded on this server");
+ return;
+ }
+
+ string kit = StringField(cmd, "kit");
+
+ if (string.IsNullOrEmpty(kit) || !(kits.Call("GetKitInfo", kit) is Newtonsoft.Json.Linq.JObject))
+ {
+ EmitRewardError("kits.error", reqId, "unknown-kit", $"this server has no kit named '{Flatten(kit ?? "")}'");
+ return;
+ }
+
+ var ids = new List();
+
+ foreach (string raw in Strings(cmd, "steamIds"))
+ {
+ ulong id;
+
+ if (ulong.TryParse(raw, out id))
+ ids.Add(id);
+ }
+
+ if (ids.Count > PermMaxOps)
+ {
+ EmitRewardError("kits.error", reqId, "too-large", $"at most {PermMaxOps} players in one question, not {ids.Count}");
+ return;
+ }
+
+ int max = CallInt(kits, "GetKitMaxUses", kit);
+ int exhausted = 0;
+ int onCooldown = 0;
+
+ foreach (ulong id in ids)
+ {
+ // A kit with no use limit is never used up.
+ if (max > 0 && CallInt(kits, "GetPlayerKitUses", id, kit) >= max)
+ exhausted++;
+
+ object cooldown = kits.Call("GetPlayerKitCooldown", id, kit);
+
+ if (cooldown is double && (double)cooldown > 0)
+ onCooldown++;
+ }
+
+ Dictionary reply = Frame("kits.uses", "reply");
+ reply["kit"] = kit;
+ reply["max"] = max;
+ reply["cooldown"] = CallInt(kits, "GetKitCooldown", kit);
+ reply["asked"] = ids.Count;
+ reply["exhausted"] = exhausted;
+ reply["onCooldown"] = onCooldown;
+
+ if (reqId != null)
+ reply["reqId"] = reqId;
+
+ EmitFrame(reply);
+ }
+
private static string CreditKey(string steamId, string kit)
{
return steamId + "|" + Lower(kit);