Files
ark-stronghold-protocol/test/ui/feedback5-module-trait.test.js
T
sganggs 02353e13f8 feedback5 CB5: a module's added trait line (official additionalDescription, PRTS 特性追加) shows after the class trait, not instead of it — the 干员调配 module card (特性 + 特性追加 rows), its 局内数值, the detail card and the 自选 module row (community report of 2026-10-06, item 16.2: 114 of the 164 modules the screen offers showed the extra line alone)
Text only: data/chess.json keeps both lines (traitOverride.desc = the class trait or the module's rewrite,
moduleDesc = the added line) and the battle unit keeps the class trait with the module on — the same trait
text, targeting and attack shape as 不装备 for every added-line module (圣约送葬人 REA-Y also rewrites it).
New helpers ui/loadoutModel.js traitLines / fullTraitText; traitText (ui/detailPanel.js) composes the two.
Golden unchanged (no sim change). Tests: test/ui/feedback5-module-trait.test.js (new), loadout-stats updated.
2026-10-07 01:33:21 +08:00

150 lines
9.2 KiB
JavaScript

// Community report of 2026-10-06, item 16.2: 「干员调配界面很多干员的某个模组只有额外特性没有基础特性,不清楚是文本错误还是
// 实际效果也有错」. A module's trait part either rewrites the class trait (official `overrideDescripton`) or adds a line
// after it (official `additionalDescription`, PRTS 「特性追加」). The data keeps both (`traitOverride.desc` = the class trait
// or its rewrite, `moduleDesc` = the added line) and the sim keeps the class trait with the module on; the text was wrong:
// the 干员调配 module card, its 局内数值, the detail card and the 自选 module row showed the added line alone (114 of the
// 164 modules the screen offers). Now the class trait comes first and the added line after it (the module card labels it 特性追加).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { traitLines, fullTraitText } = await import('../../public/js/ui/loadoutModel.js');
const { traitText } = await import('../../public/js/ui/detailPanel.js');
const { ModuleInfo } = await import('../../public/js/screens/loadout.js');
const { DiyPickerView } = await import('../../public/js/screens/diy.js');
const { chessLoadout } = await import('../../public/js/ui/gameLogic.js');
const { getDefaultSource } = await import('../../server/sim/simdata.js');
const { resolveProfile } = await import('../../server/sim/professions.js');
const { data } = await import('../../public/js/data.js');
const { KITTED_CHARS } = await import('../../server/sim/content/kits/index.js');
await data.loadAll('chess', 'bonds', 'assets', 'garrisons', 'items', 'backups');
const CHESS = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
const get = (id) => CHESS[id] ?? null;
const elites = Object.values(CHESS).filter((c) => c.visible !== false && c.isGolden && Array.isArray(c.modules));
const modules = elites.flatMap((g) => g.modules.map((m) => ({ g, m })));
const added = modules.filter(({ m }) => m.traitOverride?.moduleDesc);
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
const textOf = (v) => {
if (v == null || typeof v === 'boolean') return '';
if (typeof v === 'string' || typeof v === 'number') return String(v);
if (Array.isArray(v)) return v.map(textOf).join('');
return typeof v === 'object' ? textOf(v.props?.children) : '';
};
const richText = (v) => String([...walk(v)].find((n) => typeof n.type === 'function' && n.type.name === 'RichText')?.props.text ?? '');
/** The module card's rows: { k, text, kind } (kind = data-trait). */
const cardRows = (tree) => [...walk(tree)].filter((n) => hasClass(n, 'lo-minfo__row')).map((row) => ({
k: textOf([...walk(row)].find((n) => hasClass(n, 'lo-minfo__k'))), text: richText(row), kind: row.props['data-trait'] ?? null,
}));
test('the data: 164 elite modules offered, 114 of them add a line after the class trait (the report\'s "only the extra trait")', () => {
assert.ok(modules.length >= 160, `${modules.length} elite module records`);
assert.ok(added.length >= 110, `${added.length} modules with an added line`);
for (const { g, m } of added) {
// the record keeps the class trait (or the module's rewrite of it) beside the added line
assert.ok(m.traitOverride.desc && m.traitOverride.desc !== m.traitOverride.moduleDesc, `${g.chessId} ${m.uniEquipId}`);
}
// 隐现's MAR-X: the official candidate is additionalDescription only, so the class trait stays
const inside = get('chess_char_1_01_b').modules.find((m) => m.typeName === 'MAR-X');
assert.equal(inside.traitOverride.desc, '优先攻击空中单位');
assert.equal(inside.traitOverride.moduleDesc, '攻击空中单位时攻击力提升至110%');
});
test('traitLines / fullTraitText: the class trait, then the added line; a rewrite or a plain trait is one line', () => {
assert.deepEqual(traitLines({ desc: 'A', descRaw: '<@ba.kw>A</>', moduleDesc: 'B', moduleDescRaw: '<@ba.kw>B</>' }), { base: '<@ba.kw>A</>', added: '<@ba.kw>B</>' });
assert.equal(fullTraitText({ desc: 'A', moduleDesc: 'B' }), 'A\nB');
assert.equal(fullTraitText({ desc: 'A' }), 'A');
assert.equal(fullTraitText({ moduleDesc: 'B' }), 'B');
assert.equal(fullTraitText(null), '');
assert.deepEqual(traitLines(undefined), { base: '', added: null });
});
test('局内数值 and the detail card (traitText): every added-line module shows the class trait first, then its line', () => {
for (const { g, m } of added) {
const lo = { [g.baseId]: { module: m.uniEquipId } };
const shown = traitText(g, true, chessLoadout(g, lo, get));
const t = m.traitOverride;
assert.equal(shown, `${t.descRaw || t.desc}\n${t.moduleDescRaw || t.moduleDesc}`, `${g.chessId} ${m.typeName}`);
}
// 不装备 keeps the bare class trait; a normal chess its own
const g = get('chess_char_1_01_b');
assert.equal(traitText(g, true, chessLoadout(g, { [g.baseId]: { module: 'none' } }, get)), g.traitBase.descRaw);
assert.equal(traitText(get('chess_char_1_01_a'), false, null), get('chess_char_1_01_a').trait.descRaw);
});
test('the 干员调配 module card: 特性 = the class trait (or the rewrite), 特性追加 = the added line', () => {
for (const { g, m } of modules) {
const rows = cardRows(ModuleInfo({ m: null, golden: g, opt: { id: m.uniEquipId, rec: m, isDefault: m.isDefault } }));
const t = m.traitOverride;
const trait = rows.filter((r) => r.kind);
if (t?.moduleDesc) {
assert.deepEqual(trait.map((r) => [r.k, r.text]), [['特性', t.descRaw || t.desc], ['特性追加', t.moduleDescRaw || t.moduleDesc]], `${g.chessId} ${m.typeName}`);
} else {
assert.deepEqual(trait.map((r) => [r.k, r.text]), [['特性', t.descRaw || t.desc]], `${g.chessId} ${m.typeName}: a rewrite is one line`);
}
}
});
test('the 自选 picker\'s module rows: the class trait, then the module\'s line', () => {
const pick = { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
const slot = { slotId: 'chess_char_5_diy1_a', tier: 5, shopLevel: 5 };
const tree = DiyPickerView({ m: null, slot, picks: { [slot.slotId]: pick }, kitted: [...KITTED_CHARS], onDone() {}, onClose() {},
filter: 'all', query: '', draft: { ...pick }, onFilter() {}, onQuery() {}, onDraft() {} });
const expand = (v, d = 6) => {
if (Array.isArray(v)) return v.map((x) => expand(x, d));
if (!v || typeof v !== 'object') return v;
if (typeof v.type === 'function' && v.type.name !== 'RichText' && d > 0) { try { return expand(v.type(v.props || {}), d - 1); } catch { return v; } }
const c = v.props?.children;
return c === undefined ? v : { ...v, props: { ...v.props, children: expand(c, d) } };
};
const rows = [...walk(expand(tree))].filter((v) => v.props?.role === 'radio' && v.props['data-module'] && v.props['data-module'] !== 'none');
assert.ok(rows.length >= 1);
const backups = data.get('backups');
const mods = Object.values(backups.units.char_112_siege.forms).flatMap((f) => f.modules || []);
for (const row of rows) {
const rec = mods.find((x) => x.uniEquipId === row.props['data-module']);
const t = rec.traitOverride;
const first = [...walk(row)].find((n) => typeof n.type === 'function' && n.type.name === 'RichText');
assert.equal(first.props.text, t.moduleDesc ? `${t.descRaw || t.desc}\n${t.moduleDescRaw || t.moduleDesc}` : (t.descRaw || t.desc));
}
});
test('the sim: an added-line module keeps the class trait (the same text, targeting and attack shape as 不装备)', () => {
const ds = getDefaultSource();
const keys = ['trait', 'targetPriority', 'attackKind', 'dmgType', 'canHitFly', 'splashRadius'];
const prof = ['attack', 'dmgType', 'canHitFly', 'maxTargets', 'hitAllBlocked', 'allInRange', 'priority', 'noAttack', 'noHeal', 'blockFly'];
let same = 0;
for (const { g, m } of added) {
const a = ds.getChess(g.chessId, { moduleId: m.uniEquipId });
const b = ds.getChess(g.chessId, { moduleId: 'none' });
assert.equal(a.trait, m.traitOverride.desc, `${g.chessId} ${m.typeName}: the battle's trait text = the card's`);
if (m.traitOverride.desc !== g.traitBase.desc) continue; // 圣约送葬人 REA-Y rewrites the class trait too
const pa = resolveProfile(a), pb = resolveProfile(b);
for (const k of keys) assert.deepEqual(a[k], b[k], `${g.chessId} ${m.typeName} ${k}`);
for (const k of prof) assert.deepEqual(pa[k], pb[k], `${g.chessId} ${m.typeName} profile.${k}`);
for (const k of Object.keys(b.traitBb || {})) assert.ok(k in a.traitBb, `${g.chessId} ${m.typeName}: keeps trait bb ${k}`);
same++;
}
assert.ok(same >= 110, `${same} added-line modules keep the class trait in battle`);
});