diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 6b8727fd..e85c36b0 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -595,7 +595,7 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno **Sim:** `simdata` resolves a unit def for `(chessId, skillIndex, moduleId)` (cached): `skill` = the selected SkillRecord; stats = `statsBase` + module attr (elite) ; trait/talents = base with the selected module's overrides. Kit contract (backward compatible): a kit function `(bb, chess) => { skill?, skills?: { [skillId]: SkillSpec }, talents, trait }` receives `chess.skill` = the **selected** skill and `bb` = its blackboard; the loader uses `skills[selectedSkillId]` if present, else `skill` only when the selected skill is the chess's default, else the **generic** skill spec — talents/trait from the kit always apply. Every visible chess must get hand-authored specs for all of its selectable skills (tracked by a coverage test). -**UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows **its owner's** skill/module from its UnitInfo `skillIndex` / `moduleId`, `gameLogic.js unitLoadout`) — which also shows the stats, 特性, talents and attack range the unit fights with (`shared/loadoutRecord.js` `loadoutRecord` / `attackRangeGrid`, the same composition the sim's `getChess(id, loadout)` uses; the deploy wheel's range preview too) — the range it is deployed with: a selected passive range skill (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1) included, over all 1284 loadouts equal to the battle's range at deployment; in battle and prep the card draws the live `range` (§21.16). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (`UnitInfo.skillIndex`; `data/assets.json` lists icons, clips and sounds for every skill index). +**UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; a 局内数值 section between the skills and the modules (普通 / 精锐 toggle, 精锐 first) draws the detail card's own stats block, 特性 and 天赋 for the chosen skill / module (§22.16); the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows **its owner's** skill/module from its UnitInfo `skillIndex` / `moduleId`, `gameLogic.js unitLoadout`) — which also shows the stats, 特性, talents and attack range the unit fights with (`shared/loadoutRecord.js` `loadoutRecord` / `attackRangeGrid`, the same composition the sim's `getChess(id, loadout)` uses; the deploy wheel's range preview too) — the range it is deployed with: a selected passive range skill (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1) included, over all 1284 loadouts equal to the battle's range at deployment; in battle and prep the card draws the live `range` (§21.16). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (`UnitInfo.skillIndex`; `data/assets.json` lists icons, clips and sounds for every skill index). --- diff --git a/public/css/screens/loadout.css b/public/css/screens/loadout.css index ef2cda77..bc163e6d 100644 --- a/public/css/screens/loadout.css +++ b/public/css/screens/loadout.css @@ -1,6 +1,6 @@ /* ========================================================================================== screens/loadout.css — 干员调配 (Operator loadout, DESIGN §16; js/screens/loadout.js): full-screen overlay with a - filterable roster of the visible chess (left) and the selected chess's skills and elite modules (right). + filterable roster of the visible chess (left) and the selected chess's skills, 局内数值 and elite modules (right). Official preset sprites (ui/outer: operator_preset, image_skill_select_outline, skill_select_deco, icon_equip_non) are used when the local-client art is present; every piece has a CSS fallback. Sizes are design px ÷ 100 (theme.css root scale); text that must stay readable on phones uses max(.xxrem, N px). @@ -308,6 +308,21 @@ .lo-attr b { font-family: var(--font-num); font-size: max(.15rem, 10px); } .lo-attr.is-up b { color: var(--mint-400); } .lo-attr.is-down b { color: var(--red-premium); } + +/* 局内数值 (the chosen variant's stats, 攻击范围, 特性 and 天赋 — GitHub issue #64): the detail card's own block + (ui/detailPanel.js chessStatsBlock: .dstats / .dstat / .drange / .rgrid / .rgrid-all from game-panels.css, drawn there in + board-sized units) at this screen's reading sizes — a px floor on every text size (1 rem is 40 px on phones), the range box + dropping under the numbers when the column is too narrow for both */ +.lo-sec--stats .dstats-wrap { flex-wrap: wrap; gap: .1rem; } +.lo-sec--stats .dstats-wrap > .dstats { flex: 1 1 max(3.2rem, 190px); } +.lo-sec--stats .dstat { padding: .05rem .08rem; } +.lo-sec--stats .dstat__k { font-size: max(.13rem, 9px); } +.lo-sec--stats .dstat__v { font-size: max(.2rem, 12px); } +.lo-sec--stats .drange { min-width: max(1.1rem, 56px); } +.lo-sec--stats .rgrid { --rg: max(.14rem, 7px); } /* (a grid wider than the box keeps its inline, shrunk --rg) */ +.lo-sec--stats .rgrid-all { font-size: max(.16rem, 11px); } +.lo-minfo--kit { margin-top: 0; border-left-color: var(--mint-500); } +.lo-stats__cap { margin: 0; font-size: max(.13rem, 9px); line-height: 1.5; color: var(--text-dim); } .lo-locknote { display: flex; align-items: center; gap: .06rem; margin: .16rem 0 0; font-size: max(.14rem, 10px); color: var(--amber); } .lo-locknote .icon { width: .16rem; height: .16rem; } diff --git a/public/js/screens/loadout.js b/public/js/screens/loadout.js index 769622e8..dafc36be 100644 --- a/public/js/screens/loadout.js +++ b/public/js/screens/loadout.js @@ -7,8 +7,10 @@ // // Left: roster of the 112 visible chess (tier / class / bond filters, search, 仅看已调整) — each card shows the equipped // skill (S1–S3) and, when changed, the elite's module badge. Right: the selected chess — skills (icon, name, 默认, -// SP recovery, 初始 / 消耗 SP, duration, description at 普通 Lv.4 or 精锐 Lv.7) and the elite's modules (不装备 / X / Y … -// with the stat bonus, the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar. +// SP recovery, 初始 / 消耗 SP, duration, description at 普通 Lv.4 or 精锐 Lv.7), 局内数值 (the stats, 攻击范围, 特性 and +// 天赋 the chosen variant — 精锐 first, 普通 on the toggle — fights with under the chosen skill and module: the detail +// card's own block and pure functions, GitHub issue #64) and the elite's modules (不装备 / X / Y … with the stat bonus, +// the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar. // The loadout lives in ui/loadoutSync.js (localStorage + room.loadout); the model is ui/loadoutModel.js. // Keyboard: Esc closes, ←/→ move through the (filtered) roster when focus is not in the search field. @@ -16,6 +18,8 @@ import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module. import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline } from '../ui/components.js'; import { Img, RichText, UnitThumb } from '../ui/gameComponents.js'; import { chessAvatarUrl, chessPortraitUrl, subProfIconUrl, bondIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js'; +import { chessStatsBlock, traitText, chessTalents } from '../ui/detailPanel.js'; +import { chessLoadout } from '../ui/gameLogic.js'; import { data, useData, localAsset } from '../data.js'; import { useStore } from '../store.js'; import { PHASE } from '../../../shared/constants.js'; @@ -172,8 +176,60 @@ function ModuleInfo({ m, golden, opt }) { `; } +const getChessRec = (id) => data.lookup('chess', id); + +/** + * What 局内数值 shows (GitHub issue #64): the chess variant — the 精锐 record when asked for and the chess has one, else + * the normal one — as the stored loadout makes it. chessLoadout (ui/gameLogic.js) resolves the skill and module the way + * the in-match detail card and the sim do (shared/loadoutRecord.js): the elite's chosen module's stats / 特性 / talents + * (不装备: the base ones), the chosen skill's passive range; a normal chess has no module, the skill does not change its + * stats. Nothing is recomputed here. + * @param {any} base normal chess record @param {any} golden its elite record or null + * @param {Record} entries the stored loadout @param {'normal'|'elite'} level + * @param {(id: string) => any} getChess + * @returns {{ elite: boolean, chess: any, lo: any, record: any, trait: string, talents: any[] } | null} null without a record + */ +export function statsPreview(base, golden, entries, level, getChess) { + const elite = level === 'elite' && !!golden; + const chess = elite ? golden : base; + if (!chess) return null; + const lo = chessLoadout(chess, entries, getChess); + const record = lo?.record || chess; + // (the card's own rule: the 特性 line exists when the chess has one; its text follows the chosen module) + return { elite, chess, lo, record, trait: chess.trait?.desc ? traitText(chess, !!chess.isGolden, lo) || '' : '', talents: chessTalents(record) }; +} + +/** + * 局内数值: the stats, 攻击范围, 特性 and 天赋 of the selected chess under its chosen skill and module — the detail + * card's stats block (ui/detailPanel.js chessStatsBlock) without live numbers, so what a player reads here is what the + * shop / board card shows before a battle (not the equipment, bond or skill-cast changes of a running match). The + * toggle picks the 普通 or the 精锐 record; 精锐 is the default because the module only exists there. + * @param {{ base: any, golden: any, entries: Record, level: 'normal'|'elite', onLevel: (l: 'normal'|'elite') => void, getChess?: (id: string) => any }} props + */ +export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = getChessRec }) { + const pv = statsPreview(base, golden, entries, level, getChess); + if (!pv) return null; + return html`
+
+

