using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; using Oxide.Core; // ★ v1.1.0:webrequest 的 RequestMethod 枚举在 Oxide.Core.Libraries 下。 // 但【不要】整包 using Oxide.Core.Libraries; —— 那个命名空间里也有一个 Time, // 会和 TCBuild 大量使用的 UnityEngine.Time 撞名,真机编译直接报: // 'Time' is an ambiguous reference between 'Oxide.Core.Libraries.Time' and 'UnityEngine.Time' // (堆叠管理 / 每日签到 / 福利中心 都没踩到,因为它们计时用 timer.Once / DateTime.UtcNow,不用 Time.) // 所以只别名需要的那一个类型: using RequestMethod = Oxide.Core.Libraries.RequestMethod; using Oxide.Core.Plugins; using Oxide.Game.Rust.Cui; // v1.1.0:授权说明页的 CUI using UnityEngine; /* * 领地柜建造 TCBuild v1.1.2 — 薯片 * --------------------------------------------------------------------------- * 在授权过的领地柜范围内,用领地柜里的材料建造 / 升级 / 修复建筑。 * * 原理(为什么需要「预填」): * Rust 客户端在发建造请求【之前】就会先检查玩家背包里的材料, * 背包不够时客户端直接显示 INSUFFICIENT RESOURCES,请求根本不会发到服务器。 * 所以本插件在玩家拿起图纸 / 锤子时,把领地柜的材料「预填」进背包当缓冲, * 原版扣料扣的就是这份缓冲;切走工具立刻把剩余部分退还领地柜。 * * ===== v1.0.7(2026-09-15)改了什么 ===== * ★ 性能(本次最大的改动):不再每次查找领地柜都遍历 BaseNetworkable.serverEntities * —— 大服几十万实体,而这个查找原来是被 OnPlayerTick 每玩家每秒调用一次的, * 是拖服的主要来源。改成启动时建立「领地柜索引」+ 实体钩子增量维护, * 查找只遍历领地柜本身(通常几十个);索引万一漏了还有 30 秒一次的兜底重建。 * ★ 性能 / 正确性:取消 OnPlayerTick + 全局 _tickCounter % 30 的写法。 * 那个全局计数在多人服里等于「每 30 次玩家 tick 只检查一个人」, * 50 人服里每个玩家要 25 秒才轮到一次,预填形同失效。 * 改成 1 秒一次的定时器 + 每玩家节流(默认 1 秒),每个人都管得到。 * ★ 修漏料:OnHammerHit 那条路预填的材料没有登记到「待归还」清单里 * → 那些材料永远不会被退还,白白留在玩家背包。现在两条路统一走 GiveAndTrack。 * ★ 修薅材料:领地柜满 / 移动失败时,旧版会直接放弃追踪 * → 玩家可以把领地柜塞满来白拿材料。现在移动失败会【保留追踪】, * 下次拿工具 / 切工具时继续尝试归还;腰带里的预填材料也会尝试归还。 * ★ 新增:背包空间不足的提示加冷却(默认 5 秒一次),不再每秒刷屏。 * ★ 新增:卸载插件前尽量把预填材料还回领地柜;玩家断线清理追踪表。 * ★ 修正注释:升级按【目标等级全额】计费(已核实,不是「补差价」)。 * ★ 修复(致命):本插件原先没有重写 Oxide 基类的 SaveConfig,而基类不认识 * 本插件自己声明的 _config 字段 —— 写出来的 oxide\config\TCBuild.json * 永远只有一对空花括号 {}。买家打开配置文件什么都改不了, * 预填数量 / 三个付款开关 / 消息前缀全部形同虚设。 * 已补上 SaveConfig 重写,与同系列插件(堆叠管理 / 每日签到 / 福利中心)一致。 * ★ 修复:归还改用【源领地柜】。旧版归还时按玩家当前位置重新找领地柜, * 玩家拿着预填材料走远再切工具就找不到,于是直接放弃追踪 —— * 材料白留在背包里、领地柜被慢慢抽干。现在会给每个玩家记住这批预填材料 * 是从哪个领地柜拿的,走多远都能还回去,人直接断线也能还。 * ★ 修复:三个付款开关以前形同虚设。EnableBuildPay / EnableUpgradePay / * EnableRepairPay 原先只作用于 OnHammerHit 那条「补差」路径, * 而真正把领地柜材料搬进背包的「预填」根本没看它们 —— * 把「升级付款」关掉,玩家拿锤子照样能用领地柜的材料升级。 * 现在预填也按开关走:建筑图纸看 EnableBuildPay, * 锤子看 EnableUpgradePay / EnableRepairPay(任一开启才预填)。 * * ===== v1.1.0(2026-09-15)新增:插件授权码(软锁)===== * ★ 与同系列插件(堆叠管理 / 每日签到 / 福利中心)一致的授权模块: * 启动时联网校验授权码并绑定服务器 IP,一个码可绑 2 台(主服 + 测试服)。 * ★ 【软锁】未授权时插件照常加载、不报错、不卸载,命令照常注册 —— * 这样服主能直接用 tcbuild.auth <码> 自救,不会卡在「卸载了就没法激活」的死循环。 * 只在功能入口拦一道(EnsureAuthorized): * · 管理员 -> 弹中文激活说明页(一眼看懂怎么救) * · 普通玩家 -> 静默拒绝(不刷屏、不报红字,玩家看不出服务器有问题) * 未授权时「不预填、不代付」,玩家完全回到原版行为(用自己背包的材料建造)。 * ★ 离线宽限 3 天:激活成功后本机留凭据(oxide\data\TCBuild.auth.json), * 断网 / 授权服务临时不通照常工作,绝不因为一次网络抖动把买家的服搞停。 * ★ 激活方式:控制台执行 tcbuild.auth PL-XXXX-XXXX-XXXX * 或 config「授权 Authorize → 插件授权码 LicenseCode」填码 * 或游戏内管理员 /tcbuild auth PL-XXXX-XXXX-XXXX * ★ config 键名中文化(与同系列一致)。★ 只有在 v1.0.7 之前没有任何买家配置过 * (v1.0.6 的配置文件永远是空的 {})时才敢这么改,不存在旧配置迁移问题。 */ namespace Oxide.Plugins { [Info("领地柜建造 TCBuild", "薯片", "1.1.2")] [Description("在授权领地柜范围内,自动使用领地柜材料进行建造、升级与修复")] public class TCBuild : RustPlugin { private ConfigData _config; // 从领地柜预填到玩家背包的物品实例记录。 // 按实例追踪,ReturnPrefillToTC 归还时只退这些实例 // (绝不会误退玩家自己采集的材料)。 private readonly Dictionary> _prefillItems = new Dictionary>(); // ★ v1.0.7:全服领地柜索引。 // 旧版每次找领地柜都遍历 BaseNetworkable.serverEntities(大服几十万实体), // 而这个查找原来被 OnPlayerTick 每玩家每秒调用一次 —— 这是本插件最大的性能负担。 private readonly HashSet _allTc = new HashSet(); // ★ v1.0.7:每玩家节流表 + 提示冷却表 // (旧版用全局 _tickCounter % 30,多人服里等于随机漏检) private readonly Dictionary _lastPrefill = new Dictionary(); private readonly Dictionary _lastWarn = new Dictionary(); // ★ v1.0.7:这一批预填材料是从【哪个】领地柜拿出来的。 // 归还时必须还回源领地柜,不能靠 FindAuthorizedTC 按玩家当前位置重新找 —— // 玩家拿着预填材料走远 50 米再切工具,按范围就找不到领地柜了, // 旧版此时直接「停止追踪」,材料白留在背包里、领地柜被慢慢抽干。 private readonly Dictionary _prefillTc = new Dictionary(); private Timer _prefillTimer; private float _lastFullRescan; // ---- 授权运行时状态(v1.1.0)---- private bool _unloaded; // 已卸载,异步回调直接丢弃 private bool _authorized; // 当前是否已授权(false 时功能入口全部拦住) private bool _authPending; // 正在联网校验中,避免重复请求 private AuthCache _authCache; // 本地授权缓存 private string _authHint = "尚未校验"; // 授权状态说明(面板上显示) private string _latestVersion = ""; // 后台登记的最新版本 private string _updateHint = ""; // 版本落后提示 private int _nudgeCount; // 未授权提醒次数(上限 3 次) // ★ TCBuild 特有:OnHammerHit 是高频入口(拿着锤子每敲一下都会进来), // 未授权时不节流的话,管理员会被授权说明页刷爆。 private readonly Dictionary _authPanelAt = new Dictionary(); // 玩家位置到领地柜的距离预筛(50 米 = 原版权限气泡半径) private const float PrivilegeRadiusSqr = 2500f; // 索引兜底重建的最短间隔(秒):只在「找不到领地柜」时才可能触发 private const float RescanCooldown = 30f; private const string PlannerShortname = "building.planner"; private const string HammerShortname = "hammer"; // ---- 插件授权(v1.1.0 新增)---- private const string AuthApiBase = "http://lic.biubr.com"; // 授权服务地址,换服只改这一处 private const string AuthPluginId = "tcbuild"; // 必须与后台插件清单的 plugin_id 一致 private const string AuthCacheFile = "TCBuild.auth"; // -> oxide/data/TCBuild.auth.json private const float AuthRetrySeconds = 300f; // 未授权时每 5 分钟自动重试 private const float AuthRenewHours = 6f; // ★ v1.1.2:常驻复核间隔,每 6h 主动问一次服务器 private const float AuthNudgeSeconds = 600f; // 未授权时往控制台写提醒的间隔 private const string PanelName = "tcbuild.panel"; // 授权说明页(OnAuthFailed 收面板也用它) #region Config private class ConfigData { [JsonProperty("升级付款 EnableUpgradePay")] public bool EnableUpgradePay { get; set; } = true; [JsonProperty("修复付款 EnableRepairPay")] public bool EnableRepairPay { get; set; } = true; [JsonProperty("建造付款 EnableBuildPay")] public bool EnableBuildPay { get; set; } = true; // 手持建造计划/锤子时,每种建材从领地柜预填到背包的数量。 // 按材料分别配置,保证升级(如石→铁)所需较多材料时 // 也能通过客户端背包检查。 [JsonProperty("预填数量 PrefillAmounts")] public Dictionary PrefillAmounts { get; set; } = new Dictionary { ["wood"] = 150, ["stones"] = 250, ["metal.fragments"] = 150, ["metal.refined"] = 20 }; [JsonProperty("消息前缀 MessagePrefix")] public string MessagePrefix { get; set; } = "[TCBuild] "; // ★ v1.0.7 新增:预填巡检间隔(秒,0.5 - 10)。越小越跟手,越大越省性能。 [JsonProperty("预填巡检间隔秒 PrefillIntervalSeconds")] public float PrefillIntervalSeconds { get; set; } = 1f; // ★ v1.0.7 新增:同一玩家「背包空间不足」提示的最短间隔(秒),防止刷屏。 [JsonProperty("提示冷却秒 WarnCooldownSeconds")] public float WarnCooldownSeconds { get; set; } = 5f; [JsonProperty("授权 Authorize")] public AuthData Authorize { get; set; } = new AuthData(); } // v1.1.0 插件授权配置。授权码留空 = 未授权(功能完全回到原版)。 private class AuthData { [JsonProperty("插件授权码 LicenseCode")] public string LicenseCode = ""; [JsonProperty("离线宽限天数 OfflineGraceDays")] public int OfflineGraceDays = 3; // ★ v1.1.2:7 -> 3,只在「连不上服务器」时才启用 [JsonProperty("启动时检查新版本 CheckUpdate")] public bool CheckUpdate = true; } protected override void LoadConfig() { base.LoadConfig(); try { _config = Config.ReadObject(); if (_config == null) LoadDefaultConfig(); // 兼容旧版:旧版配置文件可能完全没有 PrefillAmounts 字段。 if (_config.PrefillAmounts == null || _config.PrefillAmounts.Count == 0) { _config.PrefillAmounts = new Dictionary { ["wood"] = 150, ["stones"] = 250, ["metal.fragments"] = 150, ["metal.refined"] = 20 }; } // ★ v1.0.7:新加的两个字段在旧配置文件里读出来是 0,钳到安全范围 if (_config.PrefillIntervalSeconds < 0.5f || _config.PrefillIntervalSeconds > 10f) _config.PrefillIntervalSeconds = 1f; if (_config.WarnCooldownSeconds < 0f || _config.WarnCooldownSeconds > 60f) _config.WarnCooldownSeconds = 5f; // ★ v1.1.0:老 config 文件没有「授权 Authorize」这一段,必须兜底 —— // 不兜底就是启动即 NullReferenceException(手册 4.13 第 3 条)。 if (_config.Authorize == null) _config.Authorize = new AuthData(); } catch { LoadDefaultConfig(); } SaveConfig(); } protected override void LoadDefaultConfig() { _config = new ConfigData(); SaveConfig(); } // ★ v1.0.7 修复(致命):必须显式重写 SaveConfig。 // Oxide 基类的 SaveConfig 不认识本插件自己声明的 _config 字段, // 不重写的话写出来的氧化物配置文件只有一对空花括号 {} —— // 买家打开 oxide\config\TCBuild.json 什么都改不了, // 预填数量 / 三个付款开关 / 消息前缀全部形同虚设。 // 同系列的 堆叠管理 / 每日签到 / 福利中心 都有这一行。 protected override void SaveConfig() => Config.WriteObject(_config, true); #endregion #region Hooks private object OnHammerHit(BasePlayer player, HitInfo info) { if (player == null || info?.HitEntity == null) return null; // ★ v1.1.0 软锁:未授权时不代付任何材料 -> 完全回到原版行为 if (!EnsureAuthorized(player)) return null; var block = info.HitEntity as BuildingBlock; if (block == null) return null; // 只有在领地柜上授权过的玩家才启用"从领地柜扣料"行为 var tc = block.GetBuildingPrivilege(); if (tc == null || !tc.IsAuthed(player)) return null; // 计算本次操作需要的材料(升级 / 建造 / 修复) var required = CollectRequiredMaterials(player, block); if (required == null || required.Count == 0) return null; // 合并同种物品的数量 var need = new Dictionary(); foreach (var amt in required) { if (amt == null || amt.itemDef == null) continue; need[amt.itemDef] = (need.TryGetValue(amt.itemDef, out var v) ? v : 0) + Mathf.CeilToInt(amt.amount); } // 库存检查:领地柜 + 玩家背包合计 var tcStock = CountContainer(tc.inventory); var playerStock = CountPlayer(player); var shortItems = new List(); foreach (var kv in need) { var have = (tcStock.TryGetValue(kv.Key, out var tv) ? tv : 0) + (playerStock.TryGetValue(kv.Key, out var pv) ? pv : 0); if (have < kv.Value) shortItems.Add($"{kv.Key.displayName} x{kv.Value - have}"); } if (shortItems.Count > 0) { player.ChatMessage(_config.MessagePrefix + $"材料不足:{string.Join("、", shortItems)}。请补充到领地柜或背包后再试。"); return null; // 不动任何材料,原版逻辑会拒绝操作 } // 领地柜优先:只补玩家背包缺的部分, // 其余由玩家身上已有的材料覆盖 foreach (var kv in need) { var playerHave = playerStock.TryGetValue(kv.Key, out var pv) ? pv : 0; var missing = kv.Value - playerHave; if (missing <= 0) continue; var tcHave = tcStock.TryGetValue(kv.Key, out var tv) ? tv : 0; var fromTc = Mathf.Min(missing, tcHave); if (fromTc <= 0) continue; var taken = TakeFromContainer(tc.inventory, kv.Key, fromTc); if (taken <= 0) continue; // ★ v1.0.7:走统一入口 —— 旧版这里忘了登记「待归还」, // 这些材料永远退不回领地柜,等于白送给玩家。 GiveAndTrack(player, kv.Key, taken, tc); } // 放行原版逻辑:由原版从背包扣除材料 return null; } // ---------- 工具预填 ---------- // Rust 客户端在发出建造/升级/修复请求前会先检查玩家背包材料。 // 背包为空时客户端直接显示 INSUFFICIENT RESOURCES,根本不会请求服务器。 // 因此我们会在玩家手持建造计划或锤子时,从领地柜把材料预填进背包。 // 材料先从领地柜转出,所以领地柜永远是第一扣款来源; // 背包只是面向客户端的缓冲。 // // v1.0.4 "无感模式":拿起图纸/锤子的瞬间立即预填(OnActiveItemChanged, // 无 0.5 秒延迟);切走工具时把剩余预填物品立刻退回领地柜。 // 实际效果:背包平时干净,只在建造瞬间短暂占用, // 即"直接从领地柜建造",不长期占背包格子。 private static readonly HashSet BuildMaterialShortnames = new HashSet { "wood", "stones", "metal.fragments", "metal.refined" }; // ★ v1.0.7:把「全服领地柜」建一次索引。 // 之后靠 OnEntitySpawned / OnEntityKill 增量维护;找不到再兜底重建。 private void OnServerInitialized() { BuildTcIndex(); StartPrefillTimer(); // ★ v1.1.0 授权初始化放最后(要用到已注册的权限 / 已加载的数据) InitAuthorize(); timer.Once(10f, CheckPluginVersion); } private void BuildTcIndex() { _allTc.Clear(); try { foreach (var net in BaseNetworkable.serverEntities) { var tc = net as BuildingPrivlidge; if (tc != null && !tc.IsDestroyed) _allTc.Add(tc); } } catch (Exception ex) { PrintWarning("建立领地柜索引失败:" + ex.Message); } _lastFullRescan = Time.realtimeSinceStartup; Puts($"领地柜索引导入完成:{_allTc.Count} 个"); } // 钩子增量维护索引(签名不匹配时 Oxide 不调用,所以还有 30 秒兜底重建) private void OnEntitySpawned(BaseNetworkable entity) { var tc = entity as BuildingPrivlidge; if (tc != null) _allTc.Add(tc); } private void OnEntityKill(BaseNetworkable entity) { var tc = entity as BuildingPrivlidge; if (tc != null) _allTc.Remove(tc); } private void StartPrefillTimer() { if (_prefillTimer != null) return; var interval = Mathf.Clamp(_config.PrefillIntervalSeconds, 0.5f, 10f); // ★ 不再用 OnPlayerTick:那个钩子每个玩家每帧都会进插件, // 而旧版还在里面用全局计数器做节流,多人服上等于漏检。 _prefillTimer = timer.Every(interval, PrefillTick); Puts($"预填巡检已启动:每 {interval:0.0} 秒一次(每玩家最少间隔 0.9 秒)"); } private void PrefillTick() { // ★ v1.1.0 软锁:未授权时不预填(完全回到原版), // 并把还留在玩家背包里的预填材料还回领地柜(归还是未完成的交易,不能被锁挡住) if (!_authorized) { if (_prefillItems.Count > 0) ReturnAllPrefills(); return; } var now = Time.realtimeSinceStartup; foreach (var player in BasePlayer.activePlayerList) { if (player == null || player.IsDestroyed) continue; // 每玩家节流(旧版是全局计数,多人服下等于没节流到正确的人) if (_lastPrefill.TryGetValue(player.userID, out var last) && now - last < 0.9f) continue; var activeItem = player.GetActiveItem(); if (activeItem == null || activeItem.info == null) continue; if (!IsBuildToolShortname(activeItem.info.shortname)) continue; // ★ v1.0.7:该工具对应的付款开关关掉就不预填(旧版完全不看开关) if (!PrefillAllowed(activeItem.info.shortname)) continue; // ★ v1.0.7:节流时间无条件更新。 // 旧版只在「找到了领地柜」时才更新,玩家拿着图纸但不在 // 领地柜范围内时,每秒都会白跑一次领地柜查找。 _lastPrefill[player.userID] = now; var tc = FindAuthorizedTC(player); if (tc == null) continue; PrefillFromTC(player, tc); } } private void Unload() { // ★ v1.1.0:异步 webrequest 回调可能在卸载之后才返回,不挡就是 NRE 刷屏 _unloaded = true; // ★ v1.1.2:先拆界面。少了这一步,oxide.reload 之后授权说明页会赖在玩家屏幕上, // 点关闭也没用(插件已经不在了,没人接收那条关闭指令)—— // 和经济中心 v1.1.0 修的是同一个毛病:"插件重载了,界面还停留在插件页面"。 try { foreach (var p in BasePlayer.activePlayerList) if (p != null && p.IsConnected) CuiHelper.DestroyUi(p, PanelName); } catch { } _authPanelAt.Clear(); if (_prefillTimer != null) { _prefillTimer.Destroy(); _prefillTimer = null; } // ★ v1.0.7:卸载前尽量把预填材料还回领地柜, // 否则它们会留在玩家背包里,等于白送。 foreach (var player in BasePlayer.activePlayerList) { if (player == null) continue; try { ReturnPrefillToTC(player); } catch { } } _prefillItems.Clear(); _prefillTc.Clear(); _lastPrefill.Clear(); _lastWarn.Clear(); _allTc.Clear(); } private void OnPlayerDisconnected(BasePlayer player, string reason) { if (player == null) return; // 断线:先尝试归还(现在会还回源领地柜,所以人离得远也能还)。 // ★ v1.0.7:不再无条件丢弃追踪表 —— 如果领地柜当时正好是满的, // 材料会留在玩家背包里,保留追踪可以让玩家下次上线时继续归还。 try { ReturnPrefillToTC(player); } catch { } _lastPrefill.Remove(player.userID); _lastWarn.Remove(player.userID); } private void OnActiveItemChanged(BasePlayer player, Item oldItem, Item newItem) { if (player == null) return; var isBuildTool = false; if (newItem != null && newItem.info != null) isBuildTool = IsBuildToolShortname(newItem.info.shortname); var wasBuildTool = false; if (oldItem != null && oldItem.info != null) wasBuildTool = IsBuildToolShortname(oldItem.info.shortname); if (isBuildTool && !wasBuildTool) { // 刚拿起图纸/锤子 -> 立即预填。 // ★ v1.0.7:该工具对应的付款开关关掉则不预填(旧版完全不看开关) // ★ v1.1.0:未授权同样不预填 if (_authorized && PrefillAllowed(newItem.info.shortname)) { var tc = FindAuthorizedTC(player); if (tc != null) PrefillFromTC(player, tc); } } else if (wasBuildTool && !isBuildTool) { // 切走了图纸/锤子 -> 把剩余预填物品退回领地柜。 ReturnPrefillToTC(player); } } private void OnPlayerRespawn(BasePlayer player) { // 死亡时:预填物品随尸体掉落,停止追踪。 if (player != null) { // 死亡时预填材料随尸体掉落,已经不归领地柜了 → 停止追踪 _prefillItems.Remove(player.userID); _prefillTc.Remove(player.userID); _lastPrefill.Remove(player.userID); } } // ★ v1.0.7:统一「给玩家 + 登记待归还」。 // 旧版只有 OnPlayerTick 那条路登记了,OnHammerHit 那条路漏了 → 漏料。 private bool GiveAndTrack(BasePlayer player, ItemDefinition def, int amount, BuildingPrivlidge tc) { if (player == null || def == null || amount <= 0) return false; Item item; try { item = ItemManager.Create(def, amount, 0UL); } catch (Exception ex) { PrintWarning($"创建预填物品失败 {def.shortname}: {ex.Message}"); return false; } if (item == null) return false; var given = false; try { given = player.inventory.GiveItem(item, player.inventory.containerMain); } catch { given = false; } if (!given) { // 背包已满:退回领地柜,退不回就掉在脚下(绝不销毁物品) if (item.amount > 0) { var back = false; try { back = item.MoveToContainer(tc.inventory); } catch { back = false; } if (!back) item.Drop(player.transform.position + player.transform.forward * 1.5f, player.transform.forward); WarnCooldown(player, $"背包空间不足,{def.displayName} 已退回领地柜,请腾出空间。"); } return false; } // 登记这个实例,之后可以精确归还 if (!_prefillItems.TryGetValue(player.userID, out var list)) { list = new List(); _prefillItems[player.userID] = list; } list.Add(item); // ★ v1.0.7:记住来源领地柜 —— 归还时不再依赖玩家当前站在哪里 if (tc != null) _prefillTc[player.userID] = tc; return true; } // 同一条提示,每人每 WarnCooldownSeconds 秒最多一次(防止每秒刷屏) private void WarnCooldown(BasePlayer player, string msg) { if (player == null) return; var now = Time.realtimeSinceStartup; if (_lastWarn.TryGetValue(player.userID, out var t) && now - t < Mathf.Max(0f, _config.WarnCooldownSeconds)) return; _lastWarn[player.userID] = now; player.ChatMessage(_config.MessagePrefix + msg); } private static bool IsBuildToolShortname(string sn) { return sn == PlannerShortname || sn == HammerShortname; } // ★ v1.0.7:三个付款开关以前只管 OnHammerHit 那条「补差」路径, // 而真正把领地柜材料搬进背包的「预填」根本没看它们 —— // 于是把开关关掉也照样从领地柜扣料,开关形同虚设。 // 预填发生在玩家按左/右键之前,没法预知他要做什么, // 所以只能按【手上的工具】判断: // 建筑图纸 -> 建造 (开关:EnableBuildPay) // 锤子 -> 升级 或 修复 (开关:EnableUpgradePay / EnableRepairPay 任一开启) private bool PrefillAllowed(string toolShortname) { if (toolShortname == PlannerShortname) return _config.EnableBuildPay; if (toolShortname == HammerShortname) return _config.EnableUpgradePay || _config.EnableRepairPay; return false; } private void PrefillFromTC(BasePlayer player, BuildingPrivlidge tc) { var tcStock = CountContainer(tc.inventory); var playerStock = CountPlayer(player); foreach (var kv in tcStock) { var itemDef = kv.Key; if (itemDef == null || !BuildMaterialShortnames.Contains(itemDef.shortname)) continue; // 每种建材按配置的数量预填(木头 150 / 石头 250 / 铁 150 / 高金 20)。 if (!_config.PrefillAmounts.TryGetValue(itemDef.shortname, out var prefillAmount)) continue; if (prefillAmount <= 0) continue; var tcAmount = kv.Value; var playerAmount = playerStock.TryGetValue(itemDef, out var v) ? v : 0; // 手持建造计划或锤子期间,背包保持配置数量的建材 if (playerAmount >= prefillAmount) continue; var need = prefillAmount - playerAmount; var fromTc = Mathf.Min(need, tcAmount); if (fromTc <= 0) continue; var taken = TakeFromContainer(tc.inventory, itemDef, fromTc); if (taken <= 0) continue; GiveAndTrack(player, itemDef, taken, tc); } } // 只归还我们预填的那些物品实例(仅限仍在玩家背包中的剩余部分)。 // 绝不碰玩家自己采集的材料。 private void ReturnPrefillToTC(BasePlayer player) { if (player == null) return; if (!_prefillItems.TryGetValue(player.userID, out var list)) return; // ★ v1.0.7:优先还回【源领地柜】。 // 旧版这里是 FindAuthorizedTC(player) —— 按玩家当前位置重新找。 // 玩家拿着预填材料走远再切工具就找不到领地柜,于是直接放弃追踪, // 材料白留在背包里、领地柜被慢慢抽干。现在走多远都能还回去。 _prefillTc.TryGetValue(player.userID, out var tc); if (tc == null || tc.IsDestroyed) { // 源领地柜被拆了 / 没有记录 → 退而求其次,按当前位置找一个 tc = FindAuthorizedTC(player); } if (tc == null) { // 暂时没有能还的领地柜:★ 保留追踪,下次继续尝试。 // 绝不在这里把材料判给玩家(这正是旧版的漏洞)。 return; } foreach (var item in list.ToList()) { if (item == null) { list.Remove(item); continue; } // ① 物品已经不在任何容器里 = 被原版建造/升级/修复扣掉了, // 或被玩家丢在地上 → 停止追踪 if (item.parent == null) { list.Remove(item); continue; } // ② 还在某个容器里(玩家背包 / 腰带 / 甚至被玩家塞进了自己的箱子) // → 它本来就是领地柜的材料,一并收回 var moved = false; try { moved = item.MoveToContainer(tc.inventory); } catch { moved = false; } if (moved) list.Remove(item); // ★ 移动失败(领地柜满 / 槽位被占)→【保留追踪】,下次继续尝试。 // 旧版这里直接停止追踪,玩家把领地柜塞满就能白拿材料。 } if (list.Count == 0) { _prefillItems.Remove(player.userID); _prefillTc.Remove(player.userID); } } // ★ v1.1.0:未授权 / 授权掉线时,把还留在玩家背包里的预填材料全部还回领地柜。 // 归还属于「未完成的交易」,绝不能被软锁挡住 —— 否则材料就白送玩家了。 private void ReturnAllPrefills() { foreach (var player in BasePlayer.activePlayerList) { if (player == null || player.IsDestroyed) continue; if (!_prefillItems.ContainsKey(player.userID)) continue; try { ReturnPrefillToTC(player); } catch { } } } #endregion // ===================================================================== // v1.1.2 授权撤销改造(★ 全项目统一标准,以后新建插件照这个来) // · ★ 加【常驻复核】:每 6 小时主动联网问一次。旧版只在 OnServerInitialized // 判断一次,且距上次成功不足 24h 连请求都不发 -> 作者在后台停用/删码之后, // 插件靠本地宽限能一直跑到过期,撤销最长要等 7 天,等于撤不掉。 // · 离线宽限 7 -> 3 天,且【只在「连不上服务器」时生效】;服务端一旦【明确拒绝】 // (码不存在 / 已停用 / 不是本插件的码 / 台数已满)→ 当场锁,一秒宽限都不给。 // · 服务端明确拒绝时抹掉本地凭据的 last_ok(Code 保留)→ 码恢复正常能自动复活。 // · CacheFresh 加两项:「配置里的码 ≠ 本地凭据的码 -> 旧凭据作废」+ 防系统时间回拨 // (当前时间比上次成功早 6h 以上 = 有人在拨表 -> 不认宽限,强制联网复核)。 // · 新增 tcbuild.deauth 注销命令(清本地凭据 + 清 config 里的码,立刻回未授权)。 // ===================================================================== #region 授权(插件授权码 · 联网校验 · 离线宽限) // ===================================================================== // 【软锁】策略(与《_插件产品化规范与售卖流程.md》§2.4 一致): // · 未授权时插件【照常加载、不报错、不卸载】,命令照常注册 —— // 这样服主能直接用 tcbuild.auth <码> 自救,不会卡在"卸载了就没法激活"的死循环。 // · 只在「功能入口」拦一道(EnsureAuthorized),未授权时只给管理员看激活说明页, // 普通玩家静默拒绝(不刷屏、不报红字,玩家看不出服务器有问题)。 // · 联网校验走 webrequest 异步回调,绝不阻塞服务器主线程。 // · 离线宽限 3 天:作者服务器挂了 / 买家断网,不能把买家的服搞停。 // · 一个授权码能绑几台服务器由授权服务(plugcodes.max_slots)说了算, // 插件侧只负责把服务端拒绝的原因(含「已经绑满 N 台」)原样翻成中文告诉服主。 // ===================================================================== // ---- 本地授权缓存:oxide/data/TCBuild.auth.json ---- private class AuthCache { [JsonProperty("code")] public string Code = ""; [JsonProperty("plugin_id")] public string PluginId = ""; [JsonProperty("bound_at")] public string BoundAt = ""; [JsonProperty("last_ok")] public string LastOk = ""; // UTC,宽限期从这里起算 [JsonProperty("plugin_ver")] public string PluginVer = ""; } // ---- /api/plugin/verify 返回体(服务端多给的字段自动忽略)---- private class AuthResp { [JsonProperty("ok")] public bool ok; [JsonProperty("error")] public string error; [JsonProperty("msg")] public string msg; [JsonProperty("plugin")] public AuthPluginInfo plugin; } private class AuthPluginInfo { [JsonProperty("plugin_id")] public string plugin_id; [JsonProperty("name_zh")] public string name_zh; [JsonProperty("version")] public string version; } // ★ 离线宽限【硬上限】:配置里写得再多,也最多按 3 天算。 // 为什么必须夹: // ① 老版本写进买家 config 的是 7 天(v1.1.2 起默认值改成 3),而 config 里 // 躺着的值会赢 —— 收紧宽限这条安全策略对老用户压根没落到地上。 // ② 以前这里是 Mathf.Clamp(d, 0, 365),买家改成 365 就能离线白用一整年。 private const int AuthMaxGraceDays = 3; private int GraceDays { get { int d = (_config != null && _config.Authorize != null) ? _config.Authorize.OfflineGraceDays : 3; return Mathf.Clamp(d, 0, AuthMaxGraceDays); } } private static bool TryParseUtc(string s, out DateTime t) { t = DateTime.MinValue; if (string.IsNullOrEmpty(s)) return false; return DateTime.TryParse(s, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out t); } private static string MaskCode(string code) { if (string.IsNullOrEmpty(code)) return "(未填写)"; if (code.Length < 12) return code; return code.Substring(0, 7) + "-****-" + code.Substring(code.Length - 4); } private void LoadAuthCache() { try { var f = Interface.Oxide.DataFileSystem.GetFile(AuthCacheFile); _authCache = (f != null && f.Exists()) ? f.ReadObject() : null; } catch (Exception ex) { PrintWarning("授权缓存读取失败(忽略,稍后重新联网校验): " + ex.Message); _authCache = null; } if (_authCache == null) _authCache = new AuthCache(); } private void SaveAuthCache() { try { Interface.Oxide.DataFileSystem.WriteObject(AuthCacheFile, _authCache); } catch (Exception ex) { PrintWarning("授权缓存保存失败: " + ex.Message); } } // 本地凭据是否还在宽限期内(这就是「服务器断网也能继续用」的依据) private bool CacheFresh() { DateTime t; if (_authCache == null) return false; if (_authCache.PluginId != AuthPluginId) return false; if (!TryParseUtc(_authCache.LastOk, out t)) return false; // ★ 配置里写了另一个码 -> 旧凭据立刻作废(换了码不许旧凭据继续生效) string cfgCode = (_config != null && _config.Authorize != null && _config.Authorize.LicenseCode != null) ? _config.Authorize.LicenseCode.Trim().ToUpperInvariant() : ""; string cacheCode = (_authCache.Code != null) ? _authCache.Code.Trim().ToUpperInvariant() : ""; if (cfgCode.Length > 0 && !string.Equals(cfgCode, cacheCode, StringComparison.OrdinalIgnoreCase)) return false; // ★ 防系统时间回拨 —— 当前时间比上次成功校验还早 6h 以上 = 有人在拨表, // 这时【不认宽限】,强制联网复核(连不上就在 5 分钟重试里自愈)。 if (DateTime.UtcNow < t.AddHours(-6)) return false; return (DateTime.UtcNow - t).TotalDays <= GraceDays; } private string GraceExpireText() { DateTime t; if (_authCache == null || !TryParseUtc(_authCache.LastOk, out t)) return "(无)"; return t.ToLocalTime().AddDays(GraceDays) .ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture); } // 配置里的码优先,其次用上次校验成功的码 private string CurrentCode() { string c = (_config != null && _config.Authorize != null && _config.Authorize.LicenseCode != null) ? _config.Authorize.LicenseCode.Trim() : ""; if (c.Length > 0) return c.ToUpperInvariant(); return (_authCache != null && _authCache.Code != null) ? _authCache.Code.Trim().ToUpperInvariant() : ""; } // ---- 入口:OnServerInitialized 里调用 ---- private void InitAuthorize() { LoadAuthCache(); timer.Every(AuthNudgeSeconds, AuthNudge); // 未授权时提醒在线管理员 timer.Every(AuthRenewHours * 3600f, AuthRenew); // ★ v1.1.2:常驻复核,撤销最长 6h 生效 if (CacheFresh()) { _authorized = true; _authHint = "已授权(本地凭据有效)"; Puts("[授权] 已授权,本地凭据有效期至 " + GraceExpireText() + "(其间断网也能正常使用)。"); DateTime last; if (!TryParseUtc(_authCache.LastOk, out last) || (DateTime.UtcNow - last).TotalHours >= AuthRenewHours) RequestVerify(null, null, false); // 启动时距上次成功已超 6h,先续一次 return; } _authorized = false; if (string.IsNullOrEmpty(CurrentCode())) { _authHint = "未填写授权码"; PrintWarning("本插件尚未授权,功能不会生效(不预填、不代付,玩家完全回到原版)。"); PrintWarning("激活方法:配置文件「授权 Authorize → 插件授权码 LicenseCode」填入 PL- 开头的码,"); PrintWarning("或在服务器控制台执行:tcbuild.auth <你的授权码>,然后 oxide.reload TCBuild。"); PrintAuthStatus(); return; // 没码就不必空转重试,等服主填码后重载 } _authHint = "正在联网校验…"; RequestVerify(null, null, false); } private void RetryAuth() { if (_unloaded || _authorized) return; if (string.IsNullOrEmpty(CurrentCode())) return; RequestVerify(null, null, false); } // ★ 常驻复核。已授权状态也必须定期问服务器一次 —— // 否则作者在后台「停用」或删掉授权码之后,插件靠本地宽限能一直跑到过期, // 等于撤销不了(旧版只在 OnServerInitialized 判一次,且距上次成功不足 24h // 连请求都不发 -> 重启插件 / 重启游戏服 / 删 config 三招全无效)。 private void AuthRenew() { if (_unloaded) return; if (string.IsNullOrEmpty(CurrentCode())) return; RequestVerify(null, null, false); } // 未授权时每隔一段时间往控制台写一条提醒(最多 3 次;★ 不再发聊天消息) private void AuthNudge() { if (_unloaded || _authorized) return; if (_nudgeCount++ >= 3) return; // ★ 2026-09-16 起只写服务器控制台,不进任何人的聊天框: // 薯片反馈三个插件的未授权提示会连着刷聊天框,看着像服务器报错。 // 插件加载时已有完整中文提示;管理员真要用功能时也会弹激活说明面板, // 所以这里只当"后台日志"留个痕迹就够了。 Puts("★ 本插件尚未授权,功能不可用(" + _authHint + ")。" + "激活:控制台执行 tcbuild.auth PL-XXXX-XXXX-XXXX 后重载插件"); } // ---- 联网校验 ---- // code 传空 = 用现有码续期;manual 非空 = 服主刚手动填码,要回显结果 private void RequestVerify(string code, BasePlayer manual, bool manualInput) { if (_unloaded) return; if (_authPending) { if (manual != null) Msg(manual, "正在校验中,请稍候…"); return; } string use = string.IsNullOrEmpty(code) ? CurrentCode() : code.Trim().ToUpperInvariant(); if (string.IsNullOrEmpty(use)) { if (manual != null) Msg(manual, "还没有授权码。用法:tcbuild.auth PL-XXXX-XXXX-XXXX"); return; } if (webrequest == null) { OnAuthFailed(manual, "插件运行环境不支持联网(webrequest 不可用)", false, manualInput, use); return; } // 故意不上报服务器 IP:IP 一律由授权服务按请求来源判定,杜绝伪造 var payload = new Dictionary { { "code", use }, { "plugin_id", AuthPluginId } }; var headers = new Dictionary { { "Content-Type", "application/json" } }; _authPending = true; Puts("[授权] 正在向授权服务器校验(" + MaskCode(use) + ")…"); webrequest.Enqueue(AuthApiBase + "/api/plugin/verify", JsonConvert.SerializeObject(payload), (status, response) => OnVerifyResponse(status, response, use, manual, manualInput), this, RequestMethod.POST, headers, 15f); } private void OnVerifyResponse(int status, string response, string code, BasePlayer manual, bool manualInput) { _authPending = false; if (_unloaded) return; if (status != 200 || string.IsNullOrEmpty(response)) { // 网络类问题:宽限期内继续放行,绝不能因为一次抖动把买家服务器搞停 OnAuthFailed(manual, status == 0 ? "连不上授权服务器(出网被拦 / 服务临时不可用)" : ("授权服务器返回 HTTP " + status), false, manualInput, code); return; } AuthResp r = null; try { r = JsonConvert.DeserializeObject(response); } catch (Exception) { OnAuthFailed(manual, "授权服务器返回内容无法解析", false, manualInput, code); return; } if (r == null || !r.ok) { // 服务端明确拒绝(码不存在 / 已停用 / 不属于本插件 / 台数已满) string why = (r != null && !string.IsNullOrEmpty(r.error)) ? r.error : "授权校验未通过"; OnAuthFailed(manual, why, true, manualInput, code); return; } // ---- 校验通过 ---- _authorized = true; _nudgeCount = 0; _authCache.Code = code; _authCache.PluginId = AuthPluginId; _authCache.LastOk = DateTime.UtcNow.ToString("o", System.Globalization.CultureInfo.InvariantCulture); _authCache.PluginVer = Version.ToString(); if (string.IsNullOrEmpty(_authCache.BoundAt)) _authCache.BoundAt = _authCache.LastOk; if (r.plugin != null && !string.IsNullOrEmpty(r.plugin.version)) _latestVersion = r.plugin.version; SaveAuthCache(); if (!string.IsNullOrEmpty(_latestVersion) && CompareVer(_latestVersion, Version.ToString()) > 0) SetUpdateHint(_latestVersion); _authHint = "已授权"; Puts("[授权] 校验通过,插件已生效。授权码 " + MaskCode(code) + ",本地凭据有效期至 " + GraceExpireText() + "。"); PrintAuthStatus(); if (manualInput && manual != null) Msg(manual, "授权成功,插件已生效。"); } // serverRejected=true = 服务端明确拒绝,不接受离线宽限 // manualInput=true = 服主刚手填的码 // attempted = 这一次拿去校验的码,用来判断要不要回退 private void OnAuthFailed(BasePlayer manual, string reason, bool serverRejected, bool manualInput, string attempted) { if (_unloaded) return; // 情形一:这次用的码没通过,但本地还存着另一份仍然有效的凭据 // → 自动把配置改回那个有效码并重新校验一次。 // 作用:服主在 config 里手抖打错一个字符,不会把原本正常的服务器搞停。 // 只有「两个码不一样」才回退,所以作者主动停用授权码时不会被绕过。 if (serverRejected && CacheFresh() && !string.IsNullOrEmpty(_authCache.Code) && !string.Equals(_authCache.Code, attempted, StringComparison.OrdinalIgnoreCase)) { string good = _authCache.Code; _config.Authorize.LicenseCode = good; SaveConfig(); PrintWarning("[授权] 这次用的码没通过(" + reason + "),已自动改回原来有效的授权码 " + MaskCode(good) + " 并重新校验。"); if (manual != null) Msg(manual, "这个码没通过(" + reason + "),已改回原来的授权码并重新校验…"); RequestVerify(good, null, false); return; } // 情形二:网络类问题 + 还在宽限期 → 继续放行(买家服务器不能因为一次抖动停摆) if (!serverRejected && CacheFresh()) { _authorized = true; _authHint = "已授权(离线宽限中)"; PrintWarning("[授权] 本次联网校验没成功(" + reason + "),但本地凭据仍在 " + GraceDays + " 天宽限期内,插件继续正常工作。"); if (manual != null) Msg(manual, "联网校验没成功(" + reason + "),但本地凭据仍在宽限期内,功能不受影响。"); timer.Once(AuthRetrySeconds, RetryAuth); return; } // 情形三:真没授权 → 功能不生效 // ★ 服务端【明确拒绝】时抹掉本地凭据的 last_ok(Code / PluginId 保留)—— // 这样「作者停用或删码」之后本地宽限立刻失效;码一旦恢复正常又能在 5 分钟重试里自动复活。 // 注意:网络类失败(serverRejected=false)绝不清凭据,不能因为一次抖动把买家搞停。 if (serverRejected && _authCache != null && !string.IsNullOrEmpty(_authCache.LastOk)) { _authCache.LastOk = ""; SaveAuthCache(); Puts("[授权] 已清除本地凭据(服务端拒绝:\"" + reason + "\"),宽限期不再生效。"); } _authorized = false; _authHint = serverRejected ? ("授权失败:" + reason) : ("暂未授权:" + reason); PrintWarning("[授权] 未通过:" + reason); PrintWarning("请确认授权码格式为 PL-XXXX-XXXX-XXXX,且买的是本插件;" + "重新校验:控制台执行 tcbuild.auth <码>。"); if (manual != null) Msg(manual, "授权失败:" + reason); // 授权掉了就把所有人的面板收掉(不能留一个点不动的空壳界面) foreach (var p in BasePlayer.activePlayerList) if (p != null && p.IsConnected) CuiHelper.DestroyUi(p, PanelName); timer.Once(AuthRetrySeconds, RetryAuth); } private void PrintAuthStatus() { Puts("===== 领地柜建造 TCBuild 授权状态 ====="); Puts(" 状态:" + (_authorized ? "已授权(功能正常)" : "未授权(功能不生效)")); Puts(" 授权码:" + MaskCode(CurrentCode())); Puts(" 插件版本:" + Version.ToString() + (string.IsNullOrEmpty(_latestVersion) ? "" : ("(后台最新 " + _latestVersion + ")"))); Puts(" 本地凭据:" + (CacheFresh() ? ("有效,至 " + GraceExpireText()) : "无 / 已过期")); Puts(" 主动复核:每 " + AuthRenewHours + " 小时一次;仅「连不上服务器」时才用 " + GraceDays + " 天宽限"); Puts(" 用法:tcbuild.auth (不带参数 = 只看状态)"); Puts(" tcbuild.deauth(★ 注销:清本地凭据 + 清配置里的码,立刻回到未授权)"); } // ---- 版本比较(x.y.z 逐段比,段数不同按补 0)---- private static int CompareVer(string a, string b) { if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return 0; string[] pa = a.Split('.'), pb = b.Split('.'); int n = Math.Max(pa.Length, pb.Length); for (int i = 0; i < n; i++) { int x = i < pa.Length ? ParseSeg(pa[i]) : 0; int y = i < pb.Length ? ParseSeg(pb[i]) : 0; if (x != y) return x > y ? 1 : -1; } return 0; } private static int ParseSeg(string s) { int v; return (s != null && int.TryParse(s.Trim(), out v)) ? v : 0; } // ---- 只提醒不拦:查后台登记的版本 ---- private void CheckPluginVersion() { if (_unloaded) return; if (_config == null || _config.Authorize == null || !_config.Authorize.CheckUpdate) return; if (webrequest == null) return; webrequest.Enqueue(AuthApiBase + "/api/plugin/info/" + AuthPluginId, null, (status, response) => { if (_unloaded || status != 200 || string.IsNullOrEmpty(response)) return; try { AuthResp r = JsonConvert.DeserializeObject(response); if (r == null || !r.ok || r.plugin == null || string.IsNullOrEmpty(r.plugin.version)) return; _latestVersion = r.plugin.version; if (CompareVer(_latestVersion, Version.ToString()) > 0) SetUpdateHint(_latestVersion); } catch (Exception) { /* 查不到就静默,绝不拦 */ } }, this, RequestMethod.GET, null, 15f); } private void SetUpdateHint(string latest) { _updateHint = "有新版本 v" + latest + ",可到插件后台下载"; PrintWarning("[版本] 后台登记最新版为 v" + latest + ",你当前是 v" + Version.ToString() + "。功能不受影响,方便时更新即可。"); } // ---- ★ 软锁统一入口:返回 true 才继续执行功能 ---- // 未授权时:管理员弹激活说明页(服主一眼知道怎么救),普通玩家静默拒绝。 private bool EnsureAuthorized(BasePlayer player) { if (_authorized) return true; if (player != null && IsAdmin(player)) ShowAuthPanel(player); return false; } // ---- 聊天回显用(带插件前缀)---- private void Msg(BasePlayer player, string text) { if (player == null) { Puts(text); return; } SendReply(player, "[领地柜建造] " + text); } // ===================================================================== #endregion // ===================================================================== // ---- 未授权时给管理员看的激活说明页(自绘 CUI,不依赖任何素材)---- // 普通玩家看不到这个页面(EnsureAuthorized 里只对管理员弹)。 // ===================================================================== private void ShowAuthPanel(BasePlayer player) { if (player == null || !player.IsConnected) return; // ★ TCBuild 特有适配:OnHammerHit 是高频入口(拿着锤子每敲一下都进来), // 不做节流的话未授权时管理员会被这个说明页刷爆。同一玩家 5 秒内只弹一次。 float now = Time.realtimeSinceStartup; if (_authPanelAt.TryGetValue(player.userID, out var lastShown) && now - lastShown < 5f) return; _authPanelAt[player.userID] = now; CuiHelper.DestroyUi(player, PanelName); const float AW = 760f; const float AH = 420f; var c = new CuiElementContainer(); var root = new CuiPanel(); root.Image.Color = "0.05 0.06 0.08 0.98"; root.RectTransform.AnchorMin = "0.5 0.5"; root.RectTransform.AnchorMax = "0.5 0.5"; root.RectTransform.OffsetMin = $"{-AW / 2f} {-AH / 2f}"; root.RectTransform.OffsetMax = $"{AW / 2f} {AH / 2f}"; root.CursorEnabled = true; c.Add(root, "Overlay", PanelName); AddAuthText(c, "领 地 柜 建 造 · 尚 未 授 权", 40, 344, 720, 384, 18, "0.95 0.74 0.42 1"); AddAuthText(c, "当前状态:" + _authHint, 40, 314, 720, 340, 12, "0.74 0.80 0.86 1"); string[] lines = { "第 1 步 把服务器的公网 IP 发给作者,拿到属于你的授权码(形如 PL-XXXX-XXXX-XXXX)", "第 2 步 服务器控制台 / RCON 执行: tcbuild.auth PL-XXXX-XXXX-XXXX", "第 3 步 看到「授权成功」后执行 oxide.reload TCBuild ,领地柜代付立刻生效", "也可以把码填进配置文件「授权 Authorize → 插件授权码 LicenseCode」再重载插件。", "", "一个授权码可以绑 2 台服务器(主服 + 测试服);要换服请联系作者解绑旧的那台。", "授权成功后本机会记一份凭据,3 天内即使断网、或授权服务临时不通,插件照常工作。" }; float y = 276f; foreach (var ln in lines) { if (!string.IsNullOrEmpty(ln)) AddAuthText(c, ln, 46, y - 22, 726, y, 12, "0.84 0.88 0.93 1"); y -= 30f; } var btn = new CuiButton(); btn.Button.Command = "tcbuild.close"; btn.Button.Color = "0.30 0.13 0.13 0.95"; btn.Text.Text = "关闭"; btn.Text.FontSize = 14; btn.Text.Align = TextAnchor.MiddleCenter; btn.Text.Color = "0.95 0.88 0.88 1"; btn.RectTransform.AnchorMin = "0 0"; btn.RectTransform.AnchorMax = "0 0"; btn.RectTransform.OffsetMin = "310 26"; btn.RectTransform.OffsetMax = "450 66"; c.Add(btn, PanelName); AddAuthText(c, "买了码用不了?加作者微信 shupian1315,把服务器 IP 和授权码发过来", 26, 6, 734, 26, 11, "0.55 0.60 0.66 1"); CuiHelper.AddUi(player, c); } private static void AddAuthText(CuiElementContainer c, string text, float x1, float y1, float x2, float y2, int size, string color) { var l = new CuiLabel(); l.Text.Text = text; l.Text.FontSize = size; l.Text.Align = TextAnchor.MiddleLeft; l.Text.Color = color; l.RectTransform.AnchorMin = "0 0"; l.RectTransform.AnchorMax = "0 0"; l.RectTransform.OffsetMin = $"{x1} {y1}"; l.RectTransform.OffsetMax = $"{x2} {y2}"; c.Add(l, PanelName); } // TCBuild 不做权限(纯靠领地柜授权),所以管理员只看游戏自带的 IsAdmin private static bool IsAdmin(BasePlayer player) { return player != null && player.IsAdmin; } #region Commands(授权自救入口 —— ★ 必须在软锁判断之前,不能被自己锁住) // 控制台 / RCON:tcbuild.auth [授权码],不带参数只看状态 [ConsoleCommand("tcbuild.deauth")] private void CcDeauth(ConsoleSystem.Arg arg) { var player = arg != null ? arg.Player() : null; // RCON / 服务器控制台 -> null if (player != null && !player.IsAdmin) { Msg(player, "只有服务器管理员能注销授权。"); return; } _authorized = false; _authHint = "已注销(请重新填授权码)"; _authCache = new AuthCache(); SaveAuthCache(); if (_config != null && _config.Authorize != null) { _config.Authorize.LicenseCode = ""; SaveConfig(); Puts("[授权] 配置里的授权码也已清空。"); } foreach (var p in BasePlayer.activePlayerList) if (p != null && p.IsConnected) CuiHelper.DestroyUi(p, PanelName); Puts("[授权] 已注销:本地凭据与配置里的授权码都已清除,插件回到未授权状态。"); Puts(" 重新激活:tcbuild.auth PL-XXXX-XXXX-XXXX"); if (player != null) Msg(player, "已注销,插件回到未授权状态。"); } [ConsoleCommand("tcbuild.auth")] private void CcAuth(ConsoleSystem.Arg arg) { var player = arg != null ? arg.Player() : null; // RCON / 服务器控制台 -> null string code = (arg != null && arg.Args != null && arg.Args.Length > 0) ? arg.GetString(0).Trim() : ""; if (string.IsNullOrEmpty(code)) { PrintAuthStatus(); if (player != null) Msg(player, "当前授权状态:" + _authHint + " 授权码 " + MaskCode(CurrentCode())); return; } if (player != null && !IsAdmin(player)) { Msg(player, "只有服务器管理员能填授权码。"); return; } if (_config == null) _config = new ConfigData(); if (_config.Authorize == null) _config.Authorize = new AuthData(); _config.Authorize.LicenseCode = code.ToUpperInvariant(); SaveConfig(); Puts("[授权] 已写入配置,开始校验 " + MaskCode(code) + " …"); RequestVerify(code, player, player != null); } // 授权说明页的关闭按钮 [ConsoleCommand("tcbuild.close")] private void CcAuthClose(ConsoleSystem.Arg arg) { var player = arg != null ? arg.Player() : null; if (player != null) CuiHelper.DestroyUi(player, PanelName); } // 游戏内自救:/tcbuild auth [授权码] // ★ 本插件对玩家是「零命令」的,这条只服务服主(管理员),且只做授权相关的事。 [ChatCommand("tcbuild")] private void CmdTcbuild(BasePlayer player, string command, string[] args) { bool isAuth = args != null && args.Length > 0 && args[0].ToLowerInvariant() == "auth"; if (!isAuth) { if (IsAdmin(player)) Msg(player, "本插件全自动运行,玩家不需要任何命令。" + "管理员可用:/tcbuild auth [PL-XXXX-XXXX-XXXX] 查看或填写授权码。"); return; } if (!IsAdmin(player)) { Msg(player, "只有服务器管理员能操作授权码。"); return; } if (args.Length < 2 || string.IsNullOrEmpty(args[1])) { PrintAuthStatus(); Msg(player, "当前授权状态:" + _authHint + " 授权码 " + MaskCode(CurrentCode())); return; } string code = args[1].Trim().ToUpperInvariant(); if (_config == null) _config = new ConfigData(); if (_config.Authorize == null) _config.Authorize = new AuthData(); _config.Authorize.LicenseCode = code; SaveConfig(); Puts("[授权] 已写入配置,开始校验 " + MaskCode(code) + " …"); RequestVerify(code, player, true); } #endregion #region Helpers // ---- 反射缓存(兼容不同 Rust 版本的 API 差异)---- private static readonly System.Reflection.FieldInfo BlockIsBlueprintField = typeof(BuildingBlock).GetField("isBlueprint", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); private static bool IsBlueprint(BuildingBlock block) { if (BlockIsBlueprintField != null) { try { var value = BlockIsBlueprintField.GetValue(block); if (value is bool b) return b; } catch { } } // 兜底判断:未完成的蓝图是树枝等级 return block.grade == BuildingGrade.Enum.Twigs; } private List CollectRequiredMaterials(BasePlayer player, BuildingBlock block) { var isUpgrade = player.serverInput.IsDown(BUTTON.FIRE_SECONDARY); if (isUpgrade) { // 右键 = 升级到下一等级 // ★ v1.0.7 已核实:原版升级收的是【目标等级的全额成本】, // 不是「目标等级 - 当前等级」的差价(证据:BuildingBlock.PayForUpgrade // 里用的是 g.CostToBuild(grade),g 就是目标等级;社区实测口径也一致: // 木墙升石墙要 300 石头,而不是 300-200 的差)。 // 所以这里直接用目标等级 CostToBuild() 是对的,不要改成减差价。 if (!_config.EnableUpgradePay) return null; var cur = (int)block.grade; if (cur >= (int)BuildingGrade.Enum.TopTier) return null; var next = block.blockDefinition.GetGrade((BuildingGrade.Enum)(cur + 1), 0); if (next == null) return null; return new List(next.CostToBuild()); } if (IsBlueprint(block)) { // 左键敲蓝图 = 完成建造; // 原版在此扣除剩余的 50% 建造费用 if (!_config.EnableBuildPay) return null; var grade = block.blockDefinition.GetGrade(block.grade, 0); if (grade == null) return null; var list = new List(); foreach (var a in grade.CostToBuild()) list.Add(new ItemAmount(a.itemDef, a.amount * 0.5f)); return list; } // 左键敲受损建筑 = 修复 if (_config.EnableRepairPay && block.health < block.MaxHealth()) return GetRepairCost(block); return null; } // 原版修复成本 = 当前等级满建造价 × 损坏比例 private List GetRepairCost(BuildingBlock block) { var grade = block.blockDefinition.GetGrade(block.grade, 0); if (grade == null) return null; var damageFrac = 1f - (block.health / block.MaxHealth()); if (damageFrac <= 0f) return null; var result = new List(); foreach (var amt in grade.CostToBuild()) result.Add(new ItemAmount(amt.itemDef, amt.amount * damageFrac)); return result; } private Dictionary CountContainer(ItemContainer container) { var result = new Dictionary(); if (container == null) return result; foreach (var item in container.itemList) { if (item == null || item.info == null) continue; result[item.info] = (result.TryGetValue(item.info, out var v) ? v : 0) + item.amount; } return result; } private Dictionary CountPlayer(BasePlayer player) { var result = new Dictionary(); foreach (var container in new[] { player.inventory.containerMain, player.inventory.containerBelt, player.inventory.containerWear }) { foreach (var kv in CountContainer(container)) result[kv.Key] = (result.TryGetValue(kv.Key, out var v) ? v : 0) + kv.Value; } return result; } private int TakeFromContainer(ItemContainer container, ItemDefinition def, int amount) { if (container == null || def == null || amount <= 0) return 0; var taken = 0; foreach (var item in container.itemList.ToList()) { if (item == null || item.info != def) continue; var toTake = Mathf.Min(item.amount, amount - taken); if (toTake <= 0) continue; item.amount -= toTake; item.MarkDirty(); if (item.amount <= 0) item.RemoveFromContainer(); taken += toTake; if (taken >= amount) break; } container.MarkDirty(); return taken; } // ★ v1.0.7:只遍历「领地柜索引」,不再遍历全服实体。 // 旧写法 `foreach (var net in BaseNetworkable.serverEntities)` 在大服是 // 几十万次类型判断,而它原来被每玩家每秒调用一次 —— 本插件最大的性能问题。 private BuildingPrivlidge FindAuthorizedTC(BasePlayer player) { if (player == null) return null; var found = FindAuthorizedTCInIndex(player); if (found != null) return found; // 兜底:索引可能漏了(钩子签名变化 / 别的方式生成的领地柜)—— // 30 秒最多重建一次,重建后只再找一次。 var now = Time.realtimeSinceStartup; if (now - _lastFullRescan < RescanCooldown) return null; BuildTcIndex(); return FindAuthorizedTCInIndex(player); } private BuildingPrivlidge FindAuthorizedTCInIndex(BasePlayer player) { var pos = player.transform.position; BuildingPrivlidge best = null; var bestSqr = float.MaxValue; foreach (var tc in _allTc) { if (tc == null || tc.IsDestroyed) continue; var sqr = (tc.transform.position - pos).sqrMagnitude; if (sqr > PrivilegeRadiusSqr) continue; // 50 米预筛,省掉后面的权限查询 if (!tc.IsAuthed(player)) continue; // 多个领地柜都覆盖时取最近的那个 if (sqr < bestSqr) { bestSqr = sqr; best = tc; } } return best; } #endregion } }