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:
@@ -26,15 +26,17 @@
|
|||||||
# the same change that alters the emitters, exactly as the sidecar bumps
|
# the same change that alters the emitters, exactly as the sidecar bumps
|
||||||
# PROTOCOL_VERSION and the module bumps its own constant.
|
# 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
|
# 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
|
# 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 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
|
# 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),
|
# 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
|
# 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.
|
# save that answers at once and reports its reload as a `config.outcome` frame,
|
||||||
protocol = 13
|
# 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 ──────────────────────────────────────────────────────
|
# ── 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
|
/// <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
|
/// overlay's <c>overlay.toml</c> carries this same number so the installer can refuse
|
||||||
/// to pair a sidecar and an overlay that disagree.
|
/// 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>
|
/// </summary>
|
||||||
private const int ProtocolVersion = 13;
|
private const int ProtocolVersion = 14;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
|
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
|
||||||
@@ -250,7 +255,8 @@ namespace Oxide.Plugins
|
|||||||
"OnExplosiveThrown", "OnExplosiveDropped", "OnMissionSucceeded",
|
"OnExplosiveThrown", "OnExplosiveDropped", "OnMissionSucceeded",
|
||||||
"OnEnterZone", "OnExitZone", "OnRgDomesReady",
|
"OnEnterZone", "OnExitZone", "OnRgDomesReady",
|
||||||
"OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged",
|
"OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged",
|
||||||
"OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied"
|
"OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied",
|
||||||
|
"OnPlayerSleepEnded"
|
||||||
};
|
};
|
||||||
|
|
||||||
private void Init()
|
private void Init()
|
||||||
@@ -1043,6 +1049,12 @@ namespace Oxide.Plugins
|
|||||||
HandleKitsList(reqId);
|
HandleKitsList(reqId);
|
||||||
break;
|
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":
|
case "chat.say":
|
||||||
HandleChatSay(cmd, reqId);
|
HandleChatSay(cmd, reqId);
|
||||||
break;
|
break;
|
||||||
@@ -3609,6 +3621,15 @@ namespace Oxide.Plugins
|
|||||||
public bool CreditsWaiting;
|
public bool CreditsWaiting;
|
||||||
public readonly List<string> CreditsUnresolved = new List<string>();
|
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>
|
/// <summary>
|
||||||
/// Whether BetterChat was loaded when this sync was compiled (protocol 12). When it
|
/// 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
|
/// 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)
|
if (job.Ops.Count > PermMaxOps)
|
||||||
{
|
{
|
||||||
EmitPermError(reqId, "too-large");
|
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);
|
SendPermReport(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4817,6 +4884,12 @@ namespace Oxide.Plugins
|
|||||||
report["creditsWaiting"] = job.CreditsWaiting;
|
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
|
// Protocol 12. Always present, so a site can tell "BetterChat is absent" from "this
|
||||||
// plugin predates styles" — the second has no `chat` at all.
|
// plugin predates styles" — the second has no `chat` at all.
|
||||||
var chat = new Dictionary<string, object> { { "loaded", job.ChatLoaded } };
|
var chat = new Dictionary<string, object> { { "loaded", job.ChatLoaded } };
|
||||||
@@ -9768,9 +9841,41 @@ namespace Oxide.Plugins
|
|||||||
public int Consumed;
|
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, RunTally> _runTallies = new Dictionary<string, RunTally>();
|
||||||
private Dictionary<string, KitCredit> _credits = new Dictionary<string, KitCredit>();
|
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>
|
/// <summary>Recently said chat keys, and when. Main thread only.</summary>
|
||||||
private readonly Dictionary<string, long> _chatKeys = new Dictionary<string, long>();
|
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");
|
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)
|
private void WriteDataFile(string path, object rows, string what)
|
||||||
{
|
{
|
||||||
try
|
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();
|
EnsureTallyTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11333,6 +11562,82 @@ namespace Oxide.Plugins
|
|||||||
EmitFrame(reply);
|
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)
|
private static string CreditKey(string steamId, string kit)
|
||||||
{
|
{
|
||||||
return steamId + "|" + Lower(kit);
|
return steamId + "|" + Lower(kit);
|
||||||
|
|||||||
Reference in New Issue
Block a user