From a3899f336f32f6b5fa8320802a483282119b8b41 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 23 Sep 2026 05:51:59 -0500 Subject: [PATCH] =?UTF-8?q?feat(bridge):=20protocol=207=20=E2=80=94=20a=20?= =?UTF-8?q?raid=20frame=20that=20names=20who=20lives=20there?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 7 +- overlay/oxide/plugins/RunicGateway.cs | 114 ++++++++++++++++++++++++-- 2 files changed, 111 insertions(+), 10 deletions(-) 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..c21244b 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,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 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); } + /// + /// 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 authorized = new List(); + + foreach (ulong steamId in cupboard.authorizedPlayers) + { + if (authorized.Count >= MaxAuthorizedOnFrame) + { + frame["authorizedTruncated"] = true; + break; + } + + authorized.Add(new Dictionary + { + { "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)