From a3899f336f32f6b5fa8320802a483282119b8b41 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 23 Sep 2026 05:51:59 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(bridge):=20protocol=207=20=E2=80=94=20?= =?UTF-8?q?a=20raid=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) -- 2.49.1 From 272e9e0f651e288a80d8f1b5d442b89013d52d4c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 23 Sep 2026 05:55:52 -0500 Subject: [PATCH 2/3] feat(bridge): clan.disbanded carries the roster it ended The website tells a disbanded clan's members, but the clan board that follows the event (and, after an outage, precedes the backlog) has already removed that roster from its store. The game walks Members to drop each membership and never empties the list, so it is still whole when OnClanDisbanded fires. Part of protocol 7. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index c21244b..6c7560c 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -1975,6 +1975,23 @@ namespace Oxide.Plugins Dictionary 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 members = new List(); + + foreach (ClanMember member in clan.Members) + members.Add(member.SteamId.ToString()); + + frame["members"] = members; + } + EmitFrame(frame); ScheduleClansBoard(); -- 2.49.1 From 77e9863e9b0519c3a450b1df8b420051818e6004 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 23 Sep 2026 13:37:05 -0500 Subject: [PATCH 3/3] fix(bridge): the raid frame's attackerId comes from userID UserIDString is filled in only when a player connects, loads as a sleeper, or is an engine bot. A player another plugin spawned has none, and the phase-10 walk's raid arrived with attackerId null. The website skips the raid alert when the attacker is authorised on the cupboard, so a null would quietly defeat that exclusion. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 6c7560c..4083667 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -1446,7 +1446,12 @@ namespace Oxide.Plugins frame["ownerId"] = entity.OwnerID.ToString(); frame["prefab"] = entity.ShortPrefabName; frame["structure"] = structure; - frame["attackerId"] = attacker.UserIDString; + + // 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(entity); -- 2.49.1