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 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) @@ -1875,6 +1980,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();