feat(bridge): protocol 14 — kits.uses and reward notices (PLAN_REDESIGNS §2) #30
@@ -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 ──────────────────────────────────────────────────────
|
||||
#
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user