feat(bridge): count the twenty-three chat-title conditions in player.tally (PLAN_REDESIGNS §5) #20

Merged
whitlocktech merged 3 commits from feat/title-conditions into edge 2026-09-29 12:01:03 -05:00

View File

@@ -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);
}
/// <summary>
/// The killing blow's weapon as the killfeed names it: the held entity's short prefab
/// name (<c>bow_hunting.entity</c>), 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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 ----
/// <summary>
@@ -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<string, object> 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.
/// <summary>How long a player's hit on a vehicle still earns its kill when the blow that ends it names nobody (§5.4).</summary>
private const float VehicleCreditSeconds = 60f;
/// <summary>The last player to hurt each APC or helicopter, by net id. A handful at most.</summary>
private readonly Dictionary<ulong, KeyValuePair<ulong, float>> _vehicleHits =
new Dictionary<ulong, KeyValuePair<ulong, float>>();
/// <summary>The APC and the two helicopters: the three things a title credits a kill of (D174).</summary>
private static bool IsVehicleKill(BaseEntity entity)
{
return entity is BradleyAPC || entity is PatrolHelicopter || entity is CH47Helicopter;
}
/// <summary>
/// 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.
/// </summary>
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<ulong>();
foreach (KeyValuePair<ulong, KeyValuePair<ulong, float>> 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<ulong, float>(player.userID, now);
}
/// <summary>
/// The killing blow's player (D174), or, when the blow names nobody — a helicopter
/// crashing — the last player who hurt it within <see cref="VehicleCreditSeconds"/>.
/// </summary>
private void CreditVehicle(BaseCombatEntity entity, BasePlayer attacker)
{
KeyValuePair<ulong, float> last = default(KeyValuePair<ulong, float>);
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++;
}
/// <summary>
/// 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).
/// </summary>
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);
}
/// <summary>No return behaviour. Only a building block is a structure built; a deployable is not.</summary>
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<BuildingBlock>() == null)
return;
TallyFor(player).Built++;
}
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// Void: a non-null return refuses the heal. <paramref name="target"/> is what is healed;
/// the tool's owner is who heals. Healing yourself is not a heal (D174).
///
/// <para>
/// The target is taken as <c>object</c>: 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.
/// </para>
/// </summary>
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++;
}
/// <summary>Void: a non-null return refuses the revive. Reviving yourself is not possible, and is not counted.</summary>
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++;
}
/// <summary>No return behaviour. One rocket leaving a launcher.</summary>
private void OnRocketLaunched(BasePlayer player, BaseEntity entity)
{
MarkHook("OnRocketLaunched");
if (player == null || player.IsNpc)
return;
TallyFor(player).Rockets++;
}
/// <summary>No return behaviour. A grenade, beancan, satchel or C4 thrown.</summary>
private void OnExplosiveThrown(BasePlayer player, BaseEntity entity, ThrownWeapon item)
{
MarkHook("OnExplosiveThrown");
CountExplosive(player, entity);
}
/// <summary>No return behaviour. The same, dropped at your feet rather than thrown.</summary>
private void OnExplosiveDropped(BasePlayer player, BaseEntity entity, ThrownWeapon item)
{
MarkHook("OnExplosiveDropped");
CountExplosive(player, entity);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
}
/// <summary>
/// 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.
/// </summary>
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++;
}
/// <summary>
/// The most people one <c>entity.destroyed</c> 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<string, int> WeaponKills = new Dictionary<string, int>();
public int Headshots;
/// <summary>The longest player kill this interval, in metres: a best, not a sum.</summary>
public float BestPvpM;
/// <summary>The longest NPC or animal kill this interval: a best, not a sum.</summary>
public float BestPveM;
public int ApcKills;
public int HeliKills;
public readonly Dictionary<string, int> Crafted = new Dictionary<string, int>();
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);
}
/// <summary>
/// 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.
/// </summary>
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<string, int> 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);
}