mirror of
https://gitee.com/xiarenalofs/squad-rain-ops-mini.git
synced 2026-08-09 14:45:58 +08:00
561 lines
21 KiB
C#
561 lines
21 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text.Json;
|
||
using System.Text.RegularExpressions;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using RainOpsMini.Helpers;
|
||
using RainOpsMini.Helpers.RCON;
|
||
using RainOpsMini;
|
||
using RainOpsMini.Models;
|
||
|
||
namespace RainOpsMini.Plugins
|
||
{
|
||
/// <summary>
|
||
/// TK自动踢出插件
|
||
/// </summary>
|
||
public class TKAutoKickPlugin : IPlugin, IDisposable
|
||
{
|
||
/// <summary>
|
||
/// 插件名称
|
||
/// </summary>
|
||
public string Name => "TK自动踢出";
|
||
|
||
/// <summary>
|
||
/// 插件描述
|
||
/// </summary>
|
||
public string Description => "TK自动踢出插件 - 自动检测队友伤害并要求道歉,否则踢出";
|
||
|
||
/// <summary>
|
||
/// 插件分类
|
||
/// </summary>
|
||
public PluginCategory Category => PluginCategory.BasicFunction;
|
||
|
||
/// <summary>
|
||
/// 是否启用
|
||
/// </summary>
|
||
public bool IsEnabled => _config.Enabled;
|
||
|
||
private TKAutoKickConfig _config = new TKAutoKickConfig();
|
||
private readonly object _lock = new object();
|
||
private HashSet<string> _adminSteamIds = new HashSet<string>();
|
||
private DateTime _lastAdminLoadTime = DateTime.MinValue;
|
||
|
||
// 存储待踢出的玩家任务:KillerSteamId -> Context
|
||
private static readonly Dictionary<string, TkContext> _pendingKicks = new Dictionary<string, TkContext>();
|
||
|
||
/// <summary>
|
||
/// TK上下文信息,用于追踪受害者和任务控制
|
||
/// </summary>
|
||
private class TkContext
|
||
{
|
||
public CancellationTokenSource Cts { get; set; } = new CancellationTokenSource();
|
||
public string VictimSteamId { get; set; } = "";
|
||
public string VictimName { get; set; } = "";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 构造函数,初始化插件并订阅事件
|
||
/// </summary>
|
||
public TKAutoKickPlugin()
|
||
{
|
||
ApplyConfig();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取当前配置
|
||
/// </summary>
|
||
/// <returns>配置对象</returns>
|
||
public object GetConfig() => _config;
|
||
|
||
/// <summary>
|
||
/// 更新配置
|
||
/// </summary>
|
||
/// <param name="config">JSON配置元素</param>
|
||
public void UpdateConfig(JsonElement config)
|
||
{
|
||
try
|
||
{
|
||
var options = new JsonSerializerOptions
|
||
{
|
||
PropertyNameCaseInsensitive = true
|
||
};
|
||
_config = JsonSerializer.Deserialize<TKAutoKickConfig>(config.GetRawText(), options) ?? new TKAutoKickConfig();
|
||
ApplyConfig();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
RainOpsLog.Log($"[TKAutoKick] 配置更新失败: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 应用配置,根据启用状态订阅或取消订阅事件
|
||
/// </summary>
|
||
private void ApplyConfig()
|
||
{
|
||
Program.OnRconMessageReceived -= OnRconMessage;
|
||
if (_config.Enabled)
|
||
{
|
||
Program.OnRconMessageReceived += OnRconMessage;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 重置配置为默认值
|
||
/// </summary>
|
||
public void ResetConfig()
|
||
{
|
||
_config = new TKAutoKickConfig();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 释放资源,取消订阅和待处理任务
|
||
/// </summary>
|
||
public void Dispose()
|
||
{
|
||
Program.OnRconMessageReceived -= OnRconMessage;
|
||
foreach (var ctx in _pendingKicks.Values)
|
||
{
|
||
ctx.Cts.Cancel();
|
||
ctx.Cts.Dispose();
|
||
}
|
||
_pendingKicks.Clear();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 处理接收到的 RCON 消息
|
||
/// </summary>
|
||
/// <param name="msg">原始消息内容</param>
|
||
private void OnRconMessage(string msg)
|
||
{
|
||
if (!IsEnabled) return;
|
||
|
||
try
|
||
{
|
||
// 1. 检测 TK 消息
|
||
// 假设消息格式包含 "Team Killed"
|
||
// 尝试解析: "Player1 Team Killed Player2" 或 "LogSquad: Player: Name (ID) Team Killed: Name (ID)"
|
||
if (msg.Contains("Team Killed"))
|
||
{
|
||
HandleTeamKill(msg);
|
||
}
|
||
|
||
// 2. 检测道歉消息
|
||
// 使用 RconParser 解析聊天消息,或者自己解析
|
||
// 这里为了方便,直接尝试解析聊天格式
|
||
var chatMsg = RconParser.ParseChatMessage(msg);
|
||
if (chatMsg != null)
|
||
{
|
||
HandleChatMessage(chatMsg);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.WriteLine($"[TKAutoKick] 处理消息异常: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 处理队友伤害事件
|
||
/// </summary>
|
||
/// <param name="msg">包含 TK 信息的日志消息</param>
|
||
private void HandleTeamKill(string msg)
|
||
{
|
||
// 用户指定的匹配格式
|
||
// 示例: [ChatAdmin] ASQKillDeathRuleset : Player Name1 Team Killed Player Name2
|
||
var regex = new Regex(@"\[ChatAdmin\] ASQKillDeathRuleset : Player (.*) Team Killed Player (.*)");
|
||
var match = regex.Match(msg);
|
||
|
||
string killerName = "";
|
||
string victimName = "";
|
||
|
||
if (match.Success)
|
||
{
|
||
killerName = match.Groups[1].Value.Trim();
|
||
victimName = match.Groups[2].Value.Trim();
|
||
}
|
||
else
|
||
{
|
||
// 保留一种备用的匹配方式,以防格式有变
|
||
// 尝试标准 Squad 日志格式 (带 SteamId 的)
|
||
var detailedMatch = Regex.Match(msg, @"Player:\s*(.+?)\s*\(SteamId:\s*(\d+)\)\s*Team Killed:\s*(.+?)\s*\(SteamId:\s*(\d+)\)");
|
||
if (detailedMatch.Success)
|
||
{
|
||
// ... 之前的逻辑,直接使用 ID
|
||
// 为保持逻辑统一,这里我们先提取名字,后面统一模糊匹配,或者如果这里能直接拿到 ID 更好
|
||
// 但为了简化代码结构,这里我们只提取名字,让后面统一处理,除非我们非常确信 ID 是对的。
|
||
// 鉴于 detailedMatch 是非常具体的,如果匹配成功,我们应该优先使用 ID。
|
||
|
||
var killerId = detailedMatch.Groups[2].Value;
|
||
var victimId = detailedMatch.Groups[4].Value;
|
||
var k = Program.RconCache.Players.FirstOrDefault(p => p.SteamId == killerId);
|
||
var v = Program.RconCache.Players.FirstOrDefault(p => p.SteamId == victimId);
|
||
|
||
if (k != null && v != null)
|
||
{
|
||
ProcessTk(k, v);
|
||
return;
|
||
}
|
||
}
|
||
|
||
return; // 如果都不匹配,则忽略
|
||
}
|
||
|
||
// 模糊匹配
|
||
var killer = FindPlayerFuzzy(killerName);
|
||
var victim = FindPlayerFuzzy(victimName);
|
||
|
||
if (killer != null && victim != null)
|
||
{
|
||
ProcessTk(killer, victim);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 执行 TK 惩罚流程(警告 + 定时踢出)
|
||
/// </summary>
|
||
/// <param name="killer">施暴者信息</param>
|
||
/// <param name="victim">受害者信息</param>
|
||
private void ProcessTk(PlayerInfo killer, PlayerInfo victim)
|
||
{
|
||
// 排除自杀
|
||
if (killer.SteamId == victim.SteamId) return;
|
||
|
||
// 1. 被TK的人是管理或者白名单(IsAdminOrWhiteList)
|
||
if (IsAdminOrWhiteList(victim.SteamId)) return;
|
||
|
||
// 2. 被TK的人是机器人
|
||
if (IsBot(victim)) return;
|
||
|
||
// 3. TK的人是机器人
|
||
if (IsBot(killer)) return;
|
||
|
||
int maxSecondInt = 60;
|
||
int.TryParse(_config.MaxSecond, out maxSecondInt);
|
||
if (maxSecondInt <= 0) maxSecondInt = 60;
|
||
|
||
// 格式化命令
|
||
var victimWarnCmd = FormatCommand(_config.VictimWarnCommand, killer.Name, killer.SteamId, victim.Name, victim.SteamId, _config.MaxSecond);
|
||
var killerWarnCmd = FormatCommand(_config.KillerWarnCommand, killer.Name, killer.SteamId, victim.Name, victim.SteamId, _config.MaxSecond);
|
||
|
||
// 发送警告
|
||
// 发给 Victim (受害者)
|
||
if (!string.IsNullOrWhiteSpace(victimWarnCmd)) SendComd(victimWarnCmd);
|
||
|
||
// 发给 Killer (施暴者)
|
||
if (!string.IsNullOrWhiteSpace(killerWarnCmd)) SendComd(killerWarnCmd);
|
||
|
||
// 全服广播
|
||
if (_config.IsBroadcastEnabled)
|
||
{
|
||
var broadcastCmd = FormatCommand(_config.BroadcastCommand, killer.Name, killer.SteamId, victim.Name, victim.SteamId, _config.MaxSecond);
|
||
if (!string.IsNullOrWhiteSpace(broadcastCmd)) SendComd(broadcastCmd);
|
||
}
|
||
|
||
// 定时踢出逻辑
|
||
if (_config.MaxSecond != "0")
|
||
{
|
||
// 设置定时任务
|
||
ScheduleKick(killer, victim, maxSecondInt);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 替换命令模板中的占位符
|
||
/// </summary>
|
||
private string FormatCommand(string template, string killerName, string killerSteamId, string victimName, string victimSteamId, string maxSecond)
|
||
{
|
||
if (string.IsNullOrEmpty(template)) return string.Empty;
|
||
|
||
return template
|
||
.Replace("{KillerName}", killerName)
|
||
.Replace("{KillerSteamId}", killerSteamId)
|
||
.Replace("{VictimName}", victimName)
|
||
.Replace("{VictimSteamId}", victimSteamId)
|
||
.Replace("{MaxSecond}", maxSecond);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 加载管理员列表
|
||
/// </summary>
|
||
private void LoadAdmins()
|
||
{
|
||
try
|
||
{
|
||
var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "SquadGame", "ServerConfig", "Admins.cfg");
|
||
if (File.Exists(path))
|
||
{
|
||
var lines = File.ReadAllLines(path);
|
||
_adminSteamIds.Clear();
|
||
foreach (var line in lines)
|
||
{
|
||
if (line.Trim().StartsWith("Admin="))
|
||
{
|
||
var parts = line.Split('=', ':');
|
||
if (parts.Length >= 2)
|
||
{
|
||
var steamId = parts[1].Trim();
|
||
if (!string.IsNullOrEmpty(steamId))
|
||
{
|
||
_adminSteamIds.Add(steamId);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
_lastAdminLoadTime = DateTime.Now;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
RainOpsLog.Log($"[TKAutoKick] Failed to load admins: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 检查是否是管理员或白名单
|
||
/// </summary>
|
||
private bool IsAdminOrWhiteList(string steamId)
|
||
{
|
||
if (string.IsNullOrEmpty(steamId)) return false;
|
||
|
||
// 每5分钟重新加载一次
|
||
if ((DateTime.Now - _lastAdminLoadTime).TotalMinutes > 5)
|
||
{
|
||
LoadAdmins();
|
||
}
|
||
|
||
if (_adminSteamIds.Contains(steamId)) return true;
|
||
if (_config.WhitelistSteamIds != null && _config.WhitelistSteamIds.Contains(steamId)) return true;
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 检查是否是机器人
|
||
/// </summary>
|
||
private bool IsBot(PlayerInfo p)
|
||
{
|
||
if (p == null) return true;
|
||
if (string.IsNullOrEmpty(p.SteamId)) return true;
|
||
// 简单的 SteamID 校验,正常 SteamID 应该是 17 位数字
|
||
if (p.SteamId.Length < 10) return true;
|
||
return false;
|
||
}
|
||
|
||
|
||
/// <summary>
|
||
/// 根据名字模糊查找在线玩家
|
||
/// </summary>
|
||
/// <param name="namePart">玩家名字片段</param>
|
||
/// <returns>匹配到的玩家信息,未找到返回 null</returns>
|
||
private PlayerInfo? FindPlayerFuzzy(string namePart)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(namePart)) return null;
|
||
|
||
var players = Program.RconCache.Players; // 已经是拷贝或引用,直接用
|
||
// 1. 精确匹配
|
||
var exact = players.FirstOrDefault(p => p.Name.Equals(namePart, StringComparison.OrdinalIgnoreCase));
|
||
if (exact != null) return exact;
|
||
|
||
// 2. 包含匹配
|
||
return players.FirstOrDefault(p => p.Name.Contains(namePart, StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 处理玩家聊天消息(用于检测道歉)
|
||
/// </summary>
|
||
/// <param name="chatMsg">聊天消息对象</param>
|
||
private void HandleChatMessage(ChatMessage chatMsg)
|
||
{
|
||
// 检查是否是道歉关键词
|
||
if (_config.ApologyKeywords.Any(k => chatMsg.Message.Contains(k, StringComparison.OrdinalIgnoreCase)))
|
||
{
|
||
// 检查该玩家是否在待踢列表
|
||
lock (_lock)
|
||
{
|
||
if (_pendingKicks.TryGetValue(chatMsg.SteamId, out var ctx))
|
||
{
|
||
// 如果已经请求取消,说明正在处理或已处理
|
||
if (ctx.Cts.IsCancellationRequested) return;
|
||
|
||
// 取消踢出
|
||
ctx.Cts.Cancel();
|
||
|
||
// 通知施暴者
|
||
var successCmd = FormatCommand(_config.ApologySuccessCommand, chatMsg.Name, chatMsg.SteamId, ctx.VictimName, ctx.VictimSteamId, _config.MaxSecond);
|
||
if (!string.IsNullOrWhiteSpace(successCmd)) SendComd(successCmd);
|
||
|
||
// 通知受害者
|
||
if (!string.IsNullOrEmpty(ctx.VictimSteamId))
|
||
{
|
||
var notifyCmd = FormatCommand(_config.ApologyNotificationCommand, chatMsg.Name, chatMsg.SteamId, ctx.VictimName, ctx.VictimSteamId, _config.MaxSecond);
|
||
if (!string.IsNullOrWhiteSpace(notifyCmd)) SendComd(notifyCmd);
|
||
}
|
||
|
||
// 不在这里移除或释放,交给 Task 的 finally 块统一处理
|
||
// 这样可以避免 Dispose 竞态条件
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 调度踢出任务
|
||
/// </summary>
|
||
/// <param name="killer">施暴者</param>
|
||
/// <param name="victim">受害者</param>
|
||
/// <param name="seconds">延迟秒数</param>
|
||
private void ScheduleKick(PlayerInfo killer, PlayerInfo victim, int seconds)
|
||
{
|
||
lock (_lock)
|
||
{
|
||
if (_pendingKicks.ContainsKey(killer.SteamId))
|
||
{
|
||
var oldCtx = _pendingKicks[killer.SteamId];
|
||
oldCtx.Cts.Cancel();
|
||
oldCtx.Cts.Dispose();
|
||
_pendingKicks.Remove(killer.SteamId);
|
||
}
|
||
|
||
var ctx = new TkContext
|
||
{
|
||
VictimSteamId = victim.SteamId,
|
||
VictimName = victim.Name
|
||
};
|
||
_pendingKicks[killer.SteamId] = ctx;
|
||
|
||
// 启动任务
|
||
Task.Run(async () =>
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(seconds * 1000, ctx.Cts.Token);
|
||
if (!ctx.Cts.Token.IsCancellationRequested)
|
||
{
|
||
// 时间到,执行踢出
|
||
ExecuteKick(killer);
|
||
}
|
||
}
|
||
catch (TaskCanceledException)
|
||
{
|
||
// 任务被取消 (道歉了)
|
||
}
|
||
finally
|
||
{
|
||
// 清理
|
||
lock (_lock)
|
||
{
|
||
// 只有当 Map 里的还是这个 ctx 时才移除 (防止已被新任务覆盖)
|
||
if (_pendingKicks.TryGetValue(killer.SteamId, out var currentCtx) && currentCtx == ctx)
|
||
{
|
||
_pendingKicks.Remove(killer.SteamId);
|
||
ctx.Cts.Dispose();
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 执行踢出操作
|
||
/// </summary>
|
||
/// <param name="killer">要踢出的玩家信息</param>
|
||
private void ExecuteKick(PlayerInfo killer)
|
||
{
|
||
Console.WriteLine($"[TKAutoKick] 踢出玩家 {killer.Name} ({killer.SteamId})");
|
||
SendComd($"AdminKick {killer.SteamId} {killer.Name} {_config.KickReason}");
|
||
|
||
var msg = FormatCommand(_config.KickBroadcastCommand, killer.Name, killer.SteamId, "", "", _config.MaxSecond);
|
||
if (!string.IsNullOrWhiteSpace(msg)) SendComd(msg);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 发送 RCON 命令
|
||
/// </summary>
|
||
/// <param name="cmd">命令内容</param>
|
||
private async void SendComd(string cmd)
|
||
{
|
||
if (Program.Client != null && Program.Client.IsConnected)
|
||
{
|
||
await Program.Client.SendCommandAsync(cmd);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// TK自动踢出插件配置类
|
||
/// </summary>
|
||
public class TKAutoKickConfig
|
||
{
|
||
/// <summary>
|
||
/// 是否启用插件
|
||
/// </summary>
|
||
public bool Enabled { get; set; } = false;
|
||
|
||
/// <summary>
|
||
/// 道歉时限(秒)
|
||
/// </summary>
|
||
public string MaxSecond { get; set; } = "60";
|
||
|
||
/// <summary>
|
||
/// 道歉关键词列表
|
||
/// </summary>
|
||
public List<string> ApologyKeywords { get; set; } = new List<string> { "s", "sor", "sorry", "sry" };
|
||
|
||
/// <summary>
|
||
/// 白名单SteamID列表 (不被踢出,且作为受害者时不触发踢人)
|
||
/// </summary>
|
||
public List<string> WhitelistSteamIds { get; set; } = new List<string>();
|
||
|
||
/// <summary>
|
||
/// 发给受害者的警告命令模板
|
||
/// 可用占位符: {KillerName}, {KillerSteamId}, {VictimName}, {VictimSteamId}, {MaxSecond}
|
||
/// </summary>
|
||
public string VictimWarnCommand { get; set; } = "AdminWarn {VictimSteamId} 您被队友({KillerName})击倒了!";
|
||
|
||
/// <summary>
|
||
/// 发给施暴者的警告命令模板
|
||
/// 可用占位符: {KillerName}, {KillerSteamId}, {VictimName}, {VictimSteamId}, {MaxSecond}
|
||
/// </summary>
|
||
public string KillerWarnCommand { get; set; } = "AdminWarn {KillerSteamId} 您击倒了队友({VictimName})!请在{MaxSecond}s内道歉!";
|
||
|
||
/// <summary>
|
||
/// 是否开启全服广播
|
||
/// </summary>
|
||
public bool IsBroadcastEnabled { get; set; } = true;
|
||
|
||
/// <summary>
|
||
/// 全服广播命令模板
|
||
/// 可用占位符: {KillerName}, {KillerSteamId}, {VictimName}, {VictimSteamId}, {MaxSecond}
|
||
/// </summary>
|
||
public string BroadcastCommand { get; set; } = "AdminBroadcast {KillerName} 痛击了队友({VictimName})请在{MaxSecond}秒内表达歉意!否则将被移出服务器!";
|
||
|
||
/// <summary>
|
||
/// 道歉成功给施暴者的命令模板
|
||
/// 可用占位符: {KillerName}, {KillerSteamId}, {VictimName}, {VictimSteamId}, {MaxSecond}
|
||
/// </summary>
|
||
public string ApologySuccessCommand { get; set; } = "AdminWarn {KillerSteamId} 道歉成功!";
|
||
|
||
/// <summary>
|
||
/// 道歉成功给受害者的命令模板
|
||
/// 可用占位符: {KillerName}, {KillerSteamId}, {VictimName}, {VictimSteamId}, {MaxSecond}
|
||
/// </summary>
|
||
public string ApologyNotificationCommand { get; set; } = "AdminWarn {VictimSteamId} 队友 {KillerName} 已向您道歉。";
|
||
|
||
/// <summary>
|
||
/// 踢出理由
|
||
/// </summary>
|
||
public string KickReason { get; set; } = "队友伤害未道歉";
|
||
|
||
/// <summary>
|
||
/// 踢出时的全服广播命令模板
|
||
/// 可用占位符: {KillerName}, {KillerSteamId}, {VictimName}, {VictimSteamId}, {MaxSecond}
|
||
/// </summary>
|
||
public string KickBroadcastCommand { get; set; } = "AdminBroadcast {KillerName} 因伤害队友未道歉被移出服务器!";
|
||
}
|
||
}
|