using System; using System.Collections.Generic; using System.Globalization; using System.IO; // 素材文件夹 / 说明书文件 using System.Linq; using System.Reflection; // v1.1.2 直接反射调经济中心 API using System.Text; // 写《★素材图放这里.txt》要 UTF8Encoding(true) using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries; // webrequest 的 RequestMethod 枚举在这个命名空间下 using Oxide.Core.Plugins; using Oxide.Game.Rust.Cui; using UnityEngine; /* * TradeCenter 交易商城 v1.1.2 — 薯片 * =========================================================================== * 第 7 支自研插件。双轨商城:金币轨(玩家自由市场)+ 钻石轨(服主官方商店)。 * * v1.1.2:修「经济中心里有钱、商城顶栏金币钻石恒为 0」—— * Oxide 的 Plugin.Call 对本插件自定义 API 方法会【静默返回 null】 * (不抛异常、日志里一点痕迹都没有)。改成直接反射调它的 public * 方法为主、Plugin.Call 兜底,并在启动日志里把两条路的结果都打出来。 * * v1.1.0:上架改成「背包里点」—— 面板右上角「+ 上架物品」打开背包网格, * 点一下就是一整摞,数量默认给到背包全部,单价在面板里填。 * 顺手修掉 6 处「子元素坐标相对父面板」导致的位移 bug(商品卡 / 商店卡 / * 我的上架 / 我的仓库 / 管理后台行 / 管理后台按钮)—— 那些图标和按钮 * 其实一直被偏移到面板外面,等于看不见。 * * 【为什么必须分两条轨】 * 金币是「玩出来的」,谁都能刷;钻石是「充出来的」,只有服主能发。 * 如果玩家能用金币买到钻石货,充值收入直接归零。 * 所以:金币轨玩家定价玩家赚,钻石轨服主定价服主赚。 * 两条轨都走「经济中心 EcoCenter」的钱包 —— 本插件【一分钱都不存】, * 避免两个插件各记一份账、余额对不上(这是硬规则,别再改成自己存钱)。 * * 【★ 系统兜底收购 —— 本插件最有价值的一条】 * 系统每隔 N 分钟扫一次玩家市场,单价 <= 兜底价的挂单,系统自己掏钱买下来, * 卖家金币秒到账 —— 等于「挂上去就一定能卖掉」。 * 每天有预算上限(默认 5000 金币 / 20 单),花完就停,不会失控通胀。 * 收来的货默认直接销毁(用金币回收市场过剩物资,压通胀)。 * 作用链:玩家知道挂了能卖 -> 愿意上架 -> 市场有货 -> 买家愿意逛 -> 正循环。 * * 【六个页签】 * ① 玩家市场 别人挂的单,筛选 / 排序 / 购买,底栏显示系统收购进度 * ② 官方商店 服主商品卡网格,限时限定标签,划线价 + 折后价 * ③ 我的上架 我挂的单:改价 / 下架 / 已售记录 * ④ 我的仓库 背包放不下的东西都在这,一键全部领取 * ⑤ 折扣与权益 我是什么 VIP、省了多少钱、活动还剩多久 * ⑥ 管理后台 仅授权 + tradecenter.admin 可见 * * 命令 /shop 开面板(/mall 同义) * /shop on | /shop off 商城总开关(管理员) * 关掉后:面板打不开、上架/购买/取件全停、兜底收购停 * 永远留着的四条:on / off / auth / deauth(防死锁) * /shop sell <单价> [件数] [小时] 把手里这件挂到玩家市场(物品由商城托管) * /shop buy <单号> [件数] 买给别人挂的货(不填件数 = 全部;点卡片默认买 1 件) * /shop mine 我的上架 * /shop cancel <单号> 下架,货退回背包 * /shop price <单号> <新单价> 改价 * /shop take | /shop takeall 从「我的仓库」取件 * /shop shop | /shop perks 官方商店 / 折扣与权益 * /shop add <钻石价> [库存] [每份件数] 手持物品 -> 官方商品(管理员) * tradecenter.auth <码> 填授权码(不带参数 = 看状态) * tradecenter.deauth 注销授权 * tradecenter.setprice <商品号> <新价> 改官方商品钻石价 * tradecenter.stock <商品号> <库存> 改库存(-1 = 不限量) * tradecenter.del <商品号> 删除官方商品 * tradecenter.grant <玩家> <商品号> [份数] 赠送 / 补发 * tradecenter.bans <玩家|steamid> 拉黑 / 解除(禁止上架) * tradecenter.scan 手动跑一次系统兜底收购 * tradecenter.stats / diag / assets 报表 / 对接诊断 / 素材自查 * 权限 tradecenter.use 能用商城(默认所有人) * tradecenter.sell 能上架(默认所有人,可单独关) * tradecenter.admin 管理后台 * tradecenter.vip1/2/3 折扣档 + 手续费减免档 * tradecenter.banned 反向权限:有 = 禁止上架 * 存储 oxide/data/TradeCenter/ (market / shop / players / stats 四个 json) * 配置 oxide/config/TradeCenter.json(全中文键名) * * ===== 素材图(背景图已随包提供,可选)===== * oxide/data/TradeCenter/assets/ —— 插件第一次启动自动建好,并写一份 * 《★素材图放这里.txt》说明书进去,服主不用问、不用猜。 * 三种来源:assets 文件夹里的 PNG / 图床直链 / 游戏内置纹理兜底。 * 一张都不放也能跑:自动降级成纯色玻璃风,功能 100% 正常。 * * ★ 三条素材硬约束(实测,别再试别的写法) * 1) ImageLibrary 只吃 PNG(源码 FileStorage.Type.png + texture.EncodeToPNG()),JPG 会裂图; * 2) Rust CUI 没有九宫格拉伸 -> 带边框的图一律按最终像素 1:1 出图,绝不放大缩小; * 3) 单张图 EncodeToPNG 后不得超过 3 MB(ImageLibrary 源码 3145728 字节判断,超了直接丢弃)。 * 注意比的是 32 位 RGBA 重编码后的体积,不是源文件体积。 * 4) ★ 图床必须是 HTTPS。ImageLibrary 源码 DownloadImage() 里有一句 * UnityWebRequest.Get(info.url.Replace("http://", "https://")) * 它把 http:// 无条件改写成 https://,所以纯 HTTP 图床必然一张都拉不到, * 而且不报错,只在服务端日志里刷 download failed。 * * ★★★ v1.0.0 起必须记住的 CUI 图片四通路(放错字段不报错,只给你一块白块) * · Sprite Sprite 资源路径(游戏内置贴图),如 assets/content/ui/ui.white.tga * -> 必须配 CuiImageComponent * · Png FileStorage 图片 id(十进制的 32 位 crc 字符串) * -> 必须配 CuiRawImageComponent.Png(JSON 键 "png") * · Url 图片直链 https://... * -> 必须配 CuiRawImageComponent.Url(JSON 键 "url") * · ItemId 物品 ID(int),客户端直接用本地物品图标 —— 零依赖最稳 * -> 必须配 CuiImageComponent.ItemId(JSON 键 "itemid") * 放错位置时客户端只在 output_log.txt 留一行 * [FileSystem] Not Found: 2781206807 (UnityEngine.Texture) * 画面上就是一块白块(RawImage 取不到 texture 时用 color 铺满)。 * ★ 排查口诀:图不显示先看【客户端】output_log.txt 有没有 [FileSystem] Not Found。 * ★ 另:CuiElement 的 Name 是客户端 GameObject 字典的键,同容器内必须唯一, * 多张图共用一个名字会互相覆盖(本插件用 _imgSeq 自增)。 * * ★ CUI 父级三种写法(真机编译各报过一次,别再改回去) * 1) CuiPanel / CuiLabel / CuiButton 这些「组件」上【没有】Parent 属性 * -> 走 c.Add(component, parent, name) * 2) 裸 CuiElement(自定义图片)不能走 c.Add(element, parent) * -> 编译器会去找 Add(CuiButton, ...) 然后报 * "cannot convert from 'CuiElement' to 'CuiButton'" * -> 正确写法:先 element.Parent = xxx,再 c.Add(element) * * ★ 商品图标一律走 ItemId(官方物品图标) * 本轮需求里最抓眼的「图片上色」在 Rust 里根本不用画 —— * 游戏本身带了全部物品的官方彩色图标。商品卡不放图片,改成按物品 ID 渲染: * 石头就显示石头的彩色图标,AK 就显示 AK 的样子,上架任何东西都不需要准备一张图。 * 玩家挂 500 种货,500 种自动都是对的图标。这比"一张张画"还省事。 * * ★ 全服唯一硬约束:面板打开后靠 CuiHelper.DestroyUi 同名前缀重建, * 所有子元素名都以 PanelName 开头,避免和经济中心/福利中心的界面打架。 * --------------------------------------------------------------------------- */ namespace Oxide.Plugins { [Info("交易商城 TradeCenter", "薯片", "1.1.3")] [Description("TradeCenter: dual-currency marketplace for Rust - a player-driven gold market with automatic system buyback, plus an admin-only diamond shop with VIP discounts")] public class TradeCenter : RustPlugin { [PluginReference] private Plugin EcoCenter; // ★ 钱包靠它,本插件不存钱 [PluginReference] private Plugin ImageLibrary; // 素材图缓存(可选) #region Constants private const string PanelName = "tradecenter.panel"; // ---- 权限 ---- private const string PermUse = "tradecenter.use"; private const string PermSell = "tradecenter.sell"; private const string PermAdmin = "tradecenter.admin"; private const string PermVip1 = "tradecenter.vip1"; private const string PermVip2 = "tradecenter.vip2"; private const string PermVip3 = "tradecenter.vip3"; private const string PermBanned = "tradecenter.banned"; // ---- 页签 ---- private const int TabMarket = 0; private const int TabShop = 1; private const int TabMine = 2; private const int TabMail = 3; private const int TabPerks = 4; private const int TabAdmin = 5; private const int TabCount = 6; // ---- 面板基准尺寸(实际 = 基准 x config 面板缩放 PanelScale)---- private const float BaseW = 1000f; private const float BaseH = 620f; // ---- 布局:照抄《交易商城-视觉方案.txt》第三节参数表,别再手算 ---- // y 换算公式:oxide_y = 620 - 图片y - 高度(Oxide 原点在左下角,y 向上) private const float TopX0 = 12f, TopY0 = 570f, TopX1 = 988f, TopY1 = 612f; // 顶部栏 private const float TabX0 = 62f, TabY0 = 8f, TabX1 = 902f, TabY1 = 36f; // 底部页签栏 private const float CatX0 = 918f, CatY0 = 36f, CatX1 = 992f, CatY1 = 570f; // 右侧分类栏 private const float GridX0 = 72f; // 第 1 列卡片左边 private const float GridStepX = 216f; // 列步进(4 列:72 / 288 / 504 / 720) private const float CardW = 172f, CardH = 182f; private const float GridY1st = 334f; // 第 1 行卡片下边(图片 y=104 换算) private const float GridY2nd = 95f; // 第 2 行卡片下边(图片 y=343 换算) // 卡片内部(172 x 182) private const float CardPad = 5f; private const float IconH = 112f; // 图标区(接近正方形,物品图标才完整好看) private const float NameH = 20f; // 名称条 private const float PriceH = 22f; // 价格条 private const int GridCols = 4; private const int GridRows = 2; private const int PageSize = GridCols * GridRows; // 8 = 一页摆 8 件货 // ---- 右侧分类栏(13 档,算出来的:13 x 37 + 12 x 4 = 529 <= 534)---- private const int CatCount = 13; private const float CatItemH = 37f; private const float CatItemGap = 4f; // ---- 货币(必须与经济中心保持一致,别自己造名字)---- private const string CurGold = "gold"; private const string CurDiamond = "diamond"; // ---- 插件授权 ---- private const string AuthApiBase = "http://lic.biubr.com"; // 授权服务地址,换服只改这一处 private const string AuthPluginId = "tradecenter"; // 必须与后台插件清单的 plugin_id 一致 private const string AuthCacheFile = "TradeCenter.auth"; // -> oxide/data/TradeCenter.auth.json private const float AuthRetrySeconds = 300f; // 未授权时每 5 分钟自动重试 private const float AuthRenewHours = 6f; // 常驻复核间隔,每 6h 主动问一次服务器 private const float AuthNudgeSeconds = 600f; // 未授权时往控制台写提醒的间隔 // ---- 存盘 ---- private const float SaveDebounceSeconds = 60f; // 变化即标记,60 秒合并写一次(防高频写盘) #endregion #region Fields private PluginConfig _config; // 运行时数据 private MarketFile _market = new MarketFile(); // 玩家市场挂单 private ShopFile _shop = new ShopFile(); // 官方商店商品 private PlayersFile _players = new PlayersFile(); // 玩家仓库 + 限购记录 + 黑名单 private StatsFile _stats = new StatsFile(); // 统计与系统收购当日用量 private bool _dirty; // 有改动待写盘 private float _saveTimer; // 合并写盘计时 // 玩家界面状态 private readonly Dictionary _ui = new Dictionary(); // ---- 授权运行时状态 ---- 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 次) // ---- 素材 ---- private bool? _embedOk; private string _assetDir; private int _registerAttempts; private List _lastMissing = new List(); // ---- 杂项 ---- private readonly Dictionary _lastTradeAt = new Dictionary(); // 防狂点刷单 private bool _ecoWarned; // 「经济中心没装」只警告一次,别刷屏 #endregion #region Config private class PluginConfig { [JsonProperty("设置 Settings")] public SettingsData Settings = new SettingsData(); [JsonProperty("素材图 Assets")] public AssetsData Assets = new AssetsData(); [JsonProperty("金币市场 Market")] public MarketData Market = new MarketData(); [JsonProperty("官方商店 Shop")] public ShopData Shop = new ShopData(); [JsonProperty("上架手续费 ListingFees")] public FeesData Fees = new FeesData(); [JsonProperty("系统兜底收购 Buyback")] public BuybackData Buyback = new BuybackData(); [JsonProperty("折扣与权益 Discounts")] public DiscountData Discounts = new DiscountData(); [JsonProperty("分类 Categories")] public CategoriesData Categories = new CategoriesData(); [JsonProperty("授权 Authorize")] public AuthData Authorize = new AuthData(); } // 插件授权配置。授权码留空 = 未授权(面板只显示激活说明页)。 private class AuthData { [JsonProperty("插件授权码 LicenseCode")] public string LicenseCode = ""; [JsonProperty("离线宽限天数 OfflineGraceDays")] public int OfflineGraceDays = 3; // 只在「连不上服务器」时才启用 [JsonProperty("启动时检查新版本 CheckUpdate")] public bool CheckUpdate = true; } private class SettingsData { [JsonProperty("面板标题 PanelTitle")] public string PanelTitle = "交 易 商 城"; [JsonProperty("面板缩放 PanelScale (0.7 - 1.4,1 = 1000x620)")] public float PanelScale = 1f; [JsonProperty("进服自动弹面板 PopupOnConnect")] public bool PopupOnConnect = false; // 商城默认不弹,免得烦人 [JsonProperty("卖家到账提示 NotifyOnSold")] public bool NotifyOnSold = true; [JsonProperty("只允许有 use 权限的人使用 UseRequiresPermission")] public bool UseRequiresPermission = false; // 关 = 全服所有人可用 [JsonProperty("只允许有 sell 权限的人上架 SellRequiresPermission")] public bool SellRequiresPermission = false; // 关 = 全服所有人可上架 [JsonProperty("交易冷却毫秒 TradeCooldownMs (防狂点刷单)")] public int TradeCooldownMs = 500; [JsonProperty("底部提示 FooterText")] public string FooterText = "聊天框输入 /shop 可随时打开交易商城"; // ★ v1.0.1:商城总开关。 // 关(false) = 玩家侧全部停用:面板打不开、上架/购买/取件/官方商店全拒, // 系统兜底收购也停(那是往外花金币的动作)。 // 开(true) = 恢复正常(默认)。 // ★ 关着的时候,「on / off / auth / deauth」四条命令仍然可用 —— // 总开关必须留自救入口,否则一旦关掉就再也打不开了(死锁)。 // ★ 到期挂单的自动退回不受开关影响:那是保护玩家资产的,关了也得照常退。 [JsonProperty("商城总开关 Enabled")] public bool Enabled = true; } // 素材表:只填文件名,真 URL = 图床前缀 + / + 文件名 // 全留空 = 纯色玻璃风(什么图都不加载,功能完全一样) private class AssetsData { [JsonProperty("★素材图放这里(说明·程序不读) AssetsFolder")] public string AssetsFolderHint = "oxide/data/TradeCenter/assets/"; [JsonProperty("图床前缀 ImageBaseUrl (留空 = 用 assets 文件夹里的 PNG)")] public string ImageBaseUrl = ""; [JsonProperty("金币市场背景 Background")] public string Background = "bg-main.png"; [JsonProperty("钻石商店背景 BackgroundShop")] public string BackgroundShop = "bg-shop.png"; [JsonProperty("单图覆盖 Overrides (键=文件名,值=完整URL)")] public Dictionary Overrides = new Dictionary(); } // ---------------- 金币市场 ---------------- private class MarketData { [JsonProperty("启用 Enabled")] public bool Enabled = true; [JsonProperty("最低单价 MinUnitPrice")] public int MinUnitPrice = 1; // ★ 你说的「1 金币兜底」的地板 [JsonProperty("最高单价 MaxUnitPrice")] public int MaxUnitPrice = 1000000; [JsonProperty("每人同时在售上限 MaxListingsPerPlayer")] public int MaxListingsPerPlayer = 5; [JsonProperty("单笔件数上限 MaxQuantityPerListing")] public int MaxQuantityPerListing = 100000; [JsonProperty("默认挂单时长小时 DefaultHours")] public int DefaultHours = 72; // ★ ObjectCreationHandling.Replace 必须加 —— 否则 Newtonsoft 反序列化集合时 // 默认是「往已有实例里 Add」,而这个字段有初始化器(默认值 3 项), // 于是每 reload 一次就往屁股后面追加一份:3 → 6 → 9 → ... 配置文件无限膨胀, // 商店里的示例商品也会跟着变成 6 件、9 件。这是 Newtonsoft 的经典陷阱。 [JsonProperty("可选挂单时长小时 HoursOptions", ObjectCreationHandling = ObjectCreationHandling.Replace)] public List HoursOptions = new List { 24, 72, 168 }; [JsonProperty("每页显示条数 PerPage")] public int PerPage = 8; [JsonProperty("禁止上架带内容的容器 BlockFilledContainers")] public bool BlockFilledContainers = true; // 防用夹带刷物品 [JsonProperty("禁止上架的物品 BlockedItems (填 shortname)")] public List BlockedItems = new List(); } // ---------------- 官方商店 ---------------- private class ShopData { [JsonProperty("启用 Enabled")] public bool Enabled = true; [JsonProperty("页签标题 TabTitle")] public string TabTitle = "官方商店"; [JsonProperty("页签副标题 TabSubtitle")] public string TabSubtitle = "服主精选 · 钻石专享"; [JsonProperty("商品列表 Items (预置了 3 件示例,可整段删掉)", ObjectCreationHandling = ObjectCreationHandling.Replace)] public List Items = new List { // ★ 预置 3 件示例商品,让你装上就能看到钻石轨的效果。 // 可以整段删掉,也可以直接在「管理后台」里改价改库存。 // 短名必须是真实存在的物品(启动时配置体检会帮你逐条检查) new ShopItem { Id = "S1", Shortname = "metal.refined", Name = "精炼金属块", Amount = 100, Price = 30, OldPrice = 50, Stock = -1, LimitPerPlayer = 0, Category = "热销", Desc = "建家升级必备", Order = 1 }, new ShopItem { Id = "S2", Shortname = "rifle.ak", Name = "AK47 突击步枪", Amount = 1, Price = 80, OldPrice = 120, Stock = 10, LimitPerPlayer = 1, Category = "限定", Desc = "每人限购 1 把", Order = 2 }, new ShopItem { Id = "S3", Shortname = "lmg.m249", Name = "M249 轻机枪", Amount = 1, Price = 200, OldPrice = 0, Stock = 3, LimitPerPlayer = 1, Category = "限定", Desc = "库存 3 把,卖完自动下架", Order = 3 } }; } // ---------------- 上架手续费 ---------------- private class FeesData { [JsonProperty("启用上架手续费 EnableListingFee")] public bool EnableListingFee = true; [JsonProperty("费率百分比 Percent (按挂单总价,0 = 不收)")] public float Percent = 1f; [JsonProperty("最低收费 MinFee")] public int MinFee = 1; // 小额单不会因为四舍五入变成免费 [JsonProperty("单笔封顶 MaxFee")] public int MaxFee = 500; // 大额单不至于交得太痛 [JsonProperty("成交抽税百分比 SalesTaxPercent (从卖家所得抽,0 = 不收)")] public float SalesTaxPercent = 0f; // ★ 别和上架手续费一起开,卖家负担太重 [JsonProperty("自己下架退还比例 RefundOnCancel (1 = 全额退)")] public float RefundOnCancel = 1f; // 挂错了不怕,鼓励试挂 [JsonProperty("过期退还比例 RefundOnExpire (0.5 = 退一半)")] public float RefundOnExpire = 0.5f; // 督促合理定价 [JsonProperty("VIP1 手续费折扣 Vip1Rate")] public float Vip1Rate = 0.8f; [JsonProperty("VIP2 手续费折扣 Vip2Rate")] public float Vip2Rate = 0.5f; [JsonProperty("VIP3 手续费折扣 Vip3Rate (0 = 全免)")] public float Vip3Rate = 0f; // 白送的卖点:为了免手续费主动想升 VIP } // ---------------- 系统兜底收购 ---------------- private class BuybackData { [JsonProperty("启用 Enabled")] public bool Enabled = true; [JsonProperty("扫描间隔分钟 IntervalMinutes")] public float IntervalMinutes = 10f; [JsonProperty("兜底单价(金币/件)UnitPrice")] public int UnitPrice = 1; [JsonProperty("兜底价格倍率 MaxPriceMultiplier (单价 <= 兜底单价 x 倍率 才收)")] public float MaxPriceMultiplier = 1f; [JsonProperty("每日预算金币 DailyBudget")] public int DailyBudget = 5000; [JsonProperty("每日最多收购单数 DailyMaxOrders")] public int DailyMaxOrders = 20; [JsonProperty("单笔最多收购件数 MaxQuantityPerOrder")] public int MaxQuantityPerOrder = 5000; [JsonProperty("兜底的物品分类 Categories (填 ItemCategory 英文名)", ObjectCreationHandling = ObjectCreationHandling.Replace)] public List Categories = new List { "Resources", "Ammunition", "Construction" }; [JsonProperty("收来的货直接销毁 DestroyItems")] public bool DestroyItems = true; // 用金币回收市场过剩物资、压通胀 [JsonProperty("收购时全服公告 BroadcastOnBuy")] public bool BroadcastOnBuy = false; } // ---------------- 折扣与权益 ---------------- private class DiscountData { [JsonProperty("VIP1 金币折扣 GoldVip1")] public float GoldVip1 = 0.95f; [JsonProperty("VIP2 金币折扣 GoldVip2")] public float GoldVip2 = 0.90f; [JsonProperty("VIP3 金币折扣 GoldVip3")] public float GoldVip3 = 0.85f; [JsonProperty("VIP1 钻石折扣 DiamondVip1")] public float DiamondVip1 = 0.95f; [JsonProperty("VIP2 钻石折扣 DiamondVip2")] public float DiamondVip2 = 0.90f; [JsonProperty("VIP3 钻石折扣 DiamondVip3")] public float DiamondVip3 = 0.85f; [JsonProperty("最低折扣保护线 MinRate (0.1 = 不低于 1 折)")] public float MinRate = 0.1f; [JsonProperty("限时活动 启用 ActivityEnabled")] public bool ActivityEnabled = false; [JsonProperty("限时活动 名称 ActivityName")] public string ActivityName = "中秋全场特惠"; [JsonProperty("限时活动 开始 ActivityStart (yyyy-MM-dd HH:mm,留空 = 不限)")] public string ActivityStart = ""; [JsonProperty("限时活动 结束 ActivityEnd (yyyy-MM-dd HH:mm,留空 = 不限)")] public string ActivityEnd = ""; [JsonProperty("限时活动 金币折扣 ActivityGoldRate")] public float ActivityGoldRate = 0.9f; [JsonProperty("限时活动 钻石折扣 ActivityDiamondRate")] public float ActivityDiamondRate = 0.9f; [JsonProperty("限时活动 全服公告 ActivityAnnounce (留空 = 不公告)")] public string ActivityAnnounce = ""; } // ---------------- 分类 ---------------- private class CategoriesData { [JsonProperty("中文名覆盖 NameOverrides (键=ItemCategory 英文名,值=你想叫的中文)")] public Dictionary NameOverrides = new Dictionary(); [JsonProperty("强制归类 ItemOverrides (键=物品 shortname,值=ItemCategory 英文名)")] public Dictionary ItemOverrides = new Dictionary(); [JsonProperty("隐藏的分类 Hidden (填 ItemCategory 英文名)")] public List Hidden = new List(); } #endregion #region Data // ===================================================================== // 一条玩家市场挂单 // Left 是关键:允许「挂 5000 个、只买 800 个」,剩下继续挂 // ===================================================================== private class Listing { [JsonProperty("id")] public string Id = ""; [JsonProperty("seller")] public ulong Seller; [JsonProperty("seller_name")] public string SellerName = ""; [JsonProperty("shortname")] public string Shortname = ""; [JsonProperty("skin")] public ulong Skin; [JsonProperty("amount")] public int Amount; // 挂单总件数 [JsonProperty("left")] public int Left; // 剩余件数 [JsonProperty("unit")] public int UnitPrice; // 单价(金币/件) [JsonProperty("listed_at")] public string ListedAt = ""; [JsonProperty("expires_at")] public string ExpiresAt = ""; [JsonProperty("fee_paid")] public int FeePaid; // 已交手续费(统计用) [JsonProperty("condition")] public float Condition; // 耐久,0 = 不适用/满耐久 } // ===================================================================== // 一条官方商品(钻石轨)—— 只有服主能建 // Skin 是重点:工坊皮肤物品靠它,客户端会直接显示皮肤图 // ===================================================================== private class ShopItem { [JsonProperty("id")] public string Id = ""; // 商品号 S1 / S2 ... [JsonProperty("shortname")] public string Shortname = ""; [JsonProperty("skin")] public ulong Skin; [JsonProperty("name")] public string Name = ""; // 显示名,留空 = 自动取物品名 [JsonProperty("amount")] public int Amount = 1; [JsonProperty("price")] public int Price; // 钻石价 [JsonProperty("old_price")] public int OldPrice; // 划线原价,0 = 不显示 [JsonProperty("stock")] public int Stock = -1; // -1 = 无限 [JsonProperty("limit_per_player")] public int LimitPerPlayer; // 0 = 不限购 [JsonProperty("start_at")] public string StartAt = ""; // 限时上架 [JsonProperty("end_at")] public string EndAt = ""; // 限时下架 [JsonProperty("vip_discount")] public bool VipDiscount = true; [JsonProperty("own_discount")] public float OwnDiscount = 1f; // 单品折扣 [JsonProperty("category")] public string Category = ""; // 分类标签(如「限定」) [JsonProperty("desc")] public string Desc = ""; [JsonProperty("order")] public int Order; [JsonProperty("enabled")] public bool Enabled = true; [JsonProperty("sold")] public int Sold; // 已售出份数(统计) } // 我的仓库里的一件东西(背包塞不下时托管在这,不丢) private class MailItem { [JsonProperty("shortname")] public string Shortname = ""; [JsonProperty("skin")] public ulong Skin; [JsonProperty("amount")] public int Amount; [JsonProperty("condition")] public float Condition; // 耐久,0 = 不适用/满耐久 [JsonProperty("from")] public string From = ""; // 来源说明(中文) [JsonProperty("at")] public string At = ""; } // 单个玩家的持久数据 private class PlayerRec { [JsonProperty("mail")] public List Mail = new List(); [JsonProperty("bought")] public Dictionary Bought = new Dictionary(); // 商品号 -> 已购数 [JsonProperty("sold_gold")] public int SoldGold; // 累计卖出所得 [JsonProperty("spent_gold")] public int SpentGold; // 累计买入花费 [JsonProperty("spent_diamond")] public int SpentDiamond; // 累计钻石花费 [JsonProperty("fee_paid")] public int FeePaid; // 累计交的手续费 [JsonProperty("pending_sold")] public List PendingSold = new List(); // 待通知的到账消息 } private class MarketFile { [JsonProperty("seq")] public int Seq; [JsonProperty("listings")] public List Listings = new List(); } private class ShopFile { [JsonProperty("seq")] public int Seq; [JsonProperty("items")] public List Items = new List(); } private class PlayersFile { [JsonProperty("players")] public Dictionary Players = new Dictionary(); [JsonProperty("banned")] public List Banned = new List(); } // 统计 + 系统收购当日用量(跨天自动重置) private class StatsFile { [JsonProperty("day")] public string Day = ""; [JsonProperty("bb_spent")] public int BuybackSpent; // 今日收购花掉的金币 [JsonProperty("bb_orders")] public int BuybackOrders; // 今日收购单数 [JsonProperty("bb_items")] public int BuybackItems; // 今日回收件数 [JsonProperty("trades")] public int TotalTrades; // 玩家市场成交笔数 [JsonProperty("gold_volume")] public int TotalGold; // 玩家市场成交额 [JsonProperty("fee_income")] public int TotalFee; // 手续费收入(服主的金币回收池) [JsonProperty("diamond_income")] public int TotalDiamond; // 官方商店钻石收入 [JsonProperty("sellers")] public Dictionary Sellers = new Dictionary(); } // 单个玩家的界面状态(不落盘) private class UiState { public int Tab = TabMarket; public int Page; public string Cat = "All"; // 当前分类(ItemCategory 枚举名,All = 不筛选) public int Sort; // 0 最新 / 1 单价升 / 2 单价降 / 3 总价 public string MineSel = ""; // 「我的上架」里选中的挂单号 public string ShopSel = ""; // 「管理后台」里选中的商品号 public string BuyId = ""; // 正在输入数量的挂单号 public string BuyQty = ""; // 数量输入缓冲 // ---- 背包上架弹层(v1.1.0)---- public bool Pick; // 弹层是否打开 public int PickPage; // 背包网格页码 public string PickSn = ""; // 选中的 shortname(空 = 还没选) public ulong PickSkin; // 选中的皮肤 ID public int PickQty; // 数量(选中时默认 = 背包全部) public int PickUnit; // 单价(0 = 还没填) public int PickHours; // 挂单时长 public string PickMsg = ""; // 弹层底部的提示 / 报错 } #endregion #region Images (ImageLibrary) // 内嵌素材位置:oxide/data/TradeCenter/assets/<素材文件名>.png(直接放 PNG 二进制) // 不走 JSON / base64:体积最小,也没有任何序列化风险;换某一张图覆盖同名文件即可 private const string EmbeddedDir = "TradeCenter"; private const string EmbeddedSub = "assets"; private static string EmbedName(string fileName) { var name = fileName ?? string.Empty; int slash = name.LastIndexOf('/'); if (slash >= 0) name = name.Substring(slash + 1); int dot = name.LastIndexOf('.'); if (dot > 0) name = name.Substring(0, dot); return name; } private static string EmbedFullPath(string fileName) { var name = EmbedName(fileName); if (string.IsNullOrEmpty(name)) return null; return Path.Combine(Interface.Oxide.DataDirectory, EmbeddedDir, EmbeddedSub, name + ".png"); } private bool UseImages() { return _config != null && _config.Assets != null && !string.IsNullOrEmpty(_config.Assets.ImageBaseUrl); } // 图床优先;没填图床但有内嵌素材就走内嵌 private bool UseEmbedded() { if (_config == null || _config.Assets == null) return false; if (UseImages()) return false; if (_embedOk == null) { try { var p = EmbedFullPath(_config.Assets.Background); _embedOk = !string.IsNullOrEmpty(p) && File.Exists(p); } catch { _embedOk = false; } } return _embedOk.Value; } private string UrlOf(string fileName) { if (!UseImages() || string.IsNullOrEmpty(fileName)) return null; string over; if (_config.Assets.Overrides != null && _config.Assets.Overrides.TryGetValue(fileName, out over) && !string.IsNullOrEmpty(over)) return over; return _config.Assets.ImageBaseUrl.TrimEnd('/') + "/" + fileName.TrimStart('/'); } // 文件名 -> ImageLibrary 键(一眼看出属于本插件) private static string KeyOf(string fileName) { if (string.IsNullOrEmpty(fileName)) return null; var name = fileName; int slash = name.LastIndexOf('/'); if (slash >= 0) name = name.Substring(slash + 1); int dot = name.LastIndexOf('.'); if (dot > 0) name = name.Substring(0, dot); return "tradecenter." + name; } // 本插件要用的素材文件清单(背景两张) private IEnumerable AllAssetFiles() { var a = _config.Assets; var list = new List { a.Background, a.BackgroundShop }; if (a.Overrides != null) foreach (var k in a.Overrides.Keys) list.Add(k); return list.Where(x => !string.IsNullOrEmpty(x)).Distinct(); } private void RegisterImages() { if (ImageLibrary == null || !Convert.ToBoolean(ImageLibrary.Call("IsReady"))) { if (_registerAttempts++ < 30) timer.Once(5f, RegisterImages); return; } if (!UseImages() && !UseEmbedded()) return; if (UseImages()) { // 图床路线 // ★ AddImage 签名 = (string url, string imageName, ulong imageId),url 在前! // 写成 (key, url, 0) 的话键会变成 "https://..._0",GetImage 永远查不到(而且不报错) foreach (var file in AllAssetFiles()) { var url = UrlOf(file); var key = KeyOf(file); if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(key)) continue; try { ImageLibrary.Call("AddImage", url, key, 0UL); } catch (Exception ex) { PrintWarning("注册素材图失败 " + file + ": " + ex.Message); } } return; } // 内嵌路线:素材字节直接喂给图片库 // ★ AddImageData 签名 = (string imageName, byte[] array, ulong imageId) // 这条路走 StoreByteArray():不碰 http/https、不需要证书、没有 3 MB 上限 int okN = 0, miss = 0; var missingNames = new List(); foreach (var file in AllAssetFiles()) { var key = KeyOf(file); if (string.IsNullOrEmpty(key)) continue; try { var path = EmbedFullPath(file); if (string.IsNullOrEmpty(path) || !File.Exists(path)) { miss++; missingNames.Add(file); continue; } var bytes = File.ReadAllBytes(path); if (bytes == null || bytes.Length < 8) { miss++; continue; } ImageLibrary.Call("AddImageData", key, bytes, 0UL); okN++; } catch (Exception ex) { miss++; PrintWarning("内嵌素材读取失败 " + file + ": " + ex.Message); } } Puts("内嵌素材已注册 " + okN + " 张" + (miss > 0 ? "(缺 " + miss + " 张,缺的那几张会退回纯色底)" : "")); if (miss > 0) { Puts(" 素材图文件夹:" + (_assetDir ?? AssetDirPath())); Puts(" 还缺:" + string.Join("、", missingNames.Distinct().Take(12).ToArray()) + " -> 补齐后执行 oxide.reload TradeCenter"); } } // ================= 素材文件夹自建 + 自查 ================= // 装完插件启动一次,oxide/data/TradeCenter/assets/ 就自动出现, // 里面还带一份《★素材图放这里.txt》—— 不用问作者、不用猜、不用去翻手册。 private string AssetDirPath() { return Path.Combine(Interface.Oxide.DataDirectory, EmbeddedDir, EmbeddedSub); } private void EnsureAssetFolder(bool withLog) { try { var dir = AssetDirPath(); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); _assetDir = dir; // 说明文件只在缺失时写,绝不覆盖服主自己改过的 var readme = Path.Combine(dir, "★素材图放这里.txt"); if (!File.Exists(readme)) File.WriteAllText(readme, AssetReadmeText(), new UTF8Encoding(true)); _lastMissing = new List(); int have = 0; foreach (var f in AllAssetFiles()) { var p = EmbedFullPath(f); if (!string.IsNullOrEmpty(p) && File.Exists(p)) have++; else _lastMissing.Add(f); } if (!withLog) return; int want = AllAssetFiles().Count(); if (have == 0) { Puts("============================================================"); Puts("【交易商城】素材图文件夹已经帮你建好了,但里面还一张图都没有:"); Puts(" " + dir); Puts(" 怎么放:把随包的 bg-main.png / bg-shop.png 拷进上面这个文件夹,"); Puts(" 文件名一个字都别改,也别再建子文件夹。"); Puts(" 放好后:控制台执行 oxide.reload TradeCenter"); Puts(" ★ 现在面板会先用「纯色玻璃风」——功能完全正常,只是没那么好看。"); Puts("============================================================"); } else { Puts("【交易商城】素材图 " + have + "/" + want + " 张就位" + (have < want ? ",还缺 " + _lastMissing.Count + " 张" : "") + "。"); } } catch (Exception ex) { PrintWarning("创建/检查素材图文件夹失败:" + ex.Message); } } // 自动生成的说明书(UTF-8 带 BOM,记事本打开不乱码) private string AssetReadmeText() { var L = new List(); L.Add("============================================================"); L.Add(" 交易商城 TradeCenter —— 素材图要放哪里?"); L.Add(" (这份说明是插件第一次启动时自动生成的,看完可以删)"); L.Add("============================================================"); L.Add(""); L.Add(" 你现在打开的【就是】插件读图的地方,不用再找别处:"); L.Add(""); L.Add(" " + AssetDirPath()); L.Add(""); L.Add("------------------------------------------------------------"); L.Add(" 一、要放什么?(一共 2 张背景图)"); L.Add("------------------------------------------------------------"); L.Add(" bg-main.png 1000 x 620 金币市场背景(暖棕木质货架)"); L.Add(" bg-shop.png 1000 x 620 钻石商店背景(冷色调变体)"); L.Add(""); L.Add(" ★ 文件名一个字都不要改!也不要再建子文件夹!"); L.Add(" ★ 必须是 PNG(JPG 会裂图)。"); L.Add(" ★ 尺寸必须是 1000x620 —— Rust 界面没有九宫格拉伸,"); L.Add(" 插件是按 1:1 画图的,尺寸不对卡片就对不上货架格位。"); L.Add(""); L.Add("------------------------------------------------------------"); L.Add(" 二、放好之后要做什么?"); L.Add("------------------------------------------------------------"); L.Add(" 回到游戏服控制台执行:"); L.Add(""); L.Add(" oxide.reload TradeCenter"); L.Add(""); L.Add(" 重启服务器同样会生效。"); L.Add(""); L.Add("------------------------------------------------------------"); L.Add(" 三、一张都不放会怎样?"); L.Add("------------------------------------------------------------"); L.Add(" 功能完全不受影响,只是界面从「精修背景图」变成「纯色玻璃风」。"); L.Add(" 商品图标本来就【不用放图】—— 插件按物品 ID 直接画游戏官方彩色图标。"); L.Add(""); L.Add("------------------------------------------------------------"); L.Add(" 四、想换成自己的图?"); L.Add("------------------------------------------------------------"); L.Add(" 直接用同名同尺寸文件覆盖就行。"); L.Add(" 另一种做法是走图床:config 里把「图床前缀 ImageBaseUrl」填成 https 地址,"); L.Add(" 图从图床取,本文件夹就不生效了。两种方式二选一,别混着用:"); L.Add(" ImageBaseUrl 留空 -> 用本文件夹里的 PNG(推荐,离线可用)"); L.Add(" ImageBaseUrl 填了 -> 用图床(必须 https,http 会被图片库改写成 https 拿不到)"); L.Add(""); L.Add("------------------------------------------------------------"); L.Add(" 五、怎么确认到底生效没有?"); L.Add("------------------------------------------------------------"); L.Add(" 控制台执行:"); L.Add(""); L.Add(" tradecenter.assets"); L.Add(""); L.Add(" 会告诉你:文件夹在哪、已有几张、还缺哪几张。"); L.Add(" 启动日志里也会打「内嵌素材已注册 N 张」。"); L.Add(""); L.Add("============================================================"); L.Add(" 作者:薯片 插件:交易商城 TradeCenter"); L.Add("============================================================"); return string.Join(Environment.NewLine, L.ToArray()); } // ★ GetImage 签名 = (string imageName, ulong imageId = 0, bool returnUrl = false) // 第三个参数是 bool,别把 userID 传进去 // "NONE_x" = 没这张图(降级纯色);"LOADING_x" = 正在下载,照常用 private string GetSprite(string key) { if (ImageLibrary == null || string.IsNullOrEmpty(key)) return null; try { var result = ImageLibrary.Call("GetImage", key, 0UL); var sprite = result == null ? null : result.ToString(); if (string.IsNullOrEmpty(sprite) || sprite == "0" || sprite.StartsWith("NONE")) return null; return sprite; } catch { return null; } } private string AssetSprite(string fileName) => string.IsNullOrEmpty(fileName) ? null : GetSprite(KeyOf(fileName)); #endregion #region 授权(插件授权码 · 联网校验 · 离线宽限) // ===================================================================== // 【软锁】策略(与《_插件产品化规范与售卖流程.md》§2.4 一致): // · 未授权时插件【照常加载、不报错、不卸载】,命令照常注册 —— // 这样服主能直接用 tradecenter.auth <码> 自救,不会卡在"卸载了就没法激活"的死循环。 // · 只在「功能入口」拦一道(EnsureAuthorized),未授权时只给管理员看激活说明页, // 普通玩家静默拒绝(不刷屏、不报红字,玩家看不出服务器有问题)。 // · 联网校验走 webrequest 异步回调,绝不阻塞服务器主线程。 // · 离线宽限 3 天:作者服务器挂了 / 买家断网,不能把买家的服搞停。 // · 一个授权码能绑几台服务器由授权服务(plugcodes.max_slots)说了算, // 插件侧只负责把服务端拒绝的原因(含「已经绑满 N 台」)原样翻成中文告诉服主。 // ===================================================================== // ---- 本地授权缓存:oxide/data/TradeCenter.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 天算。 // 为什么必须夹:以前这里是 Mathf.Clamp(d, 0, 365),等于买家把 config 改成 365 // 之后,首次激活成功就能离线白用一整年 —— 授权体系直接被架空。 // 夹完之后:老 config 写 7 也会按 3 执行,不需要买家删配置、不需要迁移。 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; // ★ v1.0.5:配置里写了另一个码 -> 旧凭据立刻作废(换了码不许旧凭据继续生效) 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; // ★ v1.0.5:防系统时间回拨 —— 当前时间比上次成功校验还早 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.0.5:常驻复核,撤销最长 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("或在服务器控制台执行:tradecenter.auth <你的授权码>,然后 oxide.reload TradeCenter。"); PrintAuthStatus(); return; // 没码就不必空转重试,等服主填码后重载 } _authHint = "正在联网校验…"; RequestVerify(null, null, false); } private void RetryAuth() { if (_unloaded || _authorized) return; if (string.IsNullOrEmpty(CurrentCode())) return; RequestVerify(null, null, false); } // ★ v1.0.5:常驻复核。已授权状态也必须定期问服务器一次 —— // 否则作者在后台「停用」或删掉授权码之后,插件靠本地宽限能一直跑到过期, // 等于撤销不了(v1.0.4 及以前:只在 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 + ")。" + "激活:控制台执行 tradecenter.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, "还没有授权码。用法:tradecenter.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; } // 情形三:真没授权 → 功能不生效 // ★ v1.0.5:服务端【明确拒绝】时抹掉本地凭据的 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,且买的是本插件;" + "重新校验:控制台执行 tradecenter.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("===== 交易商城 TradeCenter 授权状态 ====="); Puts(" 状态:" + (_authorized ? "已授权(功能正常)" : "未授权(功能不生效)")); Puts(" 授权码:" + MaskCode(CurrentCode())); Puts(" 插件版本:" + Version.ToString() + (string.IsNullOrEmpty(_latestVersion) ? "" : ("(后台最新 " + _latestVersion + ")"))); Puts(" 本地凭据:" + (CacheFresh() ? ("有效,至 " + GraceExpireText()) : "无 / 已过期")); Puts(" 商城开关:" + (ShopEnabled() ? "开启(正常使用)" : "★ 关闭(交易全停,/shop on 可重开)")); Puts(" 主动复核:每 " + AuthRenewHours + " 小时一次;仅「连不上服务器」时才用 " + GraceDays + " 天宽限"); Puts(" 用法:tradecenter.auth (不带参数 = 只看状态)"); Puts(" tradecenter.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 // ---- 未授权时的激活说明页(软锁:不报错、不卸载,只讲怎么激活)---- private void ShowAuthPanel(BasePlayer player) { if (player == null || !player.IsConnected) return; CuiHelper.DestroyUi(player, PanelName); float s = Scale(); const float AW = 760f; const float AH = 420f; float W = AW * s, H = AH * s; var c = new CuiElementContainer(); var root = new CuiPanel(); root.Image.Color = "1 0.978 0.942 0.98"; root.RectTransform.AnchorMin = "0.5 0.5"; root.RectTransform.AnchorMax = "0.5 0.5"; root.RectTransform.OffsetMin = (-W / 2f) + " " + (-H / 2f); root.RectTransform.OffsetMax = (W / 2f) + " " + (H / 2f); root.CursorEnabled = true; c.Add(root, "Overlay", PanelName); c.Add(MkLabel("交 易 商 城 · 尚 未 授 权", 40, 344, 720, 384, 18, TextAnchor.MiddleLeft, "0.55 0.29 0.02 1", s), PanelName); c.Add(MkLabel("当前状态:" + _authHint, 40, 314, 720, 340, 12, TextAnchor.MiddleLeft, "0.344 0.237 0.155 1", s), PanelName); string[] lines = { "第 1 步 把服务器的公网 IP 发给作者,拿到属于你的授权码(形如 PL-XXXX-XXXX-XXXX)", "第 2 步 服务器控制台 / RCON / F1 执行: tradecenter.auth PL-XXXX-XXXX-XXXX", "第 3 步 看到「授权成功」后执行 oxide.reload TradeCenter ,六个页签立刻全部生效", "也可以把码填进配置文件「授权 Authorize -> 插件授权码 LicenseCode」再重载插件。", "", "★ 本插件依赖「经济中心 EcoCenter」—— 没装它,商城即使授权了也不能交易。", " 清单文件:EcoCenter.cs 必须先放进 oxide/plugins/ 并加载成功。", "", "一个授权码可以绑 2 台服务器(主服 + 测试服);要换服请联系作者解绑旧的那台。", "授权成功后本机会记一份凭据,3 天内即使断网、或授权服务临时不通,插件照常工作。" }; float y = 276f; foreach (var ln in lines) { if (!string.IsNullOrEmpty(ln)) c.Add(MkLabel(ln, 46, y - 22, 726, y, 12, TextAnchor.MiddleLeft, "0.284 0.196 0.128 1", s), PanelName); y -= 26f; } c.Add(MkButton("tradecenter.close", "关闭", 310, 26, 450, 66, 14, "0.82 0.17 0.25 0.95", "1 1 1 1", s), PanelName); c.Add(MkLabel("买了码用不了?加作者微信 shupian1315,把服务器 IP 和授权码发过来", 26, 6, 734, 26, 11, TextAnchor.MiddleCenter, "0.479 0.33 0.215 1", s), PanelName); CuiHelper.AddUi(player, c); } #region Lifecycle private void OnServerInitialized() { // ---- 权限注册(全部注册,服主用 oxide.grant 分配)---- permission.RegisterPermission(PermUse, this); permission.RegisterPermission(PermSell, this); permission.RegisterPermission(PermAdmin, this); permission.RegisterPermission(PermVip1, this); permission.RegisterPermission(PermVip2, this); permission.RegisterPermission(PermVip3, this); permission.RegisterPermission(PermBanned, this); LoadAll(); EnsureAssetFolder(true); // 先把素材文件夹建好 + 写说明书 + 报缺图 ValidateConfig(); RegisterImages(); // 变化即标记 + 60 秒合并写(防高频写盘) timer.Every(SaveDebounceSeconds, () => SaveAll(false)); // 挂单到期清扫:每 5 分钟扫一次。过期的东西退回卖家仓库, // 手续费按「到期退还比例」退 —— 玩家敢挂、敢试错。 timer.Every(300f, () => SweepExpired()); // 系统兜底收购(你说的「1 金币兜底」)。 // ★ 间隔从配置读,并夹到 [1, 120] 分钟 —— 服主填 0 会变成每帧扫描, // 那就是用定时器把服务器打死;填太大又失去调度意义。 float bbSec = Mathf.Clamp(_config.Buyback.IntervalMinutes, 1f, 120f) * 60f; timer.Every(bbSec, () => DoBuyback()); // 授权判定(软锁)。没授权时下面所有功能入口都不会放行。 InitAuthorize(); // 只提醒不拦:落后于后台登记版本就在控制台提示 timer.Once(10f, CheckPluginVersion); // 依赖自检:经济中心是本插件的钱包,没它一切交易免谈 timer.Once(3f, CheckEco); PrintBanner(); } private void Unload() { _unloaded = true; // 异步回调(联网校验 / 版本查询)到此直接丢弃 foreach (var p in BasePlayer.activePlayerList) CuiHelper.DestroyUi(p, PanelName); SaveAll(true); } private void OnPluginLoaded(Plugin plugin) { if (plugin == null) return; if (plugin.Name == "ImageLibrary") RegisterImages(); if (plugin.Name == "EcoCenter") CheckEco(); } private void OnPlayerDisconnected(BasePlayer player, string reason) { if (player == null) return; _ui.Remove(player.userID); _lastTradeAt.Remove(player.userID); } private void PrintBanner() { Puts("============================================================"); Puts(" 交易商城 TradeCenter v" + Version.ToString() + " 已加载"); Puts(" 面板打开:聊天框输入 /shop" + (ShopEnabled() ? "" : " ★★ 当前总开关是【关闭】状态,管理员输入 /shop on 开启")); Puts(" 存储目录:oxide/data/TradeCenter/"); Puts(" 配置文件:oxide/config/TradeCenter.json(全中文键名,改完 oxide.reload TradeCenter)"); Puts(" ★ 依赖「经济中心 EcoCenter」:" + (EcoReady() ? (EcoAuthorized() ? "已就绪且已激活" : "已加载但【未激活】- 交易完不成") : "未检测到 - 交易不可用")); Puts("============================================================"); } // ---- 配置体检:把「配置错了但玩家只是领不到/买不到」这类静默故障提前喊出来 ---- private void ValidateConfig() { // 1) 官方商店商品:短名必须真实存在,否则玩家买到的是一团空气 foreach (var it in _config.Shop.Items) { if (string.IsNullOrEmpty(it.Shortname) || ItemManager.FindItemDefinition(it.Shortname) == null) PrintWarning("配置警告:官方商品 " + it.Id + " 的 shortname '" + it.Shortname + "' 无效,玩家买不到东西"); if (it.Price <= 0) PrintWarning("配置警告:官方商品 " + it.Id + " 的钻石价 <= 0,建议改成正数"); } // 2) 兜底分类必须是合法的 ItemCategory 枚举名,写错了会「一单都收不上来」 var valid = new HashSet(); foreach (var nm in Enum.GetNames(typeof(ItemCategory))) valid.Add(nm); foreach (var c in _config.Buyback.Categories) if (!valid.Contains(c)) PrintWarning("配置警告:系统兜底收购分类 '" + c + "' 不是合法的 ItemCategory 英文名,这一档不会生效"); // 3) 手续费口径体检(两个一起开会把卖家劝退) if (_config.Fees.EnableListingFee && _config.Fees.SalesTaxPercent > 0f) PrintWarning("配置警告:上架手续费 与 成交抽税 同时开着(" + _config.Fees.Percent + "% + " + _config.Fees.SalesTaxPercent + "%),卖家要交两次钱,建议只留一个"); // 4) 分类:中文名覆盖的键必须是合法枚举名 foreach (var kv in _config.Categories.NameOverrides) if (!valid.Contains(kv.Key) && kv.Key != "LIMITED") PrintWarning("配置警告:分类中文名覆盖里的键 '" + kv.Key + "' 不是合法分类名(合法值见面板右侧)"); // 5) 素材图说明 var imgUrl = (_config.Assets.ImageBaseUrl ?? string.Empty).Trim(); if (imgUrl.Length == 0) { if (UseEmbedded()) Puts("素材图未配图床 -> 使用内嵌素材(" + AllAssetFiles().Count() + " 张,不需要外网与证书)"); else PrintWarning("素材图没配(assets 文件夹里也没有 PNG)-> 面板使用纯色玻璃风,功能不受影响。" + "想上背景图:把 bg-main.png / bg-shop.png 拷进 " + AssetDirPath() + " 后执行 oxide.reload TradeCenter"); } else if (imgUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) { PrintWarning("素材图 ImageBaseUrl '" + imgUrl + "' 是 HTTP —— 图片库会把 http:// 无条件改写成 " + "https://,纯 HTTP 图床必然一张都拉不到。请改成 https:// 地址"); } } // ===================================================================== // 经济中心对接(★ 本插件一分钱都不存,全部走它的钱包) // 读余额 EcoCenter.Call("API_Balance", uid, "gold") // 扣钱 EcoCenter.Call("API_Take", uid, "gold", 数量, "原因") // 给钱 EcoCenter.Call("API_Add", uid, "gold", 数量, "原因") // 「原因」这一栏必须填 —— 玩家的银行面板上就能看到「在商城买了什么」。 // ===================================================================== private bool EcoReady() { return EcoCenter != null && EcoCenter.IsLoaded; } // ===================================================================== // 经济中心「授权状态」探测 —— 这一步很关键,别删。 // // EcoCenter 的加钱入口 Add() 里有一句软锁:if (!_authorized) return false; // 也就是【经济中心自己没激活时,它拒绝一切加钱】,而扣钱故意不拦 // (它只拦「给出好处」,不拦「收回去」—— 拦扣钱等于让别人白拿东西)。 // // 对本插件的后果:买家金币扣得掉,卖家却收不到钱,交易根本完不成。 // 所以必须在启动时就把这件事探出来、用中文喊给服主听。 // // ★ 为什么不用「试加 1 金币再扣回来」来探测:那会在玩家的银行流水里 // 留一条「探测」垃圾记录,玩家自己的收支明细会莫名多一笔。 // 这里改成读它的授权缓存文件,零副作用、零污染。 // // ★★ 解析为什么写得这么啰嗦(踩过一次坑): // naively 在冒号后找「第一个引号」是错的。万一文件里是 // { "last_ok": null, "hint": "xxx" } // 冒号后第一个引号属于【下一个字段的名字 "hint"】,会被误判成已授权。 // 后果很严重:把一个没激活的经济中心报成「已激活」,服主信了就开服, // 玩家照常上架,钱照样进黑洞。 // 所以这里严格按 JSON 语法走:冒号 → 跳空白 → 必须紧跟左引号 → 取到下一个引号。 // ===================================================================== private bool EcoAuthorized() { try { var p = Path.Combine(Interface.Oxide.DataDirectory, "EcoCenter.auth.json"); if (!File.Exists(p)) return false; var txt = File.ReadAllText(p); // 连引号一起找,避免命中 last_ok_xxx 这类前缀相同的别的字段 int k = txt.IndexOf("\"last_ok\"", StringComparison.Ordinal); if (k < 0) k = txt.IndexOf("last_ok", StringComparison.Ordinal); // 老格式兜底 if (k < 0) return false; int colon = txt.IndexOf(':', k); if (colon < 0) return false; // 冒号后跳过所有空白,下一个非空字符必须是左引号 int q1 = colon + 1; while (q1 < txt.Length && char.IsWhiteSpace(txt[q1])) q1++; if (q1 >= txt.Length) return false; if (txt[q1] != '"') return false; // null / 数字 / true 一律当未授权 int q2 = txt.IndexOf('"', q1 + 1); if (q2 < 0) return false; string val = txt.Substring(q1 + 1, q2 - q1 - 1); if (val.Length == 0) return false; if (val == "null" || val == "NULL") return false; return true; // 有内容 = last_ok 非空 = 凭据校验成功过 } catch (Exception) { return false; } } // ===================================================================== // 调用经济中心的对外 API // // ★★ v1.1.2 为什么改成「直接反射」为主(这一条踩得很疼,别改回去): // 实测这套 Oxide 上 EcoCenter.Call("API_Balance", uid, "gold") // 会【静默返回 null】—— 不抛异常、日志里一个字都没有,连 // PrintWarning 都不触发。后果很吓人: // 经济中心面板里明明有 5,107 金币,商城顶栏却一直是 0, // 玩家第一反应是「我的钱被插件吞了」。 // 所以主路由改成直接反射调它的 public 方法: // 反射不依赖 Oxide 的 hook 注册表,方法名 + 参数类型对上就一定 // 调得到;Plugin.Call 降级为兜底,两条都不通时日志会明确喊出来。 // ===================================================================== private int _ecoRoute; // 0=未探测/都不通 1=Plugin.Call 2=直接反射 private static readonly object[] EcoNoArgs = new object[0]; private class EcoApiRef { public Type T; public MethodInfo M; } private readonly Dictionary _ecoApiCache = new Dictionary(); // 取经济中心实例:优先 [PluginReference] 注入的,兜底 plugins.Find private Plugin EcoTarget() { if (EcoCenter != null && EcoCenter.IsLoaded) return EcoCenter; try { var p = plugins.Find("EcoCenter"); if (p != null && p.IsLoaded) return p; } catch (Exception) { } return null; } private static MethodInfo EcoApi(Type t, string api, object[] a) { if (t == null) return null; var ms = t.GetMethods(BindingFlags.Public | BindingFlags.Instance); for (int i = 0; i < ms.Length; i++) { var mi = ms[i]; if (!string.Equals(mi.Name, api, StringComparison.Ordinal)) continue; var ps = mi.GetParameters(); if (ps.Length != a.Length) continue; bool ok = true; for (int j = 0; j < ps.Length; j++) { if (a[j] == null) continue; if (!ps[j].ParameterType.IsInstanceOfType(a[j])) { ok = false; break; } } if (ok) return mi; } return null; } // 带缓存的查找;缓存同时记住「类型」,经济中心重编译后类型会变, // 类型对不上就重新找(避免拿到上一代方法句柄)。 private MethodInfo EcoApiCached(Plugin p, string api, object[] a) { if (p == null) return null; Type t = p.GetType(); string key = api + "#" + a.Length; EcoApiRef r; if (_ecoApiCache.TryGetValue(key, out r) && r != null && r.T == t && r.M != null) return r.M; var m = EcoApi(t, api, a); if (m != null) _ecoApiCache[key] = new EcoApiRef { T = t, M = m }; return m; } private static string EcoVerOf(object o) { var d = o as Dictionary; if (d == null || !d.ContainsKey("version")) return null; string s = Convert.ToString(d["version"]); return string.IsNullOrEmpty(s) ? null : s; } private static string EcoDesc(object o) { if (o == null) return "null"; var d = o as Dictionary; if (d != null) return "Dictionary(" + d.Count + " 项)"; return o.GetType().Name + " = " + Convert.ToString(o, CultureInfo.InvariantCulture); } private void CheckEco() { if (EcoReady()) { var p = EcoTarget(); object viaCall = null, viaRefl = null; string e1 = null, e2 = null; try { viaCall = p.Call("API_Version", EcoNoArgs); } catch (Exception ex) { e1 = ex.GetType().Name + ": " + ex.Message; } try { var mi = EcoApiCached(p, "API_Version", EcoNoArgs); if (mi != null) viaRefl = mi.Invoke(p, EcoNoArgs); else e2 = "没找到 API_Version 方法(插件类型 " + (p == null ? "?" : p.GetType().Name) + ")"; } catch (Exception ex) { e2 = ex.GetType().Name + ": " + ex.Message; } string ver1 = EcoVerOf(viaCall), ver2 = EcoVerOf(viaRefl); _ecoRoute = ver2 != null ? 2 : (ver1 != null ? 1 : 0); Puts("[对接] 经济中心已就绪(版本 " + (ver2 ?? ver1 ?? "?") + ")。"); Puts("[对接自检] Plugin.Call = " + EcoDesc(viaCall) + (e1 != null ? " 异常 " + e1 : "")); Puts("[对接自检] 直接反射 = " + EcoDesc(viaRefl) + (e2 != null ? " 异常 " + e2 : "")); Puts("[对接自检] 采用路由 = " + (_ecoRoute == 2 ? "直接反射(正常)" : _ecoRoute == 1 ? "Plugin.Call(正常)" : "★ 两条都不通 —— 余额会一直是 0,把上面两行发我")); if (!EcoAuthorized()) { PrintWarning("★ 但经济中心【尚未激活】—— 它拒绝一切「加钱」操作。"); PrintWarning(" 后果:买家金币扣得掉,卖家却收不到钱,商城交易无法完成。"); PrintWarning(" 请先激活经济中心:控制台执行 ecocenter.auth PL-XXXX-XXXX-XXXX"); } else if (_ecoRoute != 0) { Puts(" 经济中心授权正常,钱包读写可用。"); } _ecoWarned = false; return; } Puts("[对接] ★ 未检测到「经济中心 EcoCenter」—— 商城面板能打开,但一切交易不可用。"); Puts(" 请先安装并激活经济中心:把 EcoCenter.cs 放进 oxide/plugins/ 后执行 oxide.reload EcoCenter"); } private object EcoCall(string api, params object[] a) { var p = EcoTarget(); if (p == null) return null; // 主路:直接反射 if (_ecoRoute != 1) { try { var mi = EcoApiCached(p, api, a); if (mi != null) { object r = mi.Invoke(p, a); if (r != null) return r; } } catch (Exception ex) { _ecoApiCache.Clear(); // 经济中心可能刚重编译过,句柄作废,下次重找 PrintWarning("[对接] 反射调用经济中心 " + api + " 失败:" + ex.Message); } } // 兜底:Oxide 自带的 Plugin.Call if (_ecoRoute != 2) { try { object r = p.Call(api, a); if (r != null) return r; } catch (Exception ex) { PrintWarning("[对接] Plugin.Call 调用经济中心 " + api + " 失败:" + ex.Message); } } return null; } // 现场自检:随时确认「商城能不能读到经济中心的钱」 // 游戏内控制台:tradecenter.ecotest (查自己) // 服务器控制台:tradecenter.ecotest [ConsoleCommand("tradecenter.ecotest")] private void CcEcoTest(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); ulong uid = 0UL; if (p != null) uid = p.userID; else ulong.TryParse(arg.GetString(0, ""), out uid); if (uid == 0UL) { Puts("[对接自检] 用法:tradecenter.ecotest [steamid]"); return; } string line = "[对接自检] uid=" + uid + " 路由=" + (_ecoRoute == 2 ? "直接反射" : _ecoRoute == 1 ? "Plugin.Call" : "未探测/都不通") + " 金币=" + Fmt(EcoBalance(uid, CurGold)) + " 钻石=" + Fmt(EcoBalance(uid, CurDiamond)); Puts(line); if (p != null) Msg(p, line); } private static double ToD(object o, double def = 0) { if (o == null) return def; try { return Convert.ToDouble(o, CultureInfo.InvariantCulture); } catch (Exception) { } double d; if (double.TryParse(o.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out d)) return d; return def; } private static bool ToB(object o) { if (o == null) return false; try { return Convert.ToBoolean(o); } catch (Exception) { } bool b; return bool.TryParse(o.ToString(), out b) && b; } // 读余额(返回 long,商城内部一律用整数金币/钻石,避免小数点误差) private long EcoBalance(ulong uid, string cur) { var r = EcoCall("API_Balance", uid, cur); double d = ToD(r, 0); if (d < 0) d = 0; return (long)Math.Round(d); } private bool EcoHas(ulong uid, string cur, long amount) { var r = EcoCall("API_Has", uid, cur, (double)amount); if (r != null) return ToB(r); return EcoBalance(uid, cur) >= amount; } // 扣钱:余额不足返回 false(调用方必须先备货再扣钱,别把钱扣了才发现发不出货) private bool EcoTake(ulong uid, string cur, long amount, string reason) { if (amount <= 0) return true; var r = EcoCall("API_Take", uid, cur, (double)amount, reason); return ToB(r); } private bool EcoAdd(ulong uid, string cur, long amount, string reason) { if (amount <= 0) return true; var r = EcoCall("API_Add", uid, cur, (double)amount, reason); return ToB(r); } #endregion #region Config IO // ★ 配置加载三件套(照抄福利中心的成熟写法,别自己简化) // 1) LoadDefaultConfig:config 文件不存在时 Oxide 调它 -> 建默认配置并落盘 // 2) LoadConfig:读出来之后【逐段补 null】—— 老配置缺新段是常态, // 不补的话升级版本后 _config.Discounts 是 null,面板一开就崩(NullReference) // 3) SaveConfig:写盘。授权模块也会调它(比如自动改回有效授权码时) private PluginConfig BuildDefaultConfig() { return new PluginConfig(); } protected override void LoadDefaultConfig() { PrintWarning("已生成默认配置(全部中文键名,改完执行 oxide.reload TradeCenter)"); _config = BuildDefaultConfig(); SaveConfig(); } protected override void LoadConfig() { base.LoadConfig(); try { _config = Config.ReadObject(); if (_config == null) throw new Exception("null config"); if (_config.Settings == null) _config.Settings = new SettingsData(); if (_config.Assets == null) _config.Assets = new AssetsData(); if (_config.Assets.Overrides == null) _config.Assets.Overrides = new Dictionary(); if (_config.Market == null) _config.Market = new MarketData(); if (_config.Market.HoursOptions == null || _config.Market.HoursOptions.Count == 0) _config.Market.HoursOptions = new List { 24, 72, 168 }; if (_config.Market.BlockedItems == null) _config.Market.BlockedItems = new List(); if (_config.Shop == null) _config.Shop = new ShopData(); if (_config.Shop.Items == null) _config.Shop.Items = new List(); if (_config.Fees == null) _config.Fees = new FeesData(); if (_config.Buyback == null) _config.Buyback = new BuybackData(); if (_config.Buyback.Categories == null || _config.Buyback.Categories.Count == 0) _config.Buyback.Categories = new List { "Resources", "Ammunition", "Construction" }; if (_config.Discounts == null) _config.Discounts = new DiscountData(); if (_config.Categories == null) _config.Categories = new CategoriesData(); if (_config.Categories.NameOverrides == null) _config.Categories.NameOverrides = new Dictionary(); if (_config.Categories.ItemOverrides == null) _config.Categories.ItemOverrides = new Dictionary(); if (_config.Categories.Hidden == null) _config.Categories.Hidden = new List(); if (_config.Authorize == null) _config.Authorize = new AuthData(); // ---- 自愈:清掉历史上 Newtonsoft 追加出来的重复项 ---- // ★ 为什么需要这段:老版本的集合字段没写 ObjectCreationHandling.Replace, // Newtonsoft 反序列化时是「往已有实例里 Add」,导致每 reload 一次就多一份。 // 已经脏掉的配置文件自己不会变干净,所以在这里主动收回去, // 服主只要 reload 一次就能恢复,不用手动删 json。 int dupH = DedupIntList(_config.Market.HoursOptions); int dupC = DedupStrList(_config.Buyback.Categories); int dupI = DedupShopItems(_config.Shop.Items); if (dupH + dupC + dupI > 0) PrintWarning("[配置自愈] 已清除历史重复项:可选时长 " + dupH + " 个 / 兜底分类 " + dupC + " 个 / 示例商品 " + dupI + " 件(这是旧版本遗留问题,现已修复,不会再出现)"); } catch (Exception ex) { PrintWarning("配置文件无效(" + ex.Message + "),已重建默认配置"); _config = BuildDefaultConfig(); } SaveConfig(); } protected override void SaveConfig() => Config.WriteObject(_config); // ---- 去重三兄弟:保序去重,返回删掉了几个 ---- private static int DedupIntList(List src) { if (src == null || src.Count < 2) return 0; var seen = new HashSet(); var keep = new List(); foreach (var v in src) if (seen.Add(v)) keep.Add(v); int removed = src.Count - keep.Count; if (removed > 0) { src.Clear(); src.AddRange(keep); } return removed; } private static int DedupStrList(List src) { if (src == null || src.Count < 2) return 0; var seen = new HashSet(); var keep = new List(); foreach (var v in src) if (v != null && seen.Add(v)) keep.Add(v); int removed = src.Count - keep.Count; if (removed > 0) { src.Clear(); src.AddRange(keep); } return removed; } // ★ 商品按 Id 去重:Id 是唯一键,FindShop 按 Id 查到第一个就返回, // 重复 Id 会让「改价改库存永远改到同一件」,所以必须清。 private static int DedupShopItems(List src) { if (src == null || src.Count < 2) return 0; var seen = new HashSet(); var keep = new List(); foreach (var it in src) { if (it == null) continue; string key = string.IsNullOrEmpty(it.Id) ? ("#" + keep.Count) : it.Id; if (seen.Add(key)) keep.Add(it); } int removed = src.Count - keep.Count; if (removed > 0) { src.Clear(); src.AddRange(keep); } return removed; } #endregion #region Persist private void LoadAll() { _market = LoadJson("TradeCenter/market", new MarketFile()); _shop = LoadJson("TradeCenter/shop", new ShopFile()); _players = LoadJson("TradeCenter/players", new PlayersFile()); _stats = LoadJson("TradeCenter/stats", new StatsFile()); if (_market == null) _market = new MarketFile(); if (_shop == null) _shop = new ShopFile(); if (_players == null) _players = new PlayersFile(); if (_stats == null) _stats = new StatsFile(); if (_market.Listings == null) _market.Listings = new List(); if (_shop.Items == null) _shop.Items = new List(); if (_players.Players == null) _players.Players = new Dictionary(); if (_players.Banned == null) _players.Banned = new List(); if (_stats.Sellers == null) _stats.Sellers = new Dictionary(); // 官方商店:数据文件优先,文件为空时用配置里的种子商品初始化 if (_shop.Items.Count == 0 && _config.Shop.Items != null && _config.Shop.Items.Count > 0) { foreach (var it in _config.Shop.Items) { if (string.IsNullOrEmpty(it.Id)) it.Id = NextShopId(); _shop.Items.Add(it); } _dirty = true; Puts("[数据] 官方商店已从配置导入 " + _shop.Items.Count + " 件商品(以后改价改库存直接在面板里改)"); } // ★ 自愈:shop.json 里可能已经躺着旧版本追加出来的重复商品。 // Id 重复意味着「改价改库存永远改到同一件」,必须收回去。 int dup = DedupShopItems(_shop.Items); if (dup > 0) { _dirty = true; PrintWarning("[数据自愈] shop.json 里发现 " + dup + " 件重复商品,已回收(旧版本遗留,已修复)"); } // 每次启动先做一次过期清理,免得市场里堆着上个月的单 ResetDailyIfNeeded(); } private T LoadJson(string path, T def) where T : class { try { var f = Interface.Oxide.DataFileSystem.GetFile(path); if (f != null && f.Exists()) { var o = f.ReadObject(); if (o != null) return o; } } catch (Exception ex) { PrintWarning("读取 " + path + " 失败(按空数据处理):" + ex.Message); } return def; } private void SaveJson(string path, T o) { try { Interface.Oxide.DataFileSystem.WriteObject(path, o); } catch (Exception ex) { PrintWarning("保存 " + path + " 失败:" + ex.Message); } } private void MarkDirty() { _dirty = true; } private void SaveAll(bool force) { if (!_dirty && !force) return; SaveJson("TradeCenter/market", _market); SaveJson("TradeCenter/shop", _shop); SaveJson("TradeCenter/players", _players); SaveJson("TradeCenter/stats", _stats); _dirty = false; } // 玩家记录(没有就新建) private PlayerRec Rec(ulong uid, bool create) { PlayerRec r; if (_players.Players.TryGetValue(uid, out r) && r != null) return r; if (!create) return null; r = new PlayerRec(); _players.Players[uid] = r; MarkDirty(); return r; } // 跨天重置系统收购用量(每天 0 点自然清零) private void ResetDailyIfNeeded() { string today = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); if (_stats.Day == today) return; _stats.Day = today; _stats.BuybackSpent = 0; _stats.BuybackOrders = 0; _stats.BuybackItems = 0; MarkDirty(); } // 官方商品号:S1 / S2 / S3 ...(只增不减,删掉的商品号不复用, // 这样服主手上的「序号 -> 商品」对照表永远有效) private string NextShopId() { _shop.Seq++; return "S" + _shop.Seq; } #endregion #region Commands // ---------------- 玩家命令 ---------------- [ChatCommand("shop")] private void CmdShop(BasePlayer player, string command, string[] args) => HandleShop(player, args); [ChatCommand("mall")] private void CmdMall(BasePlayer player, string command, string[] args) => HandleShop(player, args); private void HandleShop(BasePlayer player, string[] args) { if (player == null) return; // ★ 授权命令必须在最前面:未授权时也得能用 —— 这是服主的自救入口。 // 否则就卡在「卸载了就没法激活」的死循环里。 string sub = (args != null && args.Length > 0) ? args[0].ToLowerInvariant() : ""; if (sub == "auth" || sub == "deauth") { HandleAuthChat(player, args); return; } // ★★★ 总开关必须排在「商城停用检查」之前 —— // 否则一旦关掉,连重新打开的路都没了(死锁)。同理,auth/deauth 也在它前面。 if (sub == "on" || sub == "off") { if (!IsAdmin(player)) { Msg(player, "只有管理员能开关商城。"); return; } SetShopEnabled(sub == "on", player, true); return; } if (!EnsureAuthorized(player)) return; if (!CanUse(player)) { Msg(player, "你没有使用交易商城的权限。"); return; } // ★ 商城总开关:关着的时候,管理员给「怎么开」,普通玩家给一句人话 if (!ShopEnabled()) { if (IsAdmin(player)) Msg(player, "交易商城当前是关闭状态。重新开启:/shop on"); else Msg(player, "交易商城现在已经关闭了,暂时没法使用,请等服主开放。"); return; } switch (sub) { case "": case "open": case "market": ShowPanel(player, TabMarket); return; case "shop": ShowPanel(player, TabShop); return; case "mine": case "selllist": ShowPanel(player, TabMine); return; case "mail": case "box": ShowPanel(player, TabMail); return; case "perks": case "vip": ShowPanel(player, TabPerks); return; case "admin": if (!IsAdmin(player)) { Msg(player, "只有管理员能打开管理后台。"); return; } ShowPanel(player, TabAdmin); return; case "add": // /shop add <钻石价> [库存] [每份件数] —— 管理员手持物品做成官方商品 CmdAddShop(player, args, 1); return; case "help": SendHelp(player); return; case "sell": // /shop sell <单价> [件数] [小时] TrySell(player, args, 1); return; case "buy": { // /shop buy <挂单号> [件数|all] if (args.Length < 2) { Msg(player, "用法:/shop buy 挂单号 [件数] 不填件数 = 全部买下"); return; } int q = 0; if (args.Length >= 3 && !int.TryParse(args[2], out q)) q = 0; TryBuy(player, args[1], q); return; } case "cancel": { // /shop cancel <挂单号> if (args.Length < 2) { Msg(player, "用法:/shop cancel 挂单号 挂单号在「我的上架」里看"); return; } TryCancel(player, args[1]); return; } case "price": { // /shop price <挂单号> <新单价> if (args.Length < 3) { Msg(player, "用法:/shop price 挂单号 新单价"); return; } int nu; if (!int.TryParse(args[2], out nu) || nu <= 0) { Msg(player, "新单价得是正整数。"); return; } TryReprice(player, args[1], nu); return; } case "take": case "takeall": TryTakeMail(player, (sub == "takeall")); return; default: Msg(player, "未知子命令 " + sub + ",输入 /shop help 看用法。"); return; } } private void SendHelp(BasePlayer player) { Msg(player, "—— 交易商城用法 ——"); Msg(player, "/shop 打开商城面板(/mall 同义)"); Msg(player, "/shop sell <单价> [件数] [小时] 把手里这件挂到玩家市场卖"); Msg(player, "/shop buy <单号> [件数] (不填件数=全部买下;点卡片默认买 1 件)"); Msg(player, "/shop mine 我的上架 /shop cancel <单号> 下架退货"); Msg(player, "/shop price <单号> <新单价> 改价 /shop mail 我的仓库"); Msg(player, "/shop take 领一件 /shop takeall 一键全领"); Msg(player, "/shop shop 官方商店(花钻石) /shop perks 折扣与权益"); Msg(player, "★ 挂上架的东西在成交前由商城托管,到期会自动退回「我的仓库」,不会丢。"); if (IsAdmin(player)) { Msg(player, "[管理] /shop add <钻石价> [库存] [每份件数] 手持物品做成官方商品"); Msg(player, "[管理] /shop admin 后台面板(改价 / 改库存 / 停售 / 兜底收购,都能在里面点)"); } } // 聊天框填授权码:/shop auth PL-XXXX-XXXX-XXXX private void HandleAuthChat(BasePlayer player, string[] args) { if (args == null || args.Length == 0) { PrintAuthStatus(); Msg(player, "当前授权状态:" + _authHint); return; } string sub = args[0].ToLowerInvariant(); if (sub == "deauth") { if (!IsAdmin(player)) { Msg(player, "只有管理员能注销授权。"); return; } DoDeauth(player); return; } if (args.Length < 2 || string.IsNullOrEmpty(args[1].Trim())) { Msg(player, "当前授权状态:" + _authHint + " 授权码 " + MaskCode(CurrentCode())); Msg(player, "用法:/shop auth PL-XXXX-XXXX-XXXX"); return; } if (!IsAdmin(player)) { Msg(player, "只有管理员能填授权码。"); return; } RequestVerify(args[1].Trim().ToUpperInvariant(), player, true); } // ---------------- 控制台 / RCON 命令 ---------------- [ConsoleCommand("tradecenter.auth")] private void CcAuth(ConsoleSystem.Arg arg) { // 不带参数 = 只看状态 if (arg == null || !arg.HasArgs()) { PrintAuthStatus(); return; } string code = arg.GetString(0, "").Trim(); if (string.IsNullOrEmpty(code)) { PrintAuthStatus(); return; } PrintAuthStatus(); RequestVerify(code.ToUpperInvariant(), null, false); } [ConsoleCommand("tradecenter.on")] private void CcShopOn(ConsoleSystem.Arg arg) { var p = arg != null ? arg.Player() : null; if (p != null && !IsAdmin(p)) { Msg(p, "只有管理员能开关商城。"); return; } SetShopEnabled(true, p, true); } [ConsoleCommand("tradecenter.off")] private void CcShopOff(ConsoleSystem.Arg arg) { var p = arg != null ? arg.Player() : null; if (p != null && !IsAdmin(p)) { Msg(p, "只有管理员能开关商城。"); return; } SetShopEnabled(false, p, true); } [ConsoleCommand("tradecenter.state")] private void CcShopState(ConsoleSystem.Arg arg) { Puts("===== 交易商城 开关状态 ====="); Puts(" 总开关:" + (ShopEnabled() ? "开启" : "★ 关闭")); Puts(" 授权 :" + _authHint); Puts(" 开关 :tradecenter.on / tradecenter.off(游戏内聊天框 /shop on / /shop off)"); Puts(" 提示 :到期挂单的自动退回不受总开关影响,玩家的货不会丢。"); } [ConsoleCommand("tradecenter.deauth")] private void CcDeauth(ConsoleSystem.Arg arg) { var p = arg != null ? arg.Player() : null; // RCON / 服务器控制台 -> null if (p != null && !p.IsAdmin) { Msg(p, "只有服务器管理员能注销授权。"); return; } DoDeauth(p); } // ★ 注销入口:清掉本地凭据与配置里的授权码,立刻回到未授权状态。 // 用途:换机 / 退款 / 服主自查。以前自救只能去删 oxide/data/TradeCenter.auth.json。 private void DoDeauth(BasePlayer manual) { _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(" 重新激活:tradecenter.auth PL-XXXX-XXXX-XXXX"); if (manual != null) Msg(manual, "已注销,插件回到未授权状态。"); } [ConsoleCommand("tradecenter.assets")] private void CcAssets(ConsoleSystem.Arg arg) { EnsureAssetFolder(false); Puts("===== 交易商城 素材图自查 ====="); Puts(" 文件夹:" + AssetDirPath()); Puts(" 文件存在:" + (Directory.Exists(AssetDirPath()) ? "是" : "否")); Puts(" 图床前缀 ImageBaseUrl:" + (string.IsNullOrEmpty(_config.Assets.ImageBaseUrl) ? "(空 -> 用 assets 文件夹里的 PNG)" : _config.Assets.ImageBaseUrl)); foreach (var f in AllAssetFiles()) { var p = EmbedFullPath(f); bool ok = !string.IsNullOrEmpty(p) && File.Exists(p); long sz = 0; try { if (ok) sz = new FileInfo(p).Length; } catch (Exception) { } Puts(" " + (ok ? "[有]" : "[缺]") + " " + f + (ok ? "(" + (sz / 1024) + " KB)" : "")); } Puts(" 提示:补齐后执行 oxide.reload TradeCenter"); } [ConsoleCommand("tradecenter.diag")] private void CcDiag(ConsoleSystem.Arg arg) { Puts("===== 交易商城 诊断 ====="); Puts(" 插件版本:" + Version.ToString() + "(后台最新 " + (string.IsNullOrEmpty(_latestVersion) ? "未查到" : _latestVersion) + ")"); Puts(" 授权状态:" + (_authorized ? "已授权" : "未授权") + " " + _authHint); Puts(" 本地凭据:" + (CacheFresh() ? ("有效,至 " + GraceExpireText()) : "无 / 已过期")); Puts(" 经济中心对接:" + (EcoReady() ? "已加载" : "★ 未加载(交易全不可用)")); Puts(" 图片库对接:" + (ImageLibrary != null && ImageLibrary.IsLoaded ? "已加载" : "未加载(界面走纯色风)")); Puts(" 素材图:" + (UseImages() ? "图床模式" : (UseEmbedded() ? "内嵌 assets 模式" : "未配置(纯色风)"))); Puts(" 玩家市场挂单:" + _market.Listings.Count + " 单"); Puts(" 官方商店商品:" + _shop.Items.Count + " 件"); Puts(" 今日系统收购:花 " + _stats.BuybackSpent + " 金币 / " + _stats.BuybackOrders + " 单 / " + _stats.BuybackItems + " 件(预算 " + _config.Buyback.DailyBudget + ")"); Puts(" 累计:玩家市场成交 " + _stats.TotalTrades + " 笔 / 成交额 " + _stats.TotalGold + " 金币 / 手续费收入 " + _stats.TotalFee + " 金币 / 钻石收入 " + _stats.TotalDiamond); Puts(" 存盘:待写盘=" + (_dirty ? "是" : "否") + ",每 " + (int)SaveDebounceSeconds + " 秒合并写一次"); Puts(" 命令:/shop 开面板 tradecenter.auth <码> tradecenter.assets tradecenter.stats"); } // ===================================================================== // CUI 按钮回传命令(玩家点面板按钮走这里) // ★ CUI 按钮的 command 走的是控制台命令通道,必须以玩家身份执行, // 所以每个入口都要先用 arg.Player() 拿到人,拿不到就直接丢弃。 // ===================================================================== [ConsoleCommand("tradecenter.close")] private void CcClose(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; GetUi(p).Pick = false; // 关面板 = 上架弹层一起收掉,下次打开是干净的 CuiHelper.DestroyUi(p, PanelName); } [ConsoleCommand("tradecenter.tab")] private void CcTab(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; GetUi(p).Pick = false; // 切页签就把上架弹层收掉,避免叠在别的页上 ShowPanel(p, arg.GetInt(0, TabMarket)); } private static BasePlayer UiPlayer(ConsoleSystem.Arg arg) { if (arg == null) return null; var p = arg.Player(); if (p == null || !p.IsConnected) return null; return p; } // ============================================================ // 商城总开关(v1.0.1) // ============================================================ // 为什么要有:服主维护期 / 临时出问题时,希望「一键把商城收起来」, // 但又不想卸载插件(卸载要重新配置,数据虽然还在,但设置得重来一遍)。 // // 关掉之后会发生什么: // ① 面板打不开(ShowPanel 统一拦),已经打开的面板当场收起 // ② 上架 / 购买 / 下架 / 改价 / 取件 / 官方商店购买 全部拒绝 // ③ 系统兜底收购停止(往外花金币的动作,商城关着就不该继续花) // ④ 到期挂单的自动退回照常(保护玩家资产,跟开关无关) // ⑤ 这四条命令始终可用:/shop on · /shop off · /shop auth · /shop deauth private bool ShopEnabled() { return _config != null && _config.Settings != null && _config.Settings.Enabled; } // 把所有在线玩家已经打开的面板收起来 private void CloseAllPanels() { foreach (var p in BasePlayer.activePlayerList) if (p != null && p.IsConnected) CuiHelper.DestroyUi(p, PanelName); } // 总开关开/关。needAuthOnOpen:开启时是否要求已授权 // (没授权就开,玩家照样打不开,只会让人一头雾水 —— 所以提前拦下并给提示) private bool SetShopEnabled(bool on, BasePlayer manual, bool needAuthOnOpen) { if (_config == null || _config.Settings == null) return false; if (_config.Settings.Enabled == on) { if (manual != null) Msg(manual, "交易商城已经是" + (on ? "开启" : "关闭") + "状态了。"); return true; } if (on && needAuthOnOpen && !_authorized) { if (manual != null) Msg(manual, "插件还没授权,先 /shop auth PL-XXXX-XXXX-XXXX 激活,再开启商城。"); return false; } _config.Settings.Enabled = on; SaveConfig(); // 落盘 —— 重启服务器也保持这个状态 if (!on) CloseAllPanels(); Puts("[商城开关] 交易商城已" + (on ? "开启" : "关闭") + "(已写入配置文件,重启服务器保持)。"); if (manual != null) Msg(manual, "交易商城已" + (on ? "开启" : "关闭") + "。"); return true; } private bool IsAdmin(BasePlayer player) { if (player == null) return false; if (player.IsAdmin) return true; // 游戏内置管理员(F1 里 ownerid 那个) return permission.UserHasPermission(player.UserIDString, PermAdmin); } // 被拉黑?两重判定:管理后台拉的黑名单 + 反向权限 tradecenter.banned // (反向权限是给服主用的:给谁授予 tradecenter.banned,就是把谁拉黑) private bool IsBanned(BasePlayer player) { if (player == null) return false; if (_players.Banned != null && _players.Banned.Contains(player.userID)) return true; return permission.UserHasPermission(player.UserIDString, PermBanned); } // 能用商城。方案定的是「默认所有人可用」, // 要收紧就把 config 里「只允许有 use 权限的人使用」打开。 private bool CanUse(BasePlayer player) { if (player == null) return false; if (IsBanned(player)) return false; if (IsAdmin(player)) return true; if (!_config.Settings.UseRequiresPermission) return true; return permission.UserHasPermission(player.UserIDString, PermUse); } // 能上架。同上可单独收紧;被拉黑的一律不许挂单(防捣乱卖家)。 private bool CanSell(BasePlayer player) { if (player == null) return false; if (IsBanned(player)) return false; if (IsAdmin(player)) return true; if (!_config.Settings.SellRequiresPermission) return true; return permission.UserHasPermission(player.UserIDString, PermSell); } #endregion #region UI Commands // CUI 按钮统一走控制台命令通道,参数以空格分隔。 // ★ 每个入口都必须先用 UiPlayer(arg) 拿人,拿不到直接丢弃 —— // 否则控制台/RCON 能远程触发玩家操作。 [ConsoleCommand("tradecenter.cat")] private void CcCat(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; var st = GetUi(p); string k = arg.GetString(0, "All"); // 只接受面板上真实存在的档(防伪造命令塞任意字符串进来) bool ok = false; foreach (var c in CatOrder) if (string.Equals(c, k, StringComparison.OrdinalIgnoreCase)) { ok = true; break; } if (!ok) return; st.Cat = k; st.Page = 0; ShowPanel(p, st.Tab); } [ConsoleCommand("tradecenter.sort")] private void CcSort(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; var st = GetUi(p); st.Sort = Mathf.Clamp(arg.GetInt(0, 0), 0, 3); st.Page = 0; ShowPanel(p, st.Tab); } [ConsoleCommand("tradecenter.page")] private void CcPage(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; var st = GetUi(p); st.Page = Mathf.Max(0, arg.GetInt(0, 0)); // 越界由 DrawMarket 夹回来 ShowPanel(p, st.Tab); } // ---------------- 上架(v1.1.0:改成从背包里挑)---------------- // 面板右上角「+ 上架物品」点进来 -> 打开背包网格弹层。 // ① 点物品 ② 数量默认 = 背包全部 ③ 填单价 ④ 确认上架 // 挂完不关面板,可以接着挂下一件。 [ConsoleCommand("tradecenter.sell.open")] private void CcSellOpen(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; if (!EnsureAuthorized(p)) return; if (!CanSell(p)) { Msg(p, "你当前不能上架(可能被管理员限制了)。"); return; } if (!_config.Market.Enabled) { Msg(p, "玩家市场已关闭,暂时不能上架。"); return; } var st = GetUi(p); st.Pick = true; st.PickPage = 0; st.PickSn = ""; st.PickSkin = 0UL; st.PickQty = 0; st.PickUnit = 0; st.PickHours = _config.Market.DefaultHours > 0 ? _config.Market.DefaultHours : 72; st.PickMsg = ""; ShowPanel(p, TabMine); } [ConsoleCommand("tradecenter.sell.close")] private void CcSellClose(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; var st = GetUi(p); st.Pick = false; st.PickMsg = ""; ShowPanel(p, -1); } [ConsoleCommand("tradecenter.sell.page")] private void CcSellPage(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; var st = GetUi(p); st.PickPage = Mathf.Max(0, arg.GetInt(0, 0)); ShowPanel(p, -1); } // 选中背包里的某一摞: <皮肤ID> [ConsoleCommand("tradecenter.sell.pick")] private void CcSellPick(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; if (!EnsureAuthorized(p)) return; if (!CanSell(p)) { Msg(p, "你当前不能上架(可能被管理员限制了)。"); return; } string sn = arg.GetString(0, ""); ulong skin = 0UL; ulong.TryParse(arg.GetString(1, "0"), out skin); if (string.IsNullOrEmpty(sn)) return; var st = GetUi(p); st.Pick = true; st.PickMsg = ""; int have = BagCountOf(p, sn, skin); if (have <= 0) { st.PickSn = ""; st.PickMsg = "背包里已经没有这个了(可能刚被挂上去)。"; ShowPanel(p, -1); return; } st.PickSn = sn; st.PickSkin = skin; st.PickQty = have; if (_config.Market.MaxQuantityPerListing > 0 && st.PickQty > _config.Market.MaxQuantityPerListing) st.PickQty = _config.Market.MaxQuantityPerListing; st.PickUnit = 0; // 单价必须玩家自己填,绝不自动给个 1 金币 if (st.PickHours <= 0) st.PickHours = _config.Market.DefaultHours > 0 ? _config.Market.DefaultHours : 72; ShowPanel(p, -1); } // 数量:按钮传的是增量(-10 / -1 / 1 / 10)或 max [ConsoleCommand("tradecenter.sell.qty")] private void CcSellQty(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; var st = GetUi(p); if (string.IsNullOrEmpty(st.PickSn)) return; int max = BagCountOf(p, st.PickSn, st.PickSkin); if (_config.Market.MaxQuantityPerListing > 0 && max > _config.Market.MaxQuantityPerListing) max = _config.Market.MaxQuantityPerListing; string a = arg.GetString(0, ""); if (a == "max") st.PickQty = max; else { int d; if (int.TryParse(a, out d)) st.PickQty += d; } if (st.PickQty < 1) st.PickQty = 1; if (st.PickQty > max) st.PickQty = max; ShowPanel(p, -1); } // 数量:输入框回车(客户端把框里的文字当参数送回来) [ConsoleCommand("tradecenter.sell.setqty")] private void CcSellSetQty(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; var st = GetUi(p); if (string.IsNullOrEmpty(st.PickSn)) return; int v; if (!int.TryParse(arg.GetString(0, ""), out v) || v <= 0) { ShowPanel(p, -1); return; } int max = BagCountOf(p, st.PickSn, st.PickSkin); if (_config.Market.MaxQuantityPerListing > 0 && max > _config.Market.MaxQuantityPerListing) max = _config.Market.MaxQuantityPerListing; st.PickQty = Mathf.Clamp(v, 1, Mathf.Max(1, max)); ShowPanel(p, -1); } // 单价:按钮传增量(-100 / -10 / -1 / 1 / 10 / 100) [ConsoleCommand("tradecenter.sell.price")] private void CcSellPrice(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; var st = GetUi(p); if (string.IsNullOrEmpty(st.PickSn)) return; int d; if (!int.TryParse(arg.GetString(0, ""), out d)) return; int floor = _config.Market.MinUnitPrice > 0 ? _config.Market.MinUnitPrice : 1; int ceil = _config.Market.MaxUnitPrice > 0 ? _config.Market.MaxUnitPrice : int.MaxValue; if (st.PickUnit <= 0) st.PickUnit = floor; st.PickUnit = Mathf.Clamp(st.PickUnit + d, floor, ceil); ShowPanel(p, -1); } // 单价:输入框回车 [ConsoleCommand("tradecenter.sell.setprice")] private void CcSellSetPrice(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; var st = GetUi(p); if (string.IsNullOrEmpty(st.PickSn)) return; int v; if (!int.TryParse(arg.GetString(0, ""), out v) || v <= 0) { ShowPanel(p, -1); return; } int floor = _config.Market.MinUnitPrice > 0 ? _config.Market.MinUnitPrice : 1; int ceil = _config.Market.MaxUnitPrice > 0 ? _config.Market.MaxUnitPrice : int.MaxValue; st.PickUnit = Mathf.Clamp(v, floor, ceil); ShowPanel(p, -1); } // 参考价:把同类挂单里的最低单价填进输入框,省得玩家盲填。 // 没有同类挂单就退到「系统兜底收购价」,再没有就如实说没有。 [ConsoleCommand("tradecenter.sell.suggest")] private void CcSellSuggest(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; var st = GetUi(p); if (string.IsNullOrEmpty(st.PickSn)) { ShowPanel(p, -1); return; } long best = 0; foreach (var L in _market.Listings) { if (L == null || L.Left <= 0) continue; if (!string.Equals(L.Shortname, st.PickSn, StringComparison.OrdinalIgnoreCase)) continue; if (best == 0 || L.UnitPrice < best) best = L.UnitPrice; } if (best <= 0 && _config.Buyback != null && _config.Buyback.UnitPrice > 0) best = _config.Buyback.UnitPrice; if (best > 0) { int floor = _config.Market.MinUnitPrice > 0 ? _config.Market.MinUnitPrice : 1; int ceil = _config.Market.MaxUnitPrice > 0 ? _config.Market.MaxUnitPrice : int.MaxValue; // ★ Mathf.Min 没有 (long, int) 重载,别写成 Mathf.Min(best, int.MaxValue) long capped = best > int.MaxValue ? (long)int.MaxValue : best; st.PickUnit = Mathf.Clamp((int)capped, floor, ceil); st.PickMsg = "已填入参考价 " + Fmt(st.PickUnit) + " 金币(同类最低挂单价),可以再改。"; } else { st.PickMsg = "暂时没有参考价,按你想要的价填就行。"; } ShowPanel(p, -1); } [ConsoleCommand("tradecenter.sell.hours")] private void CcSellHours(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; var st = GetUi(p); int h = arg.GetInt(0, 0); if (h > 0) st.PickHours = h; ShowPanel(p, -1); } // 确认上架:走的是跟聊天命令同一个 TrySellCore,规则只有一份 [ConsoleCommand("tradecenter.sell.confirm")] private void CcSellConfirm(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; var st = GetUi(p); if (string.IsNullOrEmpty(st.PickSn)) { st.PickMsg = "先在上方点一个物品。"; ShowPanel(p, -1); return; } if (st.PickUnit <= 0) { st.PickMsg = "单价还没填:点 +1 / +10 / +100,或在框里敲数字回车。"; ShowPanel(p, -1); return; } int have = BagCountOf(p, st.PickSn, st.PickSkin); if (st.PickQty <= 0) st.PickQty = have; if (st.PickQty > have) st.PickQty = have; bool ok = TrySellCore(p, st.PickSn, st.PickSkin, BagConditionOf(p, st.PickSn, st.PickSkin), st.PickQty, st.PickUnit, st.PickHours); if (ok) { // ★ 挂完不清弹层:可以接着挑下一件,面板不关 st.PickMsg = "上架成功,可以继续挑下一件。"; st.PickSn = ""; st.PickSkin = 0UL; st.PickQty = 0; st.PickUnit = 0; } else { st.PickMsg = "没挂上去,看聊天框里的红字原因。"; } ShowPanel(p, -1); } // ---------------- 购买 ---------------- // 参数是 <挂单号> [件数];件数不填 = 面板卡片上那 1 件 [ConsoleCommand("tradecenter.buy")] private void CcBuy(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; if (!EnsureAuthorized(p)) return; string id = arg.GetString(0, ""); if (string.IsNullOrEmpty(id)) { Msg(p, "没有指定要买的挂单。"); return; } int qty = arg.GetInt(1, 1); if (qty < 1) qty = 1; TryBuy(p, id, qty); ShowPanel(p, TabMarket); } // 官方商店购买:参数 <商品号> [份数];份数不填 = 面板卡片上那 1 份 [ConsoleCommand("tradecenter.buy.shop")] private void CcBuyShop(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; if (!EnsureAuthorized(p)) return; string id = arg.GetString(0, ""); if (string.IsNullOrEmpty(id)) { Msg(p, "没有指定要买的商品。"); return; } int qty = arg.GetInt(1, 1); if (qty < 1) qty = 1; TryBuyShop(p, id, qty); ShowPanel(p, TabShop); } // ---------------- 我的上架 ---------------- [ConsoleCommand("tradecenter.mine.cancel")] private void CcMineCancel(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; if (!EnsureAuthorized(p)) return; string id = arg.GetString(0, ""); if (string.IsNullOrEmpty(id)) { Msg(p, "没有指定要下架的单。"); return; } TryCancel(p, id); ShowPanel(p, TabMine); } // 参数是 <挂单号> <新单价> [ConsoleCommand("tradecenter.mine.price")] private void CcMinePrice(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; if (!EnsureAuthorized(p)) return; string id = arg.GetString(0, ""); int unit = arg.GetInt(1, 0); if (string.IsNullOrEmpty(id) || unit <= 0) { Msg(p, "用法:/shop price 挂单号 新单价"); return; } TryReprice(p, id, unit); ShowPanel(p, TabMine); } // ---------------- 我的仓库 ---------------- [ConsoleCommand("tradecenter.mail.take")] private void CcMailTake(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; if (!EnsureAuthorized(p)) return; TryTakeMail(p, false); ShowPanel(p, TabMail); } [ConsoleCommand("tradecenter.mail.takeall")] private void CcMailTakeAll(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; if (!EnsureAuthorized(p)) return; TryTakeMail(p, true); ShowPanel(p, TabMail); } #endregion #region GUI helpers private float Scale() { float s = _config == null || _config.Settings == null ? 1f : _config.Settings.PanelScale; if (s <= 0f) s = 1f; return Mathf.Clamp(s, 0.7f, 1.4f); } private static string P(float x, float y, float s) => (x * s) + " " + (y * s); private static int F(int size, float s) => Mathf.Max(9, Mathf.RoundToInt(size * s)); private static CuiPanel MkPanel(float x0, float y0, float x1, float y1, string color, float s) { var p = new CuiPanel(); p.Image.Color = color; p.RectTransform.AnchorMin = "0 0"; p.RectTransform.AnchorMax = "0 0"; p.RectTransform.OffsetMin = P(x0, y0, s); p.RectTransform.OffsetMax = P(x1, y1, s); return p; } private static CuiLabel MkLabel(string text, float x0, float y0, float x1, float y1, int size, TextAnchor align, string color, float s) { var l = new CuiLabel(); l.Text.Text = text ?? ""; l.Text.FontSize = F(size, s); l.Text.Align = align; l.Text.Color = color; l.RectTransform.AnchorMin = "0 0"; l.RectTransform.AnchorMax = "0 0"; l.RectTransform.OffsetMin = P(x0, y0, s); l.RectTransform.OffsetMax = P(x1, y1, s); return l; } private static CuiButton MkButton(string command, string text, float x0, float y0, float x1, float y1, int size, string bg, string fg, float s) { var b = new CuiButton(); b.Button.Command = command; b.Button.Color = bg; b.Text.Text = text ?? ""; b.Text.FontSize = F(size, s); b.Text.Align = TextAnchor.MiddleCenter; b.Text.Color = fg; b.RectTransform.AnchorMin = "0 0"; b.RectTransform.AnchorMax = "0 0"; b.RectTransform.OffsetMin = P(x0, y0, s); b.RectTransform.OffsetMax = P(x1, y1, s); return b; } // 游戏内置贴图(自定义素材取不到时兜底,保证面板「有图」而不是一片白) // 大小写与 bundle 内一致,别改 private const string SpBgTile = "assets/content/ui/ui.background.tile.psd"; private const string SpWhite = "assets/content/ui/ui.white.tga"; // 图片元素名计数器:CUI 元素名必须唯一,多张图共用一个名字会被客户端互相覆盖 private static int _imgSeq; private static bool IsBuiltinSprite(string s) => !string.IsNullOrEmpty(s) && s.StartsWith("assets/", StringComparison.OrdinalIgnoreCase); private static bool IsHttpUrl(string s) => !string.IsNullOrEmpty(s) && (s.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || s.StartsWith("https://", StringComparison.OrdinalIgnoreCase)); // 四条通路自动分流;itemId != 0 时优先用客户端本地物品图标 private static CuiElement MkImage(string sprite, float x0, float y0, float x1, float y1, string color, float s, float fade, int itemId = 0) { var rt = new CuiRectTransformComponent(); rt.AnchorMin = "0 0"; rt.AnchorMax = "0 0"; rt.OffsetMin = P(x0, y0, s); rt.OffsetMax = P(x1, y1, s); var e = new CuiElement { Name = PanelName + ".img." + (++_imgSeq) }; e.Components.Add(rt); if (itemId != 0) e.Components.Add(new CuiImageComponent { ItemId = itemId, Color = color, FadeIn = fade }); else if (IsBuiltinSprite(sprite)) e.Components.Add(new CuiImageComponent { Sprite = sprite, Color = color, FadeIn = fade }); else if (IsHttpUrl(sprite)) e.Components.Add(new CuiRawImageComponent { Url = sprite, Color = color, FadeIn = fade }); else e.Components.Add(new CuiRawImageComponent { Png = sprite, Color = color, FadeIn = fade }); return e; } // fallback = 游戏内置贴图路径:自定义素材取不到时用它顶上(可选) private void Img(CuiElementContainer c, string parent, string sprite, float x0, float y0, float x1, float y1, string color, float s, string fallback = null) { var use = sprite; if (string.IsNullOrEmpty(use)) use = fallback; if (string.IsNullOrEmpty(use)) return; var e = MkImage(use, x0, y0, x1, y1, color, s, 0.2f); e.Parent = parent; c.Add(e); } // 物品图标:直接给 itemid,客户端画本地官方彩色图标 // —— 不碰 FileStorage、不碰网络,必显示;而且上架任何东西都不需要准备一张图 private void ImgItem(CuiElementContainer c, string parent, int itemId, float x0, float y0, float x1, float y1, string color, float s) { if (itemId == 0) return; var e = MkImage(null, x0, y0, x1, y1, color, s, 0.2f, itemId); e.Parent = parent; c.Add(e); } // 输入框(v1.1.0):面板里直接敲数字用。 // ★ Command = 敲完回车时执行的控制台命令,客户端会把框里的文字当参数传回来, // 所以命令后面不要再拼参数;text 只是初始值。 // ★ 底色要另外画一层 Veil 垫在下面(输入框组件本身不带底色)。 private static CuiElement MkInput(string command, string text, float x0, float y0, float x1, float y1, int size, string color, float s, int charsLimit = 10) { var rt = new CuiRectTransformComponent(); rt.AnchorMin = "0 0"; rt.AnchorMax = "0 0"; rt.OffsetMin = P(x0, y0, s); rt.OffsetMax = P(x1, y1, s); var e = new CuiElement { Name = PanelName + ".inp." + (++_imgSeq) }; e.Components.Add(rt); e.Components.Add(new CuiInputFieldComponent { Text = text ?? "", Command = command ?? "", FontSize = F(size, s), Align = TextAnchor.MiddleCenter, Color = color, CharsLimit = charsLimit, NeedsKeyboard = true, IsPassword = false }); return e; } // shortname -> 物品 ID(找不到返回 0) private static int ItemIdOf(string shortname) { if (string.IsNullOrEmpty(shortname)) return 0; try { var def = ItemManager.FindItemDefinition(shortname); return def == null ? 0 : def.itemid; } catch (Exception) { return 0; } } // 压暗层 / 纯色底:图再花也不影响读字 // ★ 组件的父级只能通过 container.Add(component, parent, name) 传; // CuiPanel / CuiLabel 这些 ICuiComponent 上没有 Parent 属性 private void Veil(CuiElementContainer c, string parent, float x0, float y0, float x1, float y1, string color, float s, string name) { c.Add(MkPanel(x0, y0, x1, y1, color, s), parent, name); } // ---------------- 数值 / 时间显示 ---------------- // 875.2万 / 6,541 / 1.23亿 private static string Fmt(long n) { if (n >= 100000000L) return (n / 100000000.0).ToString("0.##", CultureInfo.InvariantCulture) + "亿"; if (n >= 10000L) return (n / 10000.0).ToString("0.##", CultureInfo.InvariantCulture) + "万"; return n.ToString("N0", CultureInfo.InvariantCulture); } private static string NowIso() => DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture); private static bool ParseIso(string s, out DateTime t) { t = DateTime.MinValue; if (string.IsNullOrEmpty(s)) return false; return DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out t); } // 剩余时间中文(「2天3小时」「45分钟」「已过期」) private static string RemainText(string iso) { DateTime t; if (!ParseIso(iso, out t)) return "不限"; var span = t - DateTime.UtcNow; if (span.TotalSeconds <= 0) return "已过期"; if (span.TotalDays >= 1) return ((int)span.TotalDays) + "天" + span.Hours + "小时"; if (span.TotalHours >= 1) return ((int)span.TotalHours) + "小时" + span.Minutes + "分"; return Mathf.Max(1, (int)span.TotalMinutes) + "分钟"; } // ---------------- 分类体系 ---------------- // 13 档,顺序 = 面板右侧从上到下。前面 12 档全部映射游戏官方 ItemCategory, // 最后一档 LIMITED(限定)是服主自定义、钻石轨专属。 // ★ 这样玩家挂任何东西都自动进对应分类,服主零维护 —— // 靠手工列物品清单的话,Rust 每次更新加新物品都得改配置。 private static readonly string[] CatOrder = { "All", "Resources", "Weapon", "Ammunition", "Attire", "Tool", "Construction", "Medical", "Food", "Component", "Traps", "Misc", "LIMITED" }; // 默认中文名(config 里「分类 -> 中文名覆盖」可以改,比如把「服饰」改成「护甲」) private static readonly Dictionary CatDefaultCn = new Dictionary { {"All", "全部"}, {"Resources", "资源"}, {"Weapon", "武器"}, {"Ammunition", "弹药"}, {"Attire", "服饰"}, {"Tool", "工具"}, {"Construction", "建筑"}, {"Medical", "医疗"}, {"Food", "食物"}, {"Component", "组件"}, {"Traps", "陷阱"}, {"Misc", "杂物"}, {"LIMITED", "限定"} }; private string CatCn(string key) { if (string.IsNullOrEmpty(key)) return "?"; string cn; if (_config != null && _config.Categories != null && _config.Categories.NameOverrides != null && _config.Categories.NameOverrides.TryGetValue(key, out cn) && !string.IsNullOrEmpty(cn)) return cn; return CatDefaultCn.TryGetValue(key, out cn) ? cn : key; } // 这一档是否显示(config 里可以隐藏,比如服务器没开电力系统就藏掉「组件」) private bool CatVisible(string key) { if (_config == null || _config.Categories == null || _config.Categories.Hidden == null) return true; return !_config.Categories.Hidden.Contains(key); } // 物品 -> 分类(返回 ItemCategory 枚举名字符串) // 优先看 config 的「强制归类」,其次用游戏官方的 def.category —— 服主零维护 // Rust 的 ItemCategory 枚举比面板这 13 档多:Items / Electrical / Fun / // Vehicles / Common / Search / Favourite / All …… 这些值如果原样返回, // 物品就哪一档都进不去 —— 只在「全部」里露脸,点任何具体分类都是空的。 // 所以这里统一收口:不在 13 档里的一律归到 Misc(组件类的归 Component), // 服主还能用配置里的「强制归类 ItemOverrides」把个别物品单独拎出来。 private static readonly Dictionary CatFallback = new Dictionary { {"Items", "Misc"}, {"Electrical", "Component"}, {"Fun", "Misc"}, {"Vehicles", "Misc"}, {"Common", "Misc"}, {"Search", "Misc"}, {"Favourite", "Misc"}, {"All", "Misc"} }; // 归一化:保证返回值一定是 CatOrder 里的某一档 private static string CatNorm(string c) { if (string.IsNullOrEmpty(c)) return "Misc"; for (int i = 0; i < CatOrder.Length; i++) if (string.Equals(CatOrder[i], c, StringComparison.OrdinalIgnoreCase)) return CatOrder[i]; string f; return CatFallback.TryGetValue(c, out f) ? f : "Misc"; } private string CatOf(string shortname) { if (string.IsNullOrEmpty(shortname)) return "Misc"; string over; if (_config != null && _config.Categories != null && _config.Categories.ItemOverrides != null && _config.Categories.ItemOverrides.TryGetValue(shortname, out over) && !string.IsNullOrEmpty(over)) return CatNorm(over); try { var def = ItemManager.FindItemDefinition(shortname); if (def == null) return "Misc"; return CatNorm(def.category.ToString()); } catch (Exception) { return "Misc"; } } // ---- 物品中文名(317 条,与「堆叠大师 StackMaster」同源,保证两支插件显示一致)---- // 没收录的物品自动退回游戏英文名,不会出现空白 private static readonly Dictionary ItemCnNames = new Dictionary { {"wood", "木头"}, {"stones", "石头"}, {"metal.ore", "金属矿石"}, {"sulfur.ore", "硫磺矿石"}, {"metal.fragments", "金属碎料"}, {"metal.refined", "金属块"}, {"scrap", "废料"}, {"cloth", "布料"}, {"leather", "皮革"}, {"rope", "绳索"}, {"lowgradefuel", "低品级燃料"}, {"gunpowder", "火药"}, {"charcoal", "木炭"}, {"bone.fragments", "骨头碎片"}, {"animal.fat", "动物脂肪"}, {"paper", "纸张"}, {"crude.oil", "原油"}, {"diesel_barrel", "柴油桶"}, {"ammo.pistol", "手枪子弹"}, {"ammo.pistol.fire", "燃烧手枪弹"}, {"ammo.pistol.hv", "高速手枪弹"}, {"ammo.rifle", "步枪子弹"}, {"ammo.rifle.hv", "高速步枪弹"}, {"ammo.rifle.incendiary", "燃烧步枪弹"}, {"ammo.rifle.explosive", "爆炸步枪弹"}, {"ammo.shotgun", "霰弹"}, {"ammo.shotgun.fire", "燃烧霰弹"}, {"ammo.shotgun.slug", "独头弹"}, {"ammo.handmade.shell", "手制霰弹"}, {"ammo.nailgun.nails", "射钉枪钉"}, {"ammo.grenadelauncher.he", "榴弹发射器高爆弹"}, {"ammo.rocket.basic", "火箭弹"}, {"ammo.rocket.fire", "燃烧火箭弹"}, {"ammo.rocket.hv", "高速火箭弹"}, {"ammo.rocket.smoke", "烟雾火箭弹"}, {"arrow.wooden", "木箭"}, {"arrow.bone", "骨箭"}, {"arrow.fire", "火箭"}, {"arrow.hv", "高速箭"}, {"arrow.stone", "石箭"}, {"explosive.satchel", "定时炸药包"}, {"explosive.timed", "定时炸弹"}, {"grenade.f1", "F1 手雷"}, {"grenade.smoke", "烟雾弹"}, {"grenade.flashbang", "闪光弹"}, {"survey.charge", "勘测炸药"}, {"bandage", "绷带"}, {"medical.syringe", "医疗针"}, {"antiradpills", "抗辐射药"}, {"large.medkit", "大医疗包"}, {"small.medkit", "小医疗包"}, {"hammer", "锤子"}, {"hatchet", "石斧"}, {"axe.salvaged", "废料斧"}, {"pickaxe", "石镐"}, {"pickaxe.salvaged", "废料镐"}, {"torch", "火把"}, {"flashlight", "手电筒"}, {"building.planner", "建筑蓝图"}, {"wire.tool", "电工钳"}, {"wooden.door", "木门"}, {"sheetmetal.door", "铁门"}, {"garagedoor", "车库门"}, {"code.lock", "密码锁"}, {"key.lock", "钥匙锁"}, {"metal.fence", "铁丝网"}, {"codelockedhackcrate", "密码箱"}, {"locker", "储物柜"}, {"box.wooden", "木箱"}, {"furnace", "熔炉"}, {"furnace.large", "大熔炉"}, {"campfire", "篝火"}, {"barbeque", "烧烤架"}, {"repair.bench", "维修台"}, {"research.table", "研究台"}, {"workbench1", "工作台1级"}, {"workbench2", "工作台2级"}, {"workbench3", "工作台3级"}, {"refinery", "精炼机"}, {"recycler", "回收机"}, {"vending.machine", "自动售货机"}, {"lantern", "提灯"}, {"ceiling.light", "吸顶灯"}, {"door.hinged.wood", "木铰链门"}, {"floor.ladder.hatch", "梯井盖"}, {"ladder.wooden.wall", "木梯"}, {"trap.bear", "捕熊夹"}, {"trap.landmine", "地雷"}, {"trap.spikes", "木刺"}, {"trap.wire", "电铁丝"}, {"electric.solarpanel.large", "大型太阳能板"}, {"battery.small", "小电池"}, {"battery.medium", "中电池"}, {"battery.large", "大电池"}, {"generator.small", "发电机"}, {"electric.fuelgenerator", "燃油发电机"}, {"teslacoil", "特斯拉线圈"}, {"target.reactive", "反应靶"}, {"switch", "开关"}, {"electric.button", "按钮"}, {"pressurepad", "压力板"}, {"timer", "定时器"}, {"electric.heater", "电暖器"}, {"rf.receiver", "遥控接收器"}, {"rf.transmitter", "遥控发射器"}, {"powerbank.small", "小型充电宝"}, {"electrical.combiner", "电源合并器"}, {"electrical.splitter", "电源分线器"}, {"hoodie", "连帽衫"}, {"jacket", "夹克"}, {"pants", "长裤"}, {"tshirt", "T恤"}, {"shirt.collared", "衬衫"}, {"burlap.shirt", "麻布衣"}, {"burlap.pants", "麻布裤"}, {"burlap.shoes", "麻布鞋"}, {"wood.armor.jacket", "木甲衣"}, {"wood.armor.pants", "木甲裤"}, {"roadsign.jacket", "路牌甲衣"}, {"roadsign.pants", "路牌甲裤"}, {"metal.plate.torso", "金属板甲"}, {"metal.plate.pants", "金属板裤"}, {"hide.skin", "兽皮"}, {"hazmatsuit", "防化服"}, {"riot.helmet", "防暴头盔"}, {"metal.facemask", "金属面罩"}, {"coffeecan.helmet", "咖啡罐头盔"}, {"bucket.helmet", "水桶头盔"}, {"sunglasses", "墨镜"}, {"gloves.tactical", "战术手套"}, {"boots.frog", "蛙人鞋"}, {"mask.balaclava", "巴拉克拉瓦面罩"}, {"can.beans", "豆子罐头"}, {"can.tuna", "金枪鱼罐头"}, {"canned.food", "罐头食品"}, {"chicken.cooked", "熟鸡肉"}, {"chicken.raw", "生鸡肉"}, {"bearmeat.cooked", "熟熊肉"}, {"bearmeat", "熊肉"}, {"wolfmeat.cooked", "熟狼肉"}, {"wolfmeat.raw", "生狼肉"}, {"pork.cooked", "熟猪肉"}, {"pork.raw", "生猪排"}, {"meat.boar", "野猪肉"}, {"deermeat.cooked", "熟鹿肉"}, {"deermeat.raw", "生鹿肉"}, {"fish.cooked", "熟鱼"}, {"fish.raw", "生鱼"}, {"apple", "苹果"}, {"blueberries", "蓝莓"}, {"mushroom", "蘑菇"}, {"potato", "土豆"}, {"corn", "玉米"}, {"chocholate", "巧克力"}, {"granolabar", "燕麦棒"}, {"jar.pickle", "腌菜罐"}, {"smallwaterbottle", "小水瓶"}, {"bota.bag", "水囊"}, {"skull.human", "人类头骨"}, {"skull.wolf", "狼头骨"}, {"bone.club", "骨棒"}, {"spear.wooden", "木矛"}, {"spear.stone", "石矛"}, {"knife.bone", "骨刀"}, {"salvaged.sword", "废料剑"}, {"rifle.ak", "AK-47"}, {"rifle.bolt", "栓动步枪"}, {"smg.thompson", "汤普森冲锋枪"}, {"smg.mp5", "MP5"}, {"shotgun.pump", "泵动霰弹枪"}, {"shotgun.double", "双管霰弹枪"}, {"pistol.revolver", "左轮手枪"}, {"pistol.semiauto", "半自动手枪"}, {"pistol.python", "蟒蛇左轮"}, {"pistol.m92", "M92 手枪"}, {"lmg.m249", "M249"}, {"lmg.hmlmg", "HMLMG"}, {"weapon.mod.silencer", "消音器"}, {"weapon.mod.flashlight", "武器手电"}, {"weapon.mod.lasersight", "激光瞄准器"}, {"weapon.mod.holosight", "全息瞄具"}, {"weapon.mod.small.scope", "小型瞄准镜"}, {"weapon.mod.8x.scope", "8倍镜"}, {"weapon.mod.muzzlebrake", "枪口制退器"}, {"weapon.mod.extendedmags", "加长弹匣"}, {"riflebody", "步枪枪身"}, {"smgbody", "冲锋枪枪身"}, {"shotgunbody", "霰弹枪枪身"}, {"pistolbody", "手枪枪身"}, {"semibody", "半自动枪身"}, {"metalpipe", "金属管"}, {"gears", "齿轮"}, {"spring", "弹簧"}, {"sheet.metal", "金属板"}, {"metalblade", "金属刀刃"}, {"metalspring", "金属弹簧"}, {"sewingkit", "针线包"}, {"techparts", "科技零件"}, {"cctv.camera", "监控摄像头"}, {"targeting.computer", "瞄准计算机"}, {"rf.detonator", "遥控引爆器"}, {"explosives", "炸药"}, {"fungi", "真菌"}, {"halloween.surgeonsuit", "手术服"}, // ---- v1.2.0 补充:资源 / 材料 ---- {"sulfur", "硫磺"}, {"hq.metal.ore", "高金矿石"}, {"tarp", "防水布"}, {"ducttape", "胶带"}, {"fertilizer", "肥料"}, {"water", "水"}, {"water.salt", "盐水"}, {"blood", "血包"}, {"wrappedgift", "包装礼物"}, {"horsedung", "马粪"}, {"seed.hemp", "大麻种子"}, {"seed.corn", "玉米种子"}, {"seed.pumpkin", "南瓜种子"}, {"seed.sunflower", "向日葵种子"}, {"clone.hemp", "大麻基因"}, {"clone.corn", "玉米基因"}, {"clone.pumpkin", "南瓜基因"}, {"pumpkin", "南瓜"}, {"sunflower", "向日葵"}, {"wheat", "小麦"}, {"hay", "干草"}, // ---- 食物 ---- {"fish.minnows", "小鱼"}, {"worm", "蚯蚓"}, {"grub", "幼虫"}, {"horsemeat.raw", "生马肉"}, {"horsemeat.cooked", "熟马肉"}, {"humanmeat.raw", "生人肉"}, {"humanmeat.cooked", "熟人肉"}, {"cactusflesh", "仙人掌肉"}, {"can.chicken", "鸡肉罐头"}, // ---- 茶饮 / 药品 ---- {"healthtea", "治疗茶"}, {"maxhealthtea", "生命上限茶"}, {"radiationremovetea", "去辐射茶"}, {"radiationresisttea", "抗辐射茶"}, {"oretea", "矿石茶"}, {"scraptea", "废料茶"}, {"woodtea", "木材茶"}, {"supertea", "超级茶"}, {"pureoretea", "纯矿茶"}, // ---- 弹药 ---- {"ammo.rocket.seeker", "追踪火箭弹"}, {"ammo.grenadelauncher.buckshot", "榴弹霰弹"}, {"ammo.grenadelauncher.smoke", "榴弹烟雾弹"}, {"mortar.shell", "迫击炮弹"}, {"mortar.shell.smoke", "迫击炮烟雾弹"}, {"grenade.beancan", "豆罐手雷"}, {"grenade.molotov", "燃烧瓶"}, {"snowball", "雪球"}, {"ammo.snowballgun", "雪球弹药"}, // ---- 武器 / 工具 ---- {"jackhammer", "凿岩机"}, {"chainsaw", "电锯"}, {"machete", "砍刀"}, {"knife.combat", "战斗刀"}, {"multiplegrenade.launcher", "多发榴弹发射器"}, {"rocket.launcher", "火箭筒"}, {"homingmissile.launcher", "追踪导弹发射器"}, {"minigun", "加特林机枪"}, {"snowballgun", "雪球枪"}, {"paddle", "船桨"}, {"fishingrod", "钓竿"}, {"bucket.water", "水桶"}, {"megaphone", "扩音器"}, {"note", "便签"}, {"tool.binoculars", "望远镜"}, {"parachute", "降落伞"}, // ---- 建造 ---- {"door.hinged.metal", "铁铰链门"}, {"door.hinged.toptier", "高金铰链门"}, {"door.double.hinged.wood", "双开木门"}, {"door.double.hinged.metal", "双开铁门"}, {"door.double.hinged.toptier", "双开高金门"}, {"shutter.wood.a", "木百叶窗"}, {"wall.frame.shopfront", "售货窗框"}, {"wall.window.bars.wood", "木窗栅栏"}, {"wall.window.bars.metal", "铁窗栅栏"}, {"wall.window.bars.toptier", "高金窗栅栏"}, // ---- 电气 ---- {"electrical.branch", "电源支线"}, {"electrical.blocker", "电源阻断器"}, {"electrical.memorycell", "记忆电池"}, {"electrical.random.switch", "随机开关"}, {"electric.orswitch", "或开关"}, {"electric.xorswitch", "异或开关"}, {"electric.andswitch", "与开关"}, {"electric.igniter", "点火器"}, {"electric.hbhfsensor", "心跳传感器"}, {"electric.doorcontroller", "门控制器"}, {"electric.simplelight", "简易灯"}, {"electric.flasherlight", "闪烁灯"}, {"electric.sirenlight", "警示灯"}, {"electric.searchlight", "探照灯"}, {"electric.counter", "计数器"}, {"electric.windturbine", "风力发电机"}, {"electric.audioalarm", "声音警报器"}, {"electric.laserdetector", "激光探测器"}, {"electric.digitalclock", "数字时钟"}, {"samsite", "防空炮台"}, {"autoturret", "自动炮台"}, {"flameturret", "火焰炮台"}, // ---- 工业 ---- {"industrial.crafter", "工业制造机"}, {"industrial.conveyor", "工业传送带"}, {"industrial.splitter", "工业分线器"}, {"industrial.combiner", "工业合并器"}, {"industrial.storage", "工业储物箱"}, {"industrial.pipe", "工业管道"}, {"fluidswitch", "流体开关"}, {"mixingtable", "混合台"}, {"small.oil.rig", "小型抽油机"}, {"mining.quarry", "矿石开采机"}, {"mining.pumpjack", "抽油机"}, // ---- 潜水 / 骑乘 ---- {"diving.tank", "氧气瓶"}, {"diving.fins", "脚蹼"}, {"diving.mask", "潜水镜"}, {"wetsuit", "潜水服"}, {"horse.saddle", "马鞍"}, {"horse.saddlebag", "马鞍包"} }; // 物品中文显示名:字典优先;没收录的退回游戏英文名(保证面板不会只剩短名) private static string ItemNameOf(ItemDefinition def) { if (def == null) return "?"; string cn; if (ItemCnNames.TryGetValue(def.shortname, out cn)) return cn; return string.IsNullOrEmpty(def.displayName?.english) ? def.shortname : def.displayName.english; } private static string ItemName(string shortname) { if (string.IsNullOrEmpty(shortname)) return "?"; try { return ItemNameOf(ItemManager.FindItemDefinition(shortname)); } catch (Exception) { return shortname; } } #endregion #region Panel content // ===================================================================== // 面板总览(1000 x 620,原点左下角) // // ┌──────────────────────── 顶部栏 (12,570)-(988,612) ─────────────┐ // │ 交 易 商 城 金币 875.2万 钻石 6,541 [+上架] [×] │ // ├─────────────────────────────────────────┬────────────────────┤ // │ 工具条 (62,518)-(902,562) 全部·共12单 │ 右侧分类栏 │ // │ ┌────┐┌────┐┌────┐┌────┐ 第1行 y334 │ (918,36) │ // │ │卡片││卡片││卡片││卡片│ │ - (992,570) │ // │ └────┘└────┘└────┘└────┘ │ 13 档,每档37高 │ // │ ┌────┐┌────┐┌────┐┌────┐ 第2行 y95 │ 间距 4 │ // │ │卡片││卡片││卡片││卡片│ │ ★ 背景图右侧本来 │ // │ └────┘└────┘└────┘└────┘ │ 就有一条暗色竖槽 │ // ├─────────────────────────────────────────┴────────────────────┤ // │ 底部页签 (62,8)-(902,36) 市场|商店|上架|仓库|权益|后台 │ // └──────────────────────────────────────────────────────────────┘ // // 卡片内部(172 x 182,padding 5) // 角标 y0+169 .. y0+181 (热销 / 限购 / 限时,右上角) // 图标 y0+55 .. y0+167 高 112 —— 物品图标是方的,给足高度才好看 // 名称 y0+31 .. y0+51 高 20 // 价格 y0+5 .. y0+27 高 22 // ===================================================================== private UiState GetUi(BasePlayer player) { UiState st; if (!_ui.TryGetValue(player.userID, out st) || st == null) { st = new UiState(); _ui[player.userID] = st; } return st; } // 主入口:tab < 0 = 保持当前页 private void ShowPanel(BasePlayer player, int tab = -1) { if (player == null || !player.IsConnected) return; // ★ 软锁:未授权时只给管理员看激活说明页,普通玩家静默拒绝 if (!_authorized) { if (IsAdmin(player)) ShowAuthPanel(player); return; } if (!CanUse(player)) return; // ★ 总开关:商城关着 -> 面板一律不开。 // 这里拦的是「所有」入口,包括管理后台、UI 按钮回调、控制台 tab 切换。 if (!ShopEnabled()) return; var st = GetUi(player); if (tab >= 0) st.Tab = Mathf.Clamp(tab, 0, TabCount - 1); if (st.Tab == TabAdmin && !IsAdmin(player)) st.Tab = TabMarket; float s = Scale(); float W = BaseW * s, H = BaseH * s; var c = new CuiElementContainer(); CuiHelper.DestroyUi(player, PanelName); // ---- 根面板(0.5,0.5 锚点居中;子元素坐标相对它的左下角)---- var root = new CuiPanel(); root.Image.Color = "1 0.988 0.962 0.97"; root.RectTransform.AnchorMin = "0.5 0.5"; root.RectTransform.AnchorMax = "0.5 0.5"; root.RectTransform.OffsetMin = (-W / 2f) + " " + (-H / 2f); root.RectTransform.OffsetMax = (W / 2f) + " " + (H / 2f); root.CursorEnabled = true; c.Add(root, "Overlay", PanelName); DrawBackground(c, st, s); DrawTopBar(c, player, st, s); // ★ 分类栏只对「玩家市场」有效。 // 我的上架 / 我的仓库 / 官方商店 / 折扣权益 / 管理后台 都不按 ItemCategory 分组, // 以前这些页也画了分类栏,点了不会有任何变化 —— 玩家看到的就是 // 「点资源、点武器,显示的还是这个东西」。干脆只在市场页画。 if (st.Tab == TabMarket) DrawCatBar(c, player, st, s); DrawTabs(c, player, st, s); switch (st.Tab) { case TabMarket: DrawMarket(c, player, st, s); break; case TabShop: DrawShop(c, player, st, s); break; case TabMine: DrawMine(c, player, st, s); break; case TabMail: DrawMail(c, player, st, s); break; case TabPerks: DrawPerks(c, player, st, s); break; case TabAdmin: DrawAdmin(c, player, st, s); break; } if (_config.Settings.FooterText != null && _config.Settings.FooterText.Length > 0) c.Add(MkLabel(_config.Settings.FooterText, 62, 36, 902, 52, 10, TextAnchor.MiddleLeft, "0.526 0.363 0.237 1", s), PanelName); // ★ 背包上架弹层:画在所有内容之上(它自带整块压暗层,天然浮在顶) if (st.Pick) DrawSellPicker(c, player, st, s); CuiHelper.AddUi(player, c); } // ---------------- 背景 ---------------- private void DrawBackground(CuiElementContainer c, UiState st, float s) { // 钻石商店用冷色变体,跟金币市场区分开;其它页统一用主背景 var file = (st.Tab == TabShop) ? _config.Assets.BackgroundShop : _config.Assets.Background; var sp = AssetSprite(file); if (!string.IsNullOrEmpty(sp)) { Img(c, PanelName, sp, 0, 0, BaseW, BaseH, "1 1 1 0.92", s); // 底部再压一层暗色,保证页签和底部提示的字读得清 Veil(c, PanelName, 0, 0, BaseW, 60, "0.97 0.9 0.78 0.75", s, PanelName + ".bgfoot"); return; } // 降级:纯色玻璃风(功能一模一样,只是没那股利味) Veil(c, PanelName, 0, 0, BaseW, BaseH, "1 0.978 0.942 0.98", s, PanelName + ".bg"); Veil(c, PanelName, 0, 556, BaseW, BaseH, "0.97 0.9 0.78 0.55", s, PanelName + ".bghead"); } // ---------------- 顶部栏 ---------------- private void DrawTopBar(CuiElementContainer c, BasePlayer player, UiState st, float s) { Veil(c, PanelName, TopX0, TopY0, TopX1, TopY1, "0.5 0.3 0.13 0.94", s, PanelName + ".top"); c.Add(MkLabel(_config.Settings.PanelTitle, TopX0 + 14, TopY0, TopX0 + 240, TopY1, 19, TextAnchor.MiddleLeft, "1 0.90 0.70 1", s), PanelName); // 余额:读经济中心(没装就显示 0,并在下面给出明显提示) long gold = EcoBalance(player.userID, CurGold); long dia = EcoBalance(player.userID, CurDiamond); // 金币 Veil(c, PanelName, 262, TopY0 + 11, 418, TopY1 - 11, "1 0.95 0.81 0.92", s, PanelName + ".goldbg"); c.Add(MkLabel("金币", 270, TopY0 + 11, 300, TopY1 - 11, 11, TextAnchor.MiddleLeft, "0.58 0.31 0.02 1", s), PanelName); c.Add(MkLabel(Fmt(gold), 302, TopY0 + 11, 412, TopY1 - 11, 14, TextAnchor.MiddleRight, "0.58 0.32 0.02 1", s), PanelName); // 钻石 Veil(c, PanelName, 426, TopY0 + 11, 582, TopY1 - 11, "0.88 0.96 0.98 0.80", s, PanelName + ".diabg"); c.Add(MkLabel("钻石", 434, TopY0 + 11, 464, TopY1 - 11, 11, TextAnchor.MiddleLeft, "0.03 0.42 0.52 1", s), PanelName); c.Add(MkLabel(Fmt(dia), 466, TopY0 + 11, 576, TopY1 - 11, 14, TextAnchor.MiddleRight, "0.03 0.44 0.55 1", s), PanelName); // 版本 / 授权状态(小字,作者自己看得见) string tip = _authorized ? ("v" + Version.ToString()) : "未授权"; if (!string.IsNullOrEmpty(_updateHint)) tip += " · 有新版本"; if (!EcoReady()) tip = "★ 未装经济中心"; c.Add(MkLabel(tip, 590, TopY0, 700, TopY1, 10, TextAnchor.MiddleLeft, EcoReady() ? "0.97 0.92 0.83 1" : "1 0.88 0.56 1", s), PanelName); // 上架按钮(★ 8 个格位全部留给商品,上架入口放顶栏,不做「第 8 格是 + 号」) bool canSell = CanSell(player) && _config.Market.Enabled; c.Add(MkButton("tradecenter.sell.open", canSell ? "+ 上架物品" : "上架已关闭", 706, TopY0 + 5, 856, TopY1 - 5, 13, canSell ? "0.24 0.48 0.07 1" : "0.78 0.81 0.84 0.85", canSell ? "1 1 1 1" : "0.34 0.37 0.4 1", s), PanelName); // 刷新 + 关闭 c.Add(MkButton("tradecenter.tab " + st.Tab, "刷新", 862, TopY0 + 5, 918, TopY1 - 5, 12, "0.69 0.73 0.77 0.92", "0.2 0.23 0.27 1", s), PanelName); c.Add(MkButton("tradecenter.close", "×", 928, TopY0 + 5, 976, TopY1 - 5, 14, "0.82 0.17 0.25 0.92", "1 1 1 1", s), PanelName); } // ---------------- 右侧分类栏(13 档)---------------- private void DrawCatBar(CuiElementContainer c, BasePlayer player, UiState st, float s) { Veil(c, PanelName, CatX0, CatY0, CatX1, CatY1, "0.44 0.26 0.11 0.86", s, PanelName + ".catbg"); float y1 = CatY1 - 4; for (int i = 0; i < CatOrder.Length; i++) { string key = CatOrder[i]; if (!CatVisible(key)) continue; float cy1 = y1 - i * (CatItemH + CatItemGap); float cy0 = cy1 - CatItemH; bool on = string.Equals(st.Cat, key, StringComparison.OrdinalIgnoreCase); int n = CatCount_Listings(key); // 选中态:暖金描边感(左竖条 + 亮底) string bg = on ? "0.98 0.55 0.2 0.94" : "0.99 0.94 0.85 0.72"; string fg = on ? "0.22 0.09 0.02 1" : "0.367 0.253 0.165 1"; if (on) Veil(c, PanelName, CatX0 + 2, cy0, CatX0 + 5, cy1, "0.98 0.55 0.2 0.95", s, PanelName + ".catmark" + i); c.Add(MkButton("tradecenter.cat " + key, CatCn(key) + " " + n, CatX0 + 2, cy0, CatX1 - 2, cy1, 11, bg, fg, s), PanelName); } } // ---------------- 底部页签 ---------------- private void DrawTabs(CuiElementContainer c, BasePlayer player, UiState st, float s) { string[] names = { "玩家市场", "官方商店", "我的上架", "我的仓库", "折扣权益", "管理后台" }; int shown = IsAdmin(player) ? TabCount : TabCount - 1; // 非管理员不显示「管理后台」 float w = (TabX1 - TabX0) / shown; Veil(c, PanelName, TabX0, TabY0, TabX1, TabY1, "0.44 0.26 0.11 0.90", s, PanelName + ".tabbar"); for (int i = 0; i < shown; i++) { float x0 = TabX0 + i * w + 2; float x1 = TabX0 + (i + 1) * w - 2; bool on = (st.Tab == i); string bg = on ? "0.98 0.55 0.2 0.95" : "0.995 0.96 0.9 0.80"; string fg = on ? "0.22 0.09 0.02 1" : "0.401 0.277 0.18 1"; c.Add(MkButton("tradecenter.tab " + i, names[i], x0, TabY0 + 2, x1, TabY1 - 2, 12, bg, fg, s), PanelName); } } // ---------------- 内容区工具条(当前分类 / 挂单数 / 排序 / 翻页)---------------- private void DrawToolbar(CuiElementContainer c, BasePlayer player, UiState st, float s, int total, int pages) { Veil(c, PanelName, 62, 518, 902, 562, "0.5 0.3 0.13 0.94", s, PanelName + ".bar"); string cat = CatCn(st.Cat); c.Add(MkLabel("" + cat + " 共 " + total + " 单", 74, 518, 340, 562, 12, TextAnchor.MiddleLeft, "1 0.93 0.8 1", s), PanelName); // 排序四档 string[] sn = { "最新", "价低", "价高", "总价" }; for (int i = 0; i < 4; i++) { float x0 = 470 + i * 62; bool on = (st.Sort == i); c.Add(MkButton("tradecenter.sort " + i, sn[i], x0, 526, x0 + 58, 554, 11, on ? "0.98 0.55 0.2 0.95" : "0.995 0.96 0.9 0.85", on ? "0.22 0.09 0.02 1" : "0.401 0.277 0.18 1", s), PanelName); } // 翻页 c.Add(MkButton("tradecenter.page " + (st.Page - 1), "◀", 750, 526, 780, 554, 14, st.Page > 0 ? "0.69 0.73 0.77 0.92" : "0.99 0.94 0.85 0.70", st.Page > 0 ? "0.283 0.195 0.127 1" : "0.38 0.33 0.26 1", s), PanelName); c.Add(MkLabel((st.Page + 1) + " / " + pages, 782, 526, 846, 554, 12, TextAnchor.MiddleCenter, "1 0.93 0.80 1", s), PanelName); c.Add(MkButton("tradecenter.page " + (st.Page + 1), "▶", 848, 526, 878, 554, 14, st.Page < pages - 1 ? "0.69 0.73 0.77 0.92" : "0.99 0.94 0.85 0.70", st.Page < pages - 1 ? "0.283 0.195 0.127 1" : "0.38 0.33 0.26 1", s), PanelName); } // ---------------- 商品卡 ---------------- // 参数:idx = 这一页里的第几个(0..7),决定落在哪个格位 private void DrawCard(CuiElementContainer c, BasePlayer player, UiState st, float s, Listing L, float x0, float y0, int idx) { float x1 = x0 + CardW, y1 = y0 + CardH; string pn = PanelName + ".card" + idx; // 卡片底(半透明,透出货架木纹) Veil(c, PanelName, x0, y0, x1, y1, "0.995 0.965 0.918 0.88", s, pn); // 顶部一条暖金细线,卡片有「嵌在格子里」的层次 Veil(c, PanelName, x0 + 4, y1 - 3, x1 - 4, y1 - 1, "0.84 0.47 0.18 0.85", s, pn + ".line"); // ---- 物品图标(按物品 ID 渲染游戏官方彩色图标)---- int itemId = ItemIdOf(L.Shortname); float ic = 112f; float ix = x0 + (CardW - ic) / 2f; float iy = y0 + 55f; if (itemId != 0) ImgItem(c, PanelName, itemId, ix, iy, ix + ic, iy + ic, "1 1 1 1", s); else Veil(c, PanelName, ix, iy, ix + ic, iy + ic, "0.69 0.73 0.77 0.85", s, pn + ".noicon"); // ---- 名称条 ---- Veil(c, PanelName, x0 + 5, y0 + 31, x1 - 5, y0 + 51, "1 0.97 0.92 0.90", s, pn + ".namebg"); c.Add(MkLabel(ItemName(L.Shortname) + " ×" + Fmt(L.Left), x0 + 8, y0 + 31, x1 - 8, y0 + 51, 11, TextAnchor.MiddleCenter, "0.249 0.172 0.112 1", s), PanelName); // ---- 价格条 ---- // ★ 这里显示的价 = 实际会扣的价(都走 ApplyRate + GoldRate), // 不这么统一的话会出现「面板写 100、VIP 实际扣 90」的糊涂账, // 玩家第一反应是插件黑钱。同一个函数,两处都用,错不了。 Veil(c, PanelName, x0 + 5, y0 + 5, x1 - 5, y0 + 27, "1 0.945 0.855 0.90", s, pn + ".pricebg"); long payUnit = ApplyRate((long)L.UnitPrice, GoldRate(player)); c.Add(MkLabel("" + Fmt((int)payUnit) + " 金币/件", x0 + 8, y0 + 5, x1 - 8, y0 + 27, 12, TextAnchor.MiddleCenter, "0.265 0.183 0.119 1", s), PanelName); // 左上角标明省了多少 —— 这是 VIP 最直观的价值感 if (payUnit < (long)L.UnitPrice) c.Add(MkLabel("省 " + Fmt(L.UnitPrice - (int)payUnit) + "", x0 + 6, y0 + 167, x0 + 86, y0 + 181, 9, TextAnchor.MiddleLeft, "0.16 0.44 0.03 1", s), PanelName); // ---- 角标:剩余时间 ---- c.Add(MkLabel(RemainText(L.ExpiresAt), x0 + 6, y0 + 167, x1 - 6, y0 + 181, 9, TextAnchor.MiddleRight, "0.422 0.291 0.19 1", s), PanelName); // ---- 整张卡可点:默认【买 1 件】---- // ★ 为什么不是「点一下买光整堆」:一单可能挂 5000 件, // 手滑点一下就把钱掏空,这种坑一次就够让人退服了。 // 要一次买完就输 /shop buy <挂单号>(不填件数 = 全部)。 c.Add(MkButton("tradecenter.buy " + L.Id + " 1", "", x0 + 5, y0 + 55, x1 - 5, y0 + 167, 11, "0 0 0 0", "1 1 1 0", s), PanelName); } // 空格位:画一个暗框,让货架看起来是「卖光了的格子」而不是破了 private void DrawEmptySlot(CuiElementContainer c, float s, float x0, float y0) { float x1 = x0 + CardW, y1 = y0 + CardH; Veil(c, PanelName, x0, y0, x1, y1, "1 0.978 0.942 0.42", s, PanelName + ".slot" + Mathf.RoundToInt(x0) + "_" + Mathf.RoundToInt(y0)); Veil(c, PanelName, x0 + 60, y0 + 82, x0 + 112, y0 + 84, "0.94 0.86 0.73 0.55", s, PanelName + ".slotl" + Mathf.RoundToInt(x0) + "_" + Mathf.RoundToInt(y0)); } // 中央浮层提示(空态 / 说明文字统一走它,样式一致) private void DrawCenter(CuiElementContainer c, float s, string title, string line1, string line2) { Veil(c, PanelName, 190, 248, 774, 400, "1 0.978 0.942 0.90", s, PanelName + ".center"); c.Add(MkLabel(title, 200, 340, 764, 384, 17, TextAnchor.MiddleCenter, "0.55 0.29 0.02 1", s), PanelName); if (!string.IsNullOrEmpty(line1)) c.Add(MkLabel(line1, 210, 306, 754, 334, 12, TextAnchor.MiddleCenter, "0.305 0.211 0.137 1", s), PanelName); if (!string.IsNullOrEmpty(line2)) c.Add(MkLabel(line2, 210, 278, 754, 304, 12, TextAnchor.MiddleCenter, "0.425 0.293 0.191 1", s), PanelName); } // 分类计数:这一档现在有多少单(数字越多越吸引人点进去,形成正循环) private int CatCount_Listings(string key) { if (string.Equals(key, "All", StringComparison.OrdinalIgnoreCase)) return _market.Listings.Count; int n = 0; foreach (var L in _market.Listings) { if (L == null || L.Left <= 0) continue; if (string.Equals(CatOf(L.Shortname), key, StringComparison.OrdinalIgnoreCase)) n++; } return n; } // 按当前分类 + 排序,筛出要显示的挂单 private List FilterListings(UiState st) { var list = new List(); foreach (var L in _market.Listings) { if (L == null || L.Left <= 0) continue; if (!string.Equals(st.Cat, "All", StringComparison.OrdinalIgnoreCase) && !string.Equals(CatOf(L.Shortname), st.Cat, StringComparison.OrdinalIgnoreCase)) continue; list.Add(L); } switch (st.Sort) { case 1: list.Sort((a, b) => a.UnitPrice.CompareTo(b.UnitPrice)); break; case 2: list.Sort((a, b) => b.UnitPrice.CompareTo(a.UnitPrice)); break; case 3: list.Sort((a, b) => (b.UnitPrice * (long)b.Left).CompareTo(a.UnitPrice * (long)a.Left)); break; default: list.Sort((a, b) => string.CompareOrdinal(b.ListedAt, a.ListedAt)); // 最新在前 break; } return list; } // ---------------- ① 玩家市场 ---------------- private void DrawMarket(CuiElementContainer c, BasePlayer player, UiState st, float s) { var list = FilterListings(st); int perPage = PageSize; int pages = Mathf.Max(1, (list.Count + perPage - 1) / perPage); if (st.Page > pages - 1) st.Page = pages - 1; if (st.Page < 0) st.Page = 0; int start = st.Page * perPage; DrawToolbar(c, player, st, s, list.Count, pages); for (int i = 0; i < PageSize; i++) { float x0 = GridX0 + (i % GridCols) * GridStepX; float y0 = (i < GridCols) ? GridY1st : GridY2nd; int idx = start + i; if (idx < list.Count) DrawCard(c, player, st, s, list[idx], x0, y0, i); else DrawEmptySlot(c, s, x0, y0); } if (list.Count == 0) { // 带上报当前分类名 —— 玩家点完分类能立刻确认「筛选真的生效了」, // 而不是怀疑插件没反应。 string emptyTitle = _config.Market.Enabled ? (string.Equals(st.Cat, "All", StringComparison.OrdinalIgnoreCase) ? "还没有人上架东西" : "「" + CatCn(st.Cat) + "」分类下还没有货") : "玩家市场已关闭"; DrawCenter(c, s, emptyTitle, _config.Market.Enabled ? "点右上角「+ 上架物品」,把背包里的东西挂上来,做第一个卖家" : "服主可以在配置里把「金币市场 Market -> 启用 Enabled」打开", _config.Market.Enabled ? "★ 低于兜底价的挂单会被系统自动收购,挂上去基本就等于卖掉了" : ""); } // 底栏:系统收购进度(让玩家知道「挂着就有人收」这件事是真的) if (_config.Buyback.Enabled) { int left = Mathf.Max(0, _config.Buyback.DailyBudget - _stats.BuybackSpent); c.Add(MkLabel("系统今日收购额度剩余 " + Fmt(left) + " 金币 " + "(单价不超过 " + _config.Buyback.UnitPrice + " 金币的资源/弹药/建材会被自动收走)", 62, 54, 902, 72, 10, TextAnchor.MiddleLeft, "0.472 0.326 0.212 1", s), PanelName); } } #region 交易内核(金币轨) // ===================================================================== // 时间统一走 UTC 字符串,格式固定,跨时区不会错位 // ★ 为什么不用 ToString("...Z"):Z 在自定义格式串里不是合法说明符, // 某些版本会直接抛异常。这里用纯数位 + 空格,Parse 和 ToString 都稳。 // ===================================================================== private const string DtFmt = "yyyy-MM-dd HH:mm:ss"; private static string NowStr() { return DateTime.UtcNow.ToString(DtFmt, CultureInfo.InvariantCulture); } private static string HoursFromNow(int hours) { if (hours < 1) hours = 1; return DateTime.UtcNow.AddHours(hours).ToString(DtFmt, CultureInfo.InvariantCulture); } private static DateTime DateOf(string s) { DateTime t; if (string.IsNullOrEmpty(s)) return DateTime.MinValue; if (DateTime.TryParseExact(s, DtFmt, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out t)) return t; DateTime t2; if (DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out t2)) return t2; return DateTime.MinValue; } private bool Expired(Listing L) { if (L == null || string.IsNullOrEmpty(L.ExpiresAt)) return false; DateTime t = DateOf(L.ExpiresAt); if (t == DateTime.MinValue) return false; return DateTime.UtcNow > t; } // ===================================================================== // 手续费 / 交易税 // ★ VIP 到 V3 是「0 费率」,这时要直接免单,不能被 MinFee 又拉回去。 // 否则写着「VIP3 免手续费」,结果每单还扣保底,玩家会觉得被骗。 // ===================================================================== private int FeeFor(BasePlayer player, long totalPrice) { if (_config == null || _config.Fees == null) return 0; if (!_config.Fees.EnableListingFee) return 0; if (_config.Fees.Percent <= 0f) return 0; if (totalPrice <= 0) return 0; float rate = _config.Fees.Percent / 100f; switch (VipLevel(player)) { case 1: rate *= _config.Fees.Vip1Rate; break; case 2: rate *= _config.Fees.Vip2Rate; break; case 3: rate *= _config.Fees.Vip3Rate; break; } if (rate <= 0f) return 0; // VIP3 全免,这里直接放行 long fee = (long)Math.Round(totalPrice * rate); if (fee <= 0) return 0; if (_config.Fees.MinFee > 0 && fee < _config.Fees.MinFee) fee = _config.Fees.MinFee; if (_config.Fees.MaxFee > 0 && fee > _config.Fees.MaxFee) fee = _config.Fees.MaxFee; if (fee > totalPrice) fee = totalPrice; return (int)fee; } private int TaxFor(long gross) { if (_config == null || _config.Fees == null) return 0; if (_config.Fees.SalesTaxPercent <= 0f) return 0; if (gross <= 0) return 0; long t = (long)Math.Round(gross * (_config.Fees.SalesTaxPercent / 100f)); if (t < 0) t = 0; if (t > gross) t = gross; return (int)t; } // ===================================================================== // 物品搬运 —— 全部照抄 GiftCenter / LoginRewards 的真机写法 // 创建:ItemManager.CreateByName(短名, 数量, 皮肤) // 入包:player.inventory.GiveItem(item) // 包满:item.Drop(玩家脚下) ← 宁可掉地上也不能丢 // ===================================================================== private int CountItem(BasePlayer player, string shortname, ulong skin) { int sum = 0; if (player == null || player.inventory == null) return 0; var buckets = new List(); if (player.inventory.containerMain != null) buckets.AddRange(player.inventory.containerMain.itemList); if (player.inventory.containerBelt != null) buckets.AddRange(player.inventory.containerBelt.itemList); if (player.inventory.containerWear != null) buckets.AddRange(player.inventory.containerWear.itemList); for (int i = 0; i < buckets.Count; i++) { var it = buckets[i]; if (it == null || it.info == null) continue; if (it.info.shortname != shortname) continue; if (skin != 0UL && it.skin != skin) continue; sum += it.amount; } return sum; } // 背包里的一摞东西(同 shortname + 同皮肤 合并成一摞) private class BagStack { public string Shortname = ""; public ulong Skin; public int Amount; public float Condition; // 只有不可堆叠的(枪 / 甲 / 工具)才有意义 public int ItemId; public bool Filled; // 里面塞了配件/弹药(不允许上架) } // ★ 只扫【主背包 + 腰带】,故意不扫身上穿的那套(containerWear)—— // 否则玩家手一点就把身上穿着的甲卖了,背包里明明还有,体感非常怪。 private List BagStacks(BasePlayer player) { var map = new Dictionary(); var outp = new List(); if (player == null || player.inventory == null) return outp; var boxes = new List(); if (player.inventory.containerMain != null) boxes.Add(player.inventory.containerMain); if (player.inventory.containerBelt != null) boxes.Add(player.inventory.containerBelt); for (int b = 0; b < boxes.Count; b++) { var list = boxes[b].itemList; if (list == null) continue; for (int i = 0; i < list.Count; i++) { var it = list[i]; if (it == null || it.info == null) continue; string sn = it.info.shortname; if (_config.Market.BlockedItems != null && _config.Market.BlockedItems.Contains(sn)) continue; string key = sn + "|" + it.skin; BagStack bs; if (!map.TryGetValue(key, out bs)) { bs = new BagStack { Shortname = sn, Skin = it.skin, ItemId = it.info.itemid }; map[key] = bs; outp.Add(bs); } bs.Amount += it.amount; if (bs.Condition <= 0f && it.condition > 0f) bs.Condition = it.condition; // ★ 夹带:塞了配件的枪挂上去,买家拿到手是空壳 if (_config.Market.BlockFilledContainers && it.contents != null && it.contents.itemList != null && it.contents.itemList.Count > 0) bs.Filled = true; } } // 整摞里只要有任意一个带内容,整摞都不给挂(不然会出现「同一行有的能挂有的不能」) if (_config.Market.BlockFilledContainers) outp.RemoveAll(x => x.Filled); outp.Sort((a, b2) => { int c = string.CompareOrdinal(a.Shortname, b2.Shortname); if (c != 0) return c; return a.Skin.CompareTo(b2.Skin); }); return outp; } // 背包里这一样东西有多少件(口径跟 BagStacks 一致:主背包 + 腰带) private static int BagCountOf(BasePlayer player, string shortname, ulong skin) { int sum = 0; if (player == null || player.inventory == null || string.IsNullOrEmpty(shortname)) return 0; var boxes = new List(); if (player.inventory.containerMain != null) boxes.Add(player.inventory.containerMain); if (player.inventory.containerBelt != null) boxes.Add(player.inventory.containerBelt); for (int b = 0; b < boxes.Count; b++) { var list = boxes[b].itemList; if (list == null) continue; for (int i = 0; i < list.Count; i++) { var it = list[i]; if (it == null || it.info == null) continue; if (it.info.shortname != shortname) continue; if (skin != 0UL && it.skin != skin) continue; sum += it.amount; } } return sum; } // 取一件的耐久给挂单记账(只有枪 / 甲这类不可堆叠的才有意义) private static float BagConditionOf(BasePlayer player, string shortname, ulong skin) { if (player == null || player.inventory == null || string.IsNullOrEmpty(shortname)) return 0f; var boxes = new List(); if (player.inventory.containerMain != null) boxes.Add(player.inventory.containerMain); if (player.inventory.containerBelt != null) boxes.Add(player.inventory.containerBelt); for (int b = 0; b < boxes.Count; b++) { var list = boxes[b].itemList; if (list == null) continue; for (int i = 0; i < list.Count; i++) { var it = list[i]; if (it == null || it.info == null) continue; if (it.info.shortname != shortname) continue; if (skin != 0UL && it.skin != skin) continue; if (it.info.condition.enabled && it.condition > 0f) return it.condition; } } return 0f; } // 从背包里拿走 want 件;返回实际拿到的数量。 // // ★ 这段是【逐行照抄 TCBuild.cs 的 TakeFromContainer】(本机唯一跑真机验证过的消耗写法): // 1) container.itemList.ToList() 先做快照 —— 边遍历边删会漏取/跳项 // 2) 统一走「amount 减到 0 再 RemoveFromContainer」,不调 Remove(0f) // (Remove(0f) 在本机没有任何插件用过,先例为零,不敢用) // 3) 每个容器改完都要 MarkDirty(),否则客户端看到的数量不刷新 private int TakeFromPlayer(BasePlayer player, string shortname, ulong skin, int want) { int taken = 0; if (player == null || player.inventory == null || want <= 0) return 0; var boxes = new List(); if (player.inventory.containerMain != null) boxes.Add(player.inventory.containerMain); if (player.inventory.containerBelt != null) boxes.Add(player.inventory.containerBelt); if (player.inventory.containerWear != null) boxes.Add(player.inventory.containerWear); for (int b = 0; b < boxes.Count; b++) { var box = boxes[b]; var list = box.itemList.ToList(); for (int i = 0; i < list.Count; i++) { if (taken >= want) break; var it = list[i]; if (it == null || it.info == null) continue; if (it.info.shortname != shortname) continue; if (skin != 0UL && it.skin != skin) continue; int toTake = Mathf.Min(it.amount, want - taken); if (toTake <= 0) continue; it.amount -= toTake; it.MarkDirty(); if (it.amount <= 0) it.RemoveFromContainer(); taken += toTake; if (taken >= want) break; } box.MarkDirty(); if (taken >= want) break; } return taken; } // 给玩家发东西。背包塞不下就掉在脚下 —— 掉地上有可能被别人捡, // 但【东西一定还在游戏世界里】,比直接消失强一万倍。 private void GiveTo(BasePlayer player, string shortname, ulong skin, int amount, float condition) { if (player == null || amount <= 0) return; var def = ItemManager.FindItemDefinition(shortname); if (def == null) { PrintWarning("[交易] 物品 " + shortname + " 在游戏里不存在,已放进仓库等服主处理"); MailTo(player.userID, shortname, skin, amount, condition, "物品异常待核实"); return; } int max = def.stackable; if (max <= 0) max = 1; int left = amount; while (left > 0) { int take = left > max ? max : left; var item = ItemManager.CreateByName(shortname, take, skin); if (item == null) break; if (condition > 0f && max == 1 && def.condition.enabled) item.condition = condition; if (!player.inventory.GiveItem(item)) item.Drop(player.GetDropPosition(), player.GetDropVelocity()); left -= take; } } private void MailTo(ulong uid, string shortname, ulong skin, int amount, float condition, string from) { if (amount <= 0) return; var r = Rec(uid, true); if (r == null) return; if (r.Mail == null) r.Mail = new List(); r.Mail.Add(new MailItem { Shortname = shortname, Skin = skin, Amount = amount, Condition = condition, From = from, At = NowStr() }); MarkDirty(); } // ===================================================================== // 挂单查找 / 个人在售计数 // ===================================================================== private Listing FindListing(string id) { if (_market == null || _market.Listings == null || string.IsNullOrEmpty(id)) return null; for (int i = 0; i < _market.Listings.Count; i++) { var L = _market.Listings[i]; if (L != null && L.Id == id) return L; } return null; } private int ActiveCount(ulong uid) { int n = 0; if (_market == null || _market.Listings == null) return 0; for (int i = 0; i < _market.Listings.Count; i++) { var L = _market.Listings[i]; if (L != null && L.Seller == uid && L.Left > 0 && !Expired(L)) n++; } return n; } private string NextListId() { if (_market == null) return "L0"; _market.Seq++; return "L" + _market.Seq; } // ===================================================================== // 上架 // /shop sell <单价> [件数] [小时] // 东西在挂单期间【由插件托管】(从背包里真拿走),不是虚拟数字 —— // 否则卖家一边挂着一边自己又用掉,买家买到的就是空气。 // ===================================================================== private void TrySell(BasePlayer player, string[] args, int from) { if (player == null) return; if (!ShopEnabled()) { Msg(player, "交易商城已经关闭了,暂时不能用。"); return; } if (!EnsureAuthorized(player)) return; if (!CanSell(player)) { Msg(player, "你现在不能上架(可能被管理员限制了)。"); return; } if (!_config.Market.Enabled) { Msg(player, "玩家市场已关闭,暂时不能上架。"); return; } var held = player.GetActiveItem(); if (held == null || held.info == null) { Msg(player, "手里先拿上要卖的东西,再用 /shop sell 单价;" + "想省事就打开商城点右上角「+ 上架物品」,直接从背包里挑。"); return; } string sn = held.info.shortname; ulong skin = held.skin; if (_config.Market.BlockedItems != null && _config.Market.BlockedItems.Contains(sn)) { Msg(player, ItemName(sn) + " 被服主列入禁止上架清单,卖不了。"); return; } // ★ 夹带检查:一个塞了配件的枪 / 装了东西的包,托管的只是空壳的话, // 买家拿到手里的东西会少零件。这种一律不让挂。 if (_config.Market.BlockFilledContainers && held.contents != null && held.contents.itemList != null && held.contents.itemList.Count > 0) { Msg(player, ItemName(sn) + " 里面有配件/弹药,挂上去会丢,请先清空再上架。"); return; } // ---- 单价 ---- if (args == null || args.Length <= from) { Msg(player, "用法:/shop sell 单价 [件数] [小时] 当前手上 " + ItemName(sn) + " ×" + held.amount); return; } int unit; if (!int.TryParse(args[from], out unit) || unit <= 0) { Msg(player, "单价得是正整数哦,例如 /shop sell 25。"); return; } // ---- 件数 ---- int qty = 0; if (args.Length > from + 1) int.TryParse(args[from + 1], out qty); if (qty <= 0) qty = held.amount; // 不填 = 整堆 // ---- 挂单时长 ---- int hours = _config.Market.DefaultHours; if (args.Length > from + 2) { int h; if (int.TryParse(args[from + 2], out h) && h > 0) { if (_config.Market.HoursOptions == null || _config.Market.HoursOptions.Count == 0 || _config.Market.HoursOptions.Contains(h)) hours = h; else Msg(player, "时长只能用 " + string.Join("/", _config.Market.HoursOptions.ConvertAll(x => x.ToString()).ToArray()) + " 小时,已按默认 " + _config.Market.DefaultHours + " 小时挂出。"); } } float cond = (held.info != null && held.info.condition.enabled) ? held.condition : 0f; TrySellCore(player, sn, skin, cond, qty, unit, hours); } // ★ 上架的【唯一实现】:聊天命令 和 背包面板 都走这里。 // 价格校验 / 件数夹取 / 手续费 / 托管取货 只写一份 —— // 分成两份迟早会长歪(「命令上架不扣手续费」「面板上架不限量」这种分叉)。 private bool TrySellCore(BasePlayer player, string sn, ulong skin, float condition, int qty, int unit, int hours) { if (player == null || string.IsNullOrEmpty(sn)) return false; if (!ShopEnabled()) { Msg(player, "交易商城已经关闭了,暂时不能用。"); return false; } if (!EnsureAuthorized(player)) return false; if (!CanSell(player)) { Msg(player, "你现在不能上架(可能被管理员限制了)。"); return false; } if (!_config.Market.Enabled) { Msg(player, "玩家市场已关闭,暂时不能上架。"); return false; } if (_config.Market.BlockedItems != null && _config.Market.BlockedItems.Contains(sn)) { Msg(player, ItemName(sn) + " 被服主列入禁止上架清单,卖不了。"); return false; } // ---- 单价 ---- if (unit <= 0) { Msg(player, "请先填单价。"); return false; } if (unit < _config.Market.MinUnitPrice) { Msg(player, "单价不能低于 " + _config.Market.MinUnitPrice + " 金币。"); return false; } if (_config.Market.MaxUnitPrice > 0 && unit > _config.Market.MaxUnitPrice) { Msg(player, "单价不能高于 " + Fmt(_config.Market.MaxUnitPrice) + " 金币。"); return false; } // ---- 件数 ---- int have = CountItem(player, sn, skin); if (qty <= 0) qty = have; // 不填 = 整堆 if (qty > have) qty = have; // 不能卖出超过身上有的 if (qty <= 0) { Msg(player, "你身上没有这么多 " + ItemName(sn) + "。"); return false; } if (_config.Market.MaxQuantityPerListing > 0 && qty > _config.Market.MaxQuantityPerListing) { qty = _config.Market.MaxQuantityPerListing; Msg(player, "单笔最多挂 " + Fmt(qty) + " 件,已按上限给你挂了。"); } // ---- 挂单时长 ---- if (_config.Market.HoursOptions != null && _config.Market.HoursOptions.Count > 0 && !_config.Market.HoursOptions.Contains(hours)) hours = _config.Market.DefaultHours; if (hours < 1) hours = 1; // ---- 在售上限 ---- int active = ActiveCount(player.userID); if (_config.Market.MaxListingsPerPlayer > 0 && active >= _config.Market.MaxListingsPerPlayer) { Msg(player, "你最多同时挂 " + _config.Market.MaxListingsPerPlayer + " 单,先等几单卖掉或去「我的上架」下架几个。"); return false; } // ---- 手续费:先扣钱,再把东西收走(收不到会全额退)---- long gross = (long)unit * (long)qty; int fee = FeeFor(player, gross); long bal = EcoBalance(player.userID, CurGold); if (bal < fee) { Msg(player, "上架手续费需要 " + Fmt(fee) + " 金币,你只有 " + Fmt((int)bal) + "。"); return false; } if (fee > 0 && !EcoTake(player.userID, CurGold, fee, "商城上架手续费")) { Msg(player, "手续费扣除失败,上架取消。"); return false; } int got = TakeFromPlayer(player, sn, skin, qty); if (got <= 0) { if (fee > 0) EcoAdd(player.userID, CurGold, fee, "商城上架失败退还手续费"); Msg(player, "没能从背包里取到东西,上架取消(手续费已退)。"); return false; } if (got < qty) { // 实际拿到的比预期少 → 手续费按实际数量重算,差额退回 int fee2 = FeeFor(player, (long)unit * (long)got); if (fee2 < fee) EcoAdd(player.userID, CurGold, fee - fee2, "商城上架手续费差额退还"); qty = got; fee = fee2; } var L = new Listing { Id = NextListId(), Seller = player.userID, SellerName = player.displayName, Shortname = sn, Skin = skin, Amount = qty, Left = qty, UnitPrice = unit, ListedAt = NowStr(), ExpiresAt = HoursFromNow(hours), FeePaid = fee, Condition = condition }; _market.Listings.Add(L); if (fee > 0) _stats.TotalFee += fee; MarkDirty(); Msg(player, "已上架:" + ItemName(sn) + " ×" + Fmt(qty) + " 单价 " + Fmt(unit) + " 金币 " + hours + " 小时内有效" + (fee > 0 ? "(手续费 " + Fmt(fee) + " 金币)" : "(VIP 免手续费)")); return true; } // ===================================================================== // 购买 —— 顺序是关键 // // ★★ 为什么是「先给卖家 → 再扣买家 → 最后发货」这个看起来别扭的顺序: // 反过来的话(先发货再结算),一旦给卖家打钱失败, // 东西已经在买家背包里了、退不回来 → 等于白送。 // // 按这个顺序,每一步失败都能干净回滚: // 1) 经济中心没激活 → 直接拦住,一分钱不动(EcoAuthorized 零副作用探测) // 2) 余额不足 → 直接拦住,一分钱不动 // 3) 给卖家打钱失败 → 直接拦住,一分钱不动 // 4) 扣买家失败 → 把刚给卖家的钱再扣回来,然后中止 // 5) 发货 → 一定能成功(背包满就掉在脚下) // ===================================================================== private void TryBuy(BasePlayer buyer, string id, int qty) { if (buyer == null) return; if (!ShopEnabled()) { Msg(buyer, "交易商城已经关闭了,暂时不能用。"); return; } if (!EnsureAuthorized(buyer)) return; if (!_config.Market.Enabled) { Msg(buyer, "玩家市场已关闭。"); return; } var L = FindListing(id); if (L == null) { Msg(buyer, "这单已经不在了(可能刚被别人买走或已下架)。"); return; } if (Expired(L)) { SweepExpired(); Msg(buyer, "这单已过期,重新看看别的吧。"); return; } if (L.Left <= 0) { Msg(buyer, "这单已经被买完了。"); return; } if (L.Seller == buyer.userID) { Msg(buyer, "这是你自己挂的单,去「我的上架」里下架就行,不用自己买。"); return; } if (qty <= 0) qty = L.Left; if (qty > L.Left) qty = L.Left; // 成交价走同一套折扣算法,和面板显示的是一个数 long payUnit = ApplyRate((long)L.UnitPrice, GoldRate(buyer)); long total = payUnit * (long)qty; long bal = EcoBalance(buyer.userID, CurGold); if (bal < total) { Msg(buyer, "金币不够:需要 " + Fmt((int)total) + ",你只有 " + Fmt((int)bal) + "。"); return; } // 1) 经济中心没激活 → 扣款会成功但卖家收不到,直接拦住最干净 if (!EcoAuthorized()) { Msg(buyer, "交易暂时不可用:经济中心没有激活(这样会发生「买家扣了钱、卖家收不到」)。请联系服主。"); PrintWarning("[交易] 拦截一笔交易:经济中心未激活,否则会扣了买家的钱却发不出货钱。"); return; } string itemName = ItemName(L.Shortname); // 2) 先给卖家结算 int tax = TaxFor(total); long toSeller = total - tax; if (toSeller > 0) { if (!EcoAdd(L.Seller, CurGold, toSeller, "商城卖出 " + itemName + " ×" + qty)) { Msg(buyer, "交易失败(没能给卖家结算),一分钱没扣,请稍后再试。"); return; } } // 3) 再扣买家;失败就把刚才给卖家的钱退回去 if (!EcoTake(buyer.userID, CurGold, total, "商城购买 " + itemName + " ×" + qty)) { if (toSeller > 0) EcoTake(L.Seller, CurGold, toSeller, "商城交易回滚"); Msg(buyer, "扣款失败,交易已取消。"); return; } // 4) 发货(背包满会自动掉在脚下,不会丢) GiveTo(buyer, L.Shortname, L.Skin, qty, L.Condition); L.Left -= qty; // 5) 账本 if (tax > 0) _stats.TotalFee += tax; _stats.TotalTrades++; _stats.TotalGold += (int)Math.Min(total, int.MaxValue); string key = L.Seller.ToString(); int soldPrev = 0; if (_stats.Sellers != null) _stats.Sellers.TryGetValue(key, out soldPrev); if (_stats.Sellers != null) _stats.Sellers[key] = soldPrev + qty; var rb = Rec(buyer.userID, true); if (rb != null) rb.SpentGold += (int)Math.Min(total, int.MaxValue); var rs = Rec(L.Seller, true); if (rs != null) { rs.SoldGold += (int)Math.Min(toSeller, int.MaxValue); if (tax > 0) rs.FeePaid += tax; } // 卖家在线就当场通知他(会做人情的就是这一句) string tip = "已买入 " + itemName + " ×" + Fmt(qty) + " 实付 " + Fmt((int)total) + " 金币"; if (payUnit < (long)L.UnitPrice) tip += "(VIP 折扣已生效)"; Msg(buyer, tip); NotifySeller(L.Seller, "有人在商城买了你的 " + itemName + " ×" + Fmt(qty) + ",金币 " + Fmt((int)toSeller) + " 已到账" + (tax > 0 ? "(已扣 " + Fmt(tax) + " 交易税)" : "")); if (L.Left <= 0) { _market.Listings.Remove(L); } MarkDirty(); } private void NotifySeller(ulong sellerId, string text) { try { var sp = BasePlayer.FindByID(sellerId); if (sp != null && sp.IsConnected) Msg(sp, text); } catch (Exception) { } } // ===================================================================== // 下架 / 改价 // ===================================================================== private void TryCancel(BasePlayer seller, string id) { if (seller == null) return; if (!ShopEnabled()) { Msg(seller, "交易商城已经关闭了,暂时不能用。"); return; } if (!EnsureAuthorized(seller)) return; var L = FindListing(id); if (L == null) { Msg(seller, "找不到这一单。"); return; } if (L.Seller != seller.userID && !IsAdmin(seller)) { Msg(seller, "那不是你的单。"); return; } int qty = L.Left; if (qty > 0) GiveTo(seller, L.Shortname, L.Skin, qty, L.Condition); float rf = _config.Fees.RefundOnCancel; if (rf < 0f) rf = 0f; if (rf > 1f) rf = 1f; int back = (int)Math.Round(L.FeePaid * rf); if (back > 0) EcoAdd(seller.userID, CurGold, back, "商城下架退还手续费"); _market.Listings.Remove(L); MarkDirty(); Msg(seller, "已下架:" + ItemName(L.Shortname) + " ×" + Fmt(qty) + (back > 0 ? ",手续费退回 " + Fmt(back) + " 金币" : "")); } private void TryReprice(BasePlayer seller, string id, int newUnit) { if (seller == null) return; if (!ShopEnabled()) { Msg(seller, "交易商城已经关闭了,暂时不能用。"); return; } if (!EnsureAuthorized(seller)) return; var L = FindListing(id); if (L == null) { Msg(seller, "找不到这一单。"); return; } if (L.Seller != seller.userID && !IsAdmin(seller)) { Msg(seller, "那不是你的单。"); return; } if (Expired(L)) { Msg(seller, "这单已过期。"); return; } if (newUnit < _config.Market.MinUnitPrice) { Msg(seller, "单价不能低于 " + _config.Market.MinUnitPrice + " 金币。"); return; } if (_config.Market.MaxUnitPrice > 0 && newUnit > _config.Market.MaxUnitPrice) { Msg(seller, "单价不能高于 " + Fmt(_config.Market.MaxUnitPrice) + " 金币。"); return; } int old = L.UnitPrice; L.UnitPrice = newUnit; MarkDirty(); Msg(seller, "已改价:" + ItemName(L.Shortname) + " " + Fmt(old) + " → " + Fmt(newUnit) + " 金币/件"); } // ===================================================================== // 仓库领取 // ===================================================================== private void TryTakeMail(BasePlayer p, bool all) { if (p == null) return; if (!ShopEnabled()) { Msg(p, "交易商城已经关闭了,暂时不能用。"); return; } if (!EnsureAuthorized(p)) return; var r = Rec(p.userID, false); if (r == null || r.Mail == null || r.Mail.Count == 0) { Msg(p, "仓库是空的。"); return; } int take = all ? r.Mail.Count : 1; int done = 0; for (int i = 0; i < take; i++) { if (r.Mail.Count == 0) break; var m = r.Mail[0]; GiveTo(p, m.Shortname, m.Skin, m.Amount, m.Condition); r.Mail.RemoveAt(0); done++; } MarkDirty(); Msg(p, "已从仓库取出 " + Fmt(done) + " 件。"); } // ===================================================================== // 过期清扫 // 过期单按 RefundOnExpire 退手续费,物品进卖家仓库(不是消失!) // 这样玩家敢挂、敢试错 —— 货最后总能回到自己手里。 // ===================================================================== private void SweepExpired() { if (_market == null || _market.Listings == null) return; int swept = 0; for (int i = _market.Listings.Count - 1; i >= 0; i--) { var L = _market.Listings[i]; if (L == null) { _market.Listings.RemoveAt(i); continue; } if (!Expired(L)) continue; if (L.Left > 0) MailTo(L.Seller, L.Shortname, L.Skin, L.Left, L.Condition, "挂单到期退回"); float rf = _config.Fees.RefundOnExpire; if (rf < 0f) rf = 0f; if (rf > 1f) rf = 1f; int back = (int)Math.Round(L.FeePaid * rf); if (back > 0) EcoAdd(L.Seller, CurGold, back, "商城挂单到期退还手续费"); try { var sp = BasePlayer.FindByID(L.Seller); if (sp != null && sp.IsConnected) Msg(sp, "你有 1 单挂了太久到期了:" + ItemName(L.Shortname) + " ×" + Fmt(L.Left) + " 已放进「我的仓库」,记得去取。"); } catch (Exception) { } _market.Listings.RemoveAt(i); swept++; } if (swept > 0) MarkDirty(); } #endregion #region 官方商店(钻石轨) // ===================================================================== // 折后单价 —— 面板显示与实际扣款必须调用同一个函数 // ★ 这个「同源」是硬要求:显示一个价、扣另一个价,玩家会立刻觉得被坑, // 而且是最难排查的一类 bug(代码没报错,只是两个数不一致)。 // 三层折扣依次相乘:VIP 档位 -> 单品折扣 -> 保护线 // ===================================================================== private long ShopUnitPrice(BasePlayer player, ShopItem it) { if (it == null) return 0; long basePrice = it.Price; if (basePrice <= 0) return 0; long rate = 10000L; // 10000 = 原价 if (it.VipDiscount) rate = DiamondRate(player); // 限定可以不吃 VIP 折扣 // 单品折扣:1f = 不打折;0.8f = 再打 8 折 float own = it.OwnDiscount; if (own > 0f && Math.Abs(own - 1f) > 0.0001f) { long r2 = (long)Math.Round(rate * own); if (r2 < 1000L) r2 = 1000L; // 保护线:不低于 1 折 rate = r2; } return ApplyRate(basePrice, rate); } private ShopItem FindShop(string id) { if (_shop == null || _shop.Items == null || string.IsNullOrEmpty(id)) return null; for (int i = 0; i < _shop.Items.Count; i++) { var it = _shop.Items[i]; if (it != null && it.Id == id) return it; } return null; } private int BoughtCount(ulong uid, string shopId) { var r = Rec(uid, false); if (r == null || r.Bought == null) return 0; int got = 0; r.Bought.TryGetValue(shopId, out got); return got; } // 还能买几份(同时考虑库存 与 每人限购) private int MaxBuyable(BasePlayer player, ShopItem it) { if (it == null || player == null) return 0; if (it.Stock == 0) return 0; // 卖完了 int cap = it.Stock > 0 ? it.Stock : int.MaxValue; if (it.LimitPerPlayer > 0) { int room = it.LimitPerPlayer - BoughtCount(player.userID, it.Id); if (room <= 0) return 0; if (room < cap) cap = room; } return cap; } // ===================================================================== // 购买官方商品 // ★ 和金币轨的区别:这里没有卖家,是【系统】在卖。 // 钻石扣掉就凭空消失 —— 这正是服主要的效果:钻石是回收手段。 // 所以顺序简化成「先扣 -> 再发货」: // 1) 各种前置校验(不通过则一分钱不动) // 2) 扣钻石 // 3) 发货(背包满自动掉脚下,这一步不会失败) // 4) 库存 / 限购 / 账本 // ===================================================================== private void TryBuyShop(BasePlayer buyer, string id, int qty) { if (buyer == null) return; if (!ShopEnabled()) { Msg(buyer, "交易商城已经关闭了,暂时不能用。"); return; } if (!EnsureAuthorized(buyer)) return; if (!CanUse(buyer)) { Msg(buyer, "你现在不能使用商城。"); return; } if (!_config.Shop.Enabled) { Msg(buyer, "官方商店已关闭。"); return; } var it = FindShop(id); if (it == null) { Msg(buyer, "这件商品已经下架了。"); return; } if (!it.Enabled) { Msg(buyer, "这件商品暂时停售。"); return; } if (!InTimeWindow(it)) { Msg(buyer, "这件商品不在售卖时间内。"); return; } if (!EcoReady()) { Msg(buyer, "官方商店暂时不能用:没检测到「经济中心 EcoCenter」插件。"); return; } int per = it.Amount; if (per <= 0) per = 1; int avail = MaxBuyable(buyer, it); if (avail <= 0) { if (it.Stock == 0) Msg(buyer, ItemName(it.Shortname) + " 已经卖完了。"); else Msg(buyer, "你已达到这件商品的限购上限(每人 " + it.LimitPerPlayer + " 份)。"); return; } if (qty <= 0) qty = 1; // 面板上点卡片 = 买 1 份 if (qty > avail) qty = avail; long unit = ShopUnitPrice(buyer, it); long total = unit * (long)qty; long bal = EcoBalance(buyer.userID, CurDiamond); if (bal < total) { Msg(buyer, "钻石不够:需要 " + Fmt((int)total) + ",你只有 " + Fmt((int)bal) + "。(钻石服主发放,可以留意服里的活动)"); return; } string itemName = ItemName(it.Shortname); // 1) 先扣钻石 if (!EcoTake(buyer.userID, CurDiamond, total, "商城购买 " + itemName + " ×" + (per * qty))) { Msg(buyer, "扣钻石失败,交易已取消,一分钱没扣。"); return; } // 2) 发货。★ int 上溢保护:限购库存都可能很大,先夹到 int 上限 long give = (long)per * (long)qty; if (give > 2000000000L) give = 2000000000L; GiveTo(buyer, it.Shortname, it.Skin, (int)give, 0f); // 3) 库存递减(Stock < 0 = 无限,不减) if (it.Stock > 0) { it.Stock -= qty; if (it.Stock < 0) it.Stock = 0; } it.Sold += qty; // 4) 限购记账 + 账本 var r = Rec(buyer.userID, true); if (r != null) { if (r.Bought == null) r.Bought = new Dictionary(); int prev = 0; r.Bought.TryGetValue(it.Id, out prev); r.Bought[it.Id] = prev + qty; r.SpentDiamond += (int)Math.Min(total, int.MaxValue); } _stats.TotalDiamond += (int)Math.Min(total, int.MaxValue); MarkDirty(); string tip = "已购 " + itemName + " ×" + Fmt(give) + " 花费 " + Fmt((int)total) + " 钻石"; if (unit < (long)it.Price) tip += "(折扣已生效)"; Msg(buyer, tip); // 卖完低于阈值提醒服主补货(限购商品很常见) if (it.Stock >= 0 && it.Stock <= 3) PrintWarning("[官方商店] " + itemName + "(" + it.Id + ")库存只剩 " + it.Stock + " 份,卖完会自动下架。用 tradecenter.stock 补货。"); } // ===================================================================== // /shop add —— 管理员手持物品一键做成官方商品 // // ★ 为什么不从背包拿走:官方商品是【无限供应】的模板, // 每次被买都会凭空造一个新的出来(相当于服里的常驻补给)。 // 如果要「限量」,用 [库存] 参数卡,而不是扣服主的货。 // ★ 工坊皮肤:自动读手持物的 skin,客户端会直接显示皮肤图。 // ===================================================================== private void CmdAddShop(BasePlayer admin, string[] args, int from) { if (admin == null) return; if (!ShopEnabled()) { Msg(admin, "交易商城已经关闭了,暂时不能用。"); return; } if (!EnsureAuthorized(admin)) return; if (!IsAdmin(admin)) { Msg(admin, "只有管理员能上架官方商品。"); return; } if (!_config.Shop.Enabled) { Msg(admin, "官方商店在配置里关着。先把配置里「官方商店 Shop -> 启用 Enabled」打开,再 oxide.reload TradeCenter。"); return; } var held = admin.GetActiveItem(); if (held == null || held.info == null) { Msg(admin, "手里先拿上要上架的东西(支持带皮肤的工坊物品),再用 /shop add <钻石价>。"); return; } string sn = held.info.shortname; if (args == null || args.Length <= from) { Msg(admin, "用法:/shop add 钻石价 [库存] [每份件数]"); Msg(admin, " 当前手持:" + ItemName(sn) + " ×" + held.amount + (held.skin != 0UL ? "(皮肤 " + held.skin + ")" : "") + " 例如 /shop add 30 表示 30 钻石买一份"); return; } int price; if (!int.TryParse(args[from], out price) || price <= 0) { Msg(admin, "钻石价得是正整数,例如 /shop add 30。"); return; } // 库存:不填 = 无限 int stock = -1; if (args.Length > from + 1) { int sv; if (int.TryParse(args[from + 1], out sv)) { if (sv == 0) stock = 0; else if (sv < -1) stock = -1; else stock = sv; } } // 每份件数:不填 = 手里这堆(资源类通常整堆卖) int per = held.amount; if (args.Length > from + 2) { int pv; if (int.TryParse(args[from + 2], out pv) && pv > 0) per = pv; } if (per <= 0) per = 1; int orderNo = 1; for (int i = 0; i < _shop.Items.Count; i++) if (_shop.Items[i] != null && _shop.Items[i].Order >= orderNo) orderNo = _shop.Items[i].Order + 1; var it = new ShopItem { Id = NextShopId(), Shortname = sn, Skin = held.skin, Name = ItemName(sn), Amount = per, Price = price, OldPrice = 0, Stock = stock, LimitPerPlayer = 0, Category = "", Desc = "", Order = orderNo, Enabled = true, VipDiscount = true, OwnDiscount = 1f }; _shop.Items.Add(it); MarkDirty(); Msg(admin, "已上架官方商品:" + ItemName(sn) + " ×" + Fmt(per) + " 售价 " + Fmt(price) + " 钻石" + (stock >= 0 ? " 库存 " + stock + " 份" : " 不限量") + (held.skin != 0UL ? " 皮肤 " + held.skin : "") + " 商品号 " + it.Id); Msg(admin, "改价改库存:tradecenter.setprice " + it.Id + " 新价 " + "tradecenter.stock " + it.Id + " 库存"); } #endregion #region 系统兜底收购 // ===================================================================== // 你说的「1 金币兜底」落地在这儿: // 玩家挂了没人要的低价资源/弹药/建材,系统按【他自己挂的价】掏钱收走。 // // ★★ 为什么按「卖家挂的价」付,而不是统一付 1 金币: // 如果谁挂 3 金币/件,系统强行 1 金币收走,卖家会觉得被抢。 // 按挂单价付 = 只要定价合理就一定卖得掉 —— 这才叫兜底, // 也才能真正抬高玩家的上架意愿(挂了就一定有人收)。 // 上限由「兜底单价 x 倍率」卡住,挂 100 金币一件的别指望系统收。 // // 收来的货默认【直接销毁】= 金币兑出去、物资从市场消失 —— // 一边给卖家金币,一边回收过剩物资,两边都压通胀。 // (DestroyItems = false 则退回卖家仓库,等于系统倒货,服主自己取舍) // // 三重保险:每日预算 / 每日单数 / 单笔件数,任一用尽当天就不再收。 // ===================================================================== private void DoBuyback() { if (_config == null || _config.Buyback == null) return; if (!_config.Buyback.Enabled) return; if (!ShopEnabled()) return; // ★ 总开关关着 -> 不收货(这是往外花金币的动作) if (!_authorized) return; // 未授权不干活 if (!EcoReady() || !EcoAuthorized()) return; // 经济中心没激活 -> 卖家收不到钱,宁可不收 if (_market == null || _market.Listings == null) return; ResetDailyIfNeeded(); int budgetLeft = _config.Buyback.DailyBudget - _stats.BuybackSpent; int orderLeft = _config.Buyback.DailyMaxOrders - _stats.BuybackOrders; if (budgetLeft <= 0 || orderLeft <= 0) return; float mult = _config.Buyback.MaxPriceMultiplier; if (mult <= 0f) mult = 1f; long maxUnit = (long)Math.Round(_config.Buyback.UnitPrice * mult); if (maxUnit < 1L) maxUnit = 1L; // ---- 挑候选:分类对得上 + 单价在兜底区间内 ---- var cand = new List(); for (int i = 0; i < _market.Listings.Count; i++) { var L = _market.Listings[i]; if (L == null || L.Left <= 0 || Expired(L)) continue; if ((long)L.UnitPrice > maxUnit) continue; if (!InBuybackCategory(L.Shortname)) continue; cand.Add(L); } if (cand.Count == 0) return; // 单价升序:同样预算优先收最便宜的,能照顾到最多的卖家。 // 同价时优先收大单 —— 省下宝贵的「单数」配额。 cand.Sort((a, b) => a.UnitPrice != b.UnitPrice ? a.UnitPrice.CompareTo(b.UnitPrice) : b.Left.CompareTo(a.Left)); int done = 0, gotItems = 0, spent = 0; for (int i = 0; i < cand.Count; i++) { if (budgetLeft <= 0 || orderLeft <= 0) break; var L = cand[i]; long unit = L.UnitPrice > 0 ? (long)L.UnitPrice : 1L; // 件数:受「单笔上限」和「剩余预算」双重截断。 // ★ 必须按预算截断而不是整单全收 —— 否则最后一单会把当天预算打穿。 int qty = L.Left; if (_config.Buyback.MaxQuantityPerOrder > 0 && qty > _config.Buyback.MaxQuantityPerOrder) qty = _config.Buyback.MaxQuantityPerOrder; long afford = (long)budgetLeft / unit; if (afford < (long)qty) qty = (int)afford; if (qty <= 0) continue; long cost = unit * (long)qty; string nm = ItemName(L.Shortname); // 1) 先给卖家打钱。失败就跳过这一单 —— 钱没出去,最安全。 if (!EcoAdd(L.Seller, CurGold, cost, "系统兜底收购 " + nm + " ×" + qty)) continue; // 2) 货物处置:销毁 = 什么都不做(货已从卖家背包托管过来,随单消失) if (!_config.Buyback.DestroyItems) MailTo(L.Seller, L.Shortname, L.Skin, qty, L.Condition, "系统收购退还"); // 3) 挂单结清 L.Left -= qty; if (L.Left <= 0) _market.Listings.Remove(L); // 4) 账本 int c = (int)cost; spent += c; budgetLeft -= c; orderLeft--; done++; gotItems += qty; NotifySeller(L.Seller, "系统收购:你的 " + nm + " ×" + Fmt(qty) + " 已被官方收走," + Fmt(c) + " 金币 已到账"); } if (done > 0) { _stats.BuybackSpent += spent; _stats.BuybackOrders += done; _stats.BuybackItems += gotItems; MarkDirty(); PrintWarning("[兜底收购] 本轮 " + done + " 单 / " + Fmt(gotItems) + " 件,支出 " + Fmt(spent) + " 金币;今日累计 " + Fmt(_stats.BuybackSpent) + " / " + Fmt(_config.Buyback.DailyBudget) + " 金币(" + _stats.BuybackOrders + " / " + _config.Buyback.DailyMaxOrders + " 单)"); if (_config.Buyback.BroadcastOnBuy) PrintWarning("[兜底收购] " + Fmt(gotItems) + " 件过剩物资已被官方回收,资金流入市场,挂东西不用担心砸手里。"); } } private bool InBuybackCategory(string shortname) { if (_config == null || _config.Buyback == null || _config.Buyback.Categories == null) return false; if (_config.Buyback.Categories.Count == 0) return false; string cat = CatOf(shortname); for (int i = 0; i < _config.Buyback.Categories.Count; i++) if (string.Equals(_config.Buyback.Categories[i], cat, StringComparison.OrdinalIgnoreCase)) return true; return false; } #endregion #region 管理命令 // 服务器控制台 / RCON 用的回话:有玩家就发聊天,控制台则去掉富文本标签 private void ReplyArg(BasePlayer p, string text) { if (p != null) { Msg(p, text); return; } Puts(StripTags(text)); } private static string StripTags(string s) { if (string.IsNullOrEmpty(s)) return ""; string t = s.Replace("", "]"); return t; } // ★ 照抄 GiftCenter 的 ResolveTargetId(本机跑真机验证过的写法): // 先按 SteamID 解析,失败再按名字在线找。 private static ulong ResolveTargetId(string input) { ulong id; if (ulong.TryParse(input, out id)) return id; var p = BasePlayer.Find(input); return p != null ? p.userID : 0UL; } // 管理命令的统一门岗:控制台/RCON 一律放行,玩家必须有管理员身份 private bool AdminGate(BasePlayer p, ConsoleSystem.Arg arg) { if (p == null) return true; // 服务器控制台 if (IsAdmin(p)) return true; if (p != null) Msg(p, "只有管理员能用这条命令。"); return false; } // ---- tradecenter.setprice <商品号> <新钻石价> ---- [ConsoleCommand("tradecenter.setprice")] private void CcSetPrice(ConsoleSystem.Arg arg) { var p = arg != null ? arg.Player() : null; if (!AdminGate(p, arg)) return; string id = arg.GetString(0, ""); int price = arg.GetInt(1, 0); if (string.IsNullOrEmpty(id) || price <= 0) { ReplyArg(p, "用法:tradecenter.setprice <商品号> <新钻石价> 例如 tradecenter.setprice S1 25"); return; } var it = FindShop(id); if (it == null) { ReplyArg(p, "找不到商品号 " + id + ",可以在后台页面看列表。"); return; } int old = it.Price; it.Price = price; if (it.OldPrice > 0 && it.OldPrice <= price) it.OldPrice = 0; // 新价涨过划线价就没必要划线了 MarkDirty(); ReplyArg(p, "已改价:" + ItemName(it.Shortname) + "(" + it.Id + ") " + Fmt(old) + " → " + Fmt(price) + " 钻石"); } // ---- tradecenter.stock <商品号> <库存> (-1 = 无限)---- [ConsoleCommand("tradecenter.stock")] private void CcStock(ConsoleSystem.Arg arg) { var p = arg != null ? arg.Player() : null; if (!AdminGate(p, arg)) return; string id = arg.GetString(0, ""); int stock = arg.GetInt(1, -1); if (string.IsNullOrEmpty(id)) { ReplyArg(p, "用法:tradecenter.stock <商品号> <库存> 库存填 -1 表示不限量"); return; } var it = FindShop(id); if (it == null) { ReplyArg(p, "找不到商品号 " + id + "。"); return; } int old = it.Stock; it.Stock = stock < -1 ? -1 : stock; MarkDirty(); ReplyArg(p, "已改库存:" + ItemName(it.Shortname) + "(" + it.Id + ") " + (old < 0 ? "不限量" : Fmt(old) + " 份") + " → " + (it.Stock < 0 ? "不限量" : Fmt(it.Stock) + " 份")); } // ---- tradecenter.del <商品号> ---- [ConsoleCommand("tradecenter.del")] private void CcDel(ConsoleSystem.Arg arg) { var p = arg != null ? arg.Player() : null; if (!AdminGate(p, arg)) return; string id = arg.GetString(0, ""); if (string.IsNullOrEmpty(id)) { ReplyArg(p, "用法:tradecenter.del <商品号>"); return; } var it = FindShop(id); if (it == null) { ReplyArg(p, "找不到商品号 " + id + "。"); return; } string nm = ItemName(it.Shortname); _shop.Items.Remove(it); MarkDirty(); ReplyArg(p, "已删除官方商品:" + nm + "(" + id + ")"); } // ---- tradecenter.grant <玩家|steamid> <商品号> [份数] ---- // ★ 为什么不复用 tradecenter.buy:buy 是 CUI 按钮回传命令, // 参数是 <挂单号> <件数> 由面板拼好;管理员代发如果也走它, // 两套语义撞在一个命令上,改面板就会误伤玩家购买。宁可新建一个。 [ConsoleCommand("tradecenter.grant")] private void CcGrant(ConsoleSystem.Arg arg) { var p = arg != null ? arg.Player() : null; if (!AdminGate(p, arg)) return; string who = arg.GetString(0, ""); string id = arg.GetString(1, ""); int qty = arg.GetInt(2, 1); if (string.IsNullOrEmpty(who) || string.IsNullOrEmpty(id)) { ReplyArg(p, "用法:tradecenter.grant <玩家|steamid> <商品号> [份数]"); return; } var it = FindShop(id); if (it == null) { ReplyArg(p, "找不到商品号 " + id + "。"); return; } ulong uid = ResolveTargetId(who); if (uid == 0UL) { ReplyArg(p, "找不到玩家 " + who + "。在线的可以按名字找,离线的请填 SteamID。"); return; } var target = BasePlayer.FindByID(uid); if (target == null) { ReplyArg(p, "玩家不在线,没法把东西发到他背包里(请让他先上线)。"); return; } if (qty <= 0) qty = 1; int per = it.Amount > 0 ? it.Amount : 1; long give = (long)per * (long)qty; if (give > 2000000000L) give = 2000000000L; GiveTo(target, it.Shortname, it.Skin, (int)give, 0f); it.Sold += qty; if (it.Stock > 0) { it.Stock -= qty; if (it.Stock < 0) it.Stock = 0; } MarkDirty(); ReplyArg(p, "已赠送:" + ItemName(it.Shortname) + " ×" + Fmt(give) + " → " + target.displayName); Msg(target, "收到管理员赠送:" + ItemName(it.Shortname) + " ×" + Fmt(give)); } // ---- tradecenter.bans <玩家|steamid> ---- 拉黑 / 解除拉黑(禁止上架) [ConsoleCommand("tradecenter.bans")] private void CcBans(ConsoleSystem.Arg arg) { var p = arg != null ? arg.Player() : null; if (!AdminGate(p, arg)) return; string who = arg.GetString(0, ""); if (string.IsNullOrEmpty(who)) { ReplyArg(p, "用法:tradecenter.bans <玩家|steamid> (同一个命令反复用 = 拉黑 / 解除)"); return; } ulong uid = ResolveTargetId(who); if (uid == 0UL) { ReplyArg(p, "找不到玩家 " + who + "。在线的可以按名字找,离线的请填 SteamID。"); return; } if (_players.Banned == null) _players.Banned = new List(); bool had = _players.Banned.Contains(uid); if (had) _players.Banned.Remove(uid); else _players.Banned.Add(uid); MarkDirty(); var tp = BasePlayer.FindByID(uid); string nm = tp != null ? tp.displayName : (uid.ToString()); ReplyArg(p, (had ? "已解除拉黑:" : "已拉黑:") + nm + "(" + uid + ") " + (had ? "他现在可以正常上架了。" : "他不能再往市场挂东西了。")); if (tp != null) Msg(tp, had ? "管理员已解除对你的商城限制,现在可以正常上架了。" : "管理员已禁止你在商城上架。"); } // ---- tradecenter.stats ---- 全服报表 [ConsoleCommand("tradecenter.stats")] private void CcStats(ConsoleSystem.Arg arg) { var p = arg != null ? arg.Player() : null; if (!AdminGate(p, arg)) return; Puts("===== 交易商城 全服报表 ====="); Puts(" 玩家市场:在售 " + _market.Listings.Count + " 单" + " 累计成交 " + _stats.TotalTrades + " 笔 / " + Fmt(_stats.TotalGold) + " 金币"); Puts(" 官方商店:商品 " + _shop.Items.Count + " 件" + " 累计钻石收入 " + Fmt(_stats.TotalDiamond)); Puts(" 兜底收购:今日 " + Fmt(_stats.BuybackSpent) + " / " + Fmt(_config.Buyback.DailyBudget) + " 金币 " + _stats.BuybackOrders + " / " + _config.Buyback.DailyMaxOrders + " 单 " + Fmt(_stats.BuybackItems) + " 件"); Puts(" 手续费池:" + Fmt(_stats.TotalFee) + " 金币(服主的金币回收口)"); if (_stats.Sellers != null && _stats.Sellers.Count > 0) { int top = Mathf.Min(5, _stats.Sellers.Count); Puts(" 卖出最多的 " + top + " 位卖家:"); for (int i = 0; i < top; i++) Puts(" " + _stats.Sellers.Keys.ElementAt(i) + " 卖 " + Fmt(_stats.Sellers.Values.ElementAt(i)) + " 件"); } if (p != null) Msg(p, "报表已打印到服务器控制台。"); } // ---- tradecenter.scan ---- 手动跑一次兜底收购(不等定时器) [ConsoleCommand("tradecenter.scan")] private void CcScan(ConsoleSystem.Arg arg) { var p = arg != null ? arg.Player() : null; if (!AdminGate(p, arg)) return; int before = _stats.BuybackOrders; int coins = _stats.BuybackSpent; DoBuyback(); ReplyArg(p, "兜底收购已执行:本轮 " + (_stats.BuybackOrders - before) + " 单," + "支出 " + Fmt(_stats.BuybackSpent - coins) + " 金币。"); } // ===================================================================== // 后台面板按钮 —— 全部走 UiPlayer,管理员在面板上点一下就改完, // 不用记住命令(命令依然保留给 RCON / 控制台用) // ===================================================================== private bool AdminUi(BasePlayer p) { if (p == null || !IsAdmin(p)) return false; return true; } // 选中一个商品(选中后这一行下面才显示操作按钮) [ConsoleCommand("tradecenter.admin.sel")] private void CcAdminSel(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; if (!AdminUi(p)) return; var st = GetUi(p); string id = arg.GetString(0, ""); st.ShopSel = string.Equals(st.ShopSel, id) ? "" : id; // 再点一次 = 取消选中 ShowPanel(p, TabAdmin); } // 上架 / 停售 [ConsoleCommand("tradecenter.admin.toggle")] private void CcAdminToggle(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; if (!AdminUi(p)) return; var it = FindShop(arg.GetString(0, "")); if (it == null) return; it.Enabled = !it.Enabled; MarkDirty(); Msg(p, ItemName(it.Shortname) + "(" + it.Id + ")已" + (it.Enabled ? "重新上架" : "停售") + "。"); ShowPanel(p, TabAdmin); } // 调价:dir < 0 降 10%,dir > 0 涨 10%(取整到个位,不会出现 33.3 这种价) [ConsoleCommand("tradecenter.admin.price")] private void CcAdminPrice(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; if (!AdminUi(p)) return; var it = FindShop(arg.GetString(0, "")); if (it == null) return; int dir = arg.GetInt(1, 0); int old = it.Price; int np = (int)Math.Round(old * (dir < 0 ? 0.9f : 1.1f)); if (np < 1) np = 1; if (np > 1000000) np = 1000000; it.Price = np; if (it.OldPrice > 0 && it.OldPrice <= np) it.OldPrice = 0; MarkDirty(); Msg(p, "已调价:" + ItemName(it.Shortname) + " " + Fmt(old) + " → " + Fmt(np) + " 钻石"); ShowPanel(p, TabAdmin); } // 模式:0 = 设为不限量,1 = 库存 +1 份,-1 = 库存 -1 份 // ★ 一开始我把「不限量」也编成 -1,和「减 1 份」撞号了 —— 面板按钮 // 传 -1 到底是减库存还是变无限?这种两义参数迟早出事,改成三值。 [ConsoleCommand("tradecenter.admin.stock")] private void CcAdminStock(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; if (!AdminUi(p)) return; var it = FindShop(arg.GetString(0, "")); if (it == null) return; int mode = arg.GetInt(1, 0); if (mode == 0) { it.Stock = -1; Msg(p, ItemName(it.Shortname) + "(" + it.Id + ")已设为不限量。"); } else { int old = it.Stock < 0 ? 0 : it.Stock; int nv = old + (mode < 0 ? -1 : 1); if (nv < 0) nv = 0; it.Stock = nv; Msg(p, "库存已改为 " + (it.Stock == 0 ? "0(将自动下架)" : Fmt(it.Stock) + " 份") + " (" + ItemName(it.Shortname) + ")"); } MarkDirty(); ShowPanel(p, TabAdmin); } // 删除商品(以后不再出售) [ConsoleCommand("tradecenter.admin.del")] private void CcAdminDel(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; if (!AdminUi(p)) return; var it = FindShop(arg.GetString(0, "")); if (it == null) return; var st = GetUi(p); if (string.Equals(st.ShopSel, it.Id)) st.ShopSel = ""; string nm = ItemName(it.Shortname); _shop.Items.Remove(it); MarkDirty(); Msg(p, "已删除官方商品:" + nm + "(" + it.Id + ")。"); ShowPanel(p, TabAdmin); } // 面板上手动跑一次兜底收购 [ConsoleCommand("tradecenter.admin.scan")] private void CcAdminScan(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; if (!AdminUi(p)) return; int before = _stats.BuybackOrders; int coins = _stats.BuybackSpent; DoBuyback(); Msg(p, "兜底收购已跑一轮:" + (_stats.BuybackOrders - before) + " 单," + "花了 " + Fmt(_stats.BuybackSpent - coins) + " 金币。"); ShowPanel(p, TabAdmin); } // 兜底收购总开关(面板上用,等同于改配置) [ConsoleCommand("tradecenter.admin.bb")] private void CcAdminBb(ConsoleSystem.Arg arg) { var p = UiPlayer(arg); if (p == null) return; if (!AdminUi(p)) return; _config.Buyback.Enabled = !_config.Buyback.Enabled; SaveConfig(); Msg(p, "系统兜底收购已" + (_config.Buyback.Enabled ? "开启" : "关闭") + "。"); ShowPanel(p, TabAdmin); } #endregion // ---------------- ② 官方商店(钻石轨)---------------- private void DrawShop(CuiElementContainer c, BasePlayer player, UiState st, float s) { if (!_config.Shop.Enabled) { DrawCenter(c, s, "官方商店已关闭", "服主可以在配置里把「官方商店 Shop -> 启用 Enabled」打开", ""); return; } var list = VisibleShopItems(); Veil(c, PanelName, 62, 518, 902, 562, "0.5 0.3 0.13 0.94", s, PanelName + ".bar2"); c.Add(MkLabel("" + _config.Shop.TabTitle + " " + "" + _config.Shop.TabSubtitle + " 共 " + list.Count + " 件", 74, 518, 620, 562, 12, TextAnchor.MiddleLeft, "1 0.93 0.8 1", s), PanelName); long dia = EcoBalance(player.userID, CurDiamond); c.Add(MkLabel("我的钻石 " + Fmt(dia) + "", 640, 518, 890, 562, 12, TextAnchor.MiddleRight, "1 0.93 0.80 1", s), PanelName); int perPage = PageSize; int pages = Mathf.Max(1, (list.Count + perPage - 1) / perPage); if (st.Page > pages - 1) st.Page = pages - 1; if (st.Page < 0) st.Page = 0; int start = st.Page * perPage; for (int i = 0; i < PageSize; i++) { float x0 = GridX0 + (i % GridCols) * GridStepX; float y0 = (i < GridCols) ? GridY1st : GridY2nd; int idx = start + i; if (idx < list.Count) DrawShopCard(c, player, st, s, list[idx], x0, y0, i); else DrawEmptySlot(c, s, x0, y0); } if (list.Count == 0) { DrawCenter(c, s, "官方商店还没有上架商品", "管理员手持想卖的东西,输入 /shop add <钻石价> [库存] 就能一键做成商品", "★ 支持工坊皮肤物品:自动读取手持物的皮肤 ID,客户端会直接显示皮肤图"); } } private void DrawShopCard(CuiElementContainer c, BasePlayer player, UiState st, float s, ShopItem it, float x0, float y0, int idx) { float x1 = x0 + CardW, y1 = y0 + CardH; string pn = PanelName + ".scard" + idx; Veil(c, PanelName, x0, y0, x1, y1, "0.92 0.975 0.985 0.90", s, pn); Veil(c, PanelName, x0 + 4, y1 - 3, x1 - 4, y1 - 1, "0.22 0.74 0.81 0.85", s, pn + ".line"); int itemId = ItemIdOf(it.Shortname); float ic = 112f; float ix = x0 + (CardW - ic) / 2f; float iy = y0 + 55f; if (itemId != 0) ImgItem(c, PanelName, itemId, ix, iy, ix + ic, iy + ic, "1 1 1 1", s); else Veil(c, PanelName, ix, iy, ix + ic, iy + ic, "0.86 0.945 0.965 0.85", s, pn + ".noicon"); // 名称条 Veil(c, PanelName, x0 + 5, y0 + 31, x1 - 5, y0 + 51, "0.83 0.945 0.965 0.92", s, pn + ".namebg"); string nm = string.IsNullOrEmpty(it.Name) ? ItemName(it.Shortname) : it.Name; c.Add(MkLabel(nm + (it.Amount > 1 ? (" ×" + Fmt(it.Amount)) : ""), x0 + 8, y0 + 31, x1 - 8, y0 + 51, 11, TextAnchor.MiddleCenter, "0.241 0.167 0.109 1", s), PanelName); // 价格条 // ★ 必须显示 ShopUnitPrice 算出来的【折后价】,它和实际扣款是同一个函数。 // 这里要是图省事显示 it.Price(原价),玩家看到的和扣的不是一个数, // 就成了「明着标 30、暗扣 27」—— 不是 bug 但比 bug 更招骂。 Veil(c, PanelName, x0 + 5, y0 + 5, x1 - 5, y0 + 27, "0.78 0.925 0.952 0.92", s, pn + ".pricebg"); long pay = ShopUnitPrice(player, it); long origin = it.Price > 0 ? (long)it.Price : pay; string priceTxt; if (pay < origin) priceTxt = "" + Fmt((int)pay) + " 钻石 原价 " + Fmt((int)origin) + ""; else if (it.OldPrice > it.Price) priceTxt = "" + Fmt((int)pay) + " 钻石 原价 " + Fmt(it.OldPrice) + ""; else priceTxt = "" + Fmt((int)pay) + " 钻石"; c.Add(MkLabel(priceTxt, x0 + 8, y0 + 5, x1 - 8, y0 + 27, 12, TextAnchor.MiddleCenter, "0.265 0.183 0.119 1", s), PanelName); // 角标:分类(限定 / 热销)/ 库存 / 限购 string badge = it.Category; string badgeColor = "0.66 0.34 0.03 1"; if (it.Stock >= 0 && it.Stock <= 3) { badge = "剩 " + it.Stock; badgeColor = "0.58 0.31 0.02 1"; } else if (it.LimitPerPlayer > 0) { badge = "限购 " + it.LimitPerPlayer; badgeColor = "0.34 0.234 0.153 1"; } if (!string.IsNullOrEmpty(badge)) c.Add(MkLabel(badge, x0 + 6, y0 + 167, x1 - 6, y0 + 181, 9, TextAnchor.MiddleRight, badgeColor, s), PanelName); c.Add(MkButton("tradecenter.buy.shop " + it.Id + " 0", "", x0 + 5, y0 + 55, x1 - 5, y0 + 167, 11, "0 0 0 0", "1 1 1 0", s), PanelName); } // 现在能看到的官方商品:启用 + 有库存 + 在限时窗口内 private List VisibleShopItems() { var list = new List(); foreach (var it in _shop.Items) { if (it == null || !it.Enabled) continue; if (it.Stock == 0) continue; // 卖完自动消失 if (!InTimeWindow(it)) continue; // 限时窗口外 list.Add(it); } list.Sort((a, b) => a.Order != b.Order ? a.Order.CompareTo(b.Order) : string.CompareOrdinal(a.Id, b.Id)); return list; } private static bool InTimeWindow(ShopItem it) { DateTime t; if (!string.IsNullOrEmpty(it.StartAt) && DateTime.TryParse(it.StartAt, out t) && DateTime.Now < t) return false; if (!string.IsNullOrEmpty(it.EndAt) && DateTime.TryParse(it.EndAt, out t) && DateTime.Now > t) return false; return true; } // ===================================================================== // 背包上架弹层(v1.1.0) // // ★ 为什么取代「手持上架」:手上一件一件挂,数量还得自己数,慢而且容易挂错。 // 这里直接读背包:点一下物品 = 一整摞,数量默认给到背包里全部, // 单价在面板里点 +/- 或直接敲数字回车,全程不用记命令。 // // ★★ 坐标系(本插件最容易踩的坑,改这里之前先看懂): // CUI 子元素的坐标是【相对父面板】的。所以这一层的每一个元素都 // 【直接挂在根面板 PanelName 上】,坐标就是面板坐标 // (0..1000 × 0..620,原点在左下角)。 // —— 千万别为了「结构好看」再套一层子面板,套一层就会整体位移。 // ===================================================================== private const int PickCols = 8; private const int PickRows = 3; private const int PickPerPage = PickCols * PickRows; // 24 格 private void DrawSellPicker(CuiElementContainer c, BasePlayer player, UiState st, float s) { string k = PanelName + ".pk"; // 压暗整个面板 -> 弹层浮在最上面 Veil(c, PanelName, 0, 0, BaseW, BaseH, "0.3 0.2 0.12 0.88", s, k + ".dim"); Veil(c, PanelName, 50, 58, 950, 566, "0.995 0.965 0.918 0.99", s, k); Veil(c, PanelName, 50, 562, 950, 566, "0.84 0.47 0.18 0.90", s, k + ".topline"); c.Add(MkLabel("从背包上架", 68, 522, 300, 560, 17, TextAnchor.MiddleLeft, "0.96 0.98 1.00 1", s), PanelName); c.Add(MkLabel("① 点物品 ② 填单价 ③ 点「确认上架」", 310, 524, 690, 558, 12, TextAnchor.MiddleLeft, "0.354 0.244 0.159 1", s), PanelName); var all = BagStacks(player); int pages = Mathf.Max(1, Mathf.CeilToInt(all.Count / (float)PickPerPage)); st.PickPage = Mathf.Clamp(st.PickPage, 0, pages - 1); if (pages > 1) { c.Add(MkButton("tradecenter.sell.page " + (st.PickPage - 1), "◀", 700, 522, 750, 558, 13, st.PickPage > 0 ? "0.69 0.73 0.77 0.92" : "0.99 0.94 0.85 0.70", st.PickPage > 0 ? "0.283 0.195 0.127 1" : "0.38 0.33 0.26 1", s), PanelName); c.Add(MkLabel((st.PickPage + 1) + " / " + pages, 754, 522, 820, 558, 12, TextAnchor.MiddleCenter, "0.354 0.244 0.159 1", s), PanelName); c.Add(MkButton("tradecenter.sell.page " + (st.PickPage + 1), "▶", 824, 522, 874, 558, 13, st.PickPage < pages - 1 ? "0.69 0.73 0.77 0.92" : "0.99 0.94 0.85 0.70", st.PickPage < pages - 1 ? "0.283 0.195 0.127 1" : "0.38 0.33 0.26 1", s), PanelName); } c.Add(MkButton("tradecenter.sell.close", "关闭 ×", 884, 522, 938, 558, 12, "0.82 0.17 0.25 0.92", "1 1 1 1", s), PanelName); if (all.Count == 0) { c.Add(MkLabel("背包(主背包 + 腰带)里没有可以上架的东西。", 68, 290, 932, 336, 15, TextAnchor.MiddleCenter, "0.35 0.24 0.12 1", s), PanelName); c.Add(MkLabel("身上穿着的那套不算;禁止上架的物品、以及塞了配件的枪都不会出现。", 68, 252, 932, 288, 11, TextAnchor.MiddleCenter, "0.451 0.311 0.203 1", s), PanelName); DrawSellBar(c, player, st, s, true); return; } // ---- 背包网格:8 列 × 3 行,格位 100×88,横竖各留 10 的缝 ---- // 一行一个意思:图标在上、名字在中、数量在下,扫一眼就知道是什么、有多少。 for (int i = 0; i < PickPerPage; i++) { int idx = st.PickPage * PickPerPage + i; if (idx >= all.Count) break; var bs = all[idx]; float gx = 65f + (i % PickCols) * 110f; float gy = 432f - (i / PickCols) * 98f; bool sel = (st.PickSn == bs.Shortname && st.PickSkin == bs.Skin); Veil(c, PanelName, gx, gy, gx + 100f, gy + 88f, sel ? "0.24 0.48 0.07 1" : "0.99 0.95 0.885 0.94", s, k + ".g" + i); if (sel) Veil(c, PanelName, gx, gy + 84f, gx + 100f, gy + 88f, "0.98 0.55 0.2 0.95", s, k + ".gs" + i); if (bs.ItemId != 0) ImgItem(c, PanelName, bs.ItemId, gx + 24, gy + 30, gx + 76, gy + 82, "1 1 1 1", s); else Veil(c, PanelName, gx + 24, gy + 30, gx + 76, gy + 82, "0.69 0.73 0.77 0.85", s, k + ".gi" + i); string nm = ItemName(bs.Shortname); if (nm.Length > 7) nm = nm.Substring(0, 7); c.Add(MkLabel(nm, gx + 3, gy + 15, gx + 97, gy + 29, 10, TextAnchor.MiddleCenter, sel ? "1 1 1 1" : "0.284 0.196 0.128 1", s), PanelName); c.Add(MkLabel("×" + Fmt(bs.Amount), gx + 3, gy + 2, gx + 97, gy + 15, 11, TextAnchor.MiddleCenter, sel ? "1 0.93 0.8 1" : "0.58 0.32 0.02 1", s), PanelName); // 整格可点(透明按钮压在最上面接点击) c.Add(MkButton("tradecenter.sell.pick " + bs.Shortname + " " + bs.Skin, "", gx, gy, gx + 100f, gy + 88f, 10, "0 0 0 0", "0 0 0 0", s), PanelName); } DrawSellBar(c, player, st, s, false); } // ===================================================================== // 弹层下半部分:选中的物品 / 数量 / 单价 / 小计 / 时长 / 确认 // // ★ 排版原则(v1.1.1 重做):一个意思一行,行间留白,别把按钮排成一条。 // 玩家真正要看的只有三件事 —— 我选了什么、卖多少、到手多少钱。 // 所以:选中物品一行、数量一行、单价一行、小计放右侧、底排放动作。 // ===================================================================== private void DrawSellBar(CuiElementContainer c, BasePlayer player, UiState st, float s, bool empty) { string k = PanelName + ".pk"; Veil(c, PanelName, 50, 104, 950, 226, "1 0.978 0.942 0.96", s, k + ".barbg"); bool picked = !empty && !string.IsNullOrEmpty(st.PickSn); int have = picked ? BagCountOf(player, st.PickSn, st.PickSkin) : 0; if (!picked) { c.Add(MkLabel("在上方点一个物品,数量会自动填成背包里的全部", 68, 140, 932, 190, 14, TextAnchor.MiddleCenter, "0.354 0.244 0.159 1", s), PanelName); } else { // ---- 第 1 行:选中的是什么 ---- int itemId = ItemIdOf(st.PickSn); if (itemId != 0) ImgItem(c, PanelName, itemId, 66, 192, 98, 224, "1 1 1 1", s); else Veil(c, PanelName, 66, 192, 98, 224, "0.69 0.73 0.77 0.85", s, k + ".noicon"); c.Add(MkLabel(ItemName(st.PickSn), 106, 202, 470, 226, 14, TextAnchor.MiddleLeft, "0.235 0.162 0.106 1", s), PanelName); c.Add(MkLabel("背包 " + Fmt(have) + " 件" + (st.PickSkin != 0UL ? " 皮肤 " + st.PickSkin : ""), 106, 188, 470, 202, 11, TextAnchor.MiddleLeft, "0.395 0.272 0.178 1", s), PanelName); // ---- 第 2 行:数量 ---- c.Add(MkLabel("数量", 66, 150, 116, 186, 13, TextAnchor.MiddleLeft, "0.305 0.211 0.137 1", s), PanelName); c.Add(MkButton("tradecenter.sell.qty -10", "-10", 120, 152, 164, 184, 12, "0.94 0.86 0.72 0.92", "0.299 0.207 0.135 1", s), PanelName); c.Add(MkButton("tradecenter.sell.qty -1", "-1", 168, 152, 206, 184, 12, "0.94 0.86 0.72 0.92", "0.299 0.207 0.135 1", s), PanelName); Veil(c, PanelName, 210, 152, 286, 184, "0.995 0.96 0.9 0.95", s, k + ".qbg"); var q = MkInput("tradecenter.sell.setqty", st.PickQty > 0 ? st.PickQty.ToString(CultureInfo.InvariantCulture) : "", 210, 152, 286, 184, 14, "0.58 0.32 0.02 1", s, 8); q.Parent = PanelName; c.Add(q); c.Add(MkButton("tradecenter.sell.qty 1", "+1", 290, 152, 328, 184, 12, "0.94 0.86 0.72 0.92", "0.299 0.207 0.135 1", s), PanelName); c.Add(MkButton("tradecenter.sell.qty 10", "+10", 332, 152, 376, 184, 12, "0.94 0.86 0.72 0.92", "0.299 0.207 0.135 1", s), PanelName); c.Add(MkButton("tradecenter.sell.qty max", "全部 " + Fmt(have), 380, 152, 466, 184, 12, "0.24 0.48 0.07 1", "1 1 1 1", s), PanelName); // ---- 第 3 行:单价 ---- c.Add(MkLabel("单价", 66, 112, 116, 146, 13, TextAnchor.MiddleLeft, "0.305 0.211 0.137 1", s), PanelName); c.Add(MkButton("tradecenter.sell.price -100", "-100", 120, 114, 168, 144, 12, "0.94 0.86 0.72 0.92", "0.299 0.207 0.135 1", s), PanelName); c.Add(MkButton("tradecenter.sell.price -10", "-10", 172, 114, 212, 144, 12, "0.94 0.86 0.72 0.92", "0.299 0.207 0.135 1", s), PanelName); Veil(c, PanelName, 216, 114, 292, 144, "0.995 0.96 0.9 0.95", s, k + ".pbg"); var pi = MkInput("tradecenter.sell.setprice", st.PickUnit > 0 ? st.PickUnit.ToString(CultureInfo.InvariantCulture) : "", 216, 114, 292, 144, 14, "0.58 0.32 0.02 1", s, 9); pi.Parent = PanelName; c.Add(pi); c.Add(MkButton("tradecenter.sell.price 10", "+10", 296, 114, 336, 144, 12, "0.94 0.86 0.72 0.92", "0.299 0.207 0.135 1", s), PanelName); c.Add(MkButton("tradecenter.sell.price 100", "+100", 340, 114, 388, 144, 12, "0.94 0.86 0.72 0.92", "0.299 0.207 0.135 1", s), PanelName); c.Add(MkButton("tradecenter.sell.suggest", "参考价", 392, 114, 470, 144, 12, "0.94 0.86 0.72 0.92", "0.299 0.207 0.135 1", s), PanelName); c.Add(MkLabel("金币 / 件", 478, 114, 570, 144, 11, TextAnchor.MiddleLeft, "0.425 0.293 0.191 1", s), PanelName); // ---- 右侧:小计 / 手续费 / 到手 ---- int qty = st.PickQty > 0 ? st.PickQty : have; int unit = st.PickUnit > 0 ? st.PickUnit : 0; long total = (long)unit * (long)qty; int fee = FeeFor(player, total); c.Add(MkLabel("小计 " + Fmt(total) + " 金币", 620, 202, 934, 226, 14, TextAnchor.MiddleRight, "0.35 0.24 0.12 1", s), PanelName); c.Add(MkLabel(fee > 0 ? ("手续费 " + Fmt(fee) + " 到手 " + Fmt(total - fee) + "") : "VIP 免手续费", 620, 188, 934, 202, 11, TextAnchor.MiddleRight, "0.395 0.272 0.178 1", s), PanelName); } // ---- 底排:时长 / 提示 / 确认 / 取消 ---- var hs = new List(); if (_config.Market.HoursOptions != null) for (int i = 0; i < _config.Market.HoursOptions.Count && hs.Count < 4; i++) { int h = _config.Market.HoursOptions[i]; if (h > 0 && !hs.Contains(h)) hs.Add(h); } if (hs.Count == 0) hs.Add(_config.Market.DefaultHours > 0 ? _config.Market.DefaultHours : 72); if (st.PickHours <= 0) st.PickHours = _config.Market.DefaultHours > 0 ? _config.Market.DefaultHours : 72; for (int i = 0; i < hs.Count; i++) { float hx = 66f + i * 82f; bool on = (st.PickHours == hs[i]); c.Add(MkButton("tradecenter.sell.hours " + hs[i], hs[i] + " 小时", hx, 64, hx + 76f, 100, 12, on ? "0.98 0.55 0.2 0.95" : "0.995 0.96 0.9 0.85", on ? "0.22 0.09 0.02 1" : "0.401 0.277 0.18 1", s), PanelName); } string hint = st.PickMsg; if (string.IsNullOrEmpty(hint)) hint = picked ? "单价必须填;数量默认就是背包里的全部。" : "先在上方点一个物品。"; c.Add(MkLabel(hint, 404, 64, 690, 100, 11, TextAnchor.MiddleLeft, "0.354 0.244 0.159 1", s), PanelName); c.Add(MkButton("tradecenter.sell.confirm", picked ? "确认上架" : "先选物品", 700, 64, 838, 100, 15, picked ? "0.24 0.48 0.07 0.95" : "0.99 0.95 0.885 0.85", picked ? "1 1 1 1" : "0.51 0.352 0.23 1", s), PanelName); c.Add(MkButton("tradecenter.sell.close", "取消", 844, 64, 938, 100, 13, "0.69 0.73 0.77 0.92", "0.2 0.23 0.27 1", s), PanelName); } // ---------------- ③ 我的上架 ---------------- private void DrawMine(CuiElementContainer c, BasePlayer player, UiState st, float s) { var mine = new List(); foreach (var L in _market.Listings) if (L != null && L.Seller == player.userID && L.Left > 0) mine.Add(L); Veil(c, PanelName, 62, 518, 902, 562, "0.5 0.3 0.13 0.94", s, PanelName + ".bar3"); c.Add(MkLabel("我的上架 共 " + mine.Count + " 单 上限 " + _config.Market.MaxListingsPerPlayer + " 单", 74, 518, 700, 562, 12, TextAnchor.MiddleLeft, "1 0.93 0.8 1", s), PanelName); if (mine.Count == 0) { DrawCenter(c, s, "你还没有挂单", "聊天框输入 /shop sell <单价> [数量] 就能把手里这件挂上去卖", "也可以点右上角「+ 上架物品」,不用记命令"); return; } // 列表式(一单一行):上架管理要的是信息密度,不是好看 float y = 500f; const float rowH = 44f; int shown = 0; foreach (var L in mine) { if (shown >= 10) break; float ry0 = y - rowH, ry1 = y - 2; string rn = PanelName + ".m" + shown; Veil(c, PanelName, 62, ry0, 902, ry1, "0.995 0.965 0.918 0.88", s, rn); int itemId = ItemIdOf(L.Shortname); if (itemId != 0) ImgItem(c, PanelName, itemId, 70, ry0 + 6, 102, ry1 - 6, "1 1 1 1", s); c.Add(MkLabel(ItemName(L.Shortname) + " ×" + Fmt(L.Left), 112, ry0, 320, ry1, 12, TextAnchor.MiddleLeft, "0.249 0.172 0.112 1", s), PanelName); c.Add(MkLabel(Fmt(L.UnitPrice) + " 金币/件", 330, ry0, 460, ry1, 12, TextAnchor.MiddleLeft, "0.58 0.32 0.02 1", s), PanelName); c.Add(MkLabel("总价 " + Fmt((long)L.UnitPrice * L.Left), 470, ry0, 596, ry1, 11, TextAnchor.MiddleLeft, "0.367 0.253 0.165 1", s), PanelName); c.Add(MkLabel("剩 " + RemainText(L.ExpiresAt), 604, ry0, 706, ry1, 11, TextAnchor.MiddleLeft, "0.422 0.291 0.19 1", s), PanelName); c.Add(MkButton("tradecenter.mine.cancel " + L.Id, "下架", 718, ry0 + 8, 782, ry1 - 8, 11, "0.82 0.17 0.25 0.92", "1 1 1 1", s), PanelName); c.Add(MkButton("tradecenter.mine.price " + L.Id, "改价", 790, ry0 + 8, 894, ry1 - 8, 11, "0.94 0.86 0.72 0.92", "0.35 0.24 0.12 1", s), PanelName); y -= (rowH + 4); shown++; } if (mine.Count > shown) c.Add(MkLabel("还有 " + (mine.Count - shown) + " 单未显示(下架几单就能看到)", 62, y - 20, 902, y, 10, TextAnchor.MiddleLeft, "0.472 0.326 0.212 1", s), PanelName); } // ---------------- ④ 我的仓库 ---------------- private void DrawMail(CuiElementContainer c, BasePlayer player, UiState st, float s) { var rec = Rec(player.userID, false); var mail = (rec == null || rec.Mail == null) ? new List() : rec.Mail; Veil(c, PanelName, 62, 518, 902, 562, "0.5 0.3 0.13 0.94", s, PanelName + ".bar4"); c.Add(MkLabel("我的仓库 背包塞不下时东西会自动存到这里,不会丢", 74, 518, 700, 562, 12, TextAnchor.MiddleLeft, "1 0.93 0.8 1", s), PanelName); if (mail.Count > 0) c.Add(MkButton("tradecenter.mail.takeall", "一键全部领取", 740, 522, 894, 558, 12, "0.24 0.48 0.07 1", "1 1 1 1", s), PanelName); if (mail.Count == 0) { DrawCenter(c, s, "仓库是空的", "你在商城买到、或挂单到期退回的东西,如果背包放不下,就会存到这里", "★ 仓库里的东西永远不丢,随时可以一键领回"); return; } float y = 500f; const float rowH = 40f; int shown = 0; foreach (var m in mail) { if (shown >= 11) break; float ry0 = y - rowH, ry1 = y - 2; string rn = PanelName + ".k" + shown; Veil(c, PanelName, 62, ry0, 902, ry1, "0.995 0.965 0.918 0.88", s, rn); int itemId = ItemIdOf(m.Shortname); if (itemId != 0) ImgItem(c, PanelName, itemId, 70, ry0 + 5, 100, ry1 - 5, "1 1 1 1", s); c.Add(MkLabel(ItemName(m.Shortname) + " ×" + Fmt(m.Amount), 110, ry0, 330, ry1, 12, TextAnchor.MiddleLeft, "0.249 0.172 0.112 1", s), PanelName); c.Add(MkLabel(m.From, 340, ry0, 700, ry1, 11, TextAnchor.MiddleLeft, "0.395 0.272 0.178 1", s), PanelName); c.Add(MkButton("tradecenter.mail.take " + shown, "领取", 790, ry0 + 6, 894, ry1 - 6, 11, "0.94 0.86 0.72 0.92", "0.35 0.24 0.12 1", s), PanelName); y -= (rowH + 4); shown++; } } // ---------------- ⑤ 折扣与权益 ---------------- private void DrawPerks(CuiElementContainer c, BasePlayer player, UiState st, float s) { Veil(c, PanelName, 62, 518, 902, 562, "0.5 0.3 0.13 0.94", s, PanelName + ".bar5"); c.Add(MkLabel("折扣与权益 VIP 档位 · 手续费减免 · 限时活动", 74, 518, 700, 562, 12, TextAnchor.MiddleLeft, "1 0.93 0.8 1", s), PanelName); int vip = VipLevel(player); string[] vn = { "普通玩家", "VIP1", "VIP2", "VIP3" }; // 当前档位大牌 Veil(c, PanelName, 62, 400, 902, 508, "0.995 0.965 0.918 0.90", s, PanelName + ".vipcard"); c.Add(MkLabel("我的身份", 82, 470, 240, 500, 12, TextAnchor.MiddleLeft, "0.395 0.272 0.178 1", s), PanelName); c.Add(MkLabel("" + vn[vip] + "", 82, 424, 320, 468, 26, TextAnchor.MiddleLeft, "0.58 0.32 0.02 1", s), PanelName); long goldRate = GoldRate(player); long diaRate = DiamondRate(player); c.Add(MkLabel("金币折扣 " + ShowRate(goldRate) + "", 360, 460, 620, 492, 13, TextAnchor.MiddleLeft, "0.305 0.211 0.137 1", s), PanelName); c.Add(MkLabel("钻石折扣 " + ShowRate(diaRate) + "", 360, 420, 620, 452, 13, TextAnchor.MiddleLeft, "0.305 0.211 0.137 1", s), PanelName); c.Add(MkLabel("上架手续费 " + FeeVipText(player) + "", 640, 460, 890, 492, 13, TextAnchor.MiddleLeft, "0.305 0.211 0.137 1", s), PanelName); c.Add(MkLabel("累计省下 " + Fmt(SavedTotal(player)) + " 金币", 640, 420, 890, 452, 13, TextAnchor.MiddleLeft, "0.305 0.211 0.137 1", s), PanelName); // 三档明细 Veil(c, PanelName, 62, 230, 902, 386, "1 0.978 0.942 0.86", s, PanelName + ".viptab"); c.Add(MkLabel("档位明细", 82, 350, 300, 378, 13, TextAnchor.MiddleLeft, "0.55 0.29 0.02 1", s), PanelName); string[] head = { "档位", "金币折扣", "钻石折扣", "上架手续费" }; float[] hx = { 90, 300, 470, 640 }; for (int i = 0; i < head.Length; i++) c.Add(MkLabel(head[i], hx[i], 318, hx[i] + 150, 344, 11, TextAnchor.MiddleLeft, "0.422 0.291 0.19 1", s), PanelName); string[][] rows = { new[] { "VIP1", (100 - _config.Discounts.GoldVip1 * 100f).ToString("0.#") + "%", (100 - _config.Discounts.DiamondVip1 * 100f).ToString("0.#") + "%", (100 - _config.Fees.Vip1Rate * 100f).ToString("0.#") + "% 减免" }, new[] { "VIP2", (100 - _config.Discounts.GoldVip2 * 100f).ToString("0.#") + "%", (100 - _config.Discounts.DiamondVip2 * 100f).ToString("0.#") + "%", (100 - _config.Fees.Vip2Rate * 100f).ToString("0.#") + "% 减免" }, new[] { "VIP3", (100 - _config.Discounts.GoldVip3 * 100f).ToString("0.#") + "%", (100 - _config.Discounts.DiamondVip3 * 100f).ToString("0.#") + "%", _config.Fees.Vip3Rate <= 0f ? "全免" : ((100 - _config.Fees.Vip3Rate * 100f).ToString("0.#") + "% 减免") } }; float ry = 288f; for (int r = 0; r < 3; r++) { bool on = (vip == r + 1); if (on) Veil(c, PanelName, 74, ry - 14, 890, ry + 8, "0.98 0.55 0.2 0.80", s, PanelName + ".vrow" + r); for (int i = 0; i < 4; i++) c.Add(MkLabel(rows[r][i], hx[i], ry - 14, hx[i] + 160, ry + 8, 12, TextAnchor.MiddleLeft, on ? "0.22 0.09 0.02 1" : "0.305 0.211 0.137 1", s), PanelName); ry -= 30f; } // 限时活动 if (_config.Discounts.ActivityEnabled) { Veil(c, PanelName, 62, 96, 902, 214, "0.99 0.95 0.885 0.86", s, PanelName + ".act"); c.Add(MkLabel("限时活动 " + _config.Discounts.ActivityName + "", 82, 178, 700, 206, 14, TextAnchor.MiddleLeft, "0.66 0.34 0.03 1", s), PanelName); c.Add(MkLabel("金币 " + ((100 - _config.Discounts.ActivityGoldRate * 100f).ToString("0.#")) + "% off 钻石 " + ((100 - _config.Discounts.ActivityDiamondRate * 100f).ToString("0.#")) + "% off", 82, 142, 700, 174, 13, TextAnchor.MiddleLeft, "0.299 0.206 0.134 1", s), PanelName); c.Add(MkLabel("截止 " + (_config.Discounts.ActivityEnd.Length > 0 ? _config.Discounts.ActivityEnd : "不限"), 82, 106, 700, 138, 12, TextAnchor.MiddleLeft, "0.422 0.291 0.19 1", s), PanelName); } else { c.Add(MkLabel("当前没有限时活动。有活动时会在这里显示折扣力度和截止时间。", 62, 96, 902, 140, 12, TextAnchor.MiddleLeft, "0.472 0.326 0.212 1", s), PanelName); } c.Add(MkLabel("★ 折扣只对「花钱」生效,对「收钱」不生效 —— 免得有人自己挂单自己买刷折扣。", 62, 74, 902, 94, 10, TextAnchor.MiddleLeft, "0.493 0.34 0.222 1", s), PanelName); } // ---------------- ⑥ 管理后台 ---------------- // ★ 这一页原本印了一墙的命令速查,服主得回到控制台手敲 —— 等于没有面板。 // 重做成「点一点就改完」:选中一行 → 下方出现操作按钮。 // 命令全部保留(RCON / 服务器控制台照样能用),面板只是快捷方式。 private void DrawAdmin(CuiElementContainer c, BasePlayer player, UiState st, float s) { if (!IsAdmin(player)) { DrawCenter(c, s, "只有管理员能打开", "需要游戏内置管理员,或 tradecenter.admin 权限", ""); return; } Veil(c, PanelName, 62, 518, 902, 562, "0.5 0.3 0.13 0.94", s, PanelName + ".bar6"); c.Add(MkLabel("管理后台 官方商品 · 系统收购 · 全服报表", 74, 518, 620, 562, 12, TextAnchor.MiddleLeft, "1 0.93 0.8 1", s), PanelName); int soldAll = 0; for (int i = 0; i < _shop.Items.Count; i++) if (_shop.Items[i] != null) soldAll += _shop.Items[i].Sold; c.Add(MkLabel("钻石累计收入 " + Fmt(_stats.TotalDiamond) + "", 640, 518, 890, 562, 12, TextAnchor.MiddleRight, "1 0.93 0.8 1", s), PanelName); // ---- 四块统计 ---- float[] bx = { 62, 274, 486, 698 }; string[] bt = { "玩家市场", "官方商店", "今日系统收购", "服主收入" }; string[] bv1 = { _market.Listings.Count + " 单在售", _shop.Items.Count + " 件商品", Fmt(_stats.BuybackSpent) + " / " + Fmt(_config.Buyback.DailyBudget) + " 金币", Fmt(_stats.TotalFee + _stats.TotalDiamond) + " 合计" }; string[] bv2 = { "成交 " + _stats.TotalTrades + " 笔 / " + Fmt(_stats.TotalGold) + " 金币", "累计售出 " + Fmt(soldAll) + " 份", _stats.BuybackOrders + " 单 / " + Fmt(_stats.BuybackItems) + " 件", "手续费 " + Fmt(_stats.TotalFee) + " + 钻石 " + Fmt(_stats.TotalDiamond) }; for (int i = 0; i < 4; i++) { Veil(c, PanelName, bx[i], 448, bx[i] + 200, 512, "0.995 0.965 0.918 0.90", s, PanelName + ".stat" + i); c.Add(MkLabel(bt[i], bx[i] + 12, 484, bx[i] + 190, 508, 11, TextAnchor.MiddleLeft, "0.422 0.291 0.19 1", s), PanelName); c.Add(MkLabel(bv1[i], bx[i] + 12, 464, bx[i] + 190, 486, 13, TextAnchor.MiddleLeft, "0.55 0.29 0.02 1", s), PanelName); c.Add(MkLabel(bv2[i], bx[i] + 12, 450, bx[i] + 190, 466, 10, TextAnchor.MiddleLeft, "0.438 0.302 0.197 1", s), PanelName); } // ---- 官方商品列表 ---- Veil(c, PanelName, 62, 402, 902, 440, "1 0.978 0.942 0.86", s, PanelName + ".shophead"); string[] hd = { "商品号", "名称", "售价", "库存", "已售", "状态" }; float[] hx = { 74, 130, 400, 500, 610, 700 }; for (int i = 0; i < hd.Length; i++) c.Add(MkLabel(hd[i], hx[i], 405, hx[i] + 90, 437, 11, TextAnchor.MiddleLeft, "0.422 0.291 0.19 1", s), PanelName); c.Add(MkLabel("点一行选中 -> 下方出现操作按钮", 770, 405, 890, 437, 10, TextAnchor.MiddleRight, "0.472 0.326 0.212 1", s), PanelName); const int adminRows = 7; // v1.1.3: 表头占 y402..440,行区从 400 起;8 行会顶到下方操作区(196..250) const float adminRowH = 20f; const float adminRowStep = 21f; int shown = 0; for (int i = 0; i < _shop.Items.Count && shown < adminRows; i++) { var it = _shop.Items[i]; if (it == null) continue; float ry1 = 400f - (float)shown * adminRowStep; // v1.1.3: 原 424 会盖住表头(402..440) float ry0 = ry1 - adminRowH; string rn = PanelName + ".srow" + shown; bool on = string.Equals(st.ShopSel, it.Id); Veil(c, PanelName, 62, ry0, 902, ry1, on ? "0.98 0.55 0.2 0.92" : (shown % 2 == 0 ? "0.995 0.965 0.918 0.88" : "1 0.978 0.942 0.88"), s, rn); string nm = string.IsNullOrEmpty(it.Name) ? ItemName(it.Shortname) : it.Name; string stockTxt = it.Stock < 0 ? "不限量" : Fmt(it.Stock); bool live = it.Enabled && InTimeWindow(it) && it.Stock != 0; string stateTxt = live ? "在售" : (!it.Enabled ? "停售" : (it.Stock == 0 ? "已售空" : "未到时间")); string stateColor = on ? "0.22 0.09 0.02 1" : (live ? "0.15 0.44 0.03 1" : "0.55 0.3 0.02 1"); c.Add(MkLabel(it.Id, hx[0], ry0, hx[0] + 50, ry1, 11, TextAnchor.MiddleLeft, on ? "0.22 0.09 0.02 1" : "0.354 0.244 0.159 1", s), PanelName); c.Add(MkLabel(nm + (it.Amount > 1 ? (" ×" + Fmt(it.Amount)) : ""), hx[1], ry0, hx[1] + 250, ry1, 11, TextAnchor.MiddleLeft, on ? "0.22 0.09 0.02 1" : "0.249 0.172 0.112 1", s), PanelName); c.Add(MkLabel(on ? Fmt(it.Price) : ("" + Fmt(it.Price) + ""), hx[2], ry0, hx[2] + 85, ry1, 11, TextAnchor.MiddleLeft, on ? "0.22 0.09 0.02 1" : "0.249 0.172 0.112 1", s), PanelName); c.Add(MkLabel(stockTxt, hx[3], ry0, hx[3] + 90, ry1, 11, TextAnchor.MiddleLeft, "0.249 0.172 0.112 1", s), PanelName); c.Add(MkLabel(Fmt(it.Sold), hx[4], ry0, hx[4] + 80, ry1, 11, TextAnchor.MiddleLeft, on ? "0.22 0.09 0.02 1" : "0.354 0.244 0.159 1", s), PanelName); c.Add(MkLabel(stateTxt, hx[5], ry0, hx[5] + 90, ry1, 11, TextAnchor.MiddleLeft, stateColor, s), PanelName); c.Add(MkButton("tradecenter.admin.sel " + it.Id, "", 62, ry0, 902, ry1, 11, "0 0 0 0", "1 1 1 0", s), PanelName); shown++; } if (shown == 0) c.Add(MkLabel("还没有官方商品。手持想卖的东西,聊天框输入 /shop add 钻石价 就能上架。", 74, 336, 890, 392, 12, TextAnchor.MiddleLeft, "0.438 0.302 0.197 1", s), PanelName); // ---- 选中商品的操作按钮 ---- var sel = string.IsNullOrEmpty(st.ShopSel) ? null : FindShop(st.ShopSel); if (sel != null) { Veil(c, PanelName, 62, 196, 902, 250, "0.99 0.95 0.885 0.92", s, PanelName + ".selops"); string sname = string.IsNullOrEmpty(sel.Name) ? ItemName(sel.Shortname) : sel.Name; c.Add(MkLabel("已选中:" + sname + "(" + sel.Id + ") 当前 " + Fmt(sel.Price) + " 钻石 " + (sel.Stock < 0 ? "不限量" : "库存 " + Fmt(sel.Stock) + " 份"), 74, 226, 700, 246, 12, TextAnchor.MiddleLeft, "0.242 0.167 0.109 1", s), PanelName); string[][] ops = { new[] { "tradecenter.admin.toggle " + sel.Id, sel.Enabled ? "停售" : "上架", "0.98 0.55 0.2 0.95" }, new[] { "tradecenter.admin.price " + sel.Id + " -1", "降价 10%", "0.94 0.86 0.72 0.95" }, new[] { "tradecenter.admin.price " + sel.Id + " 1", "涨价 10%", "0.94 0.86 0.72 0.95" }, new[] { "tradecenter.admin.stock " + sel.Id + " -1", "库存 -1", "0.94 0.86 0.72 0.95" }, new[] { "tradecenter.admin.stock " + sel.Id + " 1", "库存 +1", "0.94 0.86 0.72 0.95" }, new[] { "tradecenter.admin.stock " + sel.Id + " 0", "不限量", "0.94 0.86 0.72 0.95" } }; float ox = 74f; for (int i = 0; i < ops.Length; i++) { string bp = PanelName + ".opbtn" + i; Veil(c, PanelName, ox, 202, ox + 118, 224, ops[i][2], s, bp); c.Add(MkLabel(ops[i][1], ox, 202, ox + 118, 224, 11, TextAnchor.MiddleCenter, "0.22 0.09 0.02 1", s), PanelName); c.Add(MkButton(ops[i][0], "", ox, 202, ox + 118, 224, 11, "0 0 0 0", "1 1 1 0", s), PanelName); ox += 124f; } // 删除单独甩到最右边并且留出空档 —— 这是唯一不可逆的操作,宁可难点也别误点 Veil(c, PanelName, 826, 202, 896, 224, "0.98 0.55 0.2 0.95", s, PanelName + ".delbtn"); c.Add(MkLabel("删除", 826, 202, 896, 224, 11, TextAnchor.MiddleCenter, "0.22 0.09 0.02 1", s), PanelName); c.Add(MkButton("tradecenter.admin.del " + sel.Id, "", 826, 202, 896, 224, 11, "0 0 0 0", "1 1 1 0", s), PanelName); } else { c.Add(MkLabel("从上面的商品里点一行,就能在这儿改价 / 改库存 / 停售 / 删除。", 74, 206, 890, 244, 11, TextAnchor.MiddleLeft, "0.472 0.326 0.212 1", s), PanelName); } // ---- 系统兜底收购 ---- var bb = _config.Buyback; Veil(c, PanelName, 62, 110, 902, 190, "0.72 0.94 0.62 0.90", s, PanelName + ".bb"); c.Add(MkLabel("系统兜底收购 " + (bb.Enabled ? "已开启" : "已关闭"), 74, 164, 500, 186, 13, TextAnchor.MiddleLeft, "0.256 0.177 0.115 1", s), PanelName); string catTxt = ""; for (int i = 0; i < bb.Categories.Count; i++) { if (i > 0) catTxt += "/"; catTxt += CatCn(bb.Categories[i]); } float mult = bb.MaxPriceMultiplier > 0f ? bb.MaxPriceMultiplier : 1f; c.Add(MkLabel("收 " + catTxt + " 单价不超过 " + Fmt((int)Math.Round(bb.UnitPrice * mult)) + " 金币/件 每 " + bb.IntervalMinutes + " 分钟扫一次", 74, 140, 700, 162, 11, TextAnchor.MiddleLeft, "0.361 0.249 0.162 1", s), PanelName); c.Add(MkLabel(bb.DestroyItems ? "收来的货直接销毁(压通胀)" : "收来的货退回卖家仓库", 74, 122, 700, 140, 10, TextAnchor.MiddleLeft, "0.442 0.305 0.199 1", s), PanelName); // 今日用量进度条 int budget = bb.DailyBudget > 0 ? bb.DailyBudget : 1; float pct = Mathf.Clamp01((float)_stats.BuybackSpent / (float)budget); Veil(c, PanelName, 74, 116, 610, 130, "0.995 0.965 0.918 0.95", s, PanelName + ".bbbarbg"); if (pct > 0f) Veil(c, PanelName, 74, 116, 74f + 536f * pct, 130, "0.3 0.6 0.1 0.95", s, PanelName + ".bbbar"); c.Add(MkLabel("今日已收 " + Fmt(_stats.BuybackSpent) + " / " + Fmt(bb.DailyBudget) + " 金币 " + _stats.BuybackOrders + " / " + bb.DailyMaxOrders + " 单 " + Fmt(_stats.BuybackItems) + " 件", 80, 116, 604, 130, 10, TextAnchor.MiddleCenter, "0.242 0.167 0.109 1", s), PanelName); Veil(c, PanelName, 630, 148, 760, 176, "0.24 0.48 0.07 0.95", s, PanelName + ".scanbtn"); c.Add(MkLabel("立即扫一单", 630, 148, 760, 176, 11, TextAnchor.MiddleCenter, "1 1 1 1", s), PanelName); c.Add(MkButton("tradecenter.admin.scan", "", 630, 148, 760, 176, 11, "0 0 0 0", "1 1 1 0", s), PanelName); Veil(c, PanelName, 772, 148, 890, 176, bb.Enabled ? "0.98 0.55 0.2 0.95" : "0.98 0.55 0.2 0.95", s, PanelName + ".bbtoggle"); c.Add(MkLabel(bb.Enabled ? "关闭收购" : "开启收购", 772, 148, 890, 176, 11, TextAnchor.MiddleCenter, "0.22 0.09 0.02 1", s), PanelName); c.Add(MkButton("tradecenter.admin.bb", "", 772, 148, 890, 176, 11, "0 0 0 0", "1 1 1 0", s), PanelName); // ---- 命令速查(控制台 / RCON 用)---- string[] cmds = { "/shop add 钻石价 [库存] [每份件数] 手持物品做成官方商品", "tradecenter.grant 玩家 商品号 [份数] 赠送补发  " + "tradecenter.bans 玩家 拉黑 / 解除", "tradecenter.stats 全服报表 tradecenter.scan 手动收购 " + "tradecenter.diag 对接诊断" }; for (int i = 0; i < cmds.Length; i++) { float ly0 = 62f + (float)(2 - i) * 13f; c.Add(MkLabel(cmds[i], 74, ly0, 890, ly0 + 13, 10, TextAnchor.MiddleLeft, "0.374 0.258 0.168 1", s), PanelName); } } // ---------------- VIP / 折扣口径(只算一次,面板和购买共用同一个算法)---------------- // 0 = 普通,1/2/3 = VIP 档(取最高的一档) private int VipLevel(BasePlayer player) { if (player == null) return 0; if (permission.UserHasPermission(player.UserIDString, PermVip3)) return 3; if (permission.UserHasPermission(player.UserIDString, PermVip2)) return 2; if (permission.UserHasPermission(player.UserIDString, PermVip1)) return 1; return 0; } // 金币实际折扣(万分比,10000 = 原价;VIP 折扣 x 限时活动,保护线不低于 1 折) private long GoldRate(BasePlayer player) { float r = 1f; switch (VipLevel(player)) { case 1: r *= _config.Discounts.GoldVip1; break; case 2: r *= _config.Discounts.GoldVip2; break; case 3: r *= _config.Discounts.GoldVip3; break; } if (_config.Discounts.ActivityEnabled && ActivityRunning()) r *= _config.Discounts.ActivityGoldRate; float min = Mathf.Clamp(_config.Discounts.MinRate, 0.01f, 1f); if (r < min) r = min; if (r > 1f) r = 1f; return (long)Math.Round(r * 10000f); } private long DiamondRate(BasePlayer player) { float r = 1f; switch (VipLevel(player)) { case 1: r *= _config.Discounts.DiamondVip1; break; case 2: r *= _config.Discounts.DiamondVip2; break; case 3: r *= _config.Discounts.DiamondVip3; break; } if (_config.Discounts.ActivityEnabled && ActivityRunning()) r *= _config.Discounts.ActivityDiamondRate; float min = Mathf.Clamp(_config.Discounts.MinRate, 0.01f, 1f); if (r < min) r = min; if (r > 1f) r = 1f; return (long)Math.Round(r * 10000f); } private bool ActivityRunning() { if (!_config.Discounts.ActivityEnabled) return false; DateTime t; if (!string.IsNullOrEmpty(_config.Discounts.ActivityStart) && DateTime.TryParse(_config.Discounts.ActivityStart, out t) && DateTime.Now < t) return false; if (!string.IsNullOrEmpty(_config.Discounts.ActivityEnd) && DateTime.TryParse(_config.Discounts.ActivityEnd, out t) && DateTime.Now > t) return false; return true; } private static string ShowRate(long rate) { if (rate >= 10000) return "无折扣"; return ((10000 - rate) / 100.0).ToString("0.#", CultureInfo.InvariantCulture) + "% off"; } private string FeeVipText(BasePlayer player) { switch (VipLevel(player)) { case 1: return (100 - _config.Fees.Vip1Rate * 100f).ToString("0.#") + "% 减免"; case 2: return (100 - _config.Fees.Vip2Rate * 100f).ToString("0.#") + "% 减免"; case 3: return _config.Fees.Vip3Rate <= 0f ? "全免" : ((100 - _config.Fees.Vip3Rate * 100f).ToString("0.#") + "% 减免"); default: return "无减免"; } } // 按折扣率算折后价(向上取整,绝不让服主因为四舍五入少收钱) private static long ApplyRate(long price, long rate) { if (price <= 0) return 0; long v = (price * rate + 9999L) / 10000L; return v < 1L ? 1L : v; } // 累计省下的钱(阶段1 = 已花金币;阶段3 上线后改成「原价合计 - 实付合计」的真折扣额) private long SavedTotal(BasePlayer player) { var r = Rec(player.userID, false); return r == null ? 0 : r.SpentGold; } #endregion } }