局内数值<${MicroLabel}>STATS

+
+ + +
+
+ ${chessStatsBlock({ rec: pv.record, chess: pv.chess })} + ${pv.trait || pv.talents.length ? html`
+ ${pv.trait ? html`
特性<${RichText} class="lo-minfo__v" text=${pv.trait} />
` : null} + ${pv.talents.map((t, i) => html`
天赋 + ${t.name}<${RichText} text=${t.descRaw || t.desc || ''} />
`)} +
` : null} +

${pv.elite ? '数值含所选模组;' : golden ? '普通干员没有模组,所选模组在「精锐」中生效;' : ''}不含技能发动、装备、盟约等局内加成

+
`; +} + function Detail({ m, chess, golden, entries, onChange, onReset, locked }) { const [level, setLevel] = useState('normal'); + const [statLevel, setStatLevel] = useState('elite'); // 局内数值: the 精锐 shows the chosen module's effect const bodyRef = useRef(null); useEffect(() => { if (bodyRef.current) bodyRef.current.scrollTop = 0; }, [chess?.chessId]); if (!chess) return html``; @@ -214,6 +270,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked }) { onPick=${(i) => onChange({ skill: i })} />`)} + <${LoadoutStats} base=${chess} golden=${golden} entries=${entries} level=${statLevel} onLevel=${setStatLevel} /> ${golden ? html`

模组<${MicroLabel}>MODULE

diff --git a/test/ui/loadout-stats.test.js b/test/ui/loadout-stats.test.js new file mode 100644 index 00000000..8d1fec1c --- /dev/null +++ b/test/ui/loadout-stats.test.js @@ -0,0 +1,286 @@ +// GitHub issue #64 — 干员调配 showed only the skill and module sections: no stats, no 攻击范围 (the in-match detail card has +// them). The screen's 局内数值 section now draws the detail card's own stats block (ui/detailPanel.js chessStatsBlock), +// plus 特性 and 天赋, for the selected chess under its chosen skill and module — the 精锐 record by default (the module only +// exists there), the 普通 one on the toggle. Everything comes from gameLogic.chessLoadout → shared/loadoutRecord.js, the +// composition the sim fights with; nothing is recomputed in the screen. These tests: the pure part (statsPreview), the +// rendered section against the card for the same loadout, the 特性 / 天赋 rows, the toggle, the section's place in the +// detail, and the css floors that keep it readable on a phone. (The browser side: test/ui/loadout.e2e.test.js.) + +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)), '../..'); +const read = (p) => readFileSync(path.join(ROOT, p), 'utf8'); + +// the browser data store reads the real data files from disk (the card renders below) +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 { statsPreview, LoadoutStats } = await import('../../public/js/screens/loadout.js'); +const { ChessDetail, cardRangeGrid } = await import('../../public/js/ui/detailPanel.js'); +const { fmtNum, attackInterval } = await import('../../public/js/ui/gameLogic.js'); +const { data } = await import('../../public/js/data.js'); +await data.loadAll('chess', 'garrisons', 'assets', 'bonds', 'items'); + +const get = (id) => data.lookup('chess', id); +const INSIDE = 'chess_char_1_01_a'; // 隐现: MAR-X (+80 生命上限 / +22 攻击) or 不装备; S1 / S2 +const MIXER = 'chess_char_4_01_a'; // 信仰搅拌机: SPT-X (default) / SPT-Y "攻击距离+1" +const MOSTIMA = 'chess_char_4_02_a'; // 莫斯提马: SPC-Y (default) / SPC-X (a wider range grid) +const THORN = 'chess_char_5_15_a'; // 引星棘刺: S3 "被动效果:攻击范围扩大" +const PASSENGER = 'chess_char_6_05_a'; // 异客: SPC-X-style module that rewrites her talents +const PASSENGER_ALT = 'uniequip_003_pasngr'; + +const EXPAND = new Set(['Stat', 'LiveTag', 'RangeGrid']); +/** Every vnode of a preact tree (htm output), depth first; the hook-free components of EXPAND are rendered in place. */ +function* walk(v) { + if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; } + if (!v || typeof v !== 'object') return; + yield v; + if (typeof v.type === 'function' && EXPAND.has(v.type.name)) { yield* walk(v.type(v.props)); return; } + 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(''); + if (typeof v === 'object' && typeof v.type === 'function' && EXPAND.has(v.type.name)) return textOf(v.type(v.props)); + return typeof v === 'object' ? textOf(v.props?.children) : ''; +}; +/** The eight stat cells of a tree: label → shown value. */ +const statValues = (tree) => Object.fromEntries([...walk(tree)].filter((n) => hasClass(n, 'dstat')).map((cell) => { + const parts = [...walk(cell)]; + return [textOf(parts.find((n) => hasClass(n, 'dstat__k'))), textOf(parts.find((n) => hasClass(n, 'dstat__v')))]; +})); +const onCells = (tree) => [...walk(tree)].filter((n) => n.type === 'i' && hasClass(n, 'on')).length; +const statsBlock = (tree) => [...walk(tree)].find((n) => hasClass(n, 'dstats-wrap')); +/** The 特性 / 天赋 rows of the section: { k, name, text }. */ +const kitRows = (tree) => [...walk(tree)].filter((n) => hasClass(n, 'lo-minfo__row')).map((row) => { + const parts = [...walk(row)]; + return { + k: textOf(parts.find((n) => hasClass(n, 'lo-minfo__k'))), + name: textOf(parts.find((n) => hasClass(n, 'lo-minfo__tname'))), + text: String(parts.find((n) => typeof n.type === 'function' && n.type.name === 'RichText')?.props.text ?? ''), + }; +}); + +const slot = (id) => { const base = get(id); return { base, golden: base.goldenId ? get(base.goldenId) : null }; }; +const preview = (id, level, entries = {}) => { const { base, golden } = slot(id); return statsPreview(base, golden, entries, level, get); }; +const section = (id, level, entries = {}, extra = {}) => { const { base, golden } = slot(id); return LoadoutStats({ base, golden, entries, level, onLevel() {}, getChess: get, ...extra }); }; +/** The tiles of the 攻击范围 the card / section draws for a preview. */ +const rangeTiles = (pv) => cardRangeGrid(null, pv.record, pv.chess).length; + +test('statsPreview: the 精锐 record under the chosen module (stats = base + the module\'s attr; 不装备 = the base stats), 普通 never has a module', () => { + const { base, golden } = slot(INSIDE); + const mod = golden.modules.find((m) => m.isDefault); + assert.deepEqual(mod.attr, { maxHp: 80, atk: 22 }, 'MAR-X'); + const def = preview(INSIDE, 'elite'); + assert.equal(def.elite, true); + assert.equal(def.chess, golden); + assert.equal(def.record, golden, 'the default loadout is the data record itself (the card\'s rule)'); + assert.equal(def.record.stats.maxHp, golden.statsBase.maxHp + 80); + assert.equal(def.record.stats.atk, golden.statsBase.atk + 22); + const none = preview(INSIDE, 'elite', { [INSIDE]: { module: 'none' } }); + assert.equal(none.record.stats.maxHp, golden.statsBase.maxHp, '不装备: the no-module stats'); + assert.equal(none.record.stats.atk, golden.statsBase.atk); + assert.equal(none.lo.module.none, true); + assert.notEqual(none.record.stats.maxHp, def.record.stats.maxHp, 'switching the module changes what the section shows'); + // 普通: the normal chess, whatever module is stored (modules are the elite's) + const normal = preview(INSIDE, 'normal', { [INSIDE]: { module: 'none' } }); + assert.equal(normal.elite, false); + assert.equal(normal.chess, base); + assert.equal(normal.record.stats.maxHp, base.stats.maxHp); + assert.equal(normal.lo.module, null, 'no module on a normal chess'); + assert.deepEqual(normal.record.stats, preview(INSIDE, 'normal').record.stats, 'the module choice does not reach it'); + // a skill choice is carried (lo.skillIndex) but does not change a chess's stats + const s0 = preview(INSIDE, 'elite', { [INSIDE]: { skill: 0 } }); + assert.equal(s0.lo.skillIndex, 0); + assert.equal(def.lo.skillIndex, 1, 'S2 is the default'); + assert.deepEqual(s0.record.stats, def.record.stats); + // an entry of another chess, or an option the data does not offer, falls back to the defaults + assert.equal(preview(INSIDE, 'elite', { [MIXER]: { module: 'none' } }).record, golden); + assert.equal(preview(INSIDE, 'elite', { [INSIDE]: { module: 'uniequip_999_nope' } }).record, golden); +}); + +test('statsPreview: no elite record → the normal one; no record → null; the data records are never mutated', () => { + const { base } = slot(INSIDE); + const lone = { ...base, goldenId: null }; + const pv = statsPreview(lone, null, {}, 'elite', get); + assert.equal(pv.elite, false, '精锐 asked for, none to show'); + assert.equal(pv.chess, lone); + assert.equal(statsPreview(null, null, {}, 'elite', get), null); + assert.equal(section(INSIDE, 'elite', {}, { base: null, golden: null }), null); + const before = JSON.stringify([get(INSIDE), get(get(INSIDE).goldenId), get(MIXER), get(get(MIXER).goldenId)]); + for (const level of ['normal', 'elite']) { + preview(INSIDE, level, { [INSIDE]: { skill: 0, module: 'none' } }); + preview(MIXER, level, { [MIXER]: { module: 'uniequip_003_rmixer' } }); + } + assert.equal(JSON.stringify([get(INSIDE), get(get(INSIDE).goldenId), get(MIXER), get(get(MIXER).goldenId)]), before); +}); + +test('the 攻击范围 follows the chosen module and skill (the sim\'s deployment range): SPT-Y +1, SPC-X wider, 引星棘刺 S3 passive', () => { + // 信仰搅拌机: SPT-Y's 特性 "攻击距离+1" → one more tile in her row; SPT-X, 不装备 and the normal chess keep 3 + assert.equal(rangeTiles(preview(MIXER, 'elite')), 3); + assert.equal(rangeTiles(preview(MIXER, 'elite', { [MIXER]: { module: 'uniequip_003_rmixer' } })), 4); + assert.equal(rangeTiles(preview(MIXER, 'elite', { [MIXER]: { module: 'none' } })), 3); + assert.equal(rangeTiles(preview(MIXER, 'normal', { [MIXER]: { module: 'uniequip_003_rmixer' } })), 3); + // 莫斯提马: SPC-X's own range grid (9 → 10 tiles) + assert.equal(rangeTiles(preview(MOSTIMA, 'elite')), 9); + assert.equal(rangeTiles(preview(MOSTIMA, 'elite', { [MOSTIMA]: { module: 'uniequip_003_mostma' } })), 10); + // 引星棘刺: the selected skill S3 "被动效果:攻击范围扩大" is her range while she carries it — at both levels + const s3 = get(THORN).skills.find((s) => s.index === 2); + assert.ok(s3.rangeGrid.length > get(THORN).rangeGrid.length && /被动效果:攻击范围扩大/.test(s3.desc)); + for (const level of ['normal', 'elite']) { + assert.equal(rangeTiles(preview(THORN, level)), get(THORN).rangeGrid.length, `${level}: her own range by default (S2)`); + assert.equal(rangeTiles(preview(THORN, level, { [THORN]: { skill: 0 } })), get(THORN).rangeGrid.length); + assert.equal(rangeTiles(preview(THORN, level, { [THORN]: { skill: 2 } })), s3.rangeGrid.length, `${level}: S3's grid`); + } +}); + +test('the section draws the detail card\'s own stats block: same eight values and the same range tiles as ChessDetail for that chess and loadout', () => { + const cases = [ + [INSIDE, 'elite', {}], [INSIDE, 'elite', { [INSIDE]: { module: 'none' } }], [INSIDE, 'elite', { [INSIDE]: { skill: 0 } }], [INSIDE, 'normal', {}], + [MIXER, 'elite', { [MIXER]: { module: 'uniequip_003_rmixer' } }], [MIXER, 'elite', { [MIXER]: { module: 'none' } }], + [MOSTIMA, 'elite', { [MOSTIMA]: { module: 'uniequip_003_mostma' } }], [MOSTIMA, 'normal', {}], + [THORN, 'elite', { [THORN]: { skill: 2 } }], [THORN, 'normal', { [THORN]: { skill: 2 } }], + [PASSENGER, 'elite', { [PASSENGER]: { module: PASSENGER_ALT } }], + ]; + for (const [id, level, entries] of cases) { + const pv = preview(id, level, entries); + const card = ChessDetail({ chess: pv.chess, piece: null, editable: false, bonds: [], loadout: entries }).find((b) => b.key === 'stats'); + const mine = statsBlock(section(id, level, entries)); + const what = `${id} ${level} ${JSON.stringify(entries)}`; + assert.ok(card && mine, what); + assert.equal(textOf(mine), textOf(card), `${what}: the card's numbers`); + assert.equal(onCells(mine), onCells(card), `${what}: the card's range tiles`); + assert.equal(onCells(mine), rangeTiles(pv), `${what}: and the loadout record's attackRangeGrid`); + } +}); + +test('the eight stats shown are the record\'s: 生命上限 攻击 防御 法术抗性 攻击间隔 阻挡数 部署费用 再部署; no live tag, no colours', () => { + const { golden } = slot(INSIDE); + const mod = golden.modules.find((m) => m.isDefault); + const s = golden.stats; + const vals = statValues(section(INSIDE, 'elite')); + assert.deepEqual(Object.keys(vals), ['生命上限', '攻击', '防御', '法术抗性', '攻击间隔', '阻挡数', '部署费用', '再部署']); + assert.deepEqual(vals, { + 生命上限: fmtNum(golden.statsBase.maxHp + mod.attr.maxHp), 攻击: fmtNum(golden.statsBase.atk + mod.attr.atk), 防御: fmtNum(s.def), + 法术抗性: String(s.res ?? 0), 攻击间隔: `${attackInterval(s.bat, s.aspd).toFixed(2)}s`, 阻挡数: String(s.blockCnt), + 部署费用: String(s.cost), 再部署: `${s.respawnTime}s`, + }); + // 不装备 → the base numbers; 普通 → the normal chess's (lower) ones + const none = statValues(section(INSIDE, 'elite', { [INSIDE]: { module: 'none' } })); + assert.equal(none['生命上限'], fmtNum(golden.statsBase.maxHp)); + assert.equal(none['攻击'], fmtNum(golden.statsBase.atk)); + const normal = statValues(section(INSIDE, 'normal')); + assert.equal(normal['生命上限'], fmtNum(slot(INSIDE).base.stats.maxHp)); + assert.ok(Number(normal['生命上限'].replace(/,/g, '')) < Number(none['生命上限'].replace(/,/g, ''))); + // the record's numbers, not a live entry: no 实时 / 开战时 tag and no up / down colouring + const block = statsBlock(section(INSIDE, 'elite')); + assert.ok(!hasClass(block, 'is-live') && block.props['data-live'] === undefined); + assert.ok(!/实时|开战时/.test(textOf(block))); + assert.ok(![...walk(block)].some((n) => hasClass(n, 'is-up') || hasClass(n, 'is-down'))); +}); + +test('特性 and 天赋 follow the chosen module: 隐现\'s trait upgrade is MAR-X\'s only; 异客\'s talents are rewritten by her other module', () => { + 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%/); + 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 + const talents = rows(INSIDE, 'elite', {}).filter((r) => r.k === '天赋'); + assert.deepEqual(talents.map((r) => r.name), golden.talents.filter((t) => t.name && !t.hidden).map((t) => t.name)); + assert.ok(talents.every((r) => r.text.length > 0)); + // 异客: the default module keeps 80% / 3 s, her other module rewrites 机理分析 (70% / 4 s) — what the sim gives her + const talentText = (entries) => rows(PASSENGER, 'elite', entries).filter((r) => r.k === '天赋').map((r) => `${r.name}:${r.text}`).join('\n'); + const dflt = talentText({}); + const alt = talentText({ [PASSENGER]: { module: PASSENGER_ALT } }); + assert.match(dflt, /机理分析:[^\n]*80%/); + assert.match(alt, /机理分析:[^\n]*70%/); + assert.notEqual(dflt, alt); + assert.equal(talentText({ [PASSENGER]: { module: PASSENGER_ALT, skill: 0 } }), alt, 'a skill choice leaves the talents alone'); + // the kit card is the loadout screen's own module-info card style (a mint accent), only when there is something to say + assert.ok([...walk(section(INSIDE, 'elite'))].some((n) => hasClass(n, 'lo-minfo--kit'))); + const bare = section(INSIDE, 'elite', {}, { base: { ...slot(INSIDE).base, trait: null, talents: [] }, golden: { ...golden, trait: null, talents: [], traitBase: null, statsBase: golden.stats, modules: [] } }); + assert.ok(![...walk(bare)].some((n) => hasClass(n, 'lo-minfo--kit')), 'no trait, no talents: no empty card'); +}); + +test('the 普通 / 精锐 toggle: 精锐 is the shown variant, the tabs say so, 精锐 is disabled for a chess without one, a click asks for the other', () => { + const tabs = (tree) => [...walk(tree)].filter((n) => n.type === 'button' && n.props.role === 'tab'); + const elite = section(INSIDE, 'elite'); + assert.equal(elite.props['data-variant'], 'elite'); + const [tn, te] = tabs(elite); + assert.deepEqual([textOf(tn), textOf(te)], ['普通', '精锐']); + assert.deepEqual([tn.props['aria-selected'], te.props['aria-selected']], ['false', 'true']); + const normal = section(INSIDE, 'normal'); + assert.equal(normal.props['data-variant'], 'normal'); + assert.deepEqual(tabs(normal).map((t) => t.props['aria-selected']), ['true', 'false']); + const asked = []; + const sec = section(INSIDE, 'normal', {}, { onLevel: (l) => asked.push(l) }); + const [bn, be] = tabs(sec); + be.props.onClick(); + bn.props.onClick(); + assert.deepEqual(asked, ['elite', 'normal']); + assert.ok(!be.props.disabled && !bn.props.disabled); + // the caption says what the numbers are: the chosen module's in 精锐; in 普通 that modules live in 精锐 + const cap = (tree) => textOf([...walk(tree)].find((n) => hasClass(n, 'lo-stats__cap'))); + assert.match(cap(elite), /^数值含所选模组;不含技能发动、装备、盟约等局内加成$/); + assert.match(cap(normal), /^普通干员没有模组,所选模组在「精锐」中生效;不含技能发动/); + // a chess without an elite record: only 普通 is selectable and shown + const lone = LoadoutStats({ base: { ...slot(INSIDE).base, goldenId: null }, golden: null, entries: {}, level: 'elite', onLevel() {}, getChess: get }); + assert.equal(lone.props['data-variant'], 'normal'); + assert.equal(tabs(lone)[1].props.disabled, true); + assert.match(cap(lone), /^不含技能发动、装备、盟约等局内加成$/, 'nothing to point at without an elite'); +}); + +test('place in the detail: after the skills, before the modules; the first .lo-seg stays the skill level toggle (e2e selector)', () => { + const src = read('public/js/screens/loadout.js'); + const detail = src.slice(src.indexOf('function Detail('), src.indexOf('// ---- filters')); + const toggle = detail.indexOf('aria-label="技能等级"'); + const skills = detail.indexOf('aria-label="选择技能"'); + const stats = detail.indexOf('<${LoadoutStats} base=${chess}'); + const mods = detail.indexOf('
'); + assert.ok(toggle > 0 && skills > toggle && stats > skills && mods > stats, 'skills (and their level toggle) → 局内数值 → modules'); + // one record resolution, shared with the card: chessLoadout, the card's block, trait and talent helpers — no stat arithmetic of its own + assert.match(src, /import \{ chessStatsBlock, traitText, chessTalents \} from '\.\.\/ui\/detailPanel\.js';/); + assert.match(src, /import \{ chessLoadout \} from '\.\.\/ui\/gameLogic\.js';/); + const fn = src.slice(src.indexOf('export function statsPreview'), src.indexOf('export function LoadoutStats')); + assert.ok(!/composeStats|attr\b|maxHp|statsBase/.test(fn), 'no stat arithmetic in the screen'); + // the screen's own state: 精锐 by default, independent of the skill toggle's 普通 + assert.match(src, /const \[statLevel, setStatLevel\] = useState\('elite'\)/); +}); + +test('css: the section reuses the card\'s .dstats / .drange / .rgrid, sized for the overlay with px floors (readable at 1 rem = 40 px), the range box may wrap under the numbers', () => { + const css = read('public/css/screens/loadout.css').replace(/\/\*[\s\S]*?\*\//g, ''); + const rules = [...css.matchAll(/([^{}]+)\{([^}]*)\}/g)].map((m) => ({ sel: m[1].trim(), body: m[2] })); + const mine = rules.filter((r) => /\.lo-sec--stats|\.lo-stats__cap|\.lo-minfo--kit/.test(r.sel)); + assert.ok(mine.length >= 8, 'the stats rules exist'); + for (const r of mine) for (const m of r.body.matchAll(/font-size:\s*([^;]+);/g)) { + assert.match(m[1], /^max\([^)]*\d+px\)$/, `${r.sel}: font-size ${m[1]} keeps a px floor`); + } + const rule = (sel) => rules.find((r) => r.sel === sel)?.body || ''; + assert.match(rule('.lo-sec--stats .dstats-wrap'), /flex-wrap:\s*wrap/); + assert.match(rule('.lo-sec--stats .dstats-wrap > .dstats'), /flex:\s*1 1 max\(3\.2rem, 190px\)/); + assert.match(rule('.lo-sec--stats .rgrid'), /--rg:\s*max\(\.14rem, 7px\)/); + assert.match(rule('.lo-sec--stats .drange'), /min-width:\s*max\(1\.1rem, 56px\)/); + // the base classes come from game-panels.css, which every page loads + const panels = read('public/css/screens/game-panels.css'); + for (const c of ['.dstats-wrap', '.dstats', '.dstat', '.dstat__k', '.dstat__v', '.drange', '.rgrid', '.rgrid-all']) { + assert.ok(new RegExp(`(^|\\n)${c.replace('.', '\\.')}[ ,{]`).test(panels), `${c} is defined in game-panels.css`); + } + assert.match(read('public/index.html'), //); +}); diff --git a/test/ui/loadout.e2e.test.js b/test/ui/loadout.e2e.test.js index 58191b9c..b2c63ffc 100644 --- a/test/ui/loadout.e2e.test.js +++ b/test/ui/loadout.e2e.test.js @@ -273,4 +273,112 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED assert.deepEqual(problems, []); await ctx.close(); }); + + // ---- 局内数值 (GitHub issue #64) ------------------------------------------------------------------------------------- + const CHESS = () => JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8')); + const fmt = (n) => Math.round(n).toLocaleString('en-US'); + /** The section's eight stats: label → shown value. */ + const shownStats = (page) => page.$$eval('.lo-sec--stats .dstat', (els) => Object.fromEntries(els.map((e) => [e.querySelector('.dstat__k').textContent, e.querySelector('.dstat__v').textContent]))); + const rangeTiles = (page) => page.$$eval('.lo-sec--stats .rgrid i.on', (els) => els.length); + async function pickChess(page, name) { + await page.$eval('.lo-search input', (el) => { el.focus(); el.select(); }); + await page.keyboard.press('Backspace'); + await page.type('.lo-search input', name); + await page.waitForFunction((n) => document.querySelectorAll('.lo-card').length === 1 && document.querySelector('.lo-card .lo-card__name')?.textContent === n, { timeout: 5000 }, name); + await page.click('.lo-card'); + await page.waitForSelector('.lo-sec--stats .dstat', { visible: true }); + } + + test('局内数值 (desktop): 隐现\'s stats follow the module and the 普通 / 精锐 toggle, the range follows 信仰搅拌机\'s SPT-Y', async () => { + const chess = CHESS(); + const base = chess[INSIDE]; + const golden = chess[base.goldenId]; + const mod = golden.modules.find((m) => m.isDefault); + const { ctx, page, problems } = await open(); + await clickSel(page, '.lobby-screen [data-testid="loadout-open"]'); + await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 }); + await pickChess(page, '隐现'); + // between the skills and the modules, 精锐 shown first: the default module's numbers, the 3 × 4 range + assert.deepEqual(await page.$$eval('.lo-detail__body > .lo-sec > header h3', (els) => els.map((e) => e.firstChild.textContent)), ['技能', '局内数值', '模组']); + const stats = await shownStats(page); + assert.deepEqual(Object.keys(stats), ['生命上限', '攻击', '防御', '法术抗性', '攻击间隔', '阻挡数', '部署费用', '再部署']); + assert.equal(stats['生命上限'], fmt(golden.statsBase.maxHp + mod.attr.maxHp)); + assert.equal(stats['攻击'], fmt(golden.statsBase.atk + mod.attr.atk)); + assert.equal(await rangeTiles(page), base.rangeGrid.length); + assert.equal(await page.$eval('.lo-sec--stats', (el) => el.dataset.variant), 'elite'); + await page.screenshot({ path: path.join(OUT, 'loadout-stats-desktop.png') }); + // 不装备 → the base numbers; the skill choice changes nothing; 普通 → the normal chess; both toggles are independent + await page.click('.lo-detail .lo-mod[data-module="none"]'); + await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(golden.statsBase.maxHp)); + assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk)); + await page.click('.lo-detail .lo-skill[data-skill="0"]'); + assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk), 'a skill is not a stat'); + await page.click('.lo-sec--stats .lo-seg button[data-variant="normal"]'); + await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(base.stats.maxHp)); + assert.equal(await page.$eval('.lo-sec--stats', (el) => el.dataset.variant), 'normal'); + assert.equal(await page.$eval('.lo-skill.is-on', (el) => el.dataset.skill), '0'); + assert.equal(await page.$eval('.lo-seg button.is-on', (el) => el.textContent.startsWith('普通')), true, 'the skill level toggle is its own'); + // the layout: no sideways overflow, the range box beside the numbers + const box = await page.evaluate(() => { + const body = document.querySelector('.lo-detail__body'); + const grid = document.querySelector('.lo-sec--stats .dstats').getBoundingClientRect(); + const range = document.querySelector('.lo-sec--stats .drange').getBoundingClientRect(); + return { over: body.scrollWidth - body.clientWidth, beside: range.left >= grid.right - 1 && Math.abs(range.top - grid.top) < 2 }; + }); + assert.ok(box.over <= 1 && box.beside, JSON.stringify(box)); + // 信仰搅拌机: SPT-Y's "攻击距离+1" draws one more tile in the 精锐 view (the toggle stays where it was left: 普通 has no + // module, and says so); 不装备 goes back + await pickChess(page, '信仰搅拌机'); + assert.match(await page.$eval('.lo-stats__cap', (el) => el.textContent), /普通干员没有模组/); + await page.click('.lo-sec--stats .lo-seg button[data-variant="elite"]'); + const t0 = await rangeTiles(page); + await page.click('.lo-detail .lo-mod[data-module="uniequip_003_rmixer"]'); + await page.waitForFunction((n) => document.querySelectorAll('.lo-sec--stats .rgrid i.on').length === n, {}, t0 + 1); + await page.click('.lo-detail .lo-mod[data-module="none"]'); + await page.waitForFunction((n) => document.querySelectorAll('.lo-sec--stats .rgrid i.on').length === n, {}, t0); + assert.deepEqual(problems, []); + await ctx.close(); + }); + + test('局内数值 (phone 844×390, touch): the numbers, the range box and the 特性 / 天赋 card stay inside the narrow detail, text keeps its floor', async () => { + const chess = CHESS(); + const golden = chess[chess[INSIDE].goldenId]; + const { ctx, page, problems } = await open({ w: 844, h: 390, touch: true }); + await page.tap('.lobby-screen [data-testid="loadout-open"]'); + await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 }); + await page.type('.lo-search input', '隐现'); + await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 }); + await page.tap('.lo-card'); + await page.waitForSelector('.lo-sec--stats .dstat', { visible: true }); + await page.$eval('.lo-sec--stats', (el) => el.scrollIntoView({ block: 'start' })); + const m = await page.evaluate(() => { + const bodyEl = document.querySelector('.lo-detail__body'); + const body = bodyEl.getBoundingClientRect(); + const inside = (el) => { const b = el.getBoundingClientRect(); return b.left >= body.left - 1 && b.right <= body.right + 1 && b.width > 0; }; + const fs = (sel) => parseFloat(getComputedStyle(document.querySelector(sel)).fontSize); + const grid = document.querySelector('.lo-sec--stats .dstats').getBoundingClientRect(); + const range = document.querySelector('.lo-sec--stats .drange').getBoundingClientRect(); + return { + over: bodyEl.scrollWidth - bodyEl.clientWidth, + inside: [...document.querySelectorAll('.lo-sec--stats .dstat, .lo-sec--stats .drange, .lo-sec--stats .lo-minfo--kit')].every(inside), + // beside the numbers when the column is wide enough, else under them — never over them + laidOut: range.left >= grid.right - 1 || range.top >= grid.bottom - 1, + k: fs('.lo-sec--stats .dstat__k'), v: fs('.lo-sec--stats .dstat__v'), cap: fs('.lo-stats__cap'), row: fs('.lo-sec--stats .lo-minfo__v'), + tab: Math.round(document.querySelector('.lo-sec--stats .lo-seg button').getBoundingClientRect().height), + cell: Math.min(...[...document.querySelectorAll('.lo-sec--stats .dstat')].map((c) => Math.round(c.getBoundingClientRect().width))), + clipped: [...document.querySelectorAll('.lo-sec--stats .dstat__k')].filter((e) => e.scrollWidth > e.clientWidth).map((e) => e.textContent), + }; + }); + assert.ok(m.over <= 1 && m.inside && m.laidOut, JSON.stringify(m)); + assert.ok(m.k >= 9 && m.v >= 12 && m.cap >= 9 && m.row >= 10, `text floors ${JSON.stringify(m)}`); + assert.ok(m.tab >= 22, `the toggle stays tappable ${JSON.stringify(m)}`); + assert.deepEqual(m.clipped, [], `no stat label is cut off ${JSON.stringify(m)}`); + await page.screenshot({ path: path.join(OUT, 'loadout-stats-phone.png') }); + // a choice made further down updates it (touch: the module card, 不装备) + await page.tap('.lo-detail .lo-mod[data-module="none"]'); + await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(golden.statsBase.maxHp)); + assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk)); + assert.deepEqual(problems, []); + await ctx.close(); + }); });