using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; using Oxide.Core; using UnityEngine; namespace Oxide.Plugins { [Info("MaterialDecayMultiplier", "moru96", "1.0.1")] [Description("Changes decay damage multiplier by detected material and TC management state for decay-damaged entities.")] public class MaterialDecayMultiplier : RustPlugin { private PluginConfig config; #region Constants private const string PermissionAdmin = "materialdecaymultiplier.admin"; private const string MaterialTwigs = "Twigs"; private const string MaterialWood = "Wood"; private const string MaterialStone = "Stone"; private const string MaterialMetal = "Metal"; private const string MaterialTopTier = "TopTier"; private const string MaterialOther = "Other"; private const string TcStateManaged = "Managed"; private const string TcStateUnmanaged = "Unmanaged"; private const string MatchModeContains = "Contains"; private const string MatchModeEquals = "Equals"; private const string MatchModeStartsWith = "StartsWith"; private const string MatchModeEndsWith = "EndsWith"; private const string EntityTypeBuildingBlock = "BuildingBlock"; private const string EntityTypeDecayEntity = "DecayEntity"; private const string EntityTypeOtherCombatEntity = "OtherCombatEntity"; #endregion #region Runtime State private readonly Dictionary prefabMaterialCache = new Dictionary(); private readonly Dictionary debugLastLogTimeByEntity = new Dictionary(); private readonly HashSet checkViewEnabledPlayers = new HashSet(); private Timer checkViewTimer; private int debugCurrentMinute = -1; private int debugLogsThisMinute = 0; private RuntimeStats stats = new RuntimeStats(); #endregion #region Oxide Hooks private void Init() { permission.RegisterPermission(PermissionAdmin, this); LoadConfigValues(); if (config.Debug.Enabled) { PrintWarning("Debug mode is enabled. Decay events may be logged with configured rate limits."); } if (config.ShowCompatibilityWarningOnInit) { PrintWarning("This plugin modifies Decay damage in OnEntityTakeDamage. Other decay-related plugins may also modify the same damage."); } } private void Unload() { DestroyCheckViewTimer(); ClearRuntimeState(); } private void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) { if (config == null || !config.Enabled) return; if (entity == null || info == null || info.damageTypes == null) return; if (!ShouldProcessEntity(entity)) return; if (!HasPositiveDecayDamage(info, out float originalDecayDamage)) return; MaterialDetectResult materialResult = DetectMaterial(entity); BuildingPrivilegeDetectResult privilegeResult = DetectBuildingPrivilegeScope(entity); MultiplierResult multiplierResult = GetDecayMultiplier(materialResult.MaterialKey, privilegeResult); multiplierResult = ApplyExternalMultiplierHook(entity, materialResult, privilegeResult, multiplierResult); float originalTotalDamage = info.damageTypes.Total(); info.damageTypes.Scale(Rust.DamageType.Decay, multiplierResult.Multiplier); float modifiedDecayDamage = info.damageTypes.Get(Rust.DamageType.Decay); float modifiedTotalDamage = info.damageTypes.Total(); RecordStats(entity, materialResult, privilegeResult, multiplierResult); if (config.Debug.Enabled) { DebugDecayEvent( entity, info, materialResult, privilegeResult, multiplierResult, originalDecayDamage, modifiedDecayDamage, originalTotalDamage, modifiedTotalDamage ); } } #endregion #region Entity Processing private bool ShouldProcessEntity(BaseCombatEntity entity) { if (entity == null) return false; if (config.Targets == null) config.Targets = new TargetConfig(); if (IsExcludedEntity(entity)) return false; if (entity is BuildingBlock) return config.Targets.AffectBuildingBlocks; if (entity is DecayEntity) return config.Targets.AffectDecayEntities; return config.Targets.AffectOtherCombatEntities; } private bool IsExcludedEntity(BaseCombatEntity entity) { if (entity == null || config.Exclusions == null) return false; string shortPrefabName = GetEntityShortPrefabName(entity).ToLowerInvariant(); string prefabName = GetEntityPrefabName(entity).ToLowerInvariant(); if (MatchesAnyKeyword(shortPrefabName, config.Exclusions.ExcludedShortPrefabNameKeywords)) return true; if (MatchesAnyKeyword(prefabName, config.Exclusions.ExcludedPrefabNameKeywords)) return true; return false; } private bool MatchesAnyKeyword(string source, List keywords) { if (string.IsNullOrWhiteSpace(source)) return false; if (keywords == null || keywords.Count == 0) return false; foreach (string keyword in keywords) { if (string.IsNullOrWhiteSpace(keyword)) continue; if (source.Contains(keyword.Trim().ToLowerInvariant())) return true; } return false; } private bool HasPositiveDecayDamage(HitInfo info, out float originalDecayDamage) { originalDecayDamage = 0f; if (info == null || info.damageTypes == null) return false; if (!info.damageTypes.Has(Rust.DamageType.Decay)) return false; originalDecayDamage = info.damageTypes.Get(Rust.DamageType.Decay); return originalDecayDamage > 0f; } #endregion #region Material Detection private class MaterialDetectResult { public string MaterialKey; public string DetectionSource; public string DetectionDetail; public bool IsFallback; public MaterialDetectResult() { } public MaterialDetectResult(string materialKey, string detectionSource, string detectionDetail, bool isFallback) { MaterialKey = materialKey; DetectionSource = detectionSource; DetectionDetail = detectionDetail; IsFallback = isFallback; } public MaterialDetectResult Clone() { return new MaterialDetectResult(MaterialKey, DetectionSource, DetectionDetail, IsFallback); } } private MaterialDetectResult DetectMaterial(BaseCombatEntity entity) { BuildingBlock block = entity as BuildingBlock; if (block != null) { string gradeMaterial = GetMaterialFromBuildingGrade(block.grade); return new MaterialDetectResult( gradeMaterial, "BuildingBlock.grade", block.grade.ToString(), false ); } string prefabName = GetEntityPrefabName(entity); string shortPrefabName = GetEntityShortPrefabName(entity); MaterialDetectResult resultByRule = DetectMaterialByPrefabRules(prefabName, shortPrefabName); if (resultByRule != null) return resultByRule; return new MaterialDetectResult( MaterialOther, "Fallback", "No matching building grade or prefab material rule. Treated as Other.", true ); } private string GetMaterialFromBuildingGrade(BuildingGrade.Enum grade) { switch (grade) { case BuildingGrade.Enum.Twigs: return MaterialTwigs; case BuildingGrade.Enum.Wood: return MaterialWood; case BuildingGrade.Enum.Stone: return MaterialStone; case BuildingGrade.Enum.Metal: return MaterialMetal; case BuildingGrade.Enum.TopTier: return MaterialTopTier; default: return MaterialOther; } } private MaterialDetectResult DetectMaterialByPrefabRules(string prefabName, string shortPrefabName) { string normalizedPrefab = (prefabName ?? string.Empty).ToLowerInvariant(); string normalizedShortPrefab = (shortPrefabName ?? string.Empty).ToLowerInvariant(); string cacheKey = $"{normalizedPrefab}|{normalizedShortPrefab}"; if (config.Cache.Enabled && config.Cache.CachePrefabMaterialDetection) { MaterialDetectResult cached; if (prefabMaterialCache.TryGetValue(cacheKey, out cached)) return cached.Clone(); } MaterialDetectResult result = DetectMaterialByPrefabRulesUncached(prefabName, shortPrefabName); if (result != null && config.Cache.Enabled && config.Cache.CachePrefabMaterialDetection) { if (prefabMaterialCache.Count < Math.Max(0, config.Cache.MaximumPrefabMaterialCacheEntries)) prefabMaterialCache[cacheKey] = result.Clone(); } return result; } private MaterialDetectResult DetectMaterialByPrefabRulesUncached(string prefabName, string shortPrefabName) { string fullName = ((prefabName ?? string.Empty) + " " + (shortPrefabName ?? string.Empty)).ToLowerInvariant(); if (string.IsNullOrWhiteSpace(fullName)) return null; if (config.PrefabMaterialRules == null) return null; foreach (PrefabMaterialRule rule in config.PrefabMaterialRules.OrderBy(r => r == null ? int.MaxValue : r.Priority)) { if (rule == null) continue; if (!rule.Enabled) continue; if (string.IsNullOrWhiteSpace(rule.MaterialKey)) continue; if (rule.ContainsKeywords == null || rule.ContainsKeywords.Count == 0) continue; string normalizedMaterial = NormalizeMaterialKey(rule.MaterialKey); string normalizedMatchMode = NormalizeMatchMode(rule.MatchMode); foreach (string keyword in rule.ContainsKeywords) { if (string.IsNullOrWhiteSpace(keyword)) continue; string normalizedKeyword = keyword.Trim().ToLowerInvariant(); if (!IsPrefabRuleMatch(fullName, normalizedKeyword, normalizedMatchMode)) continue; return new MaterialDetectResult( normalizedMaterial, "PrefabMaterialRules", $"Matched keyword '{keyword}' by mode '{normalizedMatchMode}' in prefab='{prefabName}', shortPrefab='{shortPrefabName}', priority='{rule.Priority}'", false ); } } return null; } private bool IsPrefabRuleMatch(string source, string keyword, string matchMode) { if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(keyword)) return false; switch (matchMode) { case MatchModeEquals: return source.Equals(keyword, StringComparison.OrdinalIgnoreCase); case MatchModeStartsWith: return source.StartsWith(keyword, StringComparison.OrdinalIgnoreCase); case MatchModeEndsWith: return source.EndsWith(keyword, StringComparison.OrdinalIgnoreCase); case MatchModeContains: default: return source.Contains(keyword); } } private string NormalizeMatchMode(string matchMode) { if (string.IsNullOrWhiteSpace(matchMode)) return MatchModeContains; switch (matchMode.Trim().ToLowerInvariant()) { case "equals": case "equal": case "exact": case "完全一致": return MatchModeEquals; case "startswith": case "starts_with": case "prefix": case "前方一致": return MatchModeStartsWith; case "endswith": case "ends_with": case "suffix": case "後方一致": return MatchModeEndsWith; case "contains": case "include": case "includes": case "部分一致": default: return MatchModeContains; } } private string NormalizeMaterialKey(string materialKey) { if (string.IsNullOrWhiteSpace(materialKey)) return MaterialOther; switch (materialKey.Trim().ToLowerInvariant()) { case "twig": case "twigs": case "藁": case "わら": case "小枝": return MaterialTwigs; case "wood": case "木": case "木材": return MaterialWood; case "stone": case "石": case "石材": return MaterialStone; case "metal": case "sheetmetal": case "sheet metal": case "sheet_metal": case "金属": return MaterialMetal; case "toptier": case "top_tier": case "hqm": case "armored": case "armoured": case "armour": case "armor": case "強化金属": case "装甲": return MaterialTopTier; case "other": case "その他": default: return MaterialOther; } } private bool IsKnownMaterialKey(string materialKey) { if (string.IsNullOrWhiteSpace(materialKey)) return false; string trimmed = materialKey.Trim(); return trimmed.Equals(MaterialTwigs, StringComparison.OrdinalIgnoreCase) || trimmed.Equals(MaterialWood, StringComparison.OrdinalIgnoreCase) || trimmed.Equals(MaterialStone, StringComparison.OrdinalIgnoreCase) || trimmed.Equals(MaterialMetal, StringComparison.OrdinalIgnoreCase) || trimmed.Equals(MaterialTopTier, StringComparison.OrdinalIgnoreCase) || trimmed.Equals(MaterialOther, StringComparison.OrdinalIgnoreCase); } private string GetEntityPrefabName(BaseEntity entity) { if (entity == null) return string.Empty; try { return entity.PrefabName ?? string.Empty; } catch { return string.Empty; } } private string GetEntityShortPrefabName(BaseEntity entity) { if (entity == null) return string.Empty; try { return entity.ShortPrefabName ?? string.Empty; } catch { return string.Empty; } } #endregion #region Building Privilege Detection private enum BuildingPrivilegeScope { Managed, Unmanaged } private class BuildingPrivilegeDetectResult { public BuildingPrivilegeScope Scope; public string ScopeKey; public bool HasPrivilege; public bool IsDetectionError; public string DetectionDetail; public string TcNetId; public string TcOwnerId; public BuildingPrivilegeDetectResult( BuildingPrivilegeScope scope, string scopeKey, bool hasPrivilege, bool isDetectionError, string detectionDetail, string tcNetId, string tcOwnerId ) { Scope = scope; ScopeKey = scopeKey; HasPrivilege = hasPrivilege; IsDetectionError = isDetectionError; DetectionDetail = detectionDetail; TcNetId = tcNetId; TcOwnerId = tcOwnerId; } } private BuildingPrivilegeDetectResult DetectBuildingPrivilegeScope(BaseCombatEntity entity) { if (entity == null) { return new BuildingPrivilegeDetectResult( BuildingPrivilegeScope.Unmanaged, TcStateUnmanaged, false, false, "Entity is null. Treated as Unmanaged.", "none", "none" ); } try { BuildingPrivlidge privilege = entity.GetBuildingPrivilege(); if (privilege == null) { return new BuildingPrivilegeDetectResult( BuildingPrivilegeScope.Unmanaged, TcStateUnmanaged, false, false, "No building privilege / TC unmanaged or outside TC range.", "none", "none" ); } string tcNetId = privilege.net != null ? privilege.net.ID.ToString() : "no-net-id"; string tcOwnerId = privilege.OwnerID.ToString(); return new BuildingPrivilegeDetectResult( BuildingPrivilegeScope.Managed, TcStateManaged, true, false, $"Has building privilege / TC detected, TCNetID={tcNetId}, TCOwnerID={tcOwnerId}.", tcNetId, tcOwnerId ); } catch (Exception ex) { bool treatAsManaged = config != null && config.TreatTcDetectionErrorAsManaged; BuildingPrivilegeScope scope = treatAsManaged ? BuildingPrivilegeScope.Managed : BuildingPrivilegeScope.Unmanaged; string scopeKey = treatAsManaged ? TcStateManaged : TcStateUnmanaged; string detail = $"Unable to detect TC state: {ex.Message}. " + $"Treated as {scopeKey} by config."; return new BuildingPrivilegeDetectResult( scope, scopeKey, treatAsManaged, true, detail, "error", "error" ); } } private string NormalizeTcStateKey(string stateKey) { if (string.IsNullOrWhiteSpace(stateKey)) return string.Empty; switch (stateKey.Trim().ToLowerInvariant()) { case "managed": case "tc": case "inside": case "protected": case "管理内": case "tc管理内": return TcStateManaged; case "unmanaged": case "notmanaged": case "no_tc": case "notc": case "outside": case "unprotected": case "管理外": case "tc管理外": return TcStateUnmanaged; default: return string.Empty; } } #endregion #region Decay Multiplier private class MultiplierResult { public float Multiplier; public string ScopeKey; public string MaterialKey; public string Source; public string Fallback; public MultiplierResult( float multiplier, string scopeKey, string materialKey, string source, string fallback ) { Multiplier = multiplier; ScopeKey = scopeKey; MaterialKey = materialKey; Source = source; Fallback = fallback; } } private MultiplierResult GetDecayMultiplier(string materialKey, BuildingPrivilegeDetectResult privilegeResult) { string normalizedMaterial = NormalizeMaterialKey(materialKey); string scopeKey = privilegeResult != null && !string.IsNullOrWhiteSpace(privilegeResult.ScopeKey) ? privilegeResult.ScopeKey : TcStateUnmanaged; if (config.DecayMultipliersByTcState == null) config.DecayMultipliersByTcState = GetDefaultDecayMultipliersByTcState(); Dictionary scopeMultipliers; if (!config.DecayMultipliersByTcState.TryGetValue(scopeKey, out scopeMultipliers) || scopeMultipliers == null) { scopeKey = TcStateUnmanaged; if (!config.DecayMultipliersByTcState.TryGetValue(scopeKey, out scopeMultipliers) || scopeMultipliers == null) { return new MultiplierResult( 1.0f, scopeKey, normalizedMaterial, "Default1.0", "ScopeMissing" ); } } float multiplier; if (scopeMultipliers.TryGetValue(normalizedMaterial, out multiplier)) { return new MultiplierResult( ClampMultiplier(multiplier), scopeKey, normalizedMaterial, "TC管理状態別の素材ごとの風化速度倍率", "None" ); } if (scopeMultipliers.TryGetValue(MaterialOther, out multiplier)) { return new MultiplierResult( ClampMultiplier(multiplier), scopeKey, MaterialOther, "TC管理状態別の素材ごとの風化速度倍率", $"MaterialMissing:{normalizedMaterial}->Other" ); } return new MultiplierResult( 1.0f, scopeKey, normalizedMaterial, "Default1.0", $"MaterialMissing:{normalizedMaterial}, OtherMissing" ); } private float ClampMultiplier(float multiplier) { float max = config != null ? Math.Max(0f, config.MaximumMultiplier) : 100f; return Math.Min(max, Math.Max(0f, multiplier)); } private MultiplierResult ApplyExternalMultiplierHook( BaseCombatEntity entity, MaterialDetectResult materialResult, BuildingPrivilegeDetectResult privilegeResult, MultiplierResult multiplierResult ) { if (config == null || config.Integration == null || !config.Integration.EnableExternalMultiplierHook) return multiplierResult; object hookResult = Interface.CallHook( "OnMaterialDecayMultiplierCalculated", entity, materialResult != null ? materialResult.MaterialKey : MaterialOther, privilegeResult != null ? privilegeResult.ScopeKey : TcStateUnmanaged, multiplierResult != null ? multiplierResult.Multiplier : 1.0f ); if (hookResult == null) return multiplierResult; float externalMultiplier; if (!TryConvertToFloat(hookResult, out externalMultiplier)) { if (config.Integration.WarnWhenExternalHookReturnsInvalidValue) PrintWarning($"External hook OnMaterialDecayMultiplierCalculated returned unsupported value '{hookResult}'. Ignored."); return multiplierResult; } return new MultiplierResult( ClampMultiplier(externalMultiplier), multiplierResult.ScopeKey, multiplierResult.MaterialKey, "ExternalHook:OnMaterialDecayMultiplierCalculated", $"Original:{multiplierResult.Multiplier}" ); } private bool TryConvertToFloat(object value, out float result) { result = 0f; if (value == null) return false; try { if (value is float) { result = (float)value; return true; } if (value is double) { result = Convert.ToSingle((double)value); return true; } if (value is int) { result = Convert.ToSingle((int)value); return true; } if (value is long) { result = Convert.ToSingle((long)value); return true; } if (value is decimal) { result = Convert.ToSingle((decimal)value); return true; } if (value is string) { return float.TryParse((string)value, out result); } return false; } catch { result = 0f; return false; } } #endregion #region Debug private void DebugDecayEvent( BaseCombatEntity entity, HitInfo info, MaterialDetectResult materialResult, BuildingPrivilegeDetectResult privilegeResult, MultiplierResult multiplierResult, float originalDecayDamage, float modifiedDecayDamage, float originalTotalDamage, float modifiedTotalDamage ) { if (!ShouldDebugEntity(entity, materialResult)) return; if (!CanWriteDebugLog(entity)) return; string entityName = entity.GetType().Name; string prefabName = GetEntityPrefabName(entity); string shortPrefabName = GetEntityShortPrefabName(entity); string ownerId = entity.OwnerID.ToString(); string netId = entity.net != null ? entity.net.ID.ToString() : "no-net-id"; string position = entity.transform != null ? entity.transform.position.ToString() : "unknown-position"; string buildingId = GetBuildingId(entity); string initiator = GetInitiatorName(info); string tcScope = privilegeResult != null ? privilegeResult.ScopeKey : "unknown"; string tcDetectionError = privilegeResult != null ? privilegeResult.IsDetectionError.ToString() : "unknown"; string tcDetectionDetail = privilegeResult != null ? privilegeResult.DetectionDetail : "unknown"; string tcNetId = privilegeResult != null ? privilegeResult.TcNetId : "unknown"; string tcOwnerId = privilegeResult != null ? privilegeResult.TcOwnerId : "unknown"; string multiplierScope = multiplierResult != null ? multiplierResult.ScopeKey : "unknown"; string multiplierMaterialKey = multiplierResult != null ? multiplierResult.MaterialKey : "unknown"; string multiplierSource = multiplierResult != null ? multiplierResult.Source : "unknown"; string multiplierFallback = multiplierResult != null ? multiplierResult.Fallback : "unknown"; string multiplier = multiplierResult != null ? multiplierResult.Multiplier.ToString() : "unknown"; string message = $"[Decay Debug] Entity='{entityName}', " + $"ShortPrefab='{shortPrefabName}', " + $"Prefab='{prefabName}', " + $"NetID='{netId}', " + $"OwnerID='{ownerId}', " + $"Position='{position}', " + $"BuildingID='{buildingId}', " + $"TCScope='{tcScope}', " + $"TCDetectionError='{tcDetectionError}', " + $"TCDetectionDetail='{tcDetectionDetail}', " + $"TCNetID='{tcNetId}', " + $"TCOwnerID='{tcOwnerId}', " + $"Material='{materialResult.MaterialKey}', " + $"DetectionSource='{materialResult.DetectionSource}', " + $"DetectionDetail='{materialResult.DetectionDetail}', " + $"Fallback='{materialResult.IsFallback}', " + $"MultiplierScope='{multiplierScope}', " + $"MultiplierMaterialKey='{multiplierMaterialKey}', " + $"MultiplierSource='{multiplierSource}', " + $"MultiplierFallback='{multiplierFallback}', " + $"Multiplier='{multiplier}', " + $"DecayDamageOnly='{originalDecayDamage}' -> '{modifiedDecayDamage}', " + $"TotalDamageAfterDecayOnlyScale='{originalTotalDamage}' -> '{modifiedTotalDamage}', " + $"Initiator='{initiator}'"; if (materialResult.IsFallback && config.Debug.WarnOnFallbackToOther) { PrintWarning(message); return; } if (config.Debug.LogToWarning) { PrintWarning(message); } else { Puts(message); } } private bool ShouldDebugEntity(BaseCombatEntity entity, MaterialDetectResult materialResult) { if (entity == null) return false; if (config.Debug == null || !config.Debug.Enabled) return false; if (materialResult != null && materialResult.IsFallback && config.Debug.AlwaysLogFallbackToOther) return true; if (config.Debug.OnlyLogFallbackToOther) return materialResult != null && materialResult.IsFallback; if (config.Debug.EntityShortPrefabNameFilter == null || config.Debug.EntityShortPrefabNameFilter.Count == 0) return true; string shortPrefabName = GetEntityShortPrefabName(entity).ToLowerInvariant(); foreach (string filter in config.Debug.EntityShortPrefabNameFilter) { if (string.IsNullOrWhiteSpace(filter)) continue; if (shortPrefabName.Contains(filter.ToLowerInvariant())) return true; } return false; } private bool CanWriteDebugLog(BaseCombatEntity entity) { if (config.Debug == null) return false; double now = Interface.Oxide.Now; int minute = (int)(now / 60d); if (debugCurrentMinute != minute) { debugCurrentMinute = minute; debugLogsThisMinute = 0; } if (config.Debug.MaxLogsPerMinute > 0 && debugLogsThisMinute >= config.Debug.MaxLogsPerMinute) return false; string entityKey = GetEntityCacheKey(entity); if (config.Debug.SameEntityLogIntervalSeconds > 0f && !string.IsNullOrWhiteSpace(entityKey)) { double lastLogTime; if (debugLastLogTimeByEntity.TryGetValue(entityKey, out lastLogTime)) { if (now - lastLogTime < config.Debug.SameEntityLogIntervalSeconds) return false; } debugLastLogTimeByEntity[entityKey] = now; } debugLogsThisMinute++; return true; } private string GetBuildingId(BaseCombatEntity entity) { BuildingBlock block = entity as BuildingBlock; if (block != null && block.buildingID != 0) return block.buildingID.ToString(); DecayEntity decayEntity = entity as DecayEntity; if (decayEntity != null && decayEntity.buildingID != 0) return decayEntity.buildingID.ToString(); return "none"; } private string GetInitiatorName(HitInfo info) { if (info == null) return "none"; if (info.Initiator == null) return "none"; string shortPrefab = GetEntityShortPrefabName(info.Initiator); string prefab = GetEntityPrefabName(info.Initiator); return $"{info.Initiator.GetType().Name}, shortPrefab='{shortPrefab}', prefab='{prefab}'"; } private string GetEntityCacheKey(BaseEntity entity) { if (entity == null) return string.Empty; try { if (entity.net != null) return entity.net.ID.ToString(); } catch { return string.Empty; } return string.Empty; } #endregion #region Commands [ChatCommand("mdm")] private void ChatCommandMdm(BasePlayer player, string command, string[] args) { if (player == null) return; if (!HasAdminPermission(player)) { SendReply(player, "You do not have permission to use this command."); return; } HandleMdmCommand(player, args); } [ConsoleCommand("mdm")] private void ConsoleCommandMdm(ConsoleSystem.Arg arg) { BasePlayer player = arg.Player(); if (player != null && !HasAdminPermission(player)) { SendReply(player, "You do not have permission to use this command."); return; } string[] args = GetConsoleArgs(arg); HandleMdmCommand(player, args); } private string[] GetConsoleArgs(ConsoleSystem.Arg arg) { if (arg == null || arg.Args == null || arg.Args.Length == 0) return new string[0]; string[] convertedArgs = new string[arg.Args.Length]; for (int i = 0; i < arg.Args.Length; i++) { convertedArgs[i] = arg.Args[i].ToString(); } return convertedArgs; } private void HandleMdmCommand(BasePlayer player, string[] args) { if (args == null || args.Length == 0) { Reply(player, GetCommandHelp()); return; } string subCommand = args[0].ToLowerInvariant(); switch (subCommand) { case "status": CommandStatus(player); return; case "reload": CommandReload(player); return; case "multipliers": case "multiplier": case "rates": CommandMultipliers(player); return; case "check": CommandCheck(player); return; case "checkview": CommandCheckView(player, args); return; case "debug": CommandDebug(player, args); return; case "stats": CommandStats(player, args); return; case "cache": CommandCache(player, args); return; case "help": default: Reply(player, GetCommandHelp()); return; } } private string GetCommandHelp() { return "MaterialDecayMultiplier commands:\n" + "/mdm status - Show plugin status.\n" + "/mdm reload - Reload config.\n" + "/mdm multipliers - Show configured multipliers.\n" + "/mdm check - Check entity you are looking at.\n" + "/mdm checkview on|off - Toggle live entity info display on looked entity.\n" + "/mdm debug on|off - Temporarily toggle debug setting and save config.\n" + "/mdm stats - Show runtime stats.\n" + "/mdm stats reset - Reset runtime stats.\n" + "/mdm cache clear - Clear runtime caches."; } private void CommandStatus(BasePlayer player) { Reply( player, "MaterialDecayMultiplier status:\n" + $"Enabled: {config.Enabled}\n" + $"ConfigVersion: {config.ConfigVersion}\n" + $"MaximumMultiplier: {config.MaximumMultiplier}\n" + $"AffectBuildingBlocks: {config.Targets.AffectBuildingBlocks}\n" + $"AffectDecayEntities: {config.Targets.AffectDecayEntities}\n" + $"AffectOtherCombatEntities: {config.Targets.AffectOtherCombatEntities}\n" + $"DebugEnabled: {config.Debug.Enabled}\n" + $"CheckViewEnabledPlayers: {checkViewEnabledPlayers.Count}\n" + $"CheckViewTimerActive: {(checkViewTimer != null)}\n" + $"PrefabMaterialCacheEntries: {prefabMaterialCache.Count}" ); } private void CommandReload(BasePlayer player) { LoadConfigValues(); ClearRuntimeState(); if (checkViewEnabledPlayers.Count > 0) RestartCheckViewTimer(); Reply(player, "MaterialDecayMultiplier config reloaded. Runtime caches and debug rate-limit state were cleared."); } private void CommandMultipliers(BasePlayer player) { List lines = new List(); lines.Add("Configured decay multipliers:"); foreach (KeyValuePair> stateEntry in config.DecayMultipliersByTcState) { lines.Add($"[{stateEntry.Key}]"); foreach (KeyValuePair materialEntry in stateEntry.Value) { lines.Add($" {materialEntry.Key}: {materialEntry.Value}"); } } Reply(player, string.Join("\n", lines.ToArray())); } private void CommandCheck(BasePlayer player) { if (player == null) { Reply(player, "This command can only be used by an in-game player."); return; } BaseCombatEntity entity = GetLookAtEntity(player, config.AdminCommands.CheckDistance) as BaseCombatEntity; if (entity == null) { Reply(player, $"No BaseCombatEntity found within {config.AdminCommands.CheckDistance}m."); return; } bool shouldProcess = ShouldProcessEntity(entity); MaterialDetectResult materialResult = DetectMaterial(entity); BuildingPrivilegeDetectResult privilegeResult = DetectBuildingPrivilegeScope(entity); MultiplierResult multiplierResult = GetDecayMultiplier(materialResult.MaterialKey, privilegeResult); string entityType = GetEntityTypeKey(entity); Reply( player, "MaterialDecayMultiplier entity check:\n" + $"ShouldProcess: {shouldProcess}\n" + $"EntityType: {entity.GetType().Name}\n" + $"EntityTypeKey: {entityType}\n" + $"ShortPrefabName: {GetEntityShortPrefabName(entity)}\n" + $"PrefabName: {GetEntityPrefabName(entity)}\n" + $"NetID: {(entity.net != null ? entity.net.ID.ToString() : "no-net-id")}\n" + $"OwnerID: {entity.OwnerID}\n" + $"BuildingID: {GetBuildingId(entity)}\n" + $"TCState: {privilegeResult.ScopeKey}\n" + $"TCDetail: {privilegeResult.DetectionDetail}\n" + $"Material: {materialResult.MaterialKey}\n" + $"MaterialDetectionSource: {materialResult.DetectionSource}\n" + $"MaterialDetectionDetail: {materialResult.DetectionDetail}\n" + $"MaterialFallback: {materialResult.IsFallback}\n" + $"Multiplier: {multiplierResult.Multiplier}\n" + $"MultiplierSource: {multiplierResult.Source}\n" + $"MultiplierFallback: {multiplierResult.Fallback}" ); } private void CommandCheckView(BasePlayer player, string[] args) { if (player == null) { Reply(player, "This command can only be used by an in-game player."); return; } if (args.Length < 2) { bool current = checkViewEnabledPlayers.Contains(player.userID); Reply(player, $"CheckView is currently {(current ? "ON" : "OFF")} for you. Usage: /mdm checkview on|off"); return; } string value = args[1].ToLowerInvariant(); if (value == "on" || value == "true" || value == "1") { checkViewEnabledPlayers.Add(player.userID); EnsureCheckViewTimer(); Reply(player, "CheckView enabled. Look at an entity to display decay information."); return; } if (value == "off" || value == "false" || value == "0") { checkViewEnabledPlayers.Remove(player.userID); if (checkViewEnabledPlayers.Count == 0) DestroyCheckViewTimer(); Reply(player, "CheckView disabled."); return; } Reply(player, "Usage: /mdm checkview on|off"); } private void EnsureCheckViewTimer() { if (checkViewTimer != null) return; float interval = 1.0f; if (config != null && config.CheckView != null && config.CheckView.UpdateIntervalSeconds > 0f) interval = config.CheckView.UpdateIntervalSeconds; checkViewTimer = timer.Every(interval, UpdateCheckViewDisplays); } private void RestartCheckViewTimer() { DestroyCheckViewTimer(); if (checkViewEnabledPlayers.Count > 0) EnsureCheckViewTimer(); } private void DestroyCheckViewTimer() { if (checkViewTimer == null) return; checkViewTimer.Destroy(); checkViewTimer = null; } private void UpdateCheckViewDisplays() { if (checkViewEnabledPlayers.Count == 0) { DestroyCheckViewTimer(); return; } if (config == null || config.CheckView == null) return; List removeList = null; foreach (ulong userId in checkViewEnabledPlayers) { BasePlayer player = BasePlayer.FindByID(userId); if (player == null || !player.IsConnected) { if (removeList == null) removeList = new List(); removeList.Add(userId); continue; } if (!HasAdminPermission(player)) { if (removeList == null) removeList = new List(); removeList.Add(userId); continue; } DrawCheckViewForPlayer(player); } if (removeList != null) { foreach (ulong userId in removeList) { checkViewEnabledPlayers.Remove(userId); } } if (checkViewEnabledPlayers.Count == 0) DestroyCheckViewTimer(); } private void DrawCheckViewForPlayer(BasePlayer player) { if (player == null || config == null || config.CheckView == null) return; BaseCombatEntity entity = GetLookAtEntity(player, config.CheckView.Distance) as BaseCombatEntity; if (entity == null) return; MaterialDetectResult materialResult = DetectMaterial(entity); BuildingPrivilegeDetectResult privilegeResult = DetectBuildingPrivilegeScope(entity); MultiplierResult multiplierResult = GetDecayMultiplier(materialResult.MaterialKey, privilegeResult); string entityType = entity.GetType().Name; string shortPrefabName = GetEntityShortPrefabName(entity); string material = materialResult != null ? materialResult.MaterialKey : MaterialOther; string tcState = privilegeResult != null ? privilegeResult.ScopeKey : TcStateUnmanaged; float multiplier = multiplierResult != null ? multiplierResult.Multiplier : 1.0f; Vector3 drawPosition = entity.transform != null ? entity.transform.position + new Vector3(0f, config.CheckView.HeightOffset, 0f) : player.transform.position + new Vector3(0f, 1.5f, 0f); string text = $"EntityType: {entityType}\n" + $"ShortPrefabName: {shortPrefabName}\n" + $"Material: {material}\n" + $"Multiplier: {multiplier} ({tcState})"; Color color = new Color( config.CheckView.ColorR, config.CheckView.ColorG, config.CheckView.ColorB, config.CheckView.ColorA ); player.SendConsoleCommand( "ddraw.text", config.CheckView.DrawDurationSeconds, color, drawPosition, text ); } private void CommandDebug(BasePlayer player, string[] args) { if (args.Length < 2) { Reply(player, $"DebugEnabled: {config.Debug.Enabled}"); return; } string value = args[1].ToLowerInvariant(); if (value == "on" || value == "true" || value == "1") { config.Debug.Enabled = true; SaveConfig(); Reply(player, "Debug enabled and config saved."); return; } if (value == "off" || value == "false" || value == "0") { config.Debug.Enabled = false; SaveConfig(); Reply(player, "Debug disabled and config saved."); return; } Reply(player, "Usage: /mdm debug on|off"); } private void CommandStats(BasePlayer player, string[] args) { if (args.Length >= 2 && args[1].Equals("reset", StringComparison.OrdinalIgnoreCase)) { stats = new RuntimeStats(); Reply(player, "Runtime stats reset."); return; } Reply( player, "MaterialDecayMultiplier runtime stats:\n" + $"ProcessedDecayEvents: {stats.ProcessedDecayEvents}\n" + $"FallbackToOtherCount: {stats.FallbackToOtherCount}\n" + $"ManagedCount: {stats.ManagedCount}\n" + $"UnmanagedCount: {stats.UnmanagedCount}\n" + $"LastProcessedEntity: {stats.LastProcessedEntity}\n" + $"LastProcessedMaterial: {stats.LastProcessedMaterial}\n" + $"LastAppliedMultiplier: {stats.LastAppliedMultiplier}\n" + $"PrefabMaterialCacheEntries: {prefabMaterialCache.Count}" ); } private void CommandCache(BasePlayer player, string[] args) { if (args.Length >= 2 && args[1].Equals("clear", StringComparison.OrdinalIgnoreCase)) { ClearRuntimeState(); Reply(player, "Runtime caches and debug rate-limit state were cleared."); return; } Reply(player, $"PrefabMaterialCacheEntries: {prefabMaterialCache.Count}. Use /mdm cache clear to clear caches."); } private bool HasAdminPermission(BasePlayer player) { if (player == null) return true; return player.IsAdmin || permission.UserHasPermission(player.UserIDString, PermissionAdmin); } private void Reply(BasePlayer player, string message) { if (player != null) { SendReply(player, message); return; } Puts(message); } private BaseEntity GetLookAtEntity(BasePlayer player, float distance) { if (player == null || player.eyes == null) return null; RaycastHit hit; if (!Physics.Raycast(player.eyes.HeadRay(), out hit, distance)) return null; BaseEntity entity = hit.GetEntity(); return entity; } #endregion #region Stats private class RuntimeStats { public long ProcessedDecayEvents; public long FallbackToOtherCount; public long ManagedCount; public long UnmanagedCount; public string LastProcessedEntity = "none"; public string LastProcessedMaterial = "none"; public float LastAppliedMultiplier = 1f; } private void RecordStats( BaseCombatEntity entity, MaterialDetectResult materialResult, BuildingPrivilegeDetectResult privilegeResult, MultiplierResult multiplierResult ) { stats.ProcessedDecayEvents++; if (materialResult != null && materialResult.IsFallback) stats.FallbackToOtherCount++; if (privilegeResult != null && privilegeResult.ScopeKey == TcStateManaged) stats.ManagedCount++; else stats.UnmanagedCount++; stats.LastProcessedEntity = GetStatsEntityLabel(entity); stats.LastProcessedMaterial = materialResult != null ? materialResult.MaterialKey : "unknown"; stats.LastAppliedMultiplier = multiplierResult != null ? multiplierResult.Multiplier : 1f; } private string GetStatsEntityLabel(BaseCombatEntity entity) { if (entity == null) return "none"; string entityType = entity.GetType().Name; string shortPrefabName = GetEntityShortPrefabName(entity); string netId = entity.net != null ? entity.net.ID.ToString() : "no-net-id"; return $"{entityType}:{shortPrefabName}:{netId}"; } #endregion #region API private float API_GetDecayMultiplier(BaseCombatEntity entity) { if (entity == null) return 1.0f; if (config == null) return 1.0f; if (!config.Enabled) return 1.0f; if (!ShouldProcessEntity(entity)) return 1.0f; MaterialDetectResult materialResult = DetectMaterial(entity); BuildingPrivilegeDetectResult privilegeResult = DetectBuildingPrivilegeScope(entity); MultiplierResult multiplierResult = GetDecayMultiplier(materialResult.MaterialKey, privilegeResult); return multiplierResult.Multiplier; } private string API_GetDetectedMaterial(BaseCombatEntity entity) { if (entity == null) return MaterialOther; if (config == null) return MaterialOther; MaterialDetectResult materialResult = DetectMaterial(entity); return materialResult != null ? materialResult.MaterialKey : MaterialOther; } private string API_GetTcState(BaseCombatEntity entity) { if (entity == null) return TcStateUnmanaged; if (config == null) return TcStateUnmanaged; BuildingPrivilegeDetectResult privilegeResult = DetectBuildingPrivilegeScope(entity); return privilegeResult != null ? privilegeResult.ScopeKey : TcStateUnmanaged; } private bool API_ShouldProcessEntity(BaseCombatEntity entity) { if (entity == null) return false; if (config == null || !config.Enabled) return false; return ShouldProcessEntity(entity); } private Dictionary API_GetEntityDecayInfo(BaseCombatEntity entity) { Dictionary result = new Dictionary(); if (entity == null || config == null) { result["Valid"] = false; return result; } bool shouldProcess = config.Enabled && ShouldProcessEntity(entity); MaterialDetectResult materialResult = DetectMaterial(entity); BuildingPrivilegeDetectResult privilegeResult = DetectBuildingPrivilegeScope(entity); MultiplierResult multiplierResult = GetDecayMultiplier(materialResult.MaterialKey, privilegeResult); result["Valid"] = true; result["PluginEnabled"] = config.Enabled; result["ShouldProcess"] = shouldProcess; result["EntityType"] = entity.GetType().Name; result["EntityTypeKey"] = GetEntityTypeKey(entity); result["ShortPrefabName"] = GetEntityShortPrefabName(entity); result["PrefabName"] = GetEntityPrefabName(entity); result["Material"] = materialResult.MaterialKey; result["MaterialDetectionSource"] = materialResult.DetectionSource; result["MaterialDetectionDetail"] = materialResult.DetectionDetail; result["MaterialFallback"] = materialResult.IsFallback; result["TcState"] = privilegeResult.ScopeKey; result["TcDetectionDetail"] = privilegeResult.DetectionDetail; result["Multiplier"] = multiplierResult.Multiplier; result["MultiplierSource"] = multiplierResult.Source; result["MultiplierFallback"] = multiplierResult.Fallback; return result; } #endregion #region Config private class PluginConfig { [JsonProperty("設定バージョン")] public int ConfigVersion = 1; [JsonProperty("プラグインを有効にする")] public bool Enabled = true; [JsonProperty("TC状態判定失敗時はTC管理内として扱う")] public bool TreatTcDetectionErrorAsManaged = false; [JsonProperty("倍率の最大値")] public float MaximumMultiplier = 100f; [JsonProperty("起動時に競合注意を表示する")] public bool ShowCompatibilityWarningOnInit = true; [JsonProperty("対象エンティティ設定")] public TargetConfig Targets = new TargetConfig(); [JsonProperty("除外設定")] public ExclusionConfig Exclusions = new ExclusionConfig(); [JsonProperty("キャッシュ設定")] public CacheConfig Cache = new CacheConfig(); [JsonProperty("管理コマンド設定")] public AdminCommandConfig AdminCommands = new AdminCommandConfig(); [JsonProperty("CheckView表示設定")] public CheckViewConfig CheckView = new CheckViewConfig(); [JsonProperty("連携設定")] public IntegrationConfig Integration = new IntegrationConfig(); [JsonProperty("TC管理状態別の素材ごとの風化速度倍率", ObjectCreationHandling = ObjectCreationHandling.Replace)] public Dictionary> DecayMultipliersByTcState; [JsonProperty("BuildingBlock以外のPrefab素材判定ルール", ObjectCreationHandling = ObjectCreationHandling.Replace)] public List PrefabMaterialRules; [JsonProperty("デバッグ設定")] public DebugConfig Debug = new DebugConfig(); } private class TargetConfig { [JsonProperty("BuildingBlockを対象にする")] public bool AffectBuildingBlocks = true; [JsonProperty("DecayEntityを対象にする")] public bool AffectDecayEntities = true; [JsonProperty("BuildingBlock以外かつDecayEntity以外のBaseCombatEntityを対象にする")] public bool AffectOtherCombatEntities = false; } private class ExclusionConfig { [JsonProperty("除外するShortPrefab名キーワード")] public List ExcludedShortPrefabNameKeywords = new List(); [JsonProperty("除外するPrefab名キーワード")] public List ExcludedPrefabNameKeywords = new List(); } private class CacheConfig { [JsonProperty("キャッシュを有効にする")] public bool Enabled = true; [JsonProperty("Prefab素材判定をキャッシュする")] public bool CachePrefabMaterialDetection = true; [JsonProperty("Prefab素材判定キャッシュ最大件数")] public int MaximumPrefabMaterialCacheEntries = 4096; } private class AdminCommandConfig { [JsonProperty("checkコマンドの視線距離")] public float CheckDistance = 6f; } private class CheckViewConfig { [JsonProperty("更新間隔 秒")] public float UpdateIntervalSeconds = 1.0f; [JsonProperty("視線距離")] public float Distance = 6f; [JsonProperty("表示時間 秒")] public float DrawDurationSeconds = 1.1f; [JsonProperty("表示高さオフセット")] public float HeightOffset = 0.8f; [JsonProperty("文字色 R")] public float ColorR = 0.3f; [JsonProperty("文字色 G")] public float ColorG = 0.9f; [JsonProperty("文字色 B")] public float ColorB = 1.0f; [JsonProperty("文字色 A")] public float ColorA = 1.0f; } private class IntegrationConfig { [JsonProperty("他プラグイン連携Hookを有効にする")] public bool EnableExternalMultiplierHook = true; [JsonProperty("外部Hookが不正な値を返した場合に警告する")] public bool WarnWhenExternalHookReturnsInvalidValue = true; } private class PrefabMaterialRule { [JsonProperty("有効")] public bool Enabled = true; [JsonProperty("優先度 数値が小さいほど先に判定")] public int Priority = 100; [JsonProperty("素材キー")] public string MaterialKey = MaterialOther; [JsonProperty("一致方式 Contains Equals StartsWith EndsWith")] public string MatchMode = MatchModeContains; [JsonProperty("Prefab名またはShortPrefab名に含まれる文字列")] public List ContainsKeywords = new List(); } private class DebugConfig { [JsonProperty("デバッグを有効にする")] public bool Enabled = false; [JsonProperty("PutsではなくWarningとして出力する")] public bool LogToWarning = false; [JsonProperty("Other判定になった場合は常にログ出力する")] public bool AlwaysLogFallbackToOther = true; [JsonProperty("Other判定になった場合のみログ出力する")] public bool OnlyLogFallbackToOther = false; [JsonProperty("Other判定時にWarningとして出力する")] public bool WarnOnFallbackToOther = true; [JsonProperty("ログ対象ShortPrefab名フィルター 空なら全対象")] public List EntityShortPrefabNameFilter = new List(); [JsonProperty("同一Entityのログ最小間隔 秒")] public float SameEntityLogIntervalSeconds = 30f; [JsonProperty("1分あたりの最大ログ数 0で無制限")] public int MaxLogsPerMinute = 60; } private static Dictionary> GetDefaultDecayMultipliersByTcState() { return new Dictionary> { [TcStateManaged] = new Dictionary { [MaterialTwigs] = 1.0f, [MaterialWood] = 1.0f, [MaterialStone] = 1.0f, [MaterialMetal] = 1.0f, [MaterialTopTier] = 1.0f, [MaterialOther] = 1.0f }, [TcStateUnmanaged] = new Dictionary { [MaterialTwigs] = 2.0f, [MaterialWood] = 2.0f, [MaterialStone] = 2.0f, [MaterialMetal] = 2.0f, [MaterialTopTier] = 2.0f, [MaterialOther] = 2.0f } }; } private static List GetDefaultPrefabMaterialRules() { return new List { new PrefabMaterialRule { Enabled = true, Priority = 10, MaterialKey = MaterialTopTier, MatchMode = MatchModeContains, ContainsKeywords = new List { "door.hinged.toptier", "door.double.hinged.toptier", "toptier", "armored", "armoured", "hqm" } }, new PrefabMaterialRule { Enabled = true, Priority = 20, MaterialKey = MaterialMetal, MatchMode = MatchModeContains, ContainsKeywords = new List { "door.hinged.metal", "door.double.hinged.metal", "shutter.metal", "floor.ladder.hatch", "floor.triangle.ladder.hatch", "sheetmetal", "sheet_metal", "metal" } }, new PrefabMaterialRule { Enabled = true, Priority = 30, MaterialKey = MaterialStone, MatchMode = MatchModeContains, ContainsKeywords = new List { "wall.external.high.stone", "gates.external.high.stone", "stone" } }, new PrefabMaterialRule { Enabled = true, Priority = 40, MaterialKey = MaterialWood, MatchMode = MatchModeContains, ContainsKeywords = new List { "door.hinged.wood", "gates.external.high.wood", "wall.external.high.wood", "barricade.wood", "ladder.wooden", "wooden", "wood" } } }; } private PluginConfig CreateDefaultConfig() { return new PluginConfig { ConfigVersion = 1, Enabled = true, TreatTcDetectionErrorAsManaged = false, MaximumMultiplier = 100f, ShowCompatibilityWarningOnInit = true, Targets = new TargetConfig { AffectBuildingBlocks = true, AffectDecayEntities = true, AffectOtherCombatEntities = false }, Exclusions = new ExclusionConfig { ExcludedShortPrefabNameKeywords = new List(), ExcludedPrefabNameKeywords = new List() }, Cache = new CacheConfig { Enabled = true, CachePrefabMaterialDetection = true, MaximumPrefabMaterialCacheEntries = 4096 }, AdminCommands = new AdminCommandConfig { CheckDistance = 6f }, CheckView = new CheckViewConfig { UpdateIntervalSeconds = 1.0f, Distance = 6f, DrawDurationSeconds = 1.1f, HeightOffset = 0.8f, ColorR = 0.3f, ColorG = 0.9f, ColorB = 1.0f, ColorA = 1.0f }, Integration = new IntegrationConfig { EnableExternalMultiplierHook = true, WarnWhenExternalHookReturnsInvalidValue = true }, DecayMultipliersByTcState = GetDefaultDecayMultipliersByTcState(), PrefabMaterialRules = GetDefaultPrefabMaterialRules(), Debug = new DebugConfig { Enabled = false, LogToWarning = false, AlwaysLogFallbackToOther = true, OnlyLogFallbackToOther = false, WarnOnFallbackToOther = true, EntityShortPrefabNameFilter = new List(), SameEntityLogIntervalSeconds = 30f, MaxLogsPerMinute = 60 } }; } protected override void LoadDefaultConfig() { config = CreateDefaultConfig(); SaveConfig(); } protected override void LoadConfig() { base.LoadConfig(); try { config = Config.ReadObject(); if (config == null) throw new Exception("Config is null"); EnsureConfigDefaults(); } catch (Exception ex) { PrintWarning($"設定ファイルの読み込みに失敗しました。デフォルト設定を生成します。 Error: {ex.Message}"); config = CreateDefaultConfig(); SaveConfig(); } } private void LoadConfigValues() { LoadConfig(); SaveConfig(); } private void EnsureConfigDefaults() { if (config == null) config = CreateDefaultConfig(); if (config.ConfigVersion <= 0) config.ConfigVersion = 1; if (config.MaximumMultiplier < 0f) { PrintWarning($"倍率の最大値が負数だったため 100 に補正しました。 Value={config.MaximumMultiplier}"); config.MaximumMultiplier = 100f; } if (config.Targets == null) config.Targets = new TargetConfig(); if (config.Exclusions == null) config.Exclusions = new ExclusionConfig(); if (config.Exclusions.ExcludedShortPrefabNameKeywords == null) config.Exclusions.ExcludedShortPrefabNameKeywords = new List(); if (config.Exclusions.ExcludedPrefabNameKeywords == null) config.Exclusions.ExcludedPrefabNameKeywords = new List(); if (config.Cache == null) config.Cache = new CacheConfig(); if (config.Cache.MaximumPrefabMaterialCacheEntries < 0) { PrintWarning($"Prefab素材判定キャッシュ最大件数が負数だったため 0 に補正しました。 Value={config.Cache.MaximumPrefabMaterialCacheEntries}"); config.Cache.MaximumPrefabMaterialCacheEntries = 0; } if (config.AdminCommands == null) config.AdminCommands = new AdminCommandConfig(); if (config.AdminCommands.CheckDistance <= 0f) { PrintWarning($"checkコマンドの視線距離が 0 以下だったため 6 に補正しました。 Value={config.AdminCommands.CheckDistance}"); config.AdminCommands.CheckDistance = 6f; } if (config.CheckView == null) config.CheckView = new CheckViewConfig(); if (config.CheckView.UpdateIntervalSeconds <= 0f) { PrintWarning($"CheckView 更新間隔 秒 が 0 以下だったため 1 に補正しました。 Value={config.CheckView.UpdateIntervalSeconds}"); config.CheckView.UpdateIntervalSeconds = 1.0f; } if (config.CheckView.Distance <= 0f) { PrintWarning($"CheckView 視線距離 が 0 以下だったため 6 に補正しました。 Value={config.CheckView.Distance}"); config.CheckView.Distance = 6f; } if (config.CheckView.DrawDurationSeconds <= 0f) { PrintWarning($"CheckView 表示時間 秒 が 0 以下だったため 1.1 に補正しました。 Value={config.CheckView.DrawDurationSeconds}"); config.CheckView.DrawDurationSeconds = 1.1f; } config.CheckView.ColorR = Clamp01(config.CheckView.ColorR); config.CheckView.ColorG = Clamp01(config.CheckView.ColorG); config.CheckView.ColorB = Clamp01(config.CheckView.ColorB); config.CheckView.ColorA = Clamp01(config.CheckView.ColorA); if (config.Integration == null) config.Integration = new IntegrationConfig(); if (config.DecayMultipliersByTcState == null || config.DecayMultipliersByTcState.Count == 0) config.DecayMultipliersByTcState = GetDefaultDecayMultipliersByTcState(); NormalizeAndClampTcStateMultipliers(); if (config.PrefabMaterialRules == null || config.PrefabMaterialRules.Count == 0) config.PrefabMaterialRules = GetDefaultPrefabMaterialRules(); NormalizePrefabMaterialRules(); if (config.Debug == null) config.Debug = new DebugConfig(); if (config.Debug.EntityShortPrefabNameFilter == null) config.Debug.EntityShortPrefabNameFilter = new List(); if (config.Debug.SameEntityLogIntervalSeconds < 0f) { PrintWarning($"同一Entityのログ最小間隔 秒 が負数だったため 0 に補正しました。 Value={config.Debug.SameEntityLogIntervalSeconds}"); config.Debug.SameEntityLogIntervalSeconds = 0f; } if (config.Debug.MaxLogsPerMinute < 0) { PrintWarning($"1分あたりの最大ログ数 が負数だったため 0 に補正しました。 Value={config.Debug.MaxLogsPerMinute}"); config.Debug.MaxLogsPerMinute = 0; } config.ConfigVersion = 1; } private void NormalizeAndClampTcStateMultipliers() { Dictionary> defaults = GetDefaultDecayMultipliersByTcState(); Dictionary> normalizedByState = new Dictionary>(); foreach (KeyValuePair> stateEntry in config.DecayMultipliersByTcState) { string normalizedStateKey = NormalizeTcStateKey(stateEntry.Key); if (string.IsNullOrWhiteSpace(normalizedStateKey)) { PrintWarning($"Unknown TC state key '{stateEntry.Key}' was ignored."); continue; } if (!normalizedByState.ContainsKey(normalizedStateKey)) normalizedByState[normalizedStateKey] = new Dictionary(); if (stateEntry.Value == null) { PrintWarning($"TC state '{stateEntry.Key}' has null multiplier table. Defaults will be used where needed."); continue; } foreach (KeyValuePair materialEntry in stateEntry.Value) { string normalizedMaterialKey = NormalizeMaterialKey(materialEntry.Key); if (!IsKnownMaterialKey(materialEntry.Key)) { PrintWarning($"Material key '{materialEntry.Key}' was normalized to '{normalizedMaterialKey}'."); } float multiplier = materialEntry.Value; if (multiplier < 0f) { PrintWarning($"Negative multiplier '{multiplier}' for state '{stateEntry.Key}', material '{materialEntry.Key}' was clamped to 0."); } if (multiplier > config.MaximumMultiplier) { PrintWarning($"Multiplier '{multiplier}' for state '{stateEntry.Key}', material '{materialEntry.Key}' exceeded MaximumMultiplier '{config.MaximumMultiplier}' and was clamped."); } normalizedByState[normalizedStateKey][normalizedMaterialKey] = ClampMultiplier(multiplier); } } EnsureStateMultipliers(normalizedByState, defaults, TcStateManaged); EnsureStateMultipliers(normalizedByState, defaults, TcStateUnmanaged); config.DecayMultipliersByTcState = normalizedByState; } private void EnsureStateMultipliers( Dictionary> normalizedByState, Dictionary> defaults, string stateKey ) { if (!normalizedByState.ContainsKey(stateKey) || normalizedByState[stateKey] == null) normalizedByState[stateKey] = new Dictionary(); Dictionary stateMultipliers = normalizedByState[stateKey]; Dictionary defaultStateMultipliers = defaults[stateKey]; foreach (KeyValuePair defaultEntry in defaultStateMultipliers) { if (!stateMultipliers.ContainsKey(defaultEntry.Key)) stateMultipliers[defaultEntry.Key] = ClampMultiplier(defaultEntry.Value); stateMultipliers[defaultEntry.Key] = ClampMultiplier(stateMultipliers[defaultEntry.Key]); } if (!stateMultipliers.ContainsKey(MaterialOther)) stateMultipliers[MaterialOther] = 1.0f; } private void NormalizePrefabMaterialRules() { foreach (PrefabMaterialRule rule in config.PrefabMaterialRules) { if (rule == null) continue; string normalizedMaterial = NormalizeMaterialKey(rule.MaterialKey); if (!IsKnownMaterialKey(rule.MaterialKey)) { PrintWarning($"Prefab material rule material key '{rule.MaterialKey}' was normalized to '{normalizedMaterial}'."); } rule.MaterialKey = normalizedMaterial; rule.MatchMode = NormalizeMatchMode(rule.MatchMode); if (rule.ContainsKeywords == null) rule.ContainsKeywords = new List(); for (int i = rule.ContainsKeywords.Count - 1; i >= 0; i--) { if (string.IsNullOrWhiteSpace(rule.ContainsKeywords[i])) { PrintWarning($"Empty prefab material rule keyword was removed. Material='{rule.MaterialKey}', Priority='{rule.Priority}'."); rule.ContainsKeywords.RemoveAt(i); } else { rule.ContainsKeywords[i] = rule.ContainsKeywords[i].Trim(); } } } config.PrefabMaterialRules = config.PrefabMaterialRules .Where(rule => rule != null) .OrderBy(rule => rule.Priority) .ToList(); } protected override void SaveConfig() { Config.WriteObject(config, true); } #endregion #region Helpers private float Clamp01(float value) { if (value < 0f) return 0f; if (value > 1f) return 1f; return value; } private void ClearRuntimeState() { prefabMaterialCache.Clear(); debugLastLogTimeByEntity.Clear(); debugCurrentMinute = -1; debugLogsThisMinute = 0; } private string GetEntityTypeKey(BaseCombatEntity entity) { if (entity == null) return "None"; if (entity is BuildingBlock) return EntityTypeBuildingBlock; if (entity is DecayEntity) return EntityTypeDecayEntity; return EntityTypeOtherCombatEntity; } #endregion } }