using System; using System.Collections.Generic; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries.Covalence; using Oxide.Core.Plugins; using UnityEngine; namespace Oxide.Plugins { [Info("RustWebManager", "Mazurk4_", "1.15.0")] [Description("Rust Web Manager との連携プラグイン — 接続イベント記録・チャット履歴・kill/death ログ・T3作業台/クレート開封ログ・チーム履歴・F7レポート・PM送信")] class RustWebManager : RustPlugin { // ── コンフィグ ──────────────────────────────────────────────────── private PluginConfig _config; class PluginConfig { [JsonProperty("SaveIntervalMinutes")] public int SaveIntervalMinutes { get; set; } = 5; // 各データファイル(events/chat/activity/team_events/team_snapshots/reports/players)の // 保持件数上限。これを超えると古い順に削除する。時間ベースのパージは行わず、 // シーズン跨ぎは rwm.resetseason で全消去する(シーズン制対応)。 [JsonProperty("MaxRecordsPerType")] public int MaxRecordsPerType { get; set; } = 50000; } // 保持件数上限(設定値。_config 未ロード時の保険として既定 50000)。 private int MaxRecords => _config?.MaxRecordsPerType ?? 50000; protected override void LoadDefaultConfig() { _config = new PluginConfig(); SaveConfig(); } protected override void LoadConfig() { base.LoadConfig(); try { _config = Config.ReadObject(); if (_config == null) LoadDefaultConfig(); _config.SaveIntervalMinutes = Math.Max(5, Math.Min(30, _config.SaveIntervalMinutes)); _config.MaxRecordsPerType = Math.Max(1000, Math.Min(500000, _config.MaxRecordsPerType)); // 旧バージョンの設定ファイルに欠けている新キーを追記し、クランプ済みの値を永続化する。 SaveConfig(); } catch { LoadDefaultConfig(); } } protected override void SaveConfig() { Config.WriteObject(_config); } // ── 接続イベント (時系列リスト) ────────────────────────────────── private const string EventDataFile = "RustWebManager/events"; private List _events = new List(); class PlayerEvent { [JsonProperty("SteamId")] public string SteamId { get; set; } [JsonProperty("DisplayName")] public string DisplayName { get; set; } [JsonProperty("EventType")] public string EventType { get; set; } [JsonProperty("Timestamp")] public string Timestamp { get; set; } [JsonProperty("SessionSeconds", NullValueHandling = NullValueHandling.Ignore)] public int? SessionSeconds { get; set; } } // ── プレイヤーサマリ (SteamID ごとの集計) ─────────────────────── private const string PlayerDataFile = "RustWebManager/players"; private Dictionary _players = new Dictionary(); private Dictionary _sessionStart = new Dictionary(); class PlayerRecord { [JsonProperty("SteamID")] public string SteamID { get; set; } [JsonProperty("DisplayName")] public string DisplayName { get; set; } [JsonProperty("EventType")] public string EventType { get; set; } [JsonProperty("LastConnected")] public string LastConnected { get; set; } [JsonProperty("FirstConnected")] public string FirstConnected { get; set; } [JsonProperty("TotalConnections")] public int TotalConnections { get; set; } [JsonProperty("LastIP")] public string LastIP { get; set; } [JsonProperty("PlayTime")] public int PlayTime { get; set; } } // ── チャット履歴 ───────────────────────────────────────────────── private const string ChatDataFile = "RustWebManager/chat"; private List _chat = new List(); class ChatRecord { [JsonProperty("channel")] public int Channel { get; set; } [JsonProperty("team_id")] public string TeamId { get; set; } [JsonProperty("username")] public string Username { get; set; } [JsonProperty("user_id")] public string UserId { get; set; } [JsonProperty("color")] public string Color { get; set; } [JsonProperty("time")] public long Time { get; set; } [JsonProperty("message")] public string Message { get; set; } } // ── アクティビティログ (kill / death / chat) ───────────────────── private const string ActivityDataFile = "RustWebManager/activity"; private List _activity = new List(); // crate_open のシーズン内重複排除用。同一クレート(net.ID)の再オープン/複数人閲覧で // 開封数が過大計上されないよう、初回だけ計上する。シーズン切替(resetseason)でクリア。 private const string OpenedCratesDataFile = "RustWebManager/opened_crates"; private HashSet _openedCrates = new HashSet(); class ActivityRecord { [JsonProperty("steam_id")] public string SteamId { get; set; } [JsonProperty("display_name")] public string DisplayName { get; set; } /// kill / death / chat / workbench_t3 / crate_open [JsonProperty("activity_type")] public string ActivityType { get; set; } /// kill: 被キル者 SteamID, death: 加害者 SteamID (PVE/自殺時は null) [JsonProperty("target_steam_id")] public string TargetSteamId { get; set; } /// kill: 被キル者名, death: 加害者名 [JsonProperty("target_name")] public string TargetName { get; set; } /// kill/death: 武器名, chat: メッセージ本文 [JsonProperty("extra_data")] public string ExtraData { get; set; } [JsonProperty("recorded_at")] public string RecordedAt { get; set; } } // ── チーム / グループ情報 ─────────────────────────────────────── private const string TeamStateDataFile = "RustWebManager/team_state"; private const string TeamEventDataFile = "RustWebManager/team_events"; private const string TeamSnapshotDataFile = "RustWebManager/team_snapshots"; private Dictionary> _knownTeamMembers = new Dictionary>(); private List _teamEvents = new List(); private List _teamSnapshots = new List(); class TeamMemberRecord { [JsonProperty("steam_id")] public string SteamId { get; set; } [JsonProperty("display_name")] public string DisplayName { get; set; } [JsonProperty("is_leader")] public bool IsLeader { get; set; } [JsonProperty("is_online")] public bool IsOnline { get; set; } } class TeamRecord { [JsonProperty("team_id")] public string TeamId { get; set; } [JsonProperty("leader_steam_id")] public string LeaderSteamId { get; set; } [JsonProperty("leader_name")] public string LeaderName { get; set; } [JsonProperty("member_count")] public int MemberCount { get; set; } [JsonProperty("online_count")] public int OnlineCount { get; set; } [JsonProperty("members")] public List Members { get; set; } = new List(); } class TeamActivityRecord { [JsonProperty("team_id")] public string TeamId { get; set; } [JsonProperty("event_type")] public string EventType { get; set; } [JsonProperty("steam_id")] public string SteamId { get; set; } [JsonProperty("display_name")] public string DisplayName { get; set; } [JsonProperty("recorded_at")] public string RecordedAt { get; set; } } class TeamSnapshotRecord { [JsonProperty("team_id")] public string TeamId { get; set; } [JsonProperty("member_count")] public int MemberCount { get; set; } [JsonProperty("online_count")] public int OnlineCount { get; set; } [JsonProperty("recorded_at")] public string RecordedAt { get; set; } } // ── F7 プレイヤーレポート ──────────────────────────────────────── private const string ReportDataFile = "RustWebManager/reports"; private const string ReportLogPrefix = "[RWM_REPORT]"; private List _reports = new List(); class PlayerReportRecord { [JsonProperty("id")] public string Id { get; set; } [JsonProperty("reporter_name")] public string ReporterName { get; set; } [JsonProperty("reporter_steam_id")] public string ReporterSteamId { get; set; } [JsonProperty("reported_name")] public string ReportedName { get; set; } [JsonProperty("reported_steam_id")] public string ReportedSteamId { get; set; } [JsonProperty("subject")] public string Subject { get; set; } [JsonProperty("message")] public string Message { get; set; } [JsonProperty("report_type")] public string ReportType { get; set; } [JsonProperty("created_at")] public string CreatedAt { get; set; } } class AuthListsResponse { [JsonProperty("owners")] public List Owners { get; set; } = new List(); [JsonProperty("moderators")] public List Moderators { get; set; } = new List(); } // ── タイマー ───────────────────────────────────────────────────── private Timer _saveTimer; // ── 初期化 ──────────────────────────────────────────────────────── void Init() { _events = Interface.Oxide.DataFileSystem.ReadObject>(EventDataFile) ?? new List(); _players = Interface.Oxide.DataFileSystem.ReadObject>(PlayerDataFile) ?? new Dictionary(); _chat = Interface.Oxide.DataFileSystem.ReadObject>(ChatDataFile) ?? new List(); _activity = Interface.Oxide.DataFileSystem.ReadObject>(ActivityDataFile) ?? new List(); _openedCrates = Interface.Oxide.DataFileSystem.ReadObject>(OpenedCratesDataFile) ?? new HashSet(); _knownTeamMembers = Interface.Oxide.DataFileSystem.ReadObject>>(TeamStateDataFile) ?? new Dictionary>(); _teamEvents = Interface.Oxide.DataFileSystem.ReadObject>(TeamEventDataFile) ?? new List(); _teamSnapshots = Interface.Oxide.DataFileSystem.ReadObject>(TeamSnapshotDataFile) ?? new List(); _reports = Interface.Oxide.DataFileSystem.ReadObject>(ReportDataFile) ?? new List(); // ホットロード時は既に在席中のプレイヤーがいる。EnforceRecordCaps は接続中 // (_sessionStart 在席)プレイヤーを players 切り詰めから保護するが、この時点では // _sessionStart が空のため、先に在席者を登録しておかないと LastConnected が古い // 長時間接続者の players レコードを失う恐れがある(OnServerInitialized が新規 // 再作成し FirstConnected / 累積接続数 / プレイ時間が消える)。サーバー起動時は // activePlayerList が空のため no-op。OnServerInitialized は ContainsKey ガード済み。 foreach (var player in BasePlayer.activePlayerList) if (!_sessionStart.ContainsKey(player.UserIDString)) _sessionStart[player.UserIDString] = DateTime.UtcNow; EnforceRecordCaps(); } // ── サーバー初期化 ──────────────────────────────────────────────── void OnServerInitialized() { bool dirty = false; foreach (var player in BasePlayer.activePlayerList) { var steamId = player.UserIDString; if (!_players.TryGetValue(steamId, out var record)) { var now = DateTime.UtcNow; record = new PlayerRecord { SteamID = steamId, DisplayName = player.displayName, EventType = "connected", LastConnected = now.ToString("o"), FirstConnected = now.ToString("o"), TotalConnections = 1, LastIP = "", PlayTime = 0, }; _players[steamId] = record; dirty = true; } if (!_sessionStart.ContainsKey(steamId)) _sessionStart[steamId] = DateTime.UtcNow; } if (dirty) Interface.Oxide.DataFileSystem.WriteObject(PlayerDataFile, _players); CaptureTeams(); float intervalSeconds = _config.SaveIntervalMinutes * 60f; _saveTimer = timer.Every(intervalSeconds, SaveAll); Puts($"[RWM] Auto-save timer started: every {_config.SaveIntervalMinutes} min(s)"); } // ── アンロード ──────────────────────────────────────────────────── void Unload() { _saveTimer?.Destroy(); var now = DateTime.UtcNow; foreach (var kv in _sessionStart) { if (_players.TryGetValue(kv.Key, out var record)) record.PlayTime += (int)(now - kv.Value).TotalSeconds; } EnforceRecordCaps(); Interface.Oxide.DataFileSystem.WriteObject(EventDataFile, _events); Interface.Oxide.DataFileSystem.WriteObject(PlayerDataFile, _players); Interface.Oxide.DataFileSystem.WriteObject(ChatDataFile, _chat); Interface.Oxide.DataFileSystem.WriteObject(ActivityDataFile, _activity); Interface.Oxide.DataFileSystem.WriteObject(OpenedCratesDataFile, _openedCrates); Interface.Oxide.DataFileSystem.WriteObject(TeamStateDataFile, _knownTeamMembers); Interface.Oxide.DataFileSystem.WriteObject(TeamEventDataFile, _teamEvents); Interface.Oxide.DataFileSystem.WriteObject(TeamSnapshotDataFile, _teamSnapshots); Interface.Oxide.DataFileSystem.WriteObject(ReportDataFile, _reports); } // ── 定期保存 ───────────────────────────────────────────────────── void SaveAll() { CaptureTeams(); EnforceRecordCaps(); Interface.Oxide.DataFileSystem.WriteObject(EventDataFile, _events); Interface.Oxide.DataFileSystem.WriteObject(PlayerDataFile, _players); Interface.Oxide.DataFileSystem.WriteObject(ChatDataFile, _chat); Interface.Oxide.DataFileSystem.WriteObject(ActivityDataFile, _activity); Interface.Oxide.DataFileSystem.WriteObject(OpenedCratesDataFile, _openedCrates); Interface.Oxide.DataFileSystem.WriteObject(TeamStateDataFile, _knownTeamMembers); Interface.Oxide.DataFileSystem.WriteObject(TeamEventDataFile, _teamEvents); Interface.Oxide.DataFileSystem.WriteObject(TeamSnapshotDataFile, _teamSnapshots); Interface.Oxide.DataFileSystem.WriteObject(ReportDataFile, _reports); } // ── 接続イベントフック ──────────────────────────────────────────── void OnPlayerConnected(BasePlayer player) { var now = DateTime.UtcNow; var steamId = player.UserIDString; RecordEvent(player, "connected"); _sessionStart[steamId] = now; if (!_players.TryGetValue(steamId, out var record)) { record = new PlayerRecord { SteamID = steamId, FirstConnected = now.ToString("o"), PlayTime = 0, TotalConnections = 0, }; _players[steamId] = record; } var rawIp = player.net?.connection?.ipaddress ?? ""; record.DisplayName = player.displayName; record.EventType = "connected"; record.LastConnected = now.ToString("o"); record.TotalConnections += 1; record.LastIP = rawIp.Contains(":") ? rawIp.Split(':')[0] : rawIp; Interface.Oxide.DataFileSystem.WriteObject(PlayerDataFile, _players); } void OnPlayerDisconnected(BasePlayer player, string reason) { var now = DateTime.UtcNow; var steamId = player.UserIDString; int? sessionSec = null; if (_sessionStart.TryGetValue(steamId, out var start)) sessionSec = (int)(now - start).TotalSeconds; RecordEvent(player, "disconnected", sessionSec); if (_players.TryGetValue(steamId, out var record)) { if (sessionSec.HasValue) { record.PlayTime += sessionSec.Value; _sessionStart.Remove(steamId); } record.DisplayName = player.displayName; record.EventType = "disconnected"; Interface.Oxide.DataFileSystem.WriteObject(PlayerDataFile, _players); } } void RecordEvent(BasePlayer player, string eventType, int? sessionSeconds = null) { _events.Add(new PlayerEvent { SteamId = player.UserIDString, DisplayName = player.displayName, EventType = eventType, Timestamp = DateTime.UtcNow.ToString("o"), SessionSeconds = sessionSeconds, }); if (_events.Count > MaxRecords) _events.RemoveRange(0, _events.Count - MaxRecords); Interface.Oxide.DataFileSystem.WriteObject(EventDataFile, _events); } // ── kill / death フック ─────────────────────────────────────────── // BasePlayer が死亡したときに呼ばれる。 // 加害者がいる場合: 被キル者に death・加害者に kill をそれぞれ記録する。 void OnEntityDeath(BaseCombatEntity entity, HitInfo info) { var victim = entity as BasePlayer; if (victim == null || victim.IsNpc) return; var attacker = info?.Initiator as BasePlayer; var now = DateTime.UtcNow.ToString("o"); var weapon = info?.WeaponPrefab?.ShortPrefabName ?? ""; // 被キル者の death イベント AddActivity(new ActivityRecord { SteamId = victim.UserIDString, DisplayName = victim.displayName, ActivityType = "death", TargetSteamId = attacker != null && !attacker.IsNpc ? attacker.UserIDString : null, TargetName = attacker != null && !attacker.IsNpc ? attacker.displayName : null, ExtraData = weapon, RecordedAt = now, }); // PVP: 加害者の kill イベント if (attacker != null && !attacker.IsNpc && attacker.UserIDString != victim.UserIDString) { AddActivity(new ActivityRecord { SteamId = attacker.UserIDString, DisplayName = attacker.displayName, ActivityType = "kill", TargetSteamId = victim.UserIDString, TargetName = victim.displayName, ExtraData = weapon, RecordedAt = now, }); } } private void AddActivity(ActivityRecord rec) { _activity.Add(rec); if (_activity.Count > MaxRecords) _activity.RemoveRange(0, _activity.Count - MaxRecords); } // ── 設置 / loot フック (Phase 2: First T3WB / Crates Opened) ──────── /// /// Tier 3 ワークベンチ(workbench3.deployed)設置を activity_type="workbench_t3" で記録。 /// シーズンレポートの「最速 T3WB 設置」称号は backend が MIN(recorded_at) で算出する。 /// void OnEntityBuilt(Planner plan, GameObject go) { if (plan == null || go == null) return; var entity = go.ToBaseEntity(); if (entity == null) return; if (entity.ShortPrefabName != "workbench3.deployed") return; var player = plan.GetOwnerPlayer(); if (player == null || player.IsNpc) return; AddActivity(new ActivityRecord { SteamId = player.UserIDString, DisplayName = player.displayName, ActivityType = "workbench_t3", ExtraData = entity.ShortPrefabName, RecordedAt = DateTime.UtcNow.ToString("o"), }); } /// /// クレート(ShortPrefabName に "crate" を含む LootContainer)の開封を /// activity_type="crate_open" で記録。プレイヤー設置の収納箱・スタッシュは除外。 /// void OnLootEntity(BasePlayer player, BaseEntity entity) { if (player == null || player.IsNpc || entity == null) return; if (!(entity is LootContainer)) return; var prefab = entity.ShortPrefabName ?? ""; if (prefab.IndexOf("crate", StringComparison.OrdinalIgnoreCase) < 0) return; // 同一クレート(net.ID)はシーズン内で初回開封のみ計上。再オープン/複数人閲覧での // 過大計上を防ぐ。HashSet.Add は既存なら false を返す。net が無い稀ケースは計上する。 var net = entity.net; if (net != null && !_openedCrates.Add(net.ID.Value)) return; AddActivity(new ActivityRecord { SteamId = player.UserIDString, DisplayName = player.displayName, ActivityType = "crate_open", ExtraData = prefab, RecordedAt = DateTime.UtcNow.ToString("o"), }); } /// /// マップワイプ検知。crate_open 重複排除集合(net.ID)は「1 ワイプ=1 マップ」内でのみ /// 有効で、ワイプ後は net.ID が振り直されるため、ここで確実にクリアする。 /// worker の自動ワイプ切替は rwm.resetseason を送らないため、それに依存せずリセットする。 /// void OnNewSave(string filename) { _openedCrates.Clear(); Interface.Oxide.DataFileSystem.WriteObject(OpenedCratesDataFile, _openedCrates); } // ── チャットフック ──────────────────────────────────────────────── void OnPlayerChat(BasePlayer player, string message, object channel) { if (player == null) return; int channelInt = NormalizeChatChannel(channel); RecordChat(channelInt, GetPlayerTeamId(player.userID), player.displayName, player.UserIDString, "#5af", message); // アクティビティログにもチャットを記録 AddActivity(new ActivityRecord { SteamId = player.UserIDString, DisplayName = player.displayName, ActivityType = "chat", ExtraData = message, RecordedAt = DateTime.UtcNow.ToString("o"), }); } private int NormalizeChatChannel(object channel) { if (channel == null) return 0; try { return Convert.ToInt32(channel); } catch { } var raw = channel.ToString(); if (string.IsNullOrEmpty(raw)) return 0; if (raw.IndexOf("Team", StringComparison.OrdinalIgnoreCase) >= 0) return 1; if (raw.IndexOf("Server", StringComparison.OrdinalIgnoreCase) >= 0) return 2; return 0; } private void RecordChat(int channel, string teamId, string username, string userId, string color, string message) { _chat.Add(new ChatRecord { Channel = channel, TeamId = teamId, Username = username, UserId = userId, Color = color, Time = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), Message = message, }); if (_chat.Count > MaxRecords) _chat.RemoveRange(0, _chat.Count - MaxRecords); Interface.Oxide.DataFileSystem.WriteObject(ChatDataFile, _chat); } // ── チーム / グループ収集 ─────────────────────────────────────── private List GetCurrentTeams() { var result = new List(); var teams = RelationshipManager.ServerInstance?.teams; if (teams == null) return result; foreach (var kv in teams) { var team = kv.Value; if (team == null || team.members == null || team.members.Count == 0) continue; var teamId = kv.Key.ToString(); var leaderId = team.teamLeader.ToString(); var record = new TeamRecord { TeamId = teamId, LeaderSteamId = leaderId, LeaderName = ResolvePlayerName(leaderId), }; foreach (var memberId in team.members) { var steamId = memberId.ToString(); var online = IsPlayerOnline(memberId); record.Members.Add(new TeamMemberRecord { SteamId = steamId, DisplayName = ResolvePlayerName(steamId), IsLeader = steamId == leaderId, IsOnline = online, }); if (online) record.OnlineCount += 1; } record.MemberCount = record.Members.Count; result.Add(record); } return result; } private void CaptureTeams() { var now = DateTime.UtcNow.ToString("o"); var current = GetCurrentTeams(); var currentMap = new Dictionary>(); foreach (var team in current) { var members = new List(); foreach (var member in team.Members) members.Add(member.SteamId); currentMap[team.TeamId] = members; if (!_knownTeamMembers.ContainsKey(team.TeamId)) AddTeamEvent(team.TeamId, "created", team.LeaderSteamId, team.LeaderName, now); var previous = _knownTeamMembers.ContainsKey(team.TeamId) ? new HashSet(_knownTeamMembers[team.TeamId]) : new HashSet(); foreach (var member in team.Members) { if (!previous.Contains(member.SteamId)) AddTeamEvent(team.TeamId, "joined", member.SteamId, member.DisplayName, now); } foreach (var oldSteamId in previous) { if (!members.Contains(oldSteamId)) AddTeamEvent(team.TeamId, "left", oldSteamId, ResolvePlayerName(oldSteamId), now); } _teamSnapshots.Add(new TeamSnapshotRecord { TeamId = team.TeamId, MemberCount = team.MemberCount, OnlineCount = team.OnlineCount, RecordedAt = now, }); } foreach (var oldTeam in _knownTeamMembers) { if (currentMap.ContainsKey(oldTeam.Key)) continue; foreach (var oldSteamId in oldTeam.Value) AddTeamEvent(oldTeam.Key, "left", oldSteamId, ResolvePlayerName(oldSteamId), now); } _knownTeamMembers = currentMap; if (_teamSnapshots.Count > MaxRecords) _teamSnapshots.RemoveRange(0, _teamSnapshots.Count - MaxRecords); } private void AddTeamEvent(string teamId, string eventType, string steamId, string displayName, string recordedAt) { _teamEvents.Add(new TeamActivityRecord { TeamId = teamId, EventType = eventType, SteamId = steamId, DisplayName = displayName, RecordedAt = recordedAt, }); if (_teamEvents.Count > MaxRecords) _teamEvents.RemoveRange(0, _teamEvents.Count - MaxRecords); } /// /// チーム解散を即時に記録する。CaptureTeams はタイマー間隔(5〜30分)と getteams 時しか /// 走らないため、「team_id の旧チームが解散 → 同じ team_id を別チームが再取得」が 1 取得 /// 間隔内に起きると、旧メンバーが新チームの left として誤記録され、STRONGEST CREW や /// 過去メンバー一覧に無関係なプレイヤーが混入する。解散時点で旧メンバーの left を確定し /// _knownTeamMembers から外すことで、team_id 再利用前にチーム同一性を締める。 /// void OnTeamDisbanded(RelationshipManager.PlayerTeam team) { if (team == null) return; var teamId = team.teamID.ToString(); if (!_knownTeamMembers.TryGetValue(teamId, out var members)) return; var now = DateTime.UtcNow.ToString("o"); foreach (var sid in members) AddTeamEvent(teamId, "left", sid, ResolvePlayerName(sid), now); _knownTeamMembers.Remove(teamId); Interface.Oxide.DataFileSystem.WriteObject(TeamStateDataFile, _knownTeamMembers); Interface.Oxide.DataFileSystem.WriteObject(TeamEventDataFile, _teamEvents); } private bool IsPlayerOnline(ulong steamId) { var player = BasePlayer.FindByID(steamId); return player != null && player.IsConnected; } private string GetPlayerTeamId(ulong steamId) { var teams = RelationshipManager.ServerInstance?.teams; if (teams == null) return null; foreach (var kv in teams) { var team = kv.Value; if (team?.members == null) continue; if (team.members.Contains(steamId)) return kv.Key.ToString(); } return null; } private string ResolvePlayerName(string steamId) { if (ulong.TryParse(steamId, out ulong id)) { var online = BasePlayer.FindByID(id); if (online != null && !string.IsNullOrEmpty(online.displayName)) return online.displayName; var sleeping = BasePlayer.FindSleeping(id); if (sleeping != null && !string.IsNullOrEmpty(sleeping.displayName)) return sleeping.displayName; } if (_players.TryGetValue(steamId, out var record) && !string.IsNullOrEmpty(record.DisplayName)) return record.DisplayName; var covalencePlayer = covalence.Players.FindPlayerById(steamId); return covalencePlayer?.Name ?? steamId; } // ── F7 レポートフック ──────────────────────────────────────────── void OnPlayerReported(BasePlayer reporter, string targetName, string targetId, string subject, string message, string reportType) { if (reporter == null) return; var rec = new PlayerReportRecord { Id = Guid.NewGuid().ToString("N"), ReporterName = reporter.displayName ?? "", ReporterSteamId = reporter.UserIDString ?? "", ReportedName = targetName ?? "", ReportedSteamId = targetId ?? "", Subject = subject ?? "", Message = message ?? "", ReportType = reportType ?? "", CreatedAt = DateTime.UtcNow.ToString("o"), }; _reports.Add(rec); if (_reports.Count > MaxRecords) _reports.RemoveRange(0, _reports.Count - MaxRecords); Interface.Oxide.DataFileSystem.WriteObject(ReportDataFile, _reports); // RWM の console WS が開いている間は、この構造化ログから即時取り込みできる。 Puts($"{ReportLogPrefix} {JsonConvert.SerializeObject(rec)}"); } // 各データを件数上限(MaxRecords)に丸める。時間ベースのパージは行わない // (シーズン制対応=シーズン跨ぎは rwm.resetseason で全消去する)。 private void EnforceRecordCaps() { var cap = MaxRecords; if (_events.Count > cap) _events.RemoveRange(0, _events.Count - cap); if (_chat.Count > cap) _chat.RemoveRange(0, _chat.Count - cap); if (_activity.Count > cap) _activity.RemoveRange(0, _activity.Count - cap); if (_teamEvents.Count > cap) _teamEvents.RemoveRange(0, _teamEvents.Count - cap); if (_teamSnapshots.Count > cap) _teamSnapshots.RemoveRange(0, _teamSnapshots.Count - cap); if (_reports.Count > cap) _reports.RemoveRange(0, _reports.Count - cap); // players は Dictionary のため、接続中プレイヤーを保護しつつ // LastConnected が古いものから削除する。 if (_players.Count > cap) { var removable = new List>(); foreach (var kv in _players) if (!_sessionStart.ContainsKey(kv.Key)) removable.Add(kv); int toRemove = _players.Count - cap; if (toRemove > 0 && removable.Count > 0) { // LastConnected は ISO 8601 ("o", UTC) のため辞書順 = 時系列順。 removable.Sort((a, b) => string.CompareOrdinal(a.Value.LastConnected, b.Value.LastConnected)); for (int i = 0; i < toRemove && i < removable.Count; i++) _players.Remove(removable[i].Key); } } } // ── コンソールコマンド ──────────────────────────────────────────── [ConsoleCommand("rwm.ping")] void CmdPing(ConsoleSystem.Arg arg) { arg.ReplyWith("pong"); } [ConsoleCommand("rwm.version")] void CmdVersion(ConsoleSystem.Arg arg) { // [Info] 属性のバージョンをそのまま返す(ハードコードだと bump 時に追従漏れするため)。 arg.ReplyWith(Version.ToString()); } [ConsoleCommand("rwm.getevents")] void CmdGetEvents(ConsoleSystem.Arg arg) { arg.ReplyWith(JsonConvert.SerializeObject(_events)); } [ConsoleCommand("rwm.getplayers")] void CmdGetPlayers(ConsoleSystem.Arg arg) { arg.ReplyWith(JsonConvert.SerializeObject(_players)); } /// /// rwm.getteams /// 現在存在する Rust チームとメンバーを返す。 /// [ConsoleCommand("rwm.getteams")] void CmdGetTeams(ConsoleSystem.Arg arg) { CaptureTeams(); arg.ReplyWith(JsonConvert.SerializeObject(new { teams = GetCurrentTeams(), })); } /// /// rwm.getteamevents [limit=200] [offset=0] /// チーム作成・参加・離脱履歴を新しい順に返す。 /// [ConsoleCommand("rwm.getteamevents")] void CmdGetTeamEvents(ConsoleSystem.Arg arg) { int limit = 200; int offset = 0; if (arg.Args != null && arg.Args.Length >= 1) int.TryParse(arg.Args[0].ToString(), out limit); if (arg.Args != null && arg.Args.Length >= 2) int.TryParse(arg.Args[1].ToString(), out offset); limit = Math.Max(1, Math.Min(limit, 500)); offset = Math.Max(0, offset); int total = _teamEvents.Count; int end = Math.Max(0, total - offset); int start = Math.Max(0, end - limit); int count = end - start; var slice = count > 0 ? _teamEvents.GetRange(start, count) : new List(); arg.ReplyWith(JsonConvert.SerializeObject(new { total = total, offset = offset, events = slice, })); } /// /// rwm.getteamsnapshots [limit=500] [offset=0] /// チーム別オンライン人数スナップショットを新しい順に返す。 /// [ConsoleCommand("rwm.getteamsnapshots")] void CmdGetTeamSnapshots(ConsoleSystem.Arg arg) { int limit = 500; int offset = 0; if (arg.Args != null && arg.Args.Length >= 1) int.TryParse(arg.Args[0].ToString(), out limit); if (arg.Args != null && arg.Args.Length >= 2) int.TryParse(arg.Args[1].ToString(), out offset); limit = Math.Max(1, Math.Min(limit, 1000)); offset = Math.Max(0, offset); int total = _teamSnapshots.Count; int end = Math.Max(0, total - offset); int start = Math.Max(0, end - limit); int count = end - start; var slice = count > 0 ? _teamSnapshots.GetRange(start, count) : new List(); arg.ReplyWith(JsonConvert.SerializeObject(new { total = total, offset = offset, snapshots = slice, })); } /// /// rwm.getauths /// Returns owner/moderator steam ids as JSON. /// [ConsoleCommand("rwm.getauths")] void CmdGetAuths(ConsoleSystem.Arg arg) { var result = new AuthListsResponse(); try { var owners = ServerUsers.GetAll(ServerUsers.UserGroup.Owner); if (owners != null) { foreach (var u in owners) { if (u == null) continue; result.Owners.Add(u.steamid.ToString()); } } var moderators = ServerUsers.GetAll(ServerUsers.UserGroup.Moderator); if (moderators != null) { foreach (var u in moderators) { if (u == null) continue; result.Moderators.Add(u.steamid.ToString()); } } } catch (Exception e) { Puts($"[RWM] rwm.getauths failed: {e.Message}"); } arg.ReplyWith(JsonConvert.SerializeObject(result)); } /// /// rwm.getactivity [steam_id] [limit=100] [offset=0] [since] /// steam_id を省略、または "*"(ワイルドカード)を渡すと全プレイヤーが対象。 /// "*" を使うと limit/offset を指定したまま全件をページング取得できる /// (バックエンドの全体 activity バルク同期で使用)。 /// since(ISO8601・UTC "o" 形式)を渡すと RecordedAt がそれより新しい /// レコードだけを返す(増分取得。バルク同期の転送量削減用)。 /// [ConsoleCommand("rwm.getactivity")] void CmdGetActivity(ConsoleSystem.Arg arg) { string steamId = null; int limit = 100; int offset = 0; string since = null; if (arg.Args != null && arg.Args.Length >= 1) steamId = arg.Args[0].ToString(); if (arg.Args != null && arg.Args.Length >= 2) int.TryParse(arg.Args[1].ToString(), out limit); if (arg.Args != null && arg.Args.Length >= 3) int.TryParse(arg.Args[2].ToString(), out offset); if (arg.Args != null && arg.Args.Length >= 4) since = arg.Args[3].ToString(); limit = Math.Max(1, Math.Min(limit, 500)); offset = Math.Max(0, offset); // steamId が null または "*" のときは全件を対象にする。 List source = (steamId != null && steamId != "*") ? _activity.FindAll(r => r.SteamId == steamId) : _activity; // since 指定時は RecordedAt がそれより新しいものだけに絞り込む。 // RecordedAt は UTC "o" 形式(末尾 Z)で序数比較=時系列順になる。 if (!string.IsNullOrEmpty(since)) source = source.FindAll(r => string.CompareOrdinal(r.RecordedAt, since) > 0); int total = source.Count; int end = Math.Max(0, total - offset); int start = Math.Max(0, end - limit); int count = end - start; var slice = count > 0 ? source.GetRange(start, count) : new List(); arg.ReplyWith(JsonConvert.SerializeObject(new { total = total, offset = offset, events = slice, })); } /// /// rwm.getreports [limit=200] [offset=0] /// F7 レポートを新しい順に返す。 /// [ConsoleCommand("rwm.getreports")] void CmdGetReports(ConsoleSystem.Arg arg) { int limit = 200; int offset = 0; if (arg.Args != null && arg.Args.Length >= 1) int.TryParse(arg.Args[0].ToString(), out limit); if (arg.Args != null && arg.Args.Length >= 2) int.TryParse(arg.Args[1].ToString(), out offset); limit = Math.Max(1, Math.Min(limit, 500)); offset = Math.Max(0, offset); int total = _reports.Count; int end = Math.Max(0, total - offset); int start = Math.Max(0, end - limit); int count = end - start; var slice = count > 0 ? _reports.GetRange(start, count) : new List(); arg.ReplyWith(JsonConvert.SerializeObject(new { total = total, offset = offset, reports = slice, })); } /// /// rwm.getchat [limit=100] [offset=0] /// [ConsoleCommand("rwm.getchat")] void CmdGetChat(ConsoleSystem.Arg arg) { int limit = 100; int offset = 0; if (arg.Args != null && arg.Args.Length >= 1) int.TryParse(arg.Args[0].ToString(), out limit); if (arg.Args != null && arg.Args.Length >= 2) int.TryParse(arg.Args[1].ToString(), out offset); limit = Math.Max(1, Math.Min(limit, 500)); offset = Math.Max(0, offset); int total = _chat.Count; int end = Math.Max(0, total - offset); int start = Math.Max(0, end - limit); int count = end - start; var slice = count > 0 ? _chat.GetRange(start, count) : new List(); arg.ReplyWith(JsonConvert.SerializeObject(new { total = total, offset = offset, messages = slice, })); } [ConsoleCommand("rwm.clearchat")] void CmdClearChat(ConsoleSystem.Arg arg) { _chat.Clear(); Interface.Oxide.DataFileSystem.WriteObject(ChatDataFile, _chat); arg.ReplyWith("ok"); } [ConsoleCommand("rwm.clearall")] void CmdClearAll(ConsoleSystem.Arg arg) { ClearAllPersistedData(); arg.ReplyWith("ok"); } /// シーズン切替時の Oxide 側データリセット。 /// 削除対象は rwm.clearall と同一だが、呼び出し側の意図(シーズン切替)を /// 区別するため別コマンドとして公開する。 [ConsoleCommand("rwm.resetseason")] void CmdResetSeason(ConsoleSystem.Arg arg) { ClearAllPersistedData(); arg.ReplyWith("ok"); } void ClearAllPersistedData() { _events.Clear(); _chat.Clear(); _activity.Clear(); _openedCrates.Clear(); _players.Clear(); _knownTeamMembers.Clear(); _teamEvents.Clear(); _teamSnapshots.Clear(); _reports.Clear(); // 切断時に TryGetValue が失敗しないよう、現在接続中のプレイヤーを再登録する。 // あわせて、切替時点で在席中のプレイヤーには「切替時刻の connected イベント」を // 記録する。これをしないと OnPlayerConnected を経由しない在席者がシーズンの // connected イベントを一切持たず、EARLY BIRD(最速接続)が切替後に新規接続した // プレイヤーまでズレる。session_seconds の二重計上を防ぐため _sessionStart も // 切替時刻に揃える。 var now = DateTime.UtcNow; foreach (var player in BasePlayer.activePlayerList) { var sid = player.UserIDString; var rawIp = player.net?.connection?.ipaddress ?? ""; _players[sid] = new PlayerRecord { SteamID = sid, DisplayName = player.displayName, EventType = "connected", FirstConnected = now.ToString("o"), LastConnected = now.ToString("o"), TotalConnections = 1, PlayTime = 0, LastIP = rawIp.Contains(":") ? rawIp.Split(':')[0] : rawIp, }; _events.Add(new PlayerEvent { SteamId = sid, DisplayName = player.displayName, EventType = "connected", Timestamp = now.ToString("o"), SessionSeconds = null, }); _sessionStart[sid] = now; } Interface.Oxide.DataFileSystem.WriteObject(EventDataFile, _events); Interface.Oxide.DataFileSystem.WriteObject(ChatDataFile, _chat); Interface.Oxide.DataFileSystem.WriteObject(ActivityDataFile, _activity); Interface.Oxide.DataFileSystem.WriteObject(OpenedCratesDataFile, _openedCrates); Interface.Oxide.DataFileSystem.WriteObject(PlayerDataFile, _players); Interface.Oxide.DataFileSystem.WriteObject(TeamStateDataFile, _knownTeamMembers); Interface.Oxide.DataFileSystem.WriteObject(TeamEventDataFile, _teamEvents); Interface.Oxide.DataFileSystem.WriteObject(TeamSnapshotDataFile, _teamSnapshots); Interface.Oxide.DataFileSystem.WriteObject(ReportDataFile, _reports); } [ConsoleCommand("rwm.pm")] void CmdPm(ConsoleSystem.Arg arg) { if (arg.Args == null || arg.Args.Length < 2) { arg.ReplyWith("Usage: rwm.pm "); return; } var steamIdStr = arg.Args[0].ToString(); if (!ulong.TryParse(steamIdStr, out ulong steamId)) { arg.ReplyWith($"Invalid SteamID: {steamIdStr}"); return; } var messageParts = new string[arg.Args.Length - 1]; for (int i = 1; i < arg.Args.Length; i++) messageParts[i - 1] = arg.Args[i].ToString(); var message = string.Join(" ", messageParts); var target = BasePlayer.FindByID(steamId); if (target == null || !target.IsConnected) { arg.ReplyWith($"Player {steamIdStr} is not online"); return; } target.ChatMessage($"[Server PM] {message}"); arg.ReplyWith($"Message sent to {target.displayName}"); } /// rwm.give [ConsoleCommand("rwm.give")] void CmdGive(ConsoleSystem.Arg arg) { if (arg.Args == null || arg.Args.Length < 3) { arg.ReplyWith("Usage: rwm.give "); return; } if (!ulong.TryParse(arg.Args[0].ToString(), out ulong steamId)) { arg.ReplyWith($"Invalid SteamID: {arg.Args[0].ToString()}"); return; } var shortname = arg.Args[1].ToString(); if (string.IsNullOrEmpty(shortname)) { arg.ReplyWith("Invalid item shortname"); return; } if (!int.TryParse(arg.Args[2].ToString(), out int amount) || amount <= 0) { arg.ReplyWith($"Invalid amount: {arg.Args[2].ToString()}"); return; } var player = BasePlayer.FindByID(steamId); if (player == null || !player.IsConnected) { arg.ReplyWith($"Player {steamId} is not online"); return; } var item = ItemManager.CreateByName(shortname, amount); if (item == null) { arg.ReplyWith($"Item not found: {shortname}"); return; } player.GiveItem(item); arg.ReplyWith($"ok: gave {amount} {shortname} to {player.displayName}"); } /// rwm.getinventory /// Returns Items/Worn/Belt as JSON arrays in the format expected by the backend parser. [ConsoleCommand("rwm.getinventory")] void CmdGetInventory(ConsoleSystem.Arg arg) { if (arg.Args == null || arg.Args.Length < 1) { arg.ReplyWith("Usage: rwm.getinventory "); return; } if (!ulong.TryParse(arg.Args[0].ToString(), out ulong steamId)) { arg.ReplyWith($"Invalid SteamID: {arg.Args[0].ToString()}"); return; } var player = BasePlayer.FindByID(steamId); if (player == null || !player.IsConnected) { arg.ReplyWith($"Player {steamId} is not online"); return; } var items = SerializeContainer(player.inventory.containerMain); var worn = SerializeContainer(player.inventory.containerWear); var belt = SerializeContainer(player.inventory.containerBelt); arg.ReplyWith( $"Items: {JsonConvert.SerializeObject(items)}\n" + $"Worn: {JsonConvert.SerializeObject(worn)}\n" + $"Belt: {JsonConvert.SerializeObject(belt)}" ); } /// /// rwm.tcauth <entityId> /// Returns the list of authorized players for the specified tool cupboard entity. /// Uses reflection to access authorizedPlayers to avoid direct BuildingPrivilege reference. /// [ConsoleCommand("rwm.tcauth")] void CmdTcAuth(ConsoleSystem.Arg arg) { if (arg.Args == null || arg.Args.Length < 1) { arg.ReplyWith("{\"error\":\"Usage: rwm.tcauth \"}"); return; } if (!ulong.TryParse(arg.Args[0].ToString(), out ulong entityId)) { arg.ReplyWith("{\"error\":\"Invalid entityId\"}"); return; } var playerList = new List>(); var entity = BaseNetworkable.serverEntities.Find(new NetworkableId(entityId)); var tc = entity as BuildingPrivlidge; if (tc?.authorizedPlayers != null) { foreach (ulong steamId in tc.authorizedPlayers) { playerList.Add(new Dictionary { ["steamId"] = steamId.ToString(), ["name"] = ResolvePlayerName(steamId.ToString()), }); } } arg.ReplyWith(JsonConvert.SerializeObject(new { entityId = entityId.ToString(), players = playerList })); } private List> SerializeContainer(ItemContainer container) { var list = new List>(); if (container == null) return list; foreach (var item in container.itemList) { list.Add(SerializeItem(item)); } return list; } private Dictionary SerializeItem(Item item) { int ammo = 0; int ammotype = -1; var gun = item.GetHeldEntity() as BaseProjectile; if (gun != null) { ammo = gun.primaryMagazine.contents; ammotype = gun.primaryMagazine.ammoType?.itemid ?? -1; } var row = new Dictionary { ["itemid"] = item.info.itemid, ["amount"] = item.amount, ["skinid"] = item.skin, ["slot"] = item.position, ["ammo"] = ammo, ["ammotype"] = ammotype, ["condition"] = item.condition, ["maxcondition"] = item.maxCondition, ["flags"] = (int)item.flags, ["name"] = item.info.displayName.translated, ["shortname"] = item.info.shortname, ["blueprint"] = item.IsBlueprint(), }; if (item.contents != null && item.contents.itemList != null && item.contents.itemList.Count > 0) { row["contents"] = SerializeContainer(item.contents); } return row; } } }