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("RustControlStation", "Mazurk4_", "1.19.0")] [Description("Rust Control Station との連携プラグイン — 接続イベント記録・チャット履歴・kill/death ログ・T3作業台/クレート開封ログ・チーム履歴・F7レポート・PM送信")] class RustControlStation : RustPlugin { // ── コンフィグ ──────────────────────────────────────────────────── private PluginConfig _config; class PluginConfig { [JsonProperty("SaveIntervalMinutes")] public int SaveIntervalMinutes { get; set; } = 5; // 各データファイル(events/chat/activity/team_events/team_snapshots/reports/players)の // 保持件数上限。これを超えると古い順に削除する。時間ベースのパージは行わず、 // シーズン跨ぎは rcs.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 = "RustControlStation/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 = "RustControlStation/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 = "RustControlStation/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 = "RustControlStation/activity"; private List _activity = new List(); // crate_open のシーズン内重複排除用。同一クレート(net.ID)の再オープン/複数人閲覧で // 開封数が過大計上されないよう、初回だけ計上する。シーズン切替(resetseason)でクリア。 private const string OpenedCratesDataFile = "RustControlStation/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 = "RustControlStation/team_state"; private const string TeamEventDataFile = "RustControlStation/team_events"; private const string TeamSnapshotDataFile = "RustControlStation/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; } [JsonProperty("tool_cupboards")] public List ToolCupboards { get; set; } = new List(); } class ToolCupboardRecord { [JsonProperty("entity_id")] public string EntityId { get; set; } [JsonProperty("x")] public float X { get; set; } [JsonProperty("y")] public float Y { get; set; } [JsonProperty("z")] public float Z { get; set; } [JsonProperty("authorized_count")] public int AuthorizedCount { 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 = "RustControlStation/reports"; private const string ReportLogPrefix = "[RCS_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(); } // ── マップ画像URL(オンメモリのみ・永続化しない) ──────────────── // vanilla Rust 本体がプロシージャル/カスタムマップのサムネイルを Facepunch CDN へ // アップロードした際に必ず出力するログ(`[Rust.MapCache-Images] Image uploaded to // backend: `)を Unity の Application.logMessageReceived で直接捕捉し保持する。 // RCON の console.tail によるログ遡及はログ流量が多いサーバーだと検知窓から // 押し出されて取りこぼす(Issue #426)ため、backend が rcs.getmapurl で能動的に // 取得できるようにする。プラグインリロード/サーバー再起動でリセットされてよい // (再起動時は Rust が起動直後に必ずこのログを再度出力するため、ユーザー指定どおり // 永続化は不要)。 private const string MapCacheLogMarker = "[Rust.MapCache-Images] Image uploaded to backend: "; private const string MapCacheAllowedHostPrefix = "https://files.facepunch.com/"; private string _mapImageUrl; private void OnUnityLogMessage(string condition, string stackTrace, LogType type) { if (string.IsNullOrEmpty(condition)) return; if (!condition.StartsWith(MapCacheLogMarker, StringComparison.Ordinal)) return; var url = condition.Substring(MapCacheLogMarker.Length).Trim(); // アップロード先ホストが正規の Facepunch CDN であることを最終防波堤として確認する // (このログ自体はゲームエンジンが出す行でプレイヤー入力の混入経路は無いが、 // backend 側の extract_map_image_url と同じ検証を防御的に踏襲する)。 if (!url.StartsWith(MapCacheAllowedHostPrefix, StringComparison.Ordinal)) return; _mapImageUrl = url; } // ── タイマー ───────────────────────────────────────────────────── private Timer _saveTimer; // ── 初期化 ──────────────────────────────────────────────────────── void Init() { // マップ画像URLのオンメモリ捕捉を開始する(Unload で解除)。 Application.logMessageReceived += OnUnityLogMessage; _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($"[RCS] Auto-save timer started: every {_config.SaveIntervalMinutes} min(s)"); } // ── アンロード ──────────────────────────────────────────────────── void Unload() { Application.logMessageReceived -= OnUnityLogMessage; _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; // 永続化は SaveAll(定期)/ Unload(アンロード時)に一本化する。rcs.getplayers は // このメモリ上の _players を直接返すため、ここでの即時 WriteObject は不要な // 同期ディスク書き込みでしかなく、毎接続でメインスレッドを詰まらせていた。 } 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"; // 永続化は SaveAll / Unload に一本化(理由は OnPlayerConnected 側コメント参照)。 } } 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); // 永続化は SaveAll / Unload に一本化。rcs.getevents はこのメモリ上の _events を // 直接返すため、毎イベントでの同期 WriteObject(配列全体の再シリアライズ)は不要。 } // ── 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 の自動ワイプ切替は rcs.resetseason を送らないため、それに依存せずリセットする。 /// `_mapImageUrl`(Issue #426)もここで無効化する: プロセス再起動を伴わないワイプでは /// 前マップのURLが Unity ログ再捕捉まで居座り、`rcs.getmapurl` が古いURLを返し続ける /// (backend の pull は無条件上書きのため、誤って新シーズンへ前マップの画像が /// 永続化されてしまう・Codexアドバーサリアルレビュー指摘)。 /// void OnNewSave(string filename) { _openedCrates.Clear(); Interface.Oxide.DataFileSystem.WriteObject(OpenedCratesDataFile, _openedCrates); _mapImageUrl = null; } // ── チャットフック ──────────────────────────────────────────────── 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); // 永続化は SaveAll / Unload に一本化。rcs.getchat はこのメモリ上の _chat を // 直接返すため、チャット1件ごとに配列全体(最大 MaxRecords 件)を同期でディスクへ // 書き直していたことが OnPlayerChat フックの 100〜200ms 遅延の主因だった。 } // ── チーム / グループ収集 ─────────────────────────────────────── /// includeToolCupboards: true の場合のみ全 TC を1回走査してメンバーごとの所有TC一覧を /// 付与する(CaptureTeams 内部の差分検出専用呼び出しでは不要な走査を避けて false にする)。 private List GetCurrentTeams(bool includeToolCupboards = false) { var result = new List(); var teams = RelationshipManager.ServerInstance?.teams; if (teams == null) return result; var tcIndex = includeToolCupboards ? BuildToolCupboardIndex() : null; 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); List tcs = null; if (tcIndex != null) tcIndex.TryGetValue(memberId, out tcs); record.Members.Add(new TeamMemberRecord { SteamId = steamId, DisplayName = ResolvePlayerName(steamId), IsLeader = steamId == leaderId, IsOnline = online, ToolCupboards = tcs ?? new List(), }); if (online) record.OnlineCount += 1; } record.MemberCount = record.Members.Count; result.Add(record); } return result; } /// 全 BuildingPrivlidge を1回走査し、認証プレイヤー SteamID → 所有TCリストの /// 逆引きインデックスを作る。チーム人数分ループのたびに全エンティティを再走査するより /// 大幅に安く済む(サーバー全体で TC 走査は1回のみ)。 private static Dictionary> BuildToolCupboardIndex() { var index = new Dictionary>(); foreach (var networkable in BaseNetworkable.serverEntities) { var tc = networkable as BuildingPrivlidge; if (tc == null || tc.IsDestroyed || tc.authorizedPlayers == null || tc.net == null) continue; var pos = tc.transform.position; var entityId = tc.net.ID.Value.ToString(); var authorizedCount = tc.authorizedPlayers.Count; foreach (var steamId in tc.authorizedPlayers) { List list; if (!index.TryGetValue(steamId, out list)) { list = new List(); index[steamId] = list; } list.Add(new ToolCupboardRecord { EntityId = entityId, X = pos.x, Y = pos.y, Z = pos.z, AuthorizedCount = authorizedCount, }); } } return index; } 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); // RCS の console WS が開いている間は、この構造化ログから即時取り込みできる。 Puts($"{ReportLogPrefix} {JsonConvert.SerializeObject(rec)}"); } // 各データを件数上限(MaxRecords)に丸める。時間ベースのパージは行わない // (シーズン制対応=シーズン跨ぎは rcs.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); } } } // ── コンソールコマンド 共通ガード / 引数バリデーション ──────────── // // security-review-oxide.md L-1 対応: 各 [ConsoleCommand] ハンドラは Oxide の // `ServerAdmin = true` 既定(ゲーム内 admin (authLevel>=2) 以外は拒否)に暗黙依存していた。 // Oxide/Carbon の登録挙動差異に備え、明示的な defense-in-depth ガードとして先頭に置く。 // arg.Connection が null(RCON / サーバーコンソール経由 = backend からの呼び出し)は // 従来どおり無条件許可し、実運用(backend は WebRCON 経由)には影響しない。 private static bool IsAuthorized(ConsoleSystem.Arg arg) { // arg.Connection が null = RCON / サーバーコンソール経由(backend からの呼び出し)は // 無条件許可。ゲーム内クライアント経由のみ admin (authLevel>=2) を要求する。 if (arg?.Connection == null) return true; return arg.Connection.authLevel >= 2; } private const int MaxArgLength = 256; private const int MaxItemShortnameLength = 64; // 数値/shortname の許容文字パターン。char.IsDigit 等での手書きループの代わりに // 正規表現で一括判定する(先頭の "+" や桁区切りなど TryParse 単独では弾けない // 表記揺れも、パターン全体一致にすることでまとめて除外できる)。 private static readonly System.Text.RegularExpressions.Regex DigitsPattern = new System.Text.RegularExpressions.Regex(@"^[0-9]+$", System.Text.RegularExpressions.RegexOptions.Compiled); private static readonly System.Text.RegularExpressions.Regex ShortnamePattern = new System.Text.RegularExpressions.Regex(@"^[A-Za-z0-9._-]+$", System.Text.RegularExpressions.RegexOptions.Compiled); private static bool TryParseStrictUlong(string value, out ulong parsed) { parsed = 0; bool valid = !string.IsNullOrEmpty(value) && value.Length <= MaxArgLength && DigitsPattern.IsMatch(value); return valid && ulong.TryParse(value, out parsed); } private static bool TryParseStrictInt(string value, out int parsed) { parsed = 0; if (string.IsNullOrEmpty(value) || value.Length > MaxArgLength) return false; var digits = value.StartsWith("-", StringComparison.Ordinal) ? value.Substring(1) : value; if (digits.Length == 0 || !DigitsPattern.IsMatch(digits)) return false; return int.TryParse(value, out parsed); } private static bool TryParseStrictFloat(string value, out float parsed) { parsed = 0f; if (string.IsNullOrEmpty(value) || value.Length > MaxArgLength) return false; if (!float.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out parsed)) return false; if (float.IsNaN(parsed) || float.IsInfinity(parsed)) return false; return true; } // アイテム shortname は英数字・"."・"_"・"-" のみ許可(ItemManager.FindItemDefinition への // 入力を絞ることで、想定外の文字列によるコンソールコマンド的な副作用の芽を摘む)。 private static bool IsSafeItemShortname(string value) { if (string.IsNullOrEmpty(value) || value.Length > MaxItemShortnameLength) return false; return ShortnamePattern.IsMatch(value); } // ── コンソールコマンド ──────────────────────────────────────────── [ConsoleCommand("rcs.ping")] void CmdPing(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; arg.ReplyWith("pong"); } [ConsoleCommand("rcs.version")] void CmdVersion(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; // [Info] 属性のバージョンをそのまま返す(ハードコードだと bump 時に追従漏れするため)。 arg.ReplyWith(Version.ToString()); } [ConsoleCommand("rcs.getevents")] void CmdGetEvents(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; arg.ReplyWith(JsonConvert.SerializeObject(_events)); } [ConsoleCommand("rcs.getmapurl")] void CmdGetMapUrl(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; arg.ReplyWith(JsonConvert.SerializeObject(new { url = _mapImageUrl })); } [ConsoleCommand("rcs.getplayers")] void CmdGetPlayers(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; arg.ReplyWith(JsonConvert.SerializeObject(_players)); } /// /// rcs.getteams /// 現在存在する Rust チームとメンバーを返す。 /// [ConsoleCommand("rcs.getteams")] void CmdGetTeams(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; CaptureTeams(); // 第1引数 "1" のときだけメンバー別 Tool Cupboard 一覧を含める(BuildToolCupboardIndex // による全 BaseNetworkable 走査を伴う)。TeamDetail(TC表示あり)専用のオプトインとし、 // Teams 一覧の定期ポーリング(TCを表示しない)でこの走査コストを払わずに済むようにする。 bool includeToolCupboards = arg.Args != null && arg.Args.Length >= 1 && arg.Args[0].ToString() == "1"; arg.ReplyWith(JsonConvert.SerializeObject(new { teams = GetCurrentTeams(includeToolCupboards), })); } /// /// rcs.getteamevents [limit=200] [offset=0] [since] /// チーム作成・参加・離脱履歴を新しい順に返す。 /// since(ISO8601・UTC "o" 形式)を渡すと RecordedAt がそれより新しいレコードだけを /// 返す(per-poll 増分同期の転送量削減用)。 /// [ConsoleCommand("rcs.getteamevents")] void CmdGetTeamEvents(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; int limit = 200; int offset = 0; string since = null; 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); if (arg.Args != null && arg.Args.Length >= 3) since = arg.Args[2].ToString(); limit = Math.Max(1, Math.Min(limit, 500)); offset = Math.Max(0, offset); // since 指定時は RecordedAt がそれより新しいものだけに絞り込む("o" 形式は序数比較=時系列順)。 List source = string.IsNullOrEmpty(since) ? _teamEvents : _teamEvents.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, })); } /// /// rcs.getteamsnapshots [limit=500] [offset=0] [since] /// チーム別オンライン人数スナップショットを新しい順に返す。 /// since(ISO8601・UTC "o" 形式)を渡すと RecordedAt がそれより新しいレコードだけを返す。 /// [ConsoleCommand("rcs.getteamsnapshots")] void CmdGetTeamSnapshots(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; int limit = 500; int offset = 0; string since = null; 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); if (arg.Args != null && arg.Args.Length >= 3) since = arg.Args[2].ToString(); limit = Math.Max(1, Math.Min(limit, 1000)); offset = Math.Max(0, offset); List source = string.IsNullOrEmpty(since) ? _teamSnapshots : _teamSnapshots.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, snapshots = slice, })); } /// /// rcs.getauths /// Returns owner/moderator steam ids as JSON. /// [ConsoleCommand("rcs.getauths")] void CmdGetAuths(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; 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($"[RCS] rcs.getauths failed: {e.Message}"); } arg.ReplyWith(JsonConvert.SerializeObject(result)); } /// /// rcs.getactivity [steam_id] [limit=100] [offset=0] [since] /// steam_id を省略、または "*"(ワイルドカード)を渡すと全プレイヤーが対象。 /// "*" を使うと limit/offset を指定したまま全件をページング取得できる /// (バックエンドの全体 activity バルク同期で使用)。 /// since(ISO8601・UTC "o" 形式)を渡すと RecordedAt がそれより新しい /// レコードだけを返す(増分取得。バルク同期の転送量削減用)。 /// [ConsoleCommand("rcs.getactivity")] void CmdGetActivity(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; 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, })); } /// /// rcs.getreports [limit=200] [offset=0] [since] /// F7 レポートを新しい順に返す。 /// since(ISO8601・UTC "o" 形式)を渡すと CreatedAt がそれより新しいレコードだけを返す。 /// [ConsoleCommand("rcs.getreports")] void CmdGetReports(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; int limit = 200; int offset = 0; string since = null; 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); if (arg.Args != null && arg.Args.Length >= 3) since = arg.Args[2].ToString(); limit = Math.Max(1, Math.Min(limit, 500)); offset = Math.Max(0, offset); List source = string.IsNullOrEmpty(since) ? _reports : _reports.FindAll(r => string.CompareOrdinal(r.CreatedAt, 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, reports = slice, })); } /// /// rcs.getchat [limit=100] [offset=0] [since] /// since(unix epoch 秒)を渡すと Time がそれ以上のレコードだけを返す。 /// チャットは Time が秒粒度のため、境界秒の取りこぼしを避けるよう ">=" で絞る /// (重複はバックエンドの ON CONFLICT DO NOTHING で吸収される)。 /// [ConsoleCommand("rcs.getchat")] void CmdGetChat(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; int limit = 100; int offset = 0; long since = -1; 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); if (arg.Args != null && arg.Args.Length >= 3) long.TryParse(arg.Args[2].ToString(), out since); limit = Math.Max(1, Math.Min(limit, 500)); offset = Math.Max(0, offset); List source = since < 0 ? _chat : _chat.FindAll(r => r.Time >= since); 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, messages = slice, })); } [ConsoleCommand("rcs.clearchat")] void CmdClearChat(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; _chat.Clear(); Interface.Oxide.DataFileSystem.WriteObject(ChatDataFile, _chat); arg.ReplyWith("ok"); } [ConsoleCommand("rcs.clearall")] void CmdClearAll(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; ClearAllPersistedData(); arg.ReplyWith("ok"); } /// シーズン切替時の Oxide 側データリセット。 /// 削除対象は rcs.clearall と同一だが、呼び出し側の意図(シーズン切替)を /// 区別するため別コマンドとして公開する。 [ConsoleCommand("rcs.resetseason")] void CmdResetSeason(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; 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("rcs.pm")] void CmdPm(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; if (arg.Args == null || arg.Args.Length < 2) { arg.ReplyWith("Usage: rcs.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}"); } /// rcs.give [container] [slot] /// container/slot 省略時は従来どおり GiveItem による自動配置(後方互換)。 /// 両方指定時は該当コンテナの指定スロットへ直接配置する。指定スロットが /// 既に埋まっている場合は既存アイテムの誤消去を避けるため拒否する。 [ConsoleCommand("rcs.give")] void CmdGive(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; // container/slot は「両方指定」か「両方省略」のみ許可する。引数4個(container のみ // 指定・slot 書き忘れ)を素通りさせると、指定を無視した自動配置が無警告で実行され、 // RCON コンソールからの入力ミスがそのままアイテム付与として成立してしまう。 if (arg.Args == null || arg.Args.Length < 3 || arg.Args.Length == 4 || arg.Args.Length > 5) { arg.ReplyWith("Usage: rcs.give [container] [slot]"); return; } if (!TryParseStrictUlong(arg.Args[0].ToString(), out ulong steamId)) { arg.ReplyWith($"Invalid SteamID: {arg.Args[0].ToString()}"); return; } var shortname = arg.Args[1].ToString(); if (!IsSafeItemShortname(shortname)) { arg.ReplyWith("Invalid item shortname"); return; } if (!TryParseStrictInt(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; } ItemContainer targetContainer = null; int slot = -1; if (arg.Args.Length >= 5) { var containerName = arg.Args[3].ToString(); targetContainer = GetPlayerNamedContainer(player, containerName); if (targetContainer == null) { arg.ReplyWith($"Invalid container: {containerName}"); return; } if (!TryParseStrictInt(arg.Args[4].ToString(), out slot) || slot < 0 || slot >= targetContainer.capacity) { arg.ReplyWith($"Invalid slot: {arg.Args[4].ToString()}"); return; } foreach (var existing in targetContainer.itemList) { if (existing.position != slot) continue; arg.ReplyWith($"Slot occupied: {containerName} {slot}"); return; } } var item = ItemManager.CreateByName(shortname, amount); if (item == null) { arg.ReplyWith($"Item not found: {shortname}"); return; } if (targetContainer != null) { if (!item.MoveToContainer(targetContainer, slot, false, false, player, true)) { item.Remove(); arg.ReplyWith($"Failed to place item into slot {slot}"); return; } } else { player.GiveItem(item); } arg.ReplyWith($"ok: gave {amount} {shortname} to {player.displayName}"); } /// rcs.removeitem /// プレイヤーの Items/Worn/Belt コンテナ(トップレベルのみ。ネストしたコンテナ内は対象外) /// から uid 一致のアイテムを削除する。応答形式は rcs.getinventory と互換の /// "Items:/Worn:/Belt:" テキストを再利用し、先頭に "Result: ok" / "Result: error:" /// 行を付与する(旧パーサーは未知の行を無視するため後方互換)。 [ConsoleCommand("rcs.removeitem")] void CmdRemoveItem(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; if (arg.Args == null || arg.Args.Length != 2) { arg.ReplyWith("Result: error:usage"); return; } if (!TryParseStrictUlong(arg.Args[0].ToString(), out ulong steamId)) { arg.ReplyWith("Result: error:invalid_steam_id"); return; } if (!TryParseStrictUlong(arg.Args[1].ToString(), out ulong uidRaw)) { arg.ReplyWith("Result: error:invalid_uid"); return; } var player = BasePlayer.FindByID(steamId); if (player == null || !player.IsConnected) { arg.ReplyWith("Result: error:player_offline"); return; } var item = FindPlayerItemByUid(player, new ItemId(uidRaw)); if (item == null) { arg.ReplyWith("Result: error:item_not_found"); return; } item.Remove(); arg.ReplyWith(BuildInventoryReply(player, "ok")); } /// rcs.moveitem /// container: items / belt / worn [ConsoleCommand("rcs.moveitem")] void CmdMoveItem(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; if (arg.Args == null || arg.Args.Length != 4) { arg.ReplyWith("Result: error:usage"); return; } if (!TryParseStrictUlong(arg.Args[0].ToString(), out ulong steamId)) { arg.ReplyWith("Result: error:invalid_steam_id"); return; } if (!TryParseStrictUlong(arg.Args[1].ToString(), out ulong uidRaw)) { arg.ReplyWith("Result: error:invalid_uid"); return; } var player = BasePlayer.FindByID(steamId); if (player == null || !player.IsConnected) { arg.ReplyWith("Result: error:player_offline"); return; } var containerName = arg.Args[2].ToString(); var targetContainer = GetPlayerNamedContainer(player, containerName); if (targetContainer == null) { arg.ReplyWith("Result: error:invalid_container"); return; } if (!TryParseStrictInt(arg.Args[3].ToString(), out int slot) || slot < 0 || slot >= targetContainer.capacity) { arg.ReplyWith("Result: error:invalid_slot"); return; } var item = FindPlayerItemByUid(player, new ItemId(uidRaw)); if (item == null) { arg.ReplyWith("Result: error:item_not_found"); return; } // 占有中のスロットは拒否する(rcs.give の拡張パスと同じ方針)。allowStack/allowSwap を // true のまま MoveToContainer へ渡すと、同種アイテムへ黙ってスタックされて移動元 uid が // 消えたり、異種アイテムが黙ってスワップされたりする(呼び出し元は「指定スロットへの // 単純配置」を期待しており、他アイテムへの副作用は想定外)。移動先が自分自身のスロット // (no-op)の場合のみ許可する。 foreach (var existing in targetContainer.itemList) { if (existing.position != slot) continue; if (ReferenceEquals(existing, item)) continue; arg.ReplyWith("Result: error:slot_occupied"); return; } if (!item.MoveToContainer(targetContainer, slot, false, false, player, false)) { arg.ReplyWith("Result: error:move_failed"); return; } arg.ReplyWith(BuildInventoryReply(player, "ok")); } /// rcs.updateitem /// field: amount / condition / maxcondition / ammo [ConsoleCommand("rcs.updateitem")] void CmdUpdateItem(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; if (arg.Args == null || arg.Args.Length != 4) { arg.ReplyWith("Result: error:usage"); return; } if (!TryParseStrictUlong(arg.Args[0].ToString(), out ulong steamId)) { arg.ReplyWith("Result: error:invalid_steam_id"); return; } if (!TryParseStrictUlong(arg.Args[1].ToString(), out ulong uidRaw)) { arg.ReplyWith("Result: error:invalid_uid"); return; } var player = BasePlayer.FindByID(steamId); if (player == null || !player.IsConnected) { arg.ReplyWith("Result: error:player_offline"); return; } var item = FindPlayerItemByUid(player, new ItemId(uidRaw)); if (item == null) { arg.ReplyWith("Result: error:item_not_found"); return; } var field = arg.Args[2].ToString(); var valueText = arg.Args[3].ToString(); switch (field) { case "amount": if (!TryParseStrictInt(valueText, out int amount) || amount < 1) { arg.ReplyWith("Result: error:invalid_amount"); return; } item.amount = amount; break; case "condition": if (!item.hasCondition) { arg.ReplyWith("Result: error:item_has_no_condition"); return; } if (!TryParseStrictFloat(valueText, out float condition) || condition < 0f) { arg.ReplyWith("Result: error:invalid_condition"); return; } item.condition = condition; break; case "maxcondition": if (!item.hasCondition) { arg.ReplyWith("Result: error:item_has_no_condition"); return; } if (!TryParseStrictFloat(valueText, out float maxCondition) || maxCondition < 0f) { arg.ReplyWith("Result: error:invalid_max_condition"); return; } item.maxCondition = maxCondition; if (item.condition > item.maxCondition) item.condition = item.maxCondition; break; case "ammo": if (!TryParseStrictInt(valueText, out int ammo) || ammo < 0) { arg.ReplyWith("Result: error:invalid_ammo"); return; } var magazine = (item.GetHeldEntity() as BaseProjectile)?.primaryMagazine; if (magazine == null) { arg.ReplyWith("Result: error:item_has_no_ammo"); return; } if (ammo > magazine.capacity) { arg.ReplyWith("Result: error:ammo_out_of_range"); return; } magazine.contents = ammo; break; default: arg.ReplyWith("Result: error:invalid_field"); return; } item.MarkDirty(); arg.ReplyWith(BuildInventoryReply(player, "ok")); } /// rcs.getinventory /// Returns Items/Worn/Belt as JSON arrays in the format expected by the backend parser. [ConsoleCommand("rcs.getinventory")] void CmdGetInventory(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; if (arg.Args == null || arg.Args.Length < 1) { arg.ReplyWith("Usage: rcs.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)}" ); } /// /// rcs.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("rcs.tcauth")] void CmdTcAuth(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; if (arg.Args == null || arg.Args.Length < 1) { arg.ReplyWith("{\"error\":\"Usage: rcs.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 })); } /// /// rcs.gettcbyplayer <steamid> /// 指定プレイヤーが認証されている Tool Cupboard 一覧(entityId・座標・認証人数)を返す。 /// プレイヤー詳細画面から「このプレイヤーの拠点」を逆引きするために使う /// (rcs.tcauth はエンティティID起点、こちらはプレイヤーID起点)。 /// [ConsoleCommand("rcs.gettcbyplayer")] void CmdGetTcByPlayer(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; if (arg.Args == null || arg.Args.Length < 1) { arg.ReplyWith("{\"error\":\"Usage: rcs.gettcbyplayer \"}"); return; } if (!TryParseStrictUlong(arg.Args[0].ToString(), out ulong steamId)) { arg.ReplyWith("{\"error\":\"Invalid steamid\"}"); return; } var cupboards = new List(); foreach (var networkable in BaseNetworkable.serverEntities) { var tc = networkable as BuildingPrivlidge; if (tc == null || tc.IsDestroyed || tc.authorizedPlayers == null || tc.net == null) continue; if (!tc.authorizedPlayers.Contains(steamId)) continue; var pos = tc.transform.position; cupboards.Add(new ToolCupboardRecord { EntityId = tc.net.ID.Value.ToString(), X = pos.x, Y = pos.y, Z = pos.z, AuthorizedCount = tc.authorizedPlayers.Count, }); } arg.ReplyWith(JsonConvert.SerializeObject(new { steam_id = steamId.ToString(), tool_cupboards = cupboards, })); } /// /// rcs.getmapevents /// ライブマップの動的イベント(ヘリ・貨物船・エアドロップ・ハッカブルクレート・チヌーク・ /// トラベリングベンダー)を全種類まとめて1回の RCON 往復で返す。backend は従来 /// entity.find_entity を6種類個別ポーリングしていた(docs/roadmap-performance.md 3-2 /// で既知課題として記録済み)が、これを1コマンドに集約しRCON往復回数を削減する。 /// 判定条件は frontend/src/hooks/useServerWs.ts の既存 entity.find_entity 出力の /// フィルタ条件(marker/gibs除外等)を踏襲した独自実装。 /// [ConsoleCommand("rcs.getmapevents")] void CmdGetMapEvents(ConsoleSystem.Arg arg) { if (!IsAuthorized(arg)) return; var heli = new List(); var cargo = new List(); var airdrop = new List(); var crate = new List(); var ch47 = new List(); var vendor = new List(); foreach (var networkable in BaseNetworkable.serverEntities) { var entity = networkable as BaseEntity; if (entity == null || entity.IsDestroyed || entity.net == null) continue; var prefab = entity.ShortPrefabName ?? string.Empty; List bucket = ClassifyMapEventBucket(prefab, heli, cargo, airdrop, crate, ch47, vendor); if (bucket == null) continue; var pos = entity.transform.position; bucket.Add(new { // entityId は既存の LiveMap 型(entityId: number)に合わせて数値のまま返す // (TC/プレイヤー紐付け系コマンドの entity_id 文字列とは異なる方針)。 entityId = entity.net.ID.Value, x = pos.x, y = pos.y, z = pos.z, }); } arg.ReplyWith(JsonConvert.SerializeObject(new { heli, cargo, airdrop, crate, ch47, vendor, })); } private static List ClassifyMapEventBucket( string prefab, List heli, List cargo, List airdrop, List crate, List ch47, List vendor) { if (prefab == "patrolhelicopter") return heli; if (prefab == "cargoshiptest") return cargo; if (prefab.IndexOf("supply_drop", StringComparison.OrdinalIgnoreCase) >= 0) return airdrop; if (prefab.IndexOf("codelockedhackablecrate", StringComparison.OrdinalIgnoreCase) >= 0) return crate; if (prefab.IndexOf("ch47", StringComparison.OrdinalIgnoreCase) >= 0) { // ch47marker(地図マーカー)・servergibs_ch47(残骸)は実体ではないため除外。 if (prefab.IndexOf("marker", StringComparison.OrdinalIgnoreCase) >= 0) return null; if (prefab.IndexOf("gibs", StringComparison.OrdinalIgnoreCase) >= 0) return null; return ch47; } if (prefab.IndexOf("travellingvendor", StringComparison.OrdinalIgnoreCase) >= 0) { // travellingvendormarker(地図マーカー)は実体ではないため除外。 if (prefab.IndexOf("marker", StringComparison.OrdinalIgnoreCase) >= 0) return null; return vendor; } return null; } /// container: items / belt / worn(rcs.getinventory の Items/Worn/Belt キーに合わせる)。 private static ItemContainer GetPlayerNamedContainer(BasePlayer player, string name) { if (player?.inventory == null) return null; switch (name) { case "items": return player.inventory.containerMain; case "belt": return player.inventory.containerBelt; case "worn": return player.inventory.containerWear; default: return null; } } /// Items/Worn/Belt の各コンテナ直下(トップレベルのみ・ネストしたコンテナは対象外)から /// uid 一致のアイテムを探す。 private static Item FindPlayerItemByUid(BasePlayer player, ItemId uid) { if (player?.inventory == null || !uid.IsValid) return null; var searchOrder = new[] { player.inventory.containerMain, player.inventory.containerBelt, player.inventory.containerWear, }; foreach (var container in searchOrder) { var found = container?.FindItemByUID(uid); if (found != null) return found; } return null; } /// rcs.removeitem/moveitem/updateitem の共通応答。既存 rcs.getinventory と同じ /// "Items:/Worn:/Belt:" 形式の前に "Result: ok" 行を足すだけなので、backend の /// 既存パーサー(Items:/Worn:/Belt: 行のみを見る)は無改修で流用できる。 private string BuildInventoryReply(BasePlayer player, string result) { var items = SerializeContainer(player.inventory.containerMain); var worn = SerializeContainer(player.inventory.containerWear); var belt = SerializeContainer(player.inventory.containerBelt); return $"Result: {result}\n" + $"Items: {JsonConvert.SerializeObject(items)}\n" + $"Worn: {JsonConvert.SerializeObject(worn)}\n" + $"Belt: {JsonConvert.SerializeObject(belt)}"; } 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 { ["uid"] = item.uid.ToString(), ["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; } } }