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' } }), /能够阻挡三个敌人/);