Files
TankIC/3DTank/tools/server/backfill_category.js
T
TermiNexus 4277e0ae08 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。
2026-09-18 12:26:01 +08:00

158 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };