Merge pull request 'feat(bridge): protocol 14 — kits.uses and reward notices (PLAN_REDESIGNS §2)' (#30) from feat/protocol-14-step-editor into edge

Reviewed-on: #30
This commit is contained in:
2026-10-12 09:52:10 +00:00
2 changed files with 313 additions and 6 deletions

View File

@@ -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 ──────────────────────────────────────────────────────
#

View File

@@ -56,8 +56,13 @@ namespace Oxide.Plugins
/// <c>PROTOCOL_VERSION</c> in the sidecar, and <c>module.json</c> in Module-Rust. The
/// overlay's <c>overlay.toml</c> carries this same number so the installer can refuse
/// to pair a sidecar and an overlay that disagree.
///
/// <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).
/// </para>
/// </summary>
private const int ProtocolVersion = 13;
private const int ProtocolVersion = 14;
/// <summary>
/// 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<string> CreditsUnresolved = new List<string>();
/// <summary>
/// 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.
/// </summary>
public List<RewardNotice> Notices;
/// <summary>The keys of <see cref="Notices"/> already told, for the report.</summary>
public readonly List<string> NoticesTold = new List<string>();
/// <summary>
/// Whether BetterChat was loaded when this sync was compiled (protocol 12). When it
/// was not, no style op is compiled and the report says <c>loaded: false</c> 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<RewardNotice>();
foreach (Dictionary<string, object> 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<string, object> { { "loaded", job.ChatLoaded } };
@@ -9768,9 +9841,41 @@ namespace Oxide.Plugins
public int Consumed;
}
/// <summary>
/// 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.
/// </summary>
private sealed class RewardNotice
{
public string Key;
public string SteamId;
public string Message;
public string Delivery;
}
/// <summary>The most notices one sync carries; the rest wait for the next.</summary>
private const int NoticesMax = 2000;
/// <summary>The longest notice key. The site's are short (<c>run:N:step:N</c>).</summary>
private const int NoticeKeyMax = 128;
private Dictionary<string, RunTally> _runTallies = new Dictionary<string, RunTally>();
private Dictionary<string, KitCredit> _credits = new Dictionary<string, KitCredit>();
/// <summary>
/// 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.
/// </summary>
private Dictionary<string, RewardNotice> _notices = new Dictionary<string, RewardNotice>();
/// <summary>
/// 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.
/// </summary>
private HashSet<string> _noticesTold = new HashSet<string>(StringComparer.Ordinal);
/// <summary>Recently said chat keys, and when. Main thread only.</summary>
private readonly Dictionary<string, long> _chatKeys = new Dictionary<string, long>();
@@ -10303,6 +10408,128 @@ namespace Oxide.Plugins
WriteDataFile(CreditsPath(), new List<KitCredit>(_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<string>(_noticesTold), "told reward notices");
}
private void LoadNoticesTold()
{
List<string> told = ReadDataFile<string>(NoticesPath(), "told reward notices");
if (told == null)
return;
foreach (string key in told)
{
if (!string.IsNullOrEmpty(key))
_noticesTold.Add(key);
}
}
/// <summary>
/// Main thread, at the end of a sync that carried <c>notices</c>. 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.
/// </summary>
private void ApplyNotices(PermSyncJob job)
{
var next = new Dictionary<string, RewardNotice>(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);
}
/// <summary>The player with this Steam id if they are connected and awake, else null.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Main thread. Protocol 14 (PROTOCOL.md §20.1; the module's PLAN_REDESIGNS.md §2.6). Of
/// the given players, how many have used <c>kit</c> up and how many are waiting out its
/// cooldown — the warning beside a kit weekend ("9 of 41 linked players have used it
/// up"). <b>Counts, never a list</b>: the website asked about players it already knows,
/// and the answer it needs is how many, not who.
///
/// <para>
/// 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.
/// </para>
/// </summary>
private void HandleKitsUses(Dictionary<string, object> 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<ulong>();
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<string, object> 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);