feat(bridge): protocol 7 — a raid frame that names who lives there #7

Merged
whitlocktech merged 3 commits from feat/phase-10-engagement into edge 2026-09-23 13:43:32 -05:00
2 changed files with 134 additions and 11 deletions

View File

@@ -26,9 +26,10 @@
# the same change that alters the emitters, exactly as the sidecar bumps
# PROTOCOL_VERSION and the module bumps its own constant.
#
# Current: 6 — the transport, the read path, identity, the permission mirror,
# configuration from the site, and first-party clans (docs/rust-link/PROTOCOL.md §12).
protocol = 6
# Current: 7 — the transport, the read path, identity, the permission mirror,
# configuration from the site, first-party clans, and a raid frame that names who
# lives there (docs/rust-link/PROTOCOL.md §13).
protocol = 7
# ── Oxide compatibility ──────────────────────────────────────────────────────
#

View File

@@ -57,7 +57,7 @@ namespace Oxide.Plugins
/// overlay's <c>overlay.toml</c> carries this same number so the installer can refuse
/// to pair a sidecar and an overlay that disagree.
/// </summary>
private const int ProtocolVersion = 6;
private const int ProtocolVersion = 7;
/// <summary>
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
@@ -1426,26 +1426,131 @@ namespace Oxide.Plugins
return;
}
BuildingBlock block = entity as BuildingBlock;
// Protocol 7 (PROTOCOL.md §13): a raid is the loss of anything a base is made of, not
// only a building block. A door is NOT a BuildingBlock — both derive from
// StabilityEntity as siblings — so until protocol 7 the most common raid there is, two
// charges on a door, reached nobody.
string structure = StructureKind(entity);
if (block == null || block.OwnerID == 0UL)
// `OwnerID == 0` is the world's own: a monument's doors and walls. Destroying those is
// not a raid on anybody, and emitting them would fill the staff feed with loot runs.
if (structure == null || entity.OwnerID == 0UL)
return;
TallyFor(attacker).Structures++;
// The tally's meaning is unchanged since protocol 2 — building blocks only — so a
// leaderboard column does not quietly change what it counts in the middle of a wipe.
if (structure == "block")
TallyFor(attacker).Structures++;
Dictionary<string, object> frame = Frame("entity.destroyed", "event");
frame["ownerId"] = block.OwnerID.ToString();
frame["prefab"] = block.ShortPrefabName;
frame["attackerId"] = attacker.UserIDString;
frame["ownerId"] = entity.OwnerID.ToString();
frame["prefab"] = entity.ShortPrefabName;
frame["structure"] = structure;
// From `userID`, not `UserIDString`. The string is filled in when a player connects,
// loads as a sleeper, or is an engine bot — and at no other time, so a player another
// plugin spawned carries none. The website skips the raid alert when the attacker is
// authorised on the cupboard (PLAN.md D59), and a null here would quietly defeat that.
frame["attackerId"] = ((ulong)attacker.userID).ToString();
frame["attackerName"] = attacker.displayName;
string grid = GridOf(block);
string grid = GridOf(entity);
if (grid != null)
frame["grid"] = grid;
AddAuthorization(frame, entity);
EmitFrame(frame);
}
/// <summary>
/// The most people one <c>entity.destroyed</c> frame names as authorised. A cupboard
/// has no hard cap on vanilla, and an unbounded list is an unbounded line on the game
/// link; 64 is several full teams, and a frame cut short says so.
/// </summary>
private const int MaxAuthorizedOnFrame = 64;
/// <summary>
/// Which of the four things a base is made of this entity is, or null for anything else.
/// Ordered by how often each dies, which is also cheapest-first.
/// </summary>
private static string StructureKind(BaseEntity entity)
{
if (entity is BuildingBlock)
return "block";
// External gates are Doors too, which is right: a gate is a door in a wall.
if (entity is Door)
return "door";
if (entity is SimpleBuildingBlock)
return "wall";
if (entity is BuildingPrivlidge)
return "cupboard";
return null;
}
/// <summary>
/// Who lives here, by the game's own definition: everyone authorised on the building's
/// tool cupboard. Both fields are ABSENT when there is no cupboard, which is a different
/// answer from an empty list — "nobody is authorised" and "there is nothing to be
/// authorised on" — and the module alerts nobody in either case (PLAN.md D67).
///
/// <para>
/// <c>authorizedPlayers</c> is the set, and nothing else is. The cupboard also holds
/// <c>recentGroupMembers</c>, which LOOKS like residents and is not: it counts code-lock
/// users towards group upkeep and prunes itself on a timer.
/// </para>
///
/// <para>
/// The lookup resolves through the entity's building, which is cheap. An external wall
/// has no building and falls back to a sphere query on this thread — acceptable because
/// it runs only after every cheap filter above has passed, at the rate walls fall rather
/// than the rate this hook fires.
/// </para>
/// </summary>
private static void AddAuthorization(Dictionary<string, object> frame, BaseEntity entity)
{
// A cupboard being destroyed still holds its list: OnEntityDeath runs before the kill.
BuildingPrivlidge cupboard = entity as BuildingPrivlidge ?? entity.GetBuildingPrivilege();
if (cupboard == null || cupboard.net == null || cupboard.authorizedPlayers == null)
return;
HashSet<ulong> online = new HashSet<ulong>();
if (BasePlayer.activePlayerList != null)
{
foreach (BasePlayer player in BasePlayer.activePlayerList)
{
if (player != null)
online.Add(player.userID);
}
}
List<object> authorized = new List<object>();
foreach (ulong steamId in cupboard.authorizedPlayers)
{
if (authorized.Count >= MaxAuthorizedOnFrame)
{
frame["authorizedTruncated"] = true;
break;
}
authorized.Add(new Dictionary<string, object>
{
{ "steamId", steamId.ToString() },
{ "online", online.Contains(steamId) },
});
}
frame["buildingId"] = cupboard.net.ID.Value.ToString();
frame["authorized"] = authorized;
}
private void OnPlayerReported(
BasePlayer reporter, string targetName, string targetId,
string subject, string message, string type)
@@ -1875,6 +1980,23 @@ namespace Oxide.Plugins
Dictionary<string, object> frame = ClanFrame("clan.disbanded", clan);
PutPlayer(frame, "steamId", "name", steamId);
// Protocol 7: who was in it. The website tells a disbanded clan's members, and by the
// time it reads this frame the next board may already have removed the roster it would
// otherwise ask — the board is re-sent seconds after this, and after an outage it is
// applied before the backlog. The game has deleted the clan from its database by now,
// and walked `Members` to drop each one's membership, but it never empties the list
// itself — so it is still whole here, bounded by the clan's own member limit.
if (clan.Members != null)
{
List<object> members = new List<object>();
foreach (ClanMember member in clan.Members)
members.Add(member.SteamId.ToString());
frame["members"] = members;
}
EmitFrame(frame);
ScheduleClansBoard();