From 4277e0ae081cba04bd5fbfce6985cfebda2d5602 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Fri, 18 Sep 2026 12:26:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20M8=20=E6=9F=A5=E6=98=8E=E7=89=A9?= =?UTF-8?q?=E5=93=81=E7=B1=BB=E5=88=AB=E6=9D=A5=E6=BA=90=E5=B9=B6=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E5=90=8C=E7=B1=BB=E4=BA=92=E6=96=A5=EF=BC=88=E5=88=87?= =?UTF-8?q?=E6=AD=A6=E5=99=A8=E6=97=B6=E6=97=A7=E4=BB=B6=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8D=B8=E4=B8=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 类别来源(完整链):挂载 reducer IB.yr9 用物品的 qr3_1 查类别表;qr3_1 = oB 构造第 4 参 = n.yr6_1.ici_1;yr6_1 来自 pt 里 model 类 Ij 那一项 = modelId 6117306895090150422(module_85196::Lo,字段名就叫 category)。交叉验证:真实 5 件 mountedItems 的类别 {enum#1,enum#0,enum#13,enum#2,enum#8} 互不相同 → 规则成立;并确认 M7 留下的第 6 件 920009631018 与 920009631039 同为 enum#1(都是底盘),正是 M7 缺口的体现。排查并排除:不是本地静态表;GarageSelectItem 的 Yy/Xy/ek/Wy 是另一套枚举(模型 ox/2114179554921095892 的 pr3_1),与挂载互斥无关。实现:schema 加 Items.Category + 索引;新增可重跑的 backfill_category.js(从 ObjectModels 回填,支持 --dry),992 件全部回填;store.SetMounted 改为「先卸同类、再置新件」且幂等,日志打印「同时卸下 N 件同类」;客户端 ClientState.MarkMounted 同步互斥(与 IB.yr9 同顺序),UI 每行显示类别。验收:连装两件 enum#1 → 库中只剩后者(日志「同时卸下 1 件同类」);浏览器 a11y 快照前后对比正确;回归 selftest 1108/9/90、CompareContainerDecode 5 帧 0 不一致、三构建全绿。未解:enum#N 只是线形序数,枚举成员名未解出,库里一律存原始令牌 enum#N。 --- 3DTank/tools/server/backfill_category.js | 157 ++++++++++++++++++ 3DTank/tools/server/schema.sql | 14 +- 3DTank/tools/server/store.js | 39 +++-- client/src/jsMain/kotlin/Main.kt | 3 +- .../src/jsMain/kotlin/Protocol/ClientState.kt | 37 ++++- .../jsMain/kotlin/Protocol/GarageSession.kt | 19 ++- 6 files changed, 249 insertions(+), 20 deletions(-) create mode 100644 3DTank/tools/server/backfill_category.js diff --git a/3DTank/tools/server/backfill_category.js b/3DTank/tools/server/backfill_category.js new file mode 100644 index 0000000..14111e9 --- /dev/null +++ b/3DTank/tools/server/backfill_category.js @@ -0,0 +1,157 @@ +// backfill_category.js - M8:把物品的「装备槽类别」回填进 Items.Category(可重跑) +// +// 用法: node tools/server/backfill_category.js [--space 100073070] [--dry] +// +// 为什么需要这张表这一列:M7 的 SetMounted 只会把该件置 Mounted=1,**不知道类别**,所以连装两件同槽 +// 物品时库里两件都是 1。M8 查清了类别来源(见 schema.sql 的 Items.Category 注释与架构结论): +// 物品对象在 pt 里的模型项 modelId=6117306895090150422(module_85196::Lo)有一个唯一字段 `category` +// (prop ici_1,线型 enum),客户端挂载 reducer IB.yr9 就是按它做同类互斥的。 +// +// 类别从哪取:ObjectModels 里 ModelId=6117306895090150422 的那些行,其 ValueJson 的 `category` 字段。 +// 同一个 ItemId 在多帧/多空间可能有多行 —— 口径 = **MIN(Seq)**(首次出现那一行,与 store.js 的 +// 「对象出现序」同源)。取不到的行 Category 留 NULL(宁可不填,也不猜;SetMounted 见 NULL 会跳过互斥并打日志)。 +// +// 幂等 / 可重跑: +// · 列不存在才 ALTER(information_schema.COLUMNS 判定;MySQL 8 没有 ADD COLUMN IF NOT EXISTS); +// · 只对「值确实要变」的行发 UPDATE,重复跑第二次 changed=0; +// · 全程不动 Mounted/InDepot/InMarket(回填不改变装备状态)。 +// +// --dry:只算不写(只打印将要发生的变更数,不碰库)。 +'use strict'; +const Db = require('./db'); + +const Argv = process.argv.slice(2); +const Arg = (Name, Def) => { + const I = Argv.indexOf('--' + Name); + return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def; +}; +const DRY = Argv.indexOf('--dry') >= 0; +const SpaceId = String(Arg('space', '100073070')); + +// 物品类别所在的模型 id(module_85196::Lo)—— 出处见文件头 +const CATEGORY_MODEL = '6117306895090150422'; + +const PRINT = (Msg) => console.log(Msg); + +// 补列(幂等):information_schema 查不到 Items.Category 才 ALTER,顺带建索引 +async function EnsureCategoryColumn(Pool) { + const Row = await Db.One( + Pool, + 'SELECT COUNT(*) AS N FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', + ['Items', 'Category'], + ); + if (Number(Row.N) > 0) { + PRINT('· Items.Category 已存在,跳过建列'); + return false; + } + await Pool.query( + "ALTER TABLE Items ADD COLUMN Category VARCHAR(16) NULL COMMENT '装备槽类别(pt 模型 6117306895090150422 的 category,如 enum#1)' AFTER OwnerUid", + ); + const Idx = await Db.One( + Pool, + 'SELECT COUNT(*) AS N FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?', + ['Items', 'ItemsCategoryIdx'], + ); + if (Number(Idx.N) === 0) await Pool.query('ALTER TABLE Items ADD KEY ItemsCategoryIdx (Category)'); + PRINT('· 已为 Items 建列 Category + 索引 ItemsCategoryIdx'); + return true; +} + +// ItemId → 类别令牌(如 'enum#1')。取不到返回 null +async function LoadCategories(Pool, ItemIds) { + const Out = new Map(); + for (let I = 0; I < ItemIds.length; I += 200) { + const Chunk = ItemIds.slice(I, I + 200); + const Rows = await Db.Many( + Pool, + 'SELECT ObjectId, ValueJson FROM ObjectModels WHERE SpaceId = ? AND ModelId = ? AND ObjectId IN (' + + Chunk.map(() => '?').join(',') + ') ORDER BY Seq', + [SpaceId, CATEGORY_MODEL].concat(Chunk), + ); + for (const R of Rows) { + const Id = String(R.ObjectId); + if (Out.has(Id)) continue; // ORDER BY Seq ⇒ 第一次出现的那行胜出 + try { + const V = JSON.parse(R.ValueJson); + if (V && typeof V.category === 'string') Out.set(Id, V.category); + } catch (Err) { + // ValueJson 不是合法 JSON:这一行不参与,继续看下一行(列本身有 JSON_VALID 约束,正常不会发生) + } + } + } + return Out; +} + +async function Main() { + PRINT('backfill_category 空间=' + SpaceId + ' 模型=' + CATEGORY_MODEL + (DRY ? ' [--dry 只算不写]' : '')); + const Pool = Db.Connect(); + try { + await EnsureCategoryColumn(Pool); + const Items = await Db.Many(Pool, 'SELECT ItemId, Category FROM Items ORDER BY ItemId'); + if (!Items.length) { + PRINT('✗ Items 表是空的:先跑 import_db.js 导入抓包'); + return 1; + } + const Cats = await LoadCategories(Pool, Items.map((R) => String(R.ItemId))); + let Filled = 0; + let Cleared = 0; + let Unknown = 0; + const Pending = []; + for (const R of Items) { + const Id = String(R.ItemId); + const Cur = R.Category === null || R.Category === undefined ? null : String(R.Category); + const Next = Cats.has(Id) ? Cats.get(Id) : null; + if (Next === Cur) continue; + if (Next === null) Cleared++; + else Filled++; + if (Cur === null && Next === null) continue; + Pending.push({ Id, Next }); + } + for (const R of Items) if (!Cats.has(String(R.ItemId))) Unknown++; + + if (DRY) { + PRINT('· [--dry] 将要变更 ' + Pending.length + ' 行(填 ' + Filled + ' / 清 ' + Cleared + ');未写库'); + } else { + for (const P of Pending) { + await Pool.query('UPDATE Items SET Category = ? WHERE ItemId = ?', [P.Next, P.Id]); + } + PRINT('· 已写库:变更 ' + Pending.length + ' 行(填 ' + Filled + ' / 清 ' + Cleared + ')'); + } + PRINT('· Items 共 ' + Items.length + ' 行;解出类别 ' + Cats.size + ' 行(本次要变 ' + Pending.length + + ');库里仍无类别 ' + Unknown + ' 行(SetMounted 见 NULL 会跳过同类互斥并打日志)'); + + // 分布:按类别统计(含未解出的 NULL) + const Dist = await Db.Many( + Pool, + 'SELECT Category, COUNT(*) AS N FROM Items GROUP BY Category ORDER BY (Category IS NULL), Category', + ); + PRINT('· Items.Category 分布(全部 992 件):'); + for (const D of Dist) PRINT(' ' + String(D.Category === null ? '' : D.Category).padEnd(8) + D.N); + + // 仓库那 15 件的明细(M8 验收口径:ItemId / Category / Mounted) + const Depot = await Db.Many( + Pool, + 'SELECT ItemId, Category, Mounted FROM Items WHERE InDepot = 1 ORDER BY ItemId', + ); + PRINT('· 仓库物品(InDepot=1)共 ' + Depot.length + ' 件:'); + for (const D of Depot) { + PRINT(' ' + String(D.ItemId).padEnd(16) + '类别=' + String(D.Category === null ? '' : D.Category).padEnd(8) + + 'Mounted=' + D.Mounted); + } + return 0; + } finally { + await Pool.end(); + } +} + +// 只有直接运行才执行(被 require 时只导出函数,不连库) +if (require.main === module) { + Main() + .then((Code) => process.exit(Code)) + .catch((Err) => { + console.error('回填失败:' + (Err && Err.stack ? Err.stack : Err)); + process.exit(1); + }); +} + +module.exports = { CATEGORY_MODEL, LoadCategories, EnsureCategoryColumn }; diff --git a/3DTank/tools/server/schema.sql b/3DTank/tools/server/schema.sql index eb63fd8..c9ba8ce 100644 --- a/3DTank/tools/server/schema.sql +++ b/3DTank/tools/server/schema.sql @@ -115,15 +115,27 @@ CREATE TABLE IF NOT EXISTS Containers ( ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '容器记录原文'; -- 物品:仓库 / 市场 / 已挂载 三个旗标(Items 表就是 itemsOnDepot / itemsOnMarket / mountedItems 的落地) +-- +-- Category(M8):物品的「装备槽类别」,同类互斥的依据(改 SetMounted 用它把同类旧件卸下)。 +-- 来源:物品对象在 pt 里的一个模型项,modelId = 6117306895090150422(module_85196::Lo), +-- 唯一字段名就叫 category(prop ici_1,线型 enum),值形如 enum#0/1/2/3/8/9/13。 +-- 客户端侧的对应关系(逐行证据):module_10032.js:5266 的 oB.qr3_1 由 sr6 的 n.yr6_1.ici_1 传入 +-- (28655 → 28746),而 hB.yr6_1 就是 pt 里那个 modelId=6117306895090150422 的项(module_18334.js:1760); +-- 挂载 reducer IB.yr9(29845-29883)按 qr3_1 找同类已挂载件并置 0,再置新件为 1。 +-- ★ 值一律存**原始枚举令牌**(如 'enum#1'),不转数字:协议里没解出枚举成员名,转数字等于自己发明口径。 +-- NULL = 该项没解出类别(此时 SetMounted 不做同类互斥,只置位)。 +-- ★ 老库不会因 CREATE TABLE IF NOT EXISTS 拿到这一列,用 backfill_category.js 补列并回填(可重跑)。 CREATE TABLE IF NOT EXISTS Items ( ItemId BIGINT NOT NULL, OwnerUid VARCHAR(24) NULL, + Category VARCHAR(16) NULL COMMENT '装备槽类别(pt 模型 6117306895090150422 的 category,如 enum#1)', InDepot TINYINT (1) NOT NULL DEFAULT 0, InMarket TINYINT (1) NOT NULL DEFAULT 0, Mounted TINYINT (1) NOT NULL DEFAULT 0, UpdatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (ItemId), - KEY ItemsOwnerIdx (OwnerUid) + KEY ItemsOwnerIdx (OwnerUid), + KEY ItemsCategoryIdx (Category) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '物品'; -- 车库挂载位(对应 devicesLoaded 记录里那些 ObjectId) diff --git a/3DTank/tools/server/store.js b/3DTank/tools/server/store.js index 5fbd693..d743496 100644 --- a/3DTank/tools/server/store.js +++ b/3DTank/tools/server/store.js @@ -176,23 +176,42 @@ function MountedFlagsSnapshot() { return Cache.MountedFlags ? Cache.MountedFlags.slice() : null; } -// 装备落库:把该 ItemId 置 Mounted=1。 -// ★ 幂等:UPDATE 带 `AND Mounted <> 1`,重复收到同一件时 affectedRows=0、不产生任何副作用。 -// ★ 类别替换(同类互斥)**未实现**:物品的「类别」字段来源还没解出来,不硬猜 —— -// 本函数只置该件为已挂载,不清除同类别里原来挂着的那件。 +// 装备落库:**先卸下同类别里的其它件,再置该件为已挂载**(M8 同类互斥)。 +// ★ 类别来源(M8 查清,逐行证据见 schema.sql 的 Items.Category 注释):物品对象在 pt 里的模型项 +// modelId = 6117306895090150422(module_85196::Lo),唯一字段 `category`(prop ici_1,线型 enum), +// 已由 backfill_category.js 回填进 Items.Category。真实客户端挂载 reducer IB.yr9(module_10032.js:29845-29883) +// 走的就是这个类别:先对「入参涉及类别里已挂载的物品」置 mounted=false,再按入参把新物品置 true。 +// ★ 幂等:两条 UPDATE 都带「只在值确实要变时才动」的条件 —— 重复收到同一件时(该件已 1、同类已 0) +// 两条都不改任何行、不产生副作用,日志里能看出「同时卸下 0 件同类 / 该件已是 1」。 +// ★ Category 为 NULL(该项没解出类别)⇒ 只置位、不做互斥,并打一行日志说明(不硬猜类别)。 async function SetMounted(ItemId) { Require(); const Id = String(ItemId); + const Row = await Db.One(Pool, 'SELECT Category FROM Items WHERE ItemId = ?', [Id]); + const Category = Row && Row.Category !== null && Row.Category !== undefined ? String(Row.Category) : null; + let Unmounted = 0; + if (Category === null) { + Log(' ⚠ 落库:ItemId=' + Id + ' 库里没有类别(Items.Category 为空)→ 只置位,不做同类互斥'); + } else { + // 同类别、当前已挂载、且不是本件 → 全部置 0(条件 Mounted=1 保证第二次调用 affectedRows=0) + const [Res2] = await Pool.query( + 'UPDATE Items SET Mounted = 0 WHERE Category = ? AND Mounted = 1 AND ItemId <> ?', + [Category, Id], + ); + Unmounted = Res2.affectedRows; + } const [Res] = await Pool.query('UPDATE Items SET Mounted = 1 WHERE ItemId = ? AND Mounted <> 1', [Id]); const Changed = Res.affectedRows > 0; - if (Changed) { - Log('落库:Items.Mounted=1(ItemId=' + Id + ')已写入'); - await LoadMountedFlags(); // 刷新快照,下一次进车库的回显覆盖就能看到它 + Log( + '→ 落库:Items.Mounted=1(ItemId=' + Id + ',类别=' + (Category === null ? '未知' : Category) + + ',同时卸下 ' + Unmounted + ' 件同类)' + (Changed ? '' : '(该件已是 1,幂等跳过)'), + ); + if (Changed || Unmounted > 0) { + await LoadMountedFlags(); // 刷新快照,下一次进车库的回显覆盖就能看到新的集合 Cache.Flags.clear(); // 查库路径(FlaggedItems)的旗标缓存一并失效 } else { - Log('落库:Items.Mounted=1(ItemId=' + Id + ')已是 1,幂等跳过(无副作用)'); - const Row = await Db.One(Pool, 'SELECT Mounted FROM Items WHERE ItemId = ?', [Id]); - if (!Row) Log(' ⚠ 库里没有 ItemId=' + Id + ' 这一行,落库未生效'); + const Row2 = await Db.One(Pool, 'SELECT Mounted FROM Items WHERE ItemId = ?', [Id]); + if (!Row2) Log(' ⚠ 库里没有 ItemId=' + Id + ' 这一行,落库未生效'); } return Changed; } diff --git a/client/src/jsMain/kotlin/Main.kt b/client/src/jsMain/kotlin/Main.kt index 8c46235..47a2559 100644 --- a/client/src/jsMain/kotlin/Main.kt +++ b/client/src/jsMain/kotlin/Main.kt @@ -70,7 +70,8 @@ private val App = FC { for (Id in Session.DepotItems) { val IsMounted = Mounted.contains(Id) li { - +("$Id " + (if (IsMounted) "已挂载" else "未挂载") + " ") + // 类别一并渲染:M8 的同类互斥就是按它判定的(来源见 ClientState.ItemCategory 头注) + +("$Id 类别 ${Session.CategoryOf(Id) ?: "?"} " + (if (IsMounted) "已挂载" else "未挂载") + " ") // 「装备」按钮:点击发 M_.fcn(ItemId);已挂载的置灰(防重复点,落库本身也幂等) button { disabled = IsMounted diff --git a/client/src/jsMain/kotlin/Protocol/ClientState.kt b/client/src/jsMain/kotlin/Protocol/ClientState.kt index 711585c..000a7c6 100644 --- a/client/src/jsMain/kotlin/Protocol/ClientState.kt +++ b/client/src/jsMain/kotlin/Protocol/ClientState.kt @@ -90,6 +90,9 @@ private const val SLOT_MARK_MODIFICATION = "2494628067134376039" private const val SLOT_MARK_DEVICES_AVAILABLE = "7391892068905080676" private const val SLOT_MARK_MODIFICATION_COUNT = 7 +// 物品「装备槽类别」所在的模型项 modelId(module_85196::Lo)—— 依据见 ItemCategory 的头注 +private const val CATEGORY_MODEL = "6117306895090150422" + internal class ClientState { // ---- 物品三旗标(每个只记最新一次,附来源帧) @@ -100,11 +103,37 @@ internal class ClientState { var MountedItems: List = emptyList(); private set var MountedItemsFrame = -1; private set - // 装备动作的本地乐观更新(真实客户端的 jU reducer 就是这么做的):点一下就把该件标成已挂载, - // 界面立即可见;下一次进车库时服务端下发的 mountedItems(已按库覆盖)会把这里整体替换成权威值。 + // 装备动作的本地乐观更新(真实客户端的 jU / IB.yr9 reducer 就是这么做的):点一下就把该件标成已挂载, + // 并**把同类别里原来挂着的那件标成未挂载**(M8 同类互斥);界面立即可见,不必等服务端回声。 + // 下一次进车库时服务端下发的 mountedItems(已按库覆盖)会把这里整体替换成权威值。 fun MarkMounted(ItemId: String) { - if (MountedItems.contains(ItemId)) return - MountedItems = MountedItems + ItemId + val Cat = ItemCategory(ItemId) + var Next = MountedItems + if (Cat != null) { + // 「类别」相同的其它已挂载件先卸下 —— 与 IB.yr9 的顺序一致(先置 false 再置 true) + val SameCat = Next.filter { it != ItemId && ItemCategory(it) == Cat } + if (SameCat.isNotEmpty()) Next = Next.filter { it !in SameCat } + } + if (!Next.contains(ItemId)) Next = Next + ItemId + if (Next != MountedItems) MountedItems = Next + } + + // ---------------------------------------------------------------- 物品类别(M8 同类互斥的依据) + + // 物品的「装备槽类别」 = 该物品对象在 pt 里的模型项 modelId=6117306895090150422(module_85196::Lo) + // 的唯一字段 `category`(prop ici_1)。逐行证据与「为什么是这个模型」见 3DTank/tools/server/schema.sql + // 的 Items.Category 注释:真实客户端 IB.yr9(module_10032.js:29845-29883)按物品的 qr3_1 做同类互斥, + // 而 qr3_1 就是 sr6 里的 n.yr6_1.ici_1(module_10032.js:28655 → 28746),yr6_1 来自 + // module_18334.js:1760 的 `Gr(n, Z(Ij))` —— 即 pt 里的 modelId=6117306895090150422 那项。 + // 没有 pt 数据 / 该项没有 category ⇒ 返回 null(此时不做互斥,不硬猜)。 + fun ItemCategory(ItemId: String): String? { + val O = PtByObjectId[ItemId] ?: return null + for (M in O.Models) { + if (M.ModelId != CATEGORY_MODEL) continue + val V = M.Value as? Map<*, *> ?: return null + return V["category"]?.toString() + } + return null } // ---- 挂载位 / 设备 diff --git a/client/src/jsMain/kotlin/Protocol/GarageSession.kt b/client/src/jsMain/kotlin/Protocol/GarageSession.kt index ed7b6e7..4ed611c 100644 --- a/client/src/jsMain/kotlin/Protocol/GarageSession.kt +++ b/client/src/jsMain/kotlin/Protocol/GarageSession.kt @@ -56,6 +56,9 @@ internal class GarageSession(private val OnChanged: () -> Unit) { val Slots: List get() = StateObj.DevicesRecords val DeviceCount: Int get() = StateObj.DeviceCount + // 某件物品的「装备槽类别」(pt 模型 6117306895090150422 的 category);没有 pt 数据时返回 null + fun CategoryOf(ItemId: String): String? = StateObj.ItemCategory(ItemId) + // ---------------------------------------------------------------- 连接 fun Connect() { @@ -222,8 +225,10 @@ internal class GarageSession(private val OnChanged: () -> Unit) { // ---------------------------------------------------------------- 装备动作(M7) - // 装备一件仓库物品:发 M_.fcn(ItemId)(靶 = 空间 id,口径同 M4/M5),并按本地乐观语义立刻标成已挂载。 - // 落库与持久化由服务端负责;这里只保证「点一下 → 上行入队(下一拍发出)→ 界面立即显示已挂载」。 + // 装备一件仓库物品:发 M_.fcn(ItemId)(靶 = 空间 id,口径同 M4/M5),并按本地乐观语义立刻标成已挂载, + // 同时**把同类别里原来挂着的那件标成未挂载**(M8 同类互斥,与真实客户端 IB.yr9 reducer 同口径)。 + // 落库与持久化由服务端负责(服务端 SetMounted 也做同类互斥);这里只保证「点一下 → 上行入队(下一拍发出) + // → 界面立即正确」。 fun Equip(ItemId: String) { val Id = ItemId.toLongOrNull() if (Id == null) { @@ -231,12 +236,18 @@ internal class GarageSession(private val OnChanged: () -> Unit) { OnChanged() return } - val Already = StateObj.MountedItems.contains(ItemId) + val Cat = StateObj.ItemCategory(ItemId) + val Before = StateObj.MountedItems + val Already = Before.contains(ItemId) + // 互斥清单要在 MarkMounted 之前算(那里会改 MountedItems) + val SameCat = if (Cat == null) emptyList() else Before.filter { it != ItemId && StateObj.ItemCategory(it) == Cat } val Bytes = Uplink.BuildMFcn(SpaceId, Id) EnqueueManual(Uplink.M_FCN, SpaceId.toString(), Bytes) StateObj.MarkMounted(ItemId) AddLog( - "⟳ 装备 $ItemId → 已入队 M_.fcn(靶=$SpaceId,${Bytes.size} 字节${if (Already) ",本地已是已挂载" else ",本地乐观标为已挂载"})" + "⟳ 装备 $ItemId(类别=${Cat ?: "未知"})→ 已入队 M_.fcn(靶=$SpaceId,${Bytes.size} 字节" + + (if (Already) ",本地已是已挂载" else ",本地乐观标为已挂载") + + (if (SameCat.isNotEmpty()) ",同时把同类 ${SameCat.joinToString(",")} 标为未挂载" else "") ) OnChanged() }