From ff49f8f6edd3805902a4669f850fdcf4456ccfe6 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 28 Sep 2026 23:32:58 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(bridge):=20count=20the=20twenty-three?= =?UTF-8?q?=20chat-title=20conditions=20in=20player.tally=20(PLAN=5FREDESI?= =?UTF-8?q?GNS=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit player.tally gains animalKills, weaponKills (by the killfeed's weapon prefab name), headshots, bestPvpM/bestPveM (this interval's maximum), apcKills, heliKills, crafted (by item category), built, repaired, healed, rockets, explosives and missions. Every field is absent when nothing moved. Ten new void hooks, all in ExpectedHooks: OnEntityTakeDamage (the last player to hurt an APC or helicopter, for a crash that names nobody), OnItemCraftFinished, OnEntityBuilt, OnStructureRepair (counted only when the health rose by the next tick), OnHealingItemUse and OnPlayerRevive (another player only; a syringe revive counts once), OnRocketLaunched, OnExplosiveThrown/Dropped (a damaging TimedExplosive only) and OnMissionSucceeded. npcKills keeps counting animals too, so the public column does not change meaning mid-wipe; the site takes human NPCs as the difference (D209). WeaponOf and DistanceOf are split out of DescribeAttacker so the killfeed and the tally read one name and one distance. Not yet compiled on a rig: the rigs' storage is unmounted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 413 ++++++++++++++++++++++++-- 1 file changed, 392 insertions(+), 21 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 23202db..d519035 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -244,7 +244,10 @@ namespace Oxide.Plugins "OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded", "OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged", "OnKitRedeemed", - "OnEntitySpawned", "OnEntityKill" + "OnEntitySpawned", "OnEntityKill", + "OnEntityTakeDamage", "OnItemCraftFinished", "OnEntityBuilt", "OnStructureRepair", + "OnHealingItemUse", "OnPlayerRevive", "OnRocketLaunched", + "OnExplosiveThrown", "OnExplosiveDropped", "OnMissionSucceeded" }; private void Init() @@ -1302,15 +1305,7 @@ namespace Oxide.Plugins BaseEntity initiator = info != null ? info.Initiator : null; BasePlayer attacker = info != null ? info.InitiatorPlayer : null; - string weapon = null; - - if (info != null) - { - if (info.WeaponPrefab != null) - weapon = info.WeaponPrefab.ShortPrefabName; - else if (info.Weapon != null) - weapon = info.Weapon.ShortPrefabName; - } + string weapon = WeaponOf(info); if (weapon != null) frame["weapon"] = weapon; @@ -1339,16 +1334,49 @@ namespace Oxide.Plugins frame["attackerName"] = initiator.ShortPrefabName; } - float distance = info != null ? info.ProjectileDistance : 0f; - - if (distance <= 0f && initiator.transform != null && victim.transform != null) - distance = UnityEngine.Vector3.Distance( - initiator.transform.position, victim.transform.position); + float distance = DistanceOf(info, victim); if (distance > 0f) frame["distance"] = (float)Math.Round(distance, 1); } + /// + /// The killing blow's weapon as the killfeed names it: the held entity's short prefab + /// name (bow_hunting.entity), or what was thrown or placed. Null when the hit + /// carries neither. The tally's weapon kills (§5.3) read this same name, so the lists + /// the site keeps are lists of what the killfeed already shows. + /// + private static string WeaponOf(HitInfo info) + { + if (info == null) + return null; + + if (info.WeaponPrefab != null) + return info.WeaponPrefab.ShortPrefabName; + + return info.Weapon != null ? info.Weapon.ShortPrefabName : null; + } + + /// + /// How far the killing blow travelled, in metres: the projectile's own distance, or the + /// gap between the two when there was no projectile. 0 when neither can be read. + /// + private static float DistanceOf(HitInfo info, BaseEntity victim) + { + if (info == null) + return 0f; + + float distance = info.ProjectileDistance; + BaseEntity initiator = info.Initiator; + + if (distance <= 0f && initiator != null && initiator.transform != null + && victim != null && victim.transform != null) + distance = UnityEngine.Vector3.Distance( + initiator.transform.position, victim.transform.position); + + return distance; + } + // ---- the map grid ---- /// @@ -1631,8 +1659,14 @@ namespace Oxide.Plugins BasePlayer killer = info != null ? info.InitiatorPlayer : null; if (killer != null && !killer.IsNpc && (ulong)killer.userID != (ulong)player.userID) + { CreditKill(killer, true); + // The kill itself is `player.death`'s, counted by the site from this frame; the + // tally adds only what a title needs about it (§5.1). + TallyFor(killer).Killed(info, player, true); + } + Dictionary frame = Frame("player.death", "event"); frame["steamId"] = SteamIdOf(player); frame["name"] = player.displayName; @@ -1755,6 +1789,15 @@ namespace Oxide.Plugins return; BasePlayer attacker = info != null ? info.InitiatorPlayer : null; + + // Before the early return below: a patrol helicopter usually dies by crashing, and a + // crash's HitInfo names no player (§5.4). + if (IsVehicleKill(entity)) + { + CreditVehicle(entity, attacker); + return; + } + bool byPlayer = attacker != null && !attacker.IsNpc; // Nothing below is interesting unless a real player did it. That single test discards @@ -1770,7 +1813,9 @@ namespace Oxide.Plugins // double every kill in the feed. Only the NPC kind is ours to count. if (victimPlayer.IsNpc) { - TallyFor(attacker).NpcKills++; + Tally tally = TallyFor(attacker); + tally.NpcKills++; + tally.Killed(info, entity, false); CreditKill(attacker, false); } @@ -1779,9 +1824,14 @@ namespace Oxide.Plugins // An animal is an NPC to a run's tally exactly as it is to the leaderboard (§29.5): // two meanings of one word would be two answers to "how many NPCs did I kill". + // `animalKills` counts it AGAIN, separately (§5.2): the public NPC column keeps its + // meaning mid-wipe, and the site takes the human NPCs as the difference. if (entity is BaseNpc) { - TallyFor(attacker).NpcKills++; + Tally tally = TallyFor(attacker); + tally.NpcKills++; + tally.AnimalKills++; + tally.Killed(info, entity, false); CreditKill(attacker, false); return; } @@ -1829,6 +1879,246 @@ namespace Oxide.Plugins EmitFrame(frame); } + // ---- what a chat title counts (PLAN_REDESIGNS §5, D172–D174) ---- + // + // Every one of these is a counter (rule 2) and void (rule 1). Three of them can veto — + // `OnStructureRepair`, `OnHealingItemUse`, `OnPlayerRevive` — and `OnEntityTakeDamage` + // can cancel the damage outright, which is exactly why none of them returns anything. + + /// How long a player's hit on a vehicle still earns its kill when the blow that ends it names nobody (§5.4). + private const float VehicleCreditSeconds = 60f; + + /// The last player to hurt each APC or helicopter, by net id. A handful at most. + private readonly Dictionary> _vehicleHits = + new Dictionary>(); + + /// The APC and the two helicopters: the three things a title credits a kill of (D174). + private static bool IsVehicleKill(BaseEntity entity) + { + return entity is BradleyAPC || entity is PatrolHelicopter || entity is CH47Helicopter; + } + + /// + /// Void: a non-null return cancels the damage. It fires for every hit on everything, + /// so the type test is the first thing it does; only a hit on one of the three + /// vehicles is remembered, and only who dealt it and when. + /// + private void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) + { + MarkHook("OnEntityTakeDamage"); + + if (entity == null || info == null || !IsVehicleKill(entity) || entity.net == null) + return; + + BasePlayer player = info.InitiatorPlayer; + if (player == null || player.IsNpc) + return; + + float now = UnityEngine.Time.realtimeSinceStartup; + + // A helicopter that flies off the map is never killed, so its row is never removed + // at death; a stale one is dropped here instead. + if (_vehicleHits.Count > 16) + { + var stale = new List(); + foreach (KeyValuePair> hit in _vehicleHits) + if (now - hit.Value.Value > VehicleCreditSeconds) + stale.Add(hit.Key); + foreach (ulong id in stale) + _vehicleHits.Remove(id); + } + + _vehicleHits[entity.net.ID.Value] = new KeyValuePair(player.userID, now); + } + + /// + /// The killing blow's player (D174), or, when the blow names nobody — a helicopter + /// crashing — the last player who hurt it within . + /// + private void CreditVehicle(BaseCombatEntity entity, BasePlayer attacker) + { + KeyValuePair last = default(KeyValuePair); + bool hit = entity.net != null && _vehicleHits.TryGetValue(entity.net.ID.Value, out last); + + if (entity.net != null) + _vehicleHits.Remove(entity.net.ID.Value); + + BasePlayer credited = attacker != null && !attacker.IsNpc ? attacker : null; + + if (credited == null && hit && UnityEngine.Time.realtimeSinceStartup - last.Value <= VehicleCreditSeconds) + credited = BasePlayer.FindByID(last.Key) ?? BasePlayer.FindSleeping(last.Key); + + if (credited == null) + return; + + Tally tally = TallyFor(credited); + + if (entity is BradleyAPC) + tally.ApcKills++; + else + tally.HeliKills++; + } + + /// No return behaviour. A crafted shirt, rifle or anything else, by its item category. + private void OnItemCraftFinished(ItemCraftTask task, Item item, ItemCrafter crafter) + { + MarkHook("OnItemCraftFinished"); + + BasePlayer player = task != null ? task.owner : null; + + if (player == null || player.IsNpc || item == null || item.info == null) + return; + + TallyFor(player).Craft(item.info.category.ToString().ToLowerInvariant(), item.amount); + } + + /// No return behaviour. Only a building block is a structure built; a deployable is not. + private void OnEntityBuilt(Planner planner, UnityEngine.GameObject go) + { + MarkHook("OnEntityBuilt"); + + BasePlayer player = planner != null ? planner.GetOwnerPlayer() : null; + + if (player == null || player.IsNpc || go == null || !(go.ToBaseEntity() is BuildingBlock)) + return; + + TallyFor(player).Built++; + } + + /// + /// Void: a non-null return refuses the repair. It fires on every hammer hit, BEFORE the + /// game decides whether there is anything to repair, the resources to pay for it, or a + /// recent attack that forbids it. So the health is read now and again next tick, and + /// only a hit that actually raised it is a repair. + /// + private void OnStructureRepair(BaseCombatEntity entity, BasePlayer player) + { + MarkHook("OnStructureRepair"); + + if (entity == null || player == null || player.IsNpc) + return; + + float before = entity.health; + ulong id = player.userID; + + NextTick(() => + { + if (entity == null || entity.IsDestroyed || entity.health <= before) + return; + + BasePlayer still = BasePlayer.FindByID(id); + if (still != null) + TallyFor(still).Repaired++; + }); + } + + // A syringe on a downed player raises `OnPlayerRevive` and then `OnHealingItemUse` for the + // same use; one act of help is one heal. The revive is remembered for the frame it happened in. + private ulong _reviveHealer; + private ulong _reviveTarget; + private int _reviveFrame = -1; + + /// + /// Void: a non-null return refuses the heal. is who is healed; + /// the tool's owner is who heals. Healing yourself is not a heal (D174). + /// + private void OnHealingItemUse(MedicalTool tool, BasePlayer player) + { + MarkHook("OnHealingItemUse"); + + BasePlayer healer = tool != null ? tool.GetOwnerPlayer() : null; + + if (healer == null || healer.IsNpc || player == null || player.IsNpc + || (ulong)healer.userID == (ulong)player.userID) + return; + + if (_reviveFrame == UnityEngine.Time.frameCount + && _reviveHealer == (ulong)healer.userID && _reviveTarget == (ulong)player.userID) + return; + + TallyFor(healer).Healed++; + } + + /// Void: a non-null return refuses the revive. Reviving yourself is not possible, and is not counted. + private void OnPlayerRevive(BasePlayer reviver, BasePlayer player) + { + MarkHook("OnPlayerRevive"); + + if (reviver == null || reviver.IsNpc || player == null || player.IsNpc + || (ulong)reviver.userID == (ulong)player.userID) + return; + + _reviveHealer = reviver.userID; + _reviveTarget = player.userID; + _reviveFrame = UnityEngine.Time.frameCount; + + TallyFor(reviver).Healed++; + } + + /// No return behaviour. One rocket leaving a launcher. + private void OnRocketLaunched(BasePlayer player, BaseEntity entity) + { + MarkHook("OnRocketLaunched"); + + if (player == null || player.IsNpc) + return; + + TallyFor(player).Rockets++; + } + + /// No return behaviour. A grenade, beancan, satchel or C4 thrown. + private void OnExplosiveThrown(BasePlayer player, BaseEntity entity, ThrownWeapon item) + { + MarkHook("OnExplosiveThrown"); + CountExplosive(player, entity); + } + + /// No return behaviour. The same, dropped at your feet rather than thrown. + private void OnExplosiveDropped(BasePlayer player, BaseEntity entity, ThrownWeapon item) + { + MarkHook("OnExplosiveDropped"); + CountExplosive(player, entity); + } + + /// + /// A supply signal and a smoke grenade are thrown through the same hooks, and neither + /// is an explosive. What is one is a timed explosive that does damage. + /// + private void CountExplosive(BasePlayer player, BaseEntity entity) + { + if (player == null || player.IsNpc) + return; + + TimedExplosive explosive = entity as TimedExplosive; + if (explosive == null || explosive is SupplySignal || explosive.damageTypes == null) + return; + + foreach (Rust.DamageTypeEntry damage in explosive.damageTypes) + { + if (damage != null && damage.amount > 0f) + { + TallyFor(player).Explosives++; + return; + } + } + } + + /// + /// No return behaviour. A mission one of the game's NPCs handed out, finished (D173). A + /// failed or abandoned one raises a different hook, and is never counted. + /// + private void OnMissionSucceeded(BaseMission mission, BaseMission.MissionInstance instance) + { + MarkHook("OnMissionSucceeded"); + + BasePlayer player = instance != null ? instance.GetAssignee() : null; + + if (player == null || player.IsNpc) + return; + + TallyFor(player).Missions++; + } + /// /// 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 @@ -2450,19 +2740,70 @@ namespace Oxide.Plugins public int Structures; public string Name; + // Protocol 13, the chat-title conditions (PLAN_REDESIGNS §5.1). Sums unless named a best. + public int AnimalKills; + public readonly Dictionary WeaponKills = new Dictionary(); + public int Headshots; + /// The longest player kill this interval, in metres: a best, not a sum. + public float BestPvpM; + /// The longest NPC or animal kill this interval: a best, not a sum. + public float BestPveM; + public int ApcKills; + public int HeliKills; + public readonly Dictionary Crafted = new Dictionary(); + public int Built; + public int Repaired; + public int Healed; + public int Rockets; + public int Explosives; + public int Missions; + public void Gather(string shortname, int amount) { - if (string.IsNullOrEmpty(shortname) || amount <= 0) + Add(Gathered, shortname, amount); + } + + public void Craft(string category, int amount) + { + Add(Crafted, category, amount); + } + + /// + /// What a credited kill adds for the titles: its weapon (any victim, D174), a + /// headshot, and its distance as this interval's best of its kind. + /// + public void Killed(HitInfo info, BaseEntity victim, bool victimIsPlayer) + { + Add(WeaponKills, WeaponOf(info), 1); + + if (info != null && info.isHeadshot) + Headshots++; + + float distance = DistanceOf(info, victim); + + if (victimIsPlayer) + BestPvpM = Math.Max(BestPvpM, distance); + else + BestPveM = Math.Max(BestPveM, distance); + } + + private static void Add(Dictionary into, string key, int amount) + { + if (string.IsNullOrEmpty(key) || amount <= 0) return; int have; - Gathered.TryGetValue(shortname, out have); - Gathered[shortname] = have + amount; + into.TryGetValue(key, out have); + into[key] = have + amount; } public bool IsEmpty() { - return Gathered.Count == 0 && NpcKills == 0 && Structures == 0; + return Gathered.Count == 0 && NpcKills == 0 && Structures == 0 + && AnimalKills == 0 && WeaponKills.Count == 0 && Headshots == 0 + && BestPvpM <= 0f && BestPveM <= 0f && ApcKills == 0 && HeliKills == 0 + && Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0 + && Rockets == 0 && Explosives == 0 && Missions == 0; } } @@ -2532,6 +2873,36 @@ namespace Oxide.Plugins if (tally.Structures > 0) frame["structures"] = tally.Structures; + // The title conditions (§5.1), each absent when nothing moved, as the three above. + if (tally.AnimalKills > 0) + frame["animalKills"] = tally.AnimalKills; + if (tally.WeaponKills.Count > 0) + frame["weaponKills"] = tally.WeaponKills; + if (tally.Headshots > 0) + frame["headshots"] = tally.Headshots; + if (tally.BestPvpM > 0f) + frame["bestPvpM"] = (float)Math.Round(tally.BestPvpM, 1); + if (tally.BestPveM > 0f) + frame["bestPveM"] = (float)Math.Round(tally.BestPveM, 1); + if (tally.ApcKills > 0) + frame["apcKills"] = tally.ApcKills; + if (tally.HeliKills > 0) + frame["heliKills"] = tally.HeliKills; + if (tally.Crafted.Count > 0) + frame["crafted"] = tally.Crafted; + if (tally.Built > 0) + frame["built"] = tally.Built; + if (tally.Repaired > 0) + frame["repaired"] = tally.Repaired; + if (tally.Healed > 0) + frame["healed"] = tally.Healed; + if (tally.Rockets > 0) + frame["rockets"] = tally.Rockets; + if (tally.Explosives > 0) + frame["explosives"] = tally.Explosives; + if (tally.Missions > 0) + frame["missions"] = tally.Missions; + EmitFrame(frame); } From f67f9e3b229b1253402a4bc6a0ef2db4a2be3628 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 29 Sep 2026 02:07:29 -0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(bridge):=20the=20three=20members=20the?= =?UTF-8?q?=20rig=20corrected=20=E2=80=94=20crafter=20owner,=20mission=20a?= =?UTF-8?q?ssignee,=20heal=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compiled on both rigs (2026-09-29): ItemCraftTask has no owner, so the crafter is ItemCrafter.owner; OnMissionSucceeded credits its third argument, the assignee, since MissionInstance has no accessor for it; OnHealingItemUse takes its target as object, because GiveEffectsTo now takes an IMedicalToolTarget, and counts only a player. GameObject.ToBaseEntity is not in scope for a plugin, so OnEntityBuilt asks GetComponent(). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 32 ++++++++++++++++++--------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index d519035..e6641c3 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -1959,12 +1959,16 @@ namespace Oxide.Plugins tally.HeliKills++; } - /// No return behaviour. A crafted shirt, rifle or anything else, by its item category. + /// + /// No return behaviour. A crafted shirt, rifle or anything else, by its item category. + /// Who crafted it is the crafter's owner: the task no longer carries one (read off the + /// rig, 2026-09-29). + /// private void OnItemCraftFinished(ItemCraftTask task, Item item, ItemCrafter crafter) { MarkHook("OnItemCraftFinished"); - BasePlayer player = task != null ? task.owner : null; + BasePlayer player = crafter != null ? crafter.owner : null; if (player == null || player.IsNpc || item == null || item.info == null) return; @@ -1979,7 +1983,7 @@ namespace Oxide.Plugins BasePlayer player = planner != null ? planner.GetOwnerPlayer() : null; - if (player == null || player.IsNpc || go == null || !(go.ToBaseEntity() is BuildingBlock)) + if (player == null || player.IsNpc || go == null || go.GetComponent() == null) return; TallyFor(player).Built++; @@ -2019,13 +2023,21 @@ namespace Oxide.Plugins private int _reviveFrame = -1; /// - /// Void: a non-null return refuses the heal. is who is healed; + /// Void: a non-null return refuses the heal. is what is healed; /// the tool's owner is who heals. Healing yourself is not a heal (D174). + /// + /// + /// The target is taken as object: the game's `GiveEffectsTo` now takes an + /// `IMedicalToolTarget`, which is not always a player, and a parameter typed + /// `BasePlayer` would simply never be called for the hook's current shape. Only a + /// player healed counts. + /// /// - private void OnHealingItemUse(MedicalTool tool, BasePlayer player) + private void OnHealingItemUse(MedicalTool tool, object target) { MarkHook("OnHealingItemUse"); + BasePlayer player = target as BasePlayer; BasePlayer healer = tool != null ? tool.GetOwnerPlayer() : null; if (healer == null || healer.IsNpc || player == null || player.IsNpc @@ -2107,16 +2119,16 @@ namespace Oxide.Plugins /// No return behaviour. A mission one of the game's NPCs handed out, finished (D173). A /// failed or abandoned one raises a different hook, and is never counted. /// - private void OnMissionSucceeded(BaseMission mission, BaseMission.MissionInstance instance) + private void OnMissionSucceeded(BaseMission mission, BaseMission.MissionInstance instance, BasePlayer assignee) { MarkHook("OnMissionSucceeded"); - BasePlayer player = instance != null ? instance.GetAssignee() : null; - - if (player == null || player.IsNpc) + // The game hands the assignee over: `MissionSuccess(instance, assignee)`. The instance + // itself has no accessor for it (read off the rig, 2026-09-29). + if (assignee == null || assignee.IsNpc) return; - TallyFor(player).Missions++; + TallyFor(assignee).Missions++; } ///