diff --git a/overlay.toml b/overlay.toml
index b62e4ca..cebf872 100644
--- a/overlay.toml
+++ b/overlay.toml
@@ -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 ──────────────────────────────────────────────────────
#
diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs
index 1de3153..4083667 100644
--- a/overlay/oxide/plugins/RunicGateway.cs
+++ b/overlay/oxide/plugins/RunicGateway.cs
@@ -57,7 +57,7 @@ namespace Oxide.Plugins
/// overlay's overlay.toml carries this same number so the installer can refuse
/// to pair a sidecar and an overlay that disagree.
///
- private const int ProtocolVersion = 6;
+ private const int ProtocolVersion = 7;
///
/// 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 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);
}
+ ///
+ /// The most people one entity.destroyed 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.
+ ///
+ private const int MaxAuthorizedOnFrame = 64;
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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).
+ ///
+ ///
+ /// authorizedPlayers is the set, and nothing else is. The cupboard also holds
+ /// recentGroupMembers, which LOOKS like residents and is not: it counts code-lock
+ /// users towards group upkeep and prunes itself on a timer.
+ ///
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ private static void AddAuthorization(Dictionary 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 online = new HashSet();
+
+ if (BasePlayer.activePlayerList != null)
+ {
+ foreach (BasePlayer player in BasePlayer.activePlayerList)
+ {
+ if (player != null)
+ online.Add(player.userID);
+ }
+ }
+
+ List