类别来源(完整链):挂载 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。
158 lines
6.8 KiB
JavaScript
158 lines
6.8 KiB
JavaScript
// 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 };
|