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); }