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.
This commit is contained in:
1 parent
30d9a41c51
commit
02353e13f8
8 files changed
+196
-20
No files matched your search
+2
-2
@@ -169,7 +169,7 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft
|
||||
| `projectile` | `"arrow"` | `arrow` (phys ranged) / `bolt` (arts ranged) / `orb` (heal) / `none` |
|
||||
| `canHitFly` | `true` | |
|
||||
| `targetPriority` | `"fly"` | from trait text: `fly` (优先攻击空中单位), `lowestDef` (防御力最低), else `null` |
|
||||
| `trait` | `{"desc":"优先攻击空中单位","descRaw":"…","bb":{"atk_scale":1.1},"bbStr":{},"rangeGrid":null,"moduleDesc":"攻击空中单位时攻击力提升至110%","moduleDescRaw":"…"}` | profession trait (+ golden module trait upgrade merged into `bb`; `rangeGrid` = trait-effect area, e.g. 散射手 front row — **not** the attack range) |
|
||||
| `trait` | `{"desc":"优先攻击空中单位","descRaw":"…","bb":{"atk_scale":1.1},"bbStr":{},"rangeGrid":null,"moduleDesc":"攻击空中单位时攻击力提升至110%","moduleDescRaw":"…"}` | profession trait (+ golden module trait upgrade merged into `bb`; `rangeGrid` = trait-effect area, e.g. 散射手 front row — **not** the attack range). `desc` = the class trait, or the module's rewrite of it (official `overrideDescripton`); `moduleDesc` = the module's added line (official `additionalDescription`, PRTS 「特性追加」), which the client shows after `desc`, never alone (0.2.0, community report item 16.2) |
|
||||
| `skill` | see below | default skill at `status.skillLevel` (normal 4, golden 7) |
|
||||
| `skills[]` | `[{…skill record…, "index":0, "isDefault":false}, {…, "index":1, "isDefault":true}]` | **loadout choices** (DESIGN §16): every skill unlocked at `status` (E1 ⇒ S1–S2, E2 ⇒ S1–S3; the default is always listed), same shape as `skill` + `isDefault`, at the chess skill level, `trigger` resolved **for that skill index** (§2.2). The `isDefault` entry equals `skill` |
|
||||
| `modules[]`, `statsBase`, `traitBase`, `talentsBase` | see §2.2 | golden chess with `equipLevel > 0` only: selectable modules + the no-module base they apply to |
|
||||
@@ -231,7 +231,7 @@ everything needed to resolve a unit for `(chessId, skillIndex, moduleId)` (`simd
|
||||
| `uniEquipId`, `name`, `typeName`, `typeIcon`, `icon` | `"uniequip_003_mlyss"`, `"落叶四季"`, `"TAC-Y"`, `"tac-y"`, `"uniequip_003_mlyss"` | |
|
||||
| `isDefault`, `level` | `false`, `3` | default = `defaultUniEquipId` (= `module.id`) |
|
||||
| `attr` | `{"maxHp":170,"atk":28,"def":28}` | flat stat additions (stat field names): `stats = statsBase[f] + attr[f]` (float-noise cleaned) |
|
||||
| `traitOverride` | `{"desc":"…提升至165%","descRaw":…,"bb":{"atk_scale":1.65},"bbStr":{},"rangeGrid":null,"moduleDesc"?:…}` \| `null` | the full trait with this module; `null` ⇒ `traitBase` |
|
||||
| `traitOverride` | `{"desc":"…提升至165%","descRaw":…,"bb":{"atk_scale":1.65},"bbStr":{},"rangeGrid":null,"moduleDesc"?:…}` \| `null` | the full trait with this module (`desc`, then the added line `moduleDesc` when present — see `trait` above); `null` ⇒ `traitBase` |
|
||||
| `talentChanges[]` | `{"talentIndex":1,"name":"开源节流","desc":…,"descRaw":…,"bb":{"cost":-2,"runtime_cost":-1},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false}` | talent additions/overrides (`talentIndex` −1 = new hidden data-only talent); applied to `talentsBase` with the build's merge rule (`simdata composeTalents`: override of an existing index merges blackboards, module keys win) |
|
||||
|
||||
- `statsBase` / `traitBase` / `talentsBase` — the golden record **without** any module (`stats` / `trait` / `talents`
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"充能": "Charges",
|
||||
"不装备模组:精锐干员以基础属性、特性与天赋作战。": "No Module: the Elite Operator fights with its base stats, Trait and Talents.",
|
||||
"特性": "Trait",
|
||||
"特性追加": "Added Trait",
|
||||
"属性": "Stats",
|
||||
"无属性加成": "No stat bonus",
|
||||
"天赋": "Talent",
|
||||
|
||||
@@ -13,7 +13,7 @@ import { html, Icon, Button, TierChip } from '../ui/components.js';
|
||||
import { Img, RichText, BondGlyph } from '../ui/gameComponents.js';
|
||||
import { chessAvatarUrl, chessPortraitUrl, profIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
|
||||
import { data } from '../data.js';
|
||||
import { PROF_NAME, skillLabel, moduleBadge } from '../ui/loadoutModel.js';
|
||||
import { PROF_NAME, skillLabel, moduleBadge, fullTraitText } from '../ui/loadoutModel.js';
|
||||
import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
@@ -117,7 +117,8 @@ function SkillRow({ m, s, on, locked, onPick }) {
|
||||
function ModuleRow({ id, rec, on, locked, onPick }) {
|
||||
const none = id == null;
|
||||
const talents = rec && Array.isArray(rec.talentChanges) ? rec.talentChanges.filter((x) => x && x.desc && !x.hidden) : [];
|
||||
const trait = rec?.traitOverride ? (rec.traitOverride.moduleDescRaw || rec.traitOverride.moduleDesc || rec.traitOverride.descRaw || rec.traitOverride.desc) : null;
|
||||
// the trait this module gives: the class trait (or the module's rewrite), then its extra line (item 16.2)
|
||||
const trait = rec?.traitOverride ? fullTraitText(rec.traitOverride) || null : null;
|
||||
return html`<button type="button" role="radio" aria-checked=${on ? 'true' : 'false'} data-module=${none ? 'none' : id} disabled=${locked}
|
||||
class=${cx('diy-choice', 'diy-choice--mod', on && 'is-on')} onClick=${() => onPick(id)}>
|
||||
<span class="diy-choice__icon">${none ? html`<b>—</b>` : html`<${Img} src=${moduleTypeIconUrl(data.get('local'), rec.typeName)} fallback=${html`<b class="num">${moduleBadge(rec)}</b>`} />`}</span>
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useStore } from '../store.js';
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
import {
|
||||
MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice,
|
||||
changedCount, skillLabel, moduleBadge, attrRows, skillTags, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
|
||||
changedCount, skillLabel, moduleBadge, attrRows, skillTags, traitLines, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
|
||||
} from '../ui/loadoutModel.js';
|
||||
import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js';
|
||||
import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js';
|
||||
@@ -198,7 +198,7 @@ function SkillOption({ m, opt, on, level, onPick }) {
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function ModuleInfo({ m, golden, opt }) {
|
||||
export function ModuleInfo({ m, golden, opt }) {
|
||||
if (!golden) return null;
|
||||
const rec = opt.id === MODULE_NONE ? null : opt.rec;
|
||||
if (!rec) {
|
||||
@@ -209,8 +209,9 @@ function ModuleInfo({ m, golden, opt }) {
|
||||
</div>`;
|
||||
}
|
||||
const rows = attrRows(rec.attr);
|
||||
const trait = rec.traitOverride;
|
||||
const traitText = trait ? (trait.moduleDescRaw || trait.moduleDesc || trait.descRaw || trait.desc) : null;
|
||||
// 特性: the trait the elite fights with under this module (the class trait, or the module's rewrite of it), then the
|
||||
// module's extra line under PRTS's own label 特性追加 — the line adds to the class trait (item 16.2)
|
||||
const lines = traitLines(rec.traitOverride);
|
||||
const talents = (Array.isArray(rec.talentChanges) ? rec.talentChanges : []).filter((t) => t && (t.name || t.desc) && !t.hidden);
|
||||
return html`<div class="lo-minfo">
|
||||
<div class="lo-minfo__title"><${Img} src=${moduleIconOf(m, rec)} class="lo-minfo__icon" /><span class="lo-minfo__type num">${rec.typeName || ''}</span><b>${rec.name || rec.uniEquipId}</b>
|
||||
@@ -219,7 +220,8 @@ function ModuleInfo({ m, golden, opt }) {
|
||||
<span class="lo-minfo__k">${t('属性')}</span>
|
||||
<span class="lo-minfo__v lo-attrs">${rows.length ? rows.map((r) => html`<span key=${r.key} class=${cx('lo-attr', r.positive ? 'is-up' : 'is-down')}>${r.label}<b class="num">${r.text}</b></span>`) : html`<span class="t-dim">${t('无属性加成')}</span>`}</span>
|
||||
</div>
|
||||
${traitText ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">${t('特性')}</span><${RichText} class="lo-minfo__v" text=${traitText} /></div>` : null}
|
||||
${lines.base ? html`<div class="lo-minfo__row" data-trait="base"><span class="lo-minfo__k">${t('特性')}</span><${RichText} class="lo-minfo__v" text=${lines.base} /></div>` : null}
|
||||
${lines.added ? html`<div class="lo-minfo__row" data-trait="added"><span class="lo-minfo__k">${t('特性追加')}</span><${RichText} class="lo-minfo__v" text=${lines.added} /></div>` : null}
|
||||
${talents.map((tal, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">${t('天赋')}</span>
|
||||
<span class="lo-minfo__v">${tal.name ? html`<b class="lo-minfo__tname">${tal.name}</b>` : null}<${RichText} text=${tal.descRaw || tal.desc || ''} /></span></div>`)}
|
||||
</div>`;
|
||||
|
||||
@@ -40,7 +40,7 @@ import { abilityRows } from './abilityLines.js';
|
||||
import { data } from '../data.js';
|
||||
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
|
||||
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
|
||||
import { moduleBadge } from './loadoutModel.js';
|
||||
import { moduleBadge, fullTraitText } from './loadoutModel.js';
|
||||
import { t, tc, N_ } from '../../../shared/i18n.js';
|
||||
import { audio } from '../audio.js';
|
||||
|
||||
@@ -277,11 +277,11 @@ export function BondChips({ bondIds, bonds = [], onBond = null, off = null, gran
|
||||
*/
|
||||
export function traitText(c, golden, lo) {
|
||||
const t = (lo?.record || c).trait || {};
|
||||
const base = t.descRaw || t.desc || '';
|
||||
if (!golden) return base;
|
||||
// the record's own module line, also for a record cloned for another skill or module (a 不装备 record carries none) —
|
||||
// until 0.1.2 a clone fell back to the class trait (Grok review of GitHub #64; the in-match card had it too)
|
||||
return t.moduleDescRaw || base;
|
||||
if (!golden) return t.descRaw || t.desc || '';
|
||||
// the class trait (or the module's rewrite of it), then the record's own module line — also for a record cloned for
|
||||
// another skill or module (a 不装备 record carries none). Until 0.1.2 a clone fell back to the class trait (Grok review
|
||||
// of GitHub #64); until 0.2.0 the module line replaced the class trait (community report of 2026-10-06, item 16.2).
|
||||
return fullTraitText(t);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -325,6 +325,29 @@ export function moduleBadge(rec, id = null) {
|
||||
return m ? m[1] : t.slice(-1) || '?';
|
||||
}
|
||||
|
||||
/**
|
||||
* The two lines of a trait record (data `trait` / `traitBase`, ModuleRecord `traitOverride`, DATA.md §2): `base` = the
|
||||
* 特性 the unit fights with — the class trait, or the module's own wording where the module rewrites it (official
|
||||
* `overrideDescripton`) — and `added` = the module's extra line (official `additionalDescription`), or null. The extra
|
||||
* line comes after the class trait, never instead of it: PRTS flags it 「特性追加」 on every such module, and the sim keeps
|
||||
* the class trait with the module equipped (community report of 2026-10-06, item 16.2: until 0.2.0 the 干员调配 module
|
||||
* card, its 局内数值 and the detail card showed the extra line alone on 114 of the 164 modules the screen offers).
|
||||
* @param {any} trait
|
||||
* @returns {{ base: string, added: string|null }}
|
||||
*/
|
||||
export function traitLines(trait) {
|
||||
if (!isObj(trait)) return { base: '', added: null };
|
||||
const base = String(trait.descRaw || trait.desc || '');
|
||||
const added = trait.moduleDescRaw || trait.moduleDesc || null;
|
||||
return { base, added: added ? String(added) : null };
|
||||
}
|
||||
|
||||
/** The whole 特性 text of a trait record: its base line, then the module's extra line (`\n` between; RichText breaks it). */
|
||||
export function fullTraitText(trait) {
|
||||
const { base, added } = traitLines(trait);
|
||||
return base && added ? `${base}\n${added}` : base || added || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Module stat bonus as display rows (non-zero entries only).
|
||||
* @param {Record<string, number> | null | undefined} attr
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// 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`);
|
||||
});
|
||||
@@ -196,9 +196,9 @@ test('特性 and 天赋 follow the chosen module: 隐现\'s trait upgrade is MAR
|
||||
const rows = (id, level, entries) => kitRows(section(id, level, entries));
|
||||
const { golden } = slot(INSIDE);
|
||||
const traitOf = (rs) => rs.find((r) => r.k === '特性');
|
||||
// default module: the module's upgraded trait text; 不装备: the plain class trait
|
||||
assert.equal(traitOf(rows(INSIDE, 'elite', {})).text, golden.trait.moduleDescRaw);
|
||||
assert.match(traitOf(rows(INSIDE, 'elite', {})).text, /110%/);
|
||||
// default module: the class trait, then the module's added line (item 16.2 of 2026-10-06); 不装备: the plain class trait
|
||||
assert.equal(traitOf(rows(INSIDE, 'elite', {})).text, `${golden.trait.descRaw}\n${golden.trait.moduleDescRaw}`);
|
||||
assert.match(traitOf(rows(INSIDE, 'elite', {})).text, /^优先攻击空中单位\n[^\n]*110%/);
|
||||
assert.equal(traitOf(rows(INSIDE, 'elite', { [INSIDE]: { module: 'none' } })).text, golden.traitBase.descRaw);
|
||||
assert.equal(traitOf(rows(INSIDE, 'normal', {})).text, slot(INSIDE).base.trait.descRaw, '普通: its own trait');
|
||||
// 天赋: named and not hidden, the record's list
|
||||
@@ -292,8 +292,8 @@ test('特性 follows the chosen module also when the skill or the module differs
|
||||
const chess = JSON.parse(fs.readFileSync(new URL('../../data/chess.json', import.meta.url), 'utf8'));
|
||||
const get = (id) => chess[id];
|
||||
const shown = (id, lo) => traitText(get(id), true, chessLoadout(get(id), lo, get));
|
||||
// 隐现 (MAR-X): its other skill keeps the module's line, not the class trait 优先攻击空中单位
|
||||
assert.match(shown('chess_char_1_01_b', { chess_char_1_01_a: { skill: 0, module: null } }), /攻击空中单位时攻击力提升至/);
|
||||
// 隐现 (MAR-X): its other skill keeps the module's line after the class trait 优先攻击空中单位 (item 16.2)
|
||||
assert.match(shown('chess_char_1_01_b', { chess_char_1_01_a: { skill: 0, module: null } }), /^优先攻击空中单位\n攻击空中单位时攻击力提升至/);
|
||||
// 信仰搅拌机: SPT-Y shows its own line; 不装备 shows the class trait
|
||||
assert.match(shown('chess_char_4_01_b', { chess_char_4_01_a: { skill: get('chess_char_4_01_b').skill.index, module: 'uniequip_003_rmixer' } }), /攻击距离\+1/);
|
||||
assert.match(shown('chess_char_4_01_b', { chess_char_4_01_a: { skill: get('chess_char_4_01_b').skill.index, module: 'none' } }), /能够阻挡三个敌人/);
|
||||
|
||||
Reference in new issue
Block a user