feat: M8 查明物品类别来源并实现同类互斥(切武器时旧件自动卸下)

类别来源(完整链):挂载 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。
This commit is contained in:
TermiNexus committed 2026-09-18 12:26:01 +08:00
1 parent ab1a905d28
commit 4277e0ae08
6 files changed
+249 -20

No files matched your search

+157
View File
@@ -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<ja>),客户端挂载 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 ? '<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 ? '<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 };
+13 -1
View File
@@ -115,15 +115,27 @@ CREATE TABLE IF NOT EXISTS Containers (
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '容器记录原文'; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '容器记录原文';
-- 物品:仓库 / 市场 / 已挂载 三个旗标(Items 表就是 itemsOnDepot / itemsOnMarket / mountedItems 的落地) -- 物品:仓库 / 市场 / 已挂载 三个旗标(Items 表就是 itemsOnDepot / itemsOnMarket / mountedItems 的落地)
--
-- Category(M8):物品的「装备槽类别」,同类互斥的依据(改 SetMounted 用它把同类旧件卸下)。
-- 来源:物品对象在 pt 里的一个模型项,modelId = 6117306895090150422(module_85196::Lo),
-- 唯一字段名就叫 category(prop ici_1,线型 enum<ja>),值形如 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 ( CREATE TABLE IF NOT EXISTS Items (
ItemId BIGINT NOT NULL, ItemId BIGINT NOT NULL,
OwnerUid VARCHAR(24) NULL, OwnerUid VARCHAR(24) NULL,
Category VARCHAR(16) NULL COMMENT '装备槽类别(pt 模型 6117306895090150422 的 category,如 enum#1)',
InDepot TINYINT (1) NOT NULL DEFAULT 0, InDepot TINYINT (1) NOT NULL DEFAULT 0,
InMarket TINYINT (1) NOT NULL DEFAULT 0, InMarket TINYINT (1) NOT NULL DEFAULT 0,
Mounted TINYINT (1) NOT NULL DEFAULT 0, Mounted TINYINT (1) NOT NULL DEFAULT 0,
UpdatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UpdatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (ItemId), PRIMARY KEY (ItemId),
KEY ItemsOwnerIdx (OwnerUid) KEY ItemsOwnerIdx (OwnerUid),
KEY ItemsCategoryIdx (Category)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '物品'; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '物品';
-- 车库挂载位(对应 devicesLoaded 记录里那些 ObjectId) -- 车库挂载位(对应 devicesLoaded 记录里那些 ObjectId)
+29 -10
View File
@@ -176,23 +176,42 @@ function MountedFlagsSnapshot() {
return Cache.MountedFlags ? Cache.MountedFlags.slice() : null; return Cache.MountedFlags ? Cache.MountedFlags.slice() : null;
} }
// 装备落库:把该 ItemId 置 Mounted=1。 // 装备落库:**先卸下同类别里的其它件,再置该件为已挂载**(M8 同类互斥)。
// ★ 幂等:UPDATE 带 `AND Mounted <> 1`,重复收到同一件时 affectedRows=0、不产生任何副作用。 // ★ 类别来源(M8 查清,逐行证据见 schema.sql 的 Items.Category 注释):物品对象在 pt 里的模型项
// ★ 类别替换(同类互斥)**未实现**:物品的「类别」字段来源还没解出来,不硬猜 —— // modelId = 6117306895090150422(module_85196::Lo),唯一字段 `category`(prop ici_1,线型 enum<ja>),
// 本函数只置该件为已挂载,不清除同类别里原来挂着的那件。 // 已由 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) { async function SetMounted(ItemId) {
Require(); Require();
const Id = String(ItemId); 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 [Res] = await Pool.query('UPDATE Items SET Mounted = 1 WHERE ItemId = ? AND Mounted <> 1', [Id]);
const Changed = Res.affectedRows > 0; const Changed = Res.affectedRows > 0;
if (Changed) { Log(
Log('落库:Items.Mounted=1(ItemId=' + Id + ')已写入'); '→ 落库:Items.Mounted=1(ItemId=' + Id + ',类别=' + (Category === null ? '未知' : Category) +
await LoadMountedFlags(); // 刷新快照,下一次进车库的回显覆盖就能看到它 ',同时卸下 ' + Unmounted + ' 件同类)' + (Changed ? '' : '(该件已是 1,幂等跳过)'),
);
if (Changed || Unmounted > 0) {
await LoadMountedFlags(); // 刷新快照,下一次进车库的回显覆盖就能看到新的集合
Cache.Flags.clear(); // 查库路径(FlaggedItems)的旗标缓存一并失效 Cache.Flags.clear(); // 查库路径(FlaggedItems)的旗标缓存一并失效
} else { } else {
Log('落库:Items.Mounted=1(ItemId=' + Id + ')已是 1,幂等跳过(无副作用)'); const Row2 = await Db.One(Pool, 'SELECT Mounted FROM Items WHERE ItemId = ?', [Id]);
const Row = await Db.One(Pool, 'SELECT Mounted FROM Items WHERE ItemId = ?', [Id]); if (!Row2) Log(' ⚠ 库里没有 ItemId=' + Id + ' 这一行,落库未生效');
if (!Row) Log(' ⚠ 库里没有 ItemId=' + Id + ' 这一行,落库未生效');
} }
return Changed; return Changed;
} }
+2 -1
View File
@@ -70,7 +70,8 @@ private val App = FC<Props> {
for (Id in Session.DepotItems) { for (Id in Session.DepotItems) {
val IsMounted = Mounted.contains(Id) val IsMounted = Mounted.contains(Id)
li { li {
+("$Id " + (if (IsMounted) "已挂载" else "未挂载") + " ") // 类别一并渲染:M8 的同类互斥就是按它判定的(来源见 ClientState.ItemCategory 头注)
+("$Id 类别 ${Session.CategoryOf(Id) ?: "?"} " + (if (IsMounted) "已挂载" else "未挂载") + " ")
// 「装备」按钮:点击发 M_.fcn(ItemId);已挂载的置灰(防重复点,落库本身也幂等) // 「装备」按钮:点击发 M_.fcn(ItemId);已挂载的置灰(防重复点,落库本身也幂等)
button { button {
disabled = IsMounted disabled = IsMounted
@@ -90,6 +90,9 @@ private const val SLOT_MARK_MODIFICATION = "2494628067134376039"
private const val SLOT_MARK_DEVICES_AVAILABLE = "7391892068905080676" private const val SLOT_MARK_DEVICES_AVAILABLE = "7391892068905080676"
private const val SLOT_MARK_MODIFICATION_COUNT = 7 private const val SLOT_MARK_MODIFICATION_COUNT = 7
// 物品「装备槽类别」所在的模型项 modelId(module_85196::Lo)—— 依据见 ItemCategory 的头注
private const val CATEGORY_MODEL = "6117306895090150422"
internal class ClientState { internal class ClientState {
// ---- 物品三旗标(每个只记最新一次,附来源帧) // ---- 物品三旗标(每个只记最新一次,附来源帧)
@@ -100,11 +103,37 @@ internal class ClientState {
var MountedItems: List<String> = emptyList(); private set var MountedItems: List<String> = emptyList(); private set
var MountedItemsFrame = -1; private set var MountedItemsFrame = -1; private set
// 装备动作的本地乐观更新(真实客户端的 jU reducer 就是这么做的):点一下就把该件标成已挂载, // 装备动作的本地乐观更新(真实客户端的 jU / IB.yr9 reducer 就是这么做的):点一下就把该件标成已挂载,
// 界面立即可见;下一次进车库时服务端下发的 mountedItems(已按库覆盖)会把这里整体替换成权威值。 // 并**把同类别里原来挂着的那件标成未挂载**(M8 同类互斥);界面立即可见,不必等服务端回声。
// 下一次进车库时服务端下发的 mountedItems(已按库覆盖)会把这里整体替换成权威值。
fun MarkMounted(ItemId: String) { fun MarkMounted(ItemId: String) {
if (MountedItems.contains(ItemId)) return val Cat = ItemCategory(ItemId)
MountedItems = MountedItems + 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
} }
// ---- 挂载位 / 设备 // ---- 挂载位 / 设备
@@ -56,6 +56,9 @@ internal class GarageSession(private val OnChanged: () -> Unit) {
val Slots: List<DevicesLoadedEntry> get() = StateObj.DevicesRecords val Slots: List<DevicesLoadedEntry> get() = StateObj.DevicesRecords
val DeviceCount: Int get() = StateObj.DeviceCount val DeviceCount: Int get() = StateObj.DeviceCount
// 某件物品的「装备槽类别」(pt 模型 6117306895090150422 的 category);没有 pt 数据时返回 null
fun CategoryOf(ItemId: String): String? = StateObj.ItemCategory(ItemId)
// ---------------------------------------------------------------- 连接 // ---------------------------------------------------------------- 连接
fun Connect() { fun Connect() {
@@ -222,8 +225,10 @@ internal class GarageSession(private val OnChanged: () -> Unit) {
// ---------------------------------------------------------------- 装备动作(M7) // ---------------------------------------------------------------- 装备动作(M7)
// 装备一件仓库物品:发 M_.fcn(ItemId)(靶 = 空间 id,口径同 M4/M5),并按本地乐观语义立刻标成已挂载。 // 装备一件仓库物品:发 M_.fcn(ItemId)(靶 = 空间 id,口径同 M4/M5),并按本地乐观语义立刻标成已挂载,
// 落库与持久化由服务端负责;这里只保证「点一下 → 上行入队(下一拍发出)→ 界面立即显示已挂载」。 // 同时**把同类别里原来挂着的那件标成未挂载**(M8 同类互斥,与真实客户端 IB.yr9 reducer 同口径)。
// 落库与持久化由服务端负责(服务端 SetMounted 也做同类互斥);这里只保证「点一下 → 上行入队(下一拍发出)
// → 界面立即正确」。
fun Equip(ItemId: String) { fun Equip(ItemId: String) {
val Id = ItemId.toLongOrNull() val Id = ItemId.toLongOrNull()
if (Id == null) { if (Id == null) {
@@ -231,12 +236,18 @@ internal class GarageSession(private val OnChanged: () -> Unit) {
OnChanged() OnChanged()
return 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) val Bytes = Uplink.BuildMFcn(SpaceId, Id)
EnqueueManual(Uplink.M_FCN, SpaceId.toString(), Bytes) EnqueueManual(Uplink.M_FCN, SpaceId.toString(), Bytes)
StateObj.MarkMounted(ItemId) StateObj.MarkMounted(ItemId)
AddLog( 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() OnChanged()
} }