C#:
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Plugins;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("CombatLog Info", "Pho3niX90", "1.0.20")]
[Description("Collects combat log entries, and submits them to SA for analysis.")]
class CombatLogInfo : RustPlugin
{
const bool debug = false;
[PluginReference] Plugin ServerArmour;
HashSet<string> usedHashes = new HashSet<string>();
// Simple stats
DateTime logsSince = DateTime.Now;
int totalLogs = 0;
int totalLogsUploaded = 0;
int pendingGeneration = 0;
int failedUploads = 0;
//
void OnServerInitialized(bool first)
{
Server.Command($"server.combatlogsize {Math.Max(config.logSize, 30)}");
if (config.advancedLogs)
cmd.AddConsoleCommand("combatlog", this, printCombatLog);
}
void OnServerSave()
{
this.cleanupHashes();
}
#region log generation
private void GenCombatLog(BasePlayer forPlayer)
{
if (forPlayer.IsNpc || !forPlayer.userID.IsSteamId())
return;
var cLog = CombatLog.Get(forPlayer.userID);
var snapshot = new List<CombatLog.Event>();
if (cLog != null)
{
foreach (CombatLog.Event evt in cLog)
snapshot.Add(evt);
}
string steamId = forPlayer.UserIDString;
string displayName = forPlayer.displayName;
float delay = ConVar.Server.combatlogdelay + 1f;
pendingGeneration++;
// Puts($"CombatLogInfo: scheduled {snapshot.Count} events for {displayName} ({steamId}) in {delay:0.#}s");
timer.Once(delay, () =>
{
var player = BasePlayer.FindByID(Convert.ToUInt64(steamId))
?? BasePlayer.FindSleeping(Convert.ToUInt64(steamId));
if (player == null)
{
// Puts($"CombatLogInfo: could not resolve player {steamId} ({displayName}) at upload time — skipped {snapshot.Count} events");
pendingGeneration--;
return;
}
AddEntries(player, snapshot);
});
}
#endregion
#region commands
[ConsoleCommand("clog.stats")]
void printStats(ConsoleSystem.Arg arg)
{
arg.ReplyWith($"Stats since {logsSince}\n\nTotal Logs: {totalLogs}\nTotal Logs Uploaded: {totalLogsUploaded}\nFailed Logs Upload: {failedUploads}\nPending Log Generation: {pendingGeneration}");
}
//[ConsoleCommand("combatlog")]
bool printCombatLog(ConsoleSystem.Arg arg)
{
BasePlayer player = arg.Player();
if (!player)
return false;
TextTable textTable = new TextTable();
textTable.AddColumns("time", "attacker", "id", "target", "id", "weapon", "ammo", "area", "distance", "old_hp", "new_hp", "info", "hits", "integrity", "travel", "mismatch", "desync");
foreach (CombatLog.Event evt in CombatLog.Get(player.userID))
{
var entry = CLogEntry.from(player, evt);
if (entry != null)
{
textTable.AddRow((Time.realtimeSinceStartup - entry.EventTime).ToString("0.0s"),
GetUsername(player, entry.AttackerSteamId), entry.AttackerSteamId,
GetUsername(player, entry.TargetSteamId), entry.TargetSteamId,
entry.Weapon, entry.Ammo,
entry.Area, entry.Distance.ToString("0.0m"),
entry.HealthOld.ToString("0.0"), entry.HealthNew.ToString("0.0"), entry.EventInfo,
entry.ProjectileTravelTime.ToString("0.0s"),
entry.ProjectileTrajectoryMismatch.ToString("0.0m"),
entry.Desync.ToString());
}
}
player.ConsoleMessage("---------- COMBATLOG ----------\n");
player.ConsoleMessage(textTable.ToString());
return true;
}
string GetUsername(BasePlayer player, string id)
{
if (id == player.UserIDString || id == player.net.ID.Value.ToString())
return "you";
BasePlayer fp = null;
try
{
fp = BasePlayer.allPlayerList.FirstOrDefault(x => x.UserIDString == id || x.net.ID.Value.ToString() == id);
}
catch (Exception e) { }
return fp?.displayName ?? "N/A";
}
#endregion
#region hooks
void processingTask()
{
var task = Task.Run(() => { });
}
object OnPlayerDeath(BasePlayer player, HitInfo info)
{
if (player == null)
return null;
var initiator = info?.InitiatorPlayer;
// Upload both perspectives on PvP deaths so killer and victim rows are captured.
if (!player.IsNpc && player.userID.IsSteamId())
GenCombatLog(player);
if (initiator != null && initiator.userID.IsSteamId() && initiator != player)
GenCombatLog(initiator);
return null;
}
private void UploadLogs(Dictionary<int, CLogEntry> logs, BasePlayer forPlayer = null)
{
pendingGeneration--;
totalLogs += logs.Count;
if (logs.Count > 0)
{
string payload = JsonConvert.SerializeObject(logs.Values);
Puts($"CombatLogInfo: uploading {logs.Count} entries for {forPlayer?.displayName} ({forPlayer?.UserIDString})");
if (!DeliverCombatPayload(payload))
{
Puts($"CombatLogInfo: ServerArmour did not accept payload for {forPlayer?.UserIDString}");
failedUploads += logs.Count;
return;
}
totalLogsUploaded += logs.Count;
}
else if (forPlayer != null)
Puts($"CombatLogInfo: No new entries to upload for {forPlayer.displayName} ({forPlayer.UserIDString})");
else
LogDebug($"Nothing to upload");
}
#endregion
#region helpers
private bool DeliverCombatPayload(string payload)
{
if (string.IsNullOrEmpty(payload))
return false;
try
{
object result = (ServerArmour != null && ServerArmour.IsLoaded)
? ServerArmour?.Call("API_UploadCombatEntries", payload)
: Interface.Call("API_UploadCombatEntries", payload);
if (TryParseQueuedResult(result, out int queued))
{
// Puts($"CombatLogInfo: delivered via API_UploadCombatEntries (queue depth {queued})");
return true;
}
}
catch (Exception ex)
{
Puts($"CombatLogInfo: API_UploadCombatEntries via Call failed: {ex.Message}");
}
// Puts("CombatLogInfo: ServerArmour.Call did not acknowledge payload (no queue depth returned)");
return false;
}
private bool TryParseQueuedResult(object result, out int queued)
{
queued = 0;
if (result is int i)
{
queued = i;
return i > 0;
}
if (result is long l)
{
queued = (int)l;
return l > 0;
}
return false;
}
private void AddEntries(BasePlayer forPlayer, List<CombatLog.Event> cLog)
{
Dictionary<int, CLogEntry> logEntries = new Dictionary<int, CLogEntry>();
int eventCount = cLog?.Count ?? 0;
string dedupScope = forPlayer.UserIDString;
foreach (CombatLog.Event evt in cLog)
{
var hash = evt.GetHashCode();
string dedupKey = $"{dedupScope}:{hash}";
if (!logEntries.ContainsKey(hash) && !usedHashes.Contains(dedupKey))
{
var entry = CreateEntry(forPlayer, evt);
if (entry != null)
{
usedHashes.Add(dedupKey);
logEntries.Add(hash, entry);
}
}
}
if (logEntries.Count == 0 && eventCount > 0)
Puts($"CombatLogInfo: All {eventCount} events skipped for {forPlayer.displayName} ({forPlayer.UserIDString}) — dedup or filter.");
UploadLogs(logEntries, forPlayer);
logEntries.Clear();
}
void cleanupHashes()
{
if (usedHashes.Count > 100000)
usedHashes = new HashSet<string>(usedHashes.Skip(usedHashes.Count - 1000));
}
private CLogEntry CreateEntry(BasePlayer forPlayer, CombatLog.Event evt)
{
if ((forPlayer is NPCPlayer || forPlayer.IsNpc || !forPlayer.userID.IsSteamId()) && !evt.target_id.IsSteamId())
{
// LogDebug($"Event = {evt.GetHashCode()} bot attacker and bot death. Skipped");
return null;
}
totalLogs++;
// LogDebug($"Event = {evt.GetHashCode()} saved.");
return CLogEntry.from(forPlayer, evt);
}
private void LogDebug(string txt)
{
if (debug) Puts($"DEBUG: {txt}");
}
static private PInfo UintFind(ulong id)
{
BasePlayer player = null;
try
{
if (id.IsSteamId())
player = BasePlayer.allPlayerList.FirstOrDefault(x => x.userID == id);
if (player == null)
player = BasePlayer.allPlayerList.FirstOrDefault(x => x.net.ID.Value == id);
}
catch (Exception e)
{
}
return player != null ? new PInfo { Name = player.displayName, SteamId = player.UserIDString } : new PInfo { Name = id.ToString(), SteamId = id.ToString() };
}
/**
* This avoids scientific notations
*/
static public void RoundOrLimitFloat(ref float value) => value = (value > 1000000) ? value % 1000000 : value;
public class PInfo
{
public string Name;
public string SteamId;
}
#endregion
public class CLogEntry
{
public int EventHash;
public float EventTime;
public string AttackerSteamId;
public string TargetSteamId;
public string Weapon;
public string Ammo;
public string Area;
public float Distance;
public float HealthOld;
public float HealthNew;
public string EventInfo;
public int ProjectileHits;
public float ProjectileIntegrity;
public float ProjectileTravelTime;
public float ProjectileTrajectoryMismatch;
public int Desync;
public static CLogEntry from(BasePlayer forPlayer, CombatLog.Event evt)
{
var pInfo = new PInfo { Name = forPlayer.displayName, SteamId = forPlayer.UserIDString };
var attacker = evt.attacker == "you" ? pInfo : UintFind(evt.attacker_id);
var target = evt.target == "you" ? pInfo : UintFind(evt.target_id);
RoundOrLimitFloat(ref evt.health_new);
RoundOrLimitFloat(ref evt.health_old);
return new CLogEntry
{
EventHash = evt.GetHashCode(),
AttackerSteamId = attacker.SteamId,
TargetSteamId = target.SteamId,
Ammo = evt.ammo,
Area = HitAreaUtil.Format(evt.area).ToLower(),
Distance = evt.distance,
EventInfo = evt.info,
EventTime = evt.time,
HealthNew = evt.health_new,
HealthOld = evt.health_old,
Desync = evt.desync,
ProjectileHits = evt.proj_hits,
ProjectileIntegrity = evt.proj_integrity,
ProjectileTrajectoryMismatch = evt.proj_mismatch,
ProjectileTravelTime = evt.proj_travel,
Weapon = evt.weapon
};
}
}
#region Configuration
private static ConfigData config = new ConfigData();
private class ConfigData
{
[JsonProperty(PropertyName = "Advanced Logs")]
public bool advancedLogs = true;
[JsonProperty(PropertyName = "Logs Size (default: 30, recommended: 45)")]
public int logSize = 45;
}
protected override void LoadConfig()
{
base.LoadConfig();
try
{
config = Config.ReadObject<ConfigData>();
if (config == null) LoadDefaultConfig();
}
catch
{
PrintError("Your config seems to be corrupted. Will load defaults.");
LoadDefaultConfig();
return;
}
SaveConfig();
}
protected override void LoadDefaultConfig()
{
config = new ConfigData();
}
protected override void SaveConfig() => Config.WriteObject(config);
#endregion
}
}