diff --git a/docs/DATA.md b/docs/DATA.md
index b1fb47a4..511b34ce 100644
--- a/docs/DATA.md
+++ b/docs/DATA.md
@@ -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`
diff --git a/public/i18n/en.json b/public/i18n/en.json
index bdf656b7..18112329 100644
--- a/public/i18n/en.json
+++ b/public/i18n/en.json
@@ -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",
diff --git a/public/js/screens/diy.js b/public/js/screens/diy.js
index 00c8a7aa..59c35ef2 100644
--- a/public/js/screens/diy.js
+++ b/public/js/screens/diy.js
@@ -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``;
}
-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 }) {
`;
}
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`
<${Img} src=${moduleIconOf(m, rec)} class="lo-minfo__icon" />${rec.typeName || ''}${rec.name || rec.uniEquipId}
@@ -219,7 +220,8 @@ function ModuleInfo({ m, golden, opt }) {
${t('属性')}
${rows.length ? rows.map((r) => html`${r.label}${r.text}`) : html`${t('无属性加成')}`}
- ${traitText ? html`
${t('特性')}<${RichText} class="lo-minfo__v" text=${traitText} />
` : null}
+ ${lines.base ? html`
${t('特性')}<${RichText} class="lo-minfo__v" text=${lines.base} />
` : null}
+ ${lines.added ? html`
${t('特性追加')}<${RichText} class="lo-minfo__v" text=${lines.added} />
` : null}
${talents.map((tal, i) => html`
${t('天赋')}
${tal.name ? html`${tal.name}` : null}<${RichText} text=${tal.descRaw || tal.desc || ''} />
`)}
`;
diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js
index 18b826ef..32b887b2 100644
--- a/public/js/ui/detailPanel.js
+++ b/public/js/ui/detailPanel.js
@@ -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);
}
/**
diff --git a/public/js/ui/loadoutModel.js b/public/js/ui/loadoutModel.js
index ab6b1ab0..1981a711 100644
--- a/public/js/ui/loadoutModel.js
+++ b/public/js/ui/loadoutModel.js
@@ -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 | null | undefined} attr
diff --git a/test/ui/feedback5-module-trait.test.js b/test/ui/feedback5-module-trait.test.js
new file mode 100644
index 00000000..9263368d
--- /dev/null
+++ b/test/ui/feedback5-module-trait.test.js
@@ -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`);
+});
diff --git a/test/ui/loadout-stats.test.js b/test/ui/loadout-stats.test.js
index 1f742c0f..6be6bf72 100644
--- a/test/ui/loadout-stats.test.js
+++ b/test/ui/loadout-stats.test.js
@@ -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' } }), /能够阻挡三个敌人/);