diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs
index f748189..5001b9b 100644
--- a/overlay/oxide/plugins/RunicGateway.cs
+++ b/overlay/oxide/plugins/RunicGateway.cs
@@ -245,6 +245,9 @@ namespace Oxide.Plugins
"OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged",
"OnKitRedeemed",
"OnEntitySpawned", "OnEntityKill",
+ "OnEntityTakeDamage", "OnItemCraftFinished", "OnEntityBuilt", "OnStructureRepair",
+ "OnHealingItemUse", "OnPlayerRevive", "OnRocketLaunched",
+ "OnExplosiveThrown", "OnExplosiveDropped", "OnMissionSucceeded",
"OnEnterZone", "OnExitZone", "OnRgDomesReady"
};
@@ -1314,15 +1317,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;
@@ -1351,16 +1346,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 ----
///
@@ -1643,8 +1671,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;
@@ -1767,6 +1801,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
@@ -1782,7 +1825,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);
}
@@ -1791,9 +1836,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;
}
@@ -1841,6 +1891,258 @@ 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.
+ /// 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 = crafter != null ? crafter.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.GetComponent() == null)
+ 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 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, 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
+ || (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, BasePlayer assignee)
+ {
+ MarkHook("OnMissionSucceeded");
+
+ // 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(assignee).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
@@ -2462,19 +2764,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;
}
}
@@ -2544,6 +2897,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);
}