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

entity.destroyed now covers doors, external walls and gates, and the
tool cupboard itself as well as building blocks. A door was never a
BuildingBlock, so a door-only raid reached nobody. The frame gains
`structure`, the cupboard's `buildingId`, and `authorized`: each Steam
id on the cupboard with whether they are online now, bounded at 64.
Both fields are absent when there is no cupboard. The structures tally
still counts building blocks only, so the leaderboard column keeps its
meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-23 05:51:59 -05:00
parent 7096e06257
commit a3899f336f
2 changed files with 111 additions and 10 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,126 @@ 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["ownerId"] = entity.OwnerID.ToString();
frame["prefab"] = entity.ShortPrefabName;
frame["structure"] = structure;
frame["attackerId"] = attacker.UserIDString;
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)