feedback2: merge WL — the 干员调配 screen shows 局内数值: the chosen skill / module's stats, attack range, 特性 and 天赋 (GitHub #64); 特性 follows the chosen module on the in-match card too

This commit is contained in:
sganggs committed 2026-10-04 03:03:00 +08:00
commit af68c31d17
7 files changed
+537 -33

No files matched your search

+1 -1
View File
@@ -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).
---
+16 -1
View File
@@ -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; }
+59 -2
View File
@@ -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 }) {
</div>`;
}
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<string, any>} 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<string, any>, 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`<section class="lo-sec lo-sec--stats" aria-label="局内数值" data-variant=${pv.elite ? 'elite' : 'normal'}>
<header class="lo-sec__head">
<h3>局内数值<${MicroLabel}>STATS<//></h3>
<div class="lo-seg" role="tablist" aria-label="数值版本">
<button type="button" role="tab" aria-selected=${pv.elite ? 'false' : 'true'} class=${cx(!pv.elite && 'is-on')} data-variant="normal" onClick=${() => onLevel('normal')}>普通</button>
<button type="button" role="tab" aria-selected=${pv.elite ? 'true' : 'false'} class=${cx(pv.elite && 'is-on')} data-variant="elite" disabled=${!golden} onClick=${() => onLevel('elite')}>精锐</button>
</div>
</header>
${chessStatsBlock({ rec: pv.record, chess: pv.chess })}
${pv.trait || pv.talents.length ? html`<div class="lo-minfo lo-minfo--kit">
${pv.trait ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">特性</span><${RichText} class="lo-minfo__v" text=${pv.trait} /></div>` : null}
${pv.talents.map((t, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">天赋</span>
<span class="lo-minfo__v"><b class="lo-minfo__tname">${t.name}</b><${RichText} text=${t.descRaw || t.desc || ''} /></span></div>`)}
</div>` : null}
<p class="lo-stats__cap">${pv.elite ? '数值含所选模组;' : golden ? '普通干员没有模组,所选模组在「精锐」中生效;' : ''}不含技能发动、装备、盟约等局内加成</p>
</section>`;
}
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`<aside class="lo-detail lo-detail--empty"><p class="t-dim">没有符合条件的干员</p></aside>`;
@@ -214,6 +270,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked }) {
onPick=${(i) => onChange({ skill: i })} />`)}
</div>
</section>
<${LoadoutStats} base=${chess} golden=${golden} entries=${entries} level=${statLevel} onLevel=${setStatLevel} />
${golden ? html`<section class="lo-sec lo-sec--mod">
<header class="lo-sec__head">
<h3>模组<${MicroLabel}>MODULE<//></h3>
+50 -28
View File
@@ -28,6 +28,8 @@
// mini-map follows the live entry's `range` too (cardRangeGrid: the grid the unit attacks with now — a running skill's
// range such as 烛煌 S3's 4-11, rangeExtend included; community report E1 after 0.1.0, it used to stay the base grid);
// a grid larger than the box (RANGE_FIT) draws smaller cells (rangeGridStyle), a whole-field one reads 全场.
// The stats block (chessStatsBlock), the 特性 text (traitText) and the talent list (chessTalents) are exported: the 干员调配
// screen's 局内数值 section draws the same ones for the chosen skill / module, without a live entry (GitHub issue #64).
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
@@ -267,12 +269,14 @@ export function BondChips({ bondIds, bonds = [], onBond = null, off = null, gran
/**
* 特性 text of the record the unit fights with (DESIGN §16: `lo.record` = the chosen module's traitOverride, or the
* no-module traitBase for 不装备 — data `trait` is the default module's).
* no-module traitBase for 不装备 — data `trait` is the default module's). Also the 干员调配 screen's (screens/loadout.js).
*/
function traitText(c, golden, lo) {
export function traitText(c, golden, lo) {
const t = (lo?.record || c).trait || {};
const base = t.descRaw || t.desc || '';
if (!golden || lo?.record !== c) return base;
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;
}
@@ -310,6 +314,45 @@ function GarrisonBlock({ garrison, m }) {
</section>`;
}
/** The talents the card lists (天赋): the loadout record's named, not hidden ones. */
export function chessTalents(rec) {
return (Array.isArray(rec?.talents) ? rec.talents : []).filter((t) => t && t.name && !t.hidden);
}
/**
* The card's stats block (block key "stats"): the eight stats and the 攻击范围 mini-map. `rec` = the loadout-resolved
* record the unit fights with (chessLoadout `.record`: the chosen module's stats, the chosen skill's passive range),
* `chess` the chess record (the range's fallback), `live` the live / start-of-battle entry (liveStat) or null — the
* record's own numbers, untagged. A plain function (its root node keeps the key), shared by the detail card and the
* 干员调配 screen (screens/loadout.js 局内数值, GitHub issue #64): one block, so the two never disagree.
* @param {{ rec: any, chess: any, live?: any }} p
*/
export function chessStatsBlock({ rec, chess, live = null }) {
const s = rec?.stats || {};
const interval = attackInterval(s.bat, s.aspd);
// live (battle) / start-of-battle (prep) values against the base, else the record's (liveStat)
const st = {
maxHp: liveStat(live, 'maxHp', s.maxHp), atk: liveStat(live, 'atk', s.atk), def: liveStat(live, 'def', s.def),
res: liveStat(live, 'res', s.res ?? 0, fmtRes), interval: liveStat(live, 'interval', interval, fmtInterval),
blockCnt: liveStat(live, 'blockCnt', s.blockCnt, (v) => String(v)),
};
return html`
<div key="stats" class=${cx('dstats-wrap', live && 'is-live')} data-live=${live ? live.src || 'prep' : undefined}>
<div class="dstats">
<${LiveTag} live=${live} />
<${Stat} k="生命上限" ...${st.maxHp} />
<${Stat} k="攻击" ...${st.atk} />
<${Stat} k="防御" ...${st.def} />
<${Stat} k="法术抗性" ...${st.res} />
<${Stat} k="攻击间隔" ...${st.interval} />
<${Stat} k="阻挡数" ...${st.blockCnt} />
<${Stat} k="部署费用" v=${s.cost ?? '—'} />
<${Stat} k="再部署" v=${s.respawnTime != null ? `${s.respawnTime}s` : '—'} />
</div>
<div class="drange"><span class="dstat__k">攻击范围</span><${RangeGrid} grid=${cardRangeGrid(live, rec, chess)} /></div>
</div>`;
}
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null }) {
const m = data.get('assets');
const hp = hpOf(live, snapHp);
@@ -317,9 +360,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
const c = chess;
// stats / talents the unit fights with: the chosen module's (or none — statsBase) for an elite (DESIGN §16)
const fr = lo?.record || c;
const s = fr.stats || {};
const golden = !!(c.isGolden || piece?.golden);
const interval = attackInterval(s.bat, s.aspd);
const sk = lo?.skill || c.skill || null;
// a chosen skill the manifest has no icon for (only the default skills' icons are fetched): its slot letter
const skIcon = sk && lo && !lo.defaultSkill ? skillRecordIconUrl(m, sk, { empty: false }) : skillIconUrl(m, c);
@@ -361,27 +402,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
</div>`;
blocks.garrison = garrison ? html`<${GarrisonBlock} key="garrison" garrison=${garrison} m=${m} />` : null;
blocks.trait = c.trait?.desc ? html`<p key="trait" class="dtrait"><${Icon} name="info" /><${RichText} text=${traitText(c, golden, lo)} /></p>` : null;
// live (battle) / start-of-battle (prep) values against the base, else the record's (liveStat)
const st = {
maxHp: liveStat(live, 'maxHp', s.maxHp), atk: liveStat(live, 'atk', s.atk), def: liveStat(live, 'def', s.def),
res: liveStat(live, 'res', s.res ?? 0, fmtRes), interval: liveStat(live, 'interval', interval, fmtInterval),
blockCnt: liveStat(live, 'blockCnt', s.blockCnt, (v) => String(v)),
};
blocks.stats = html`
<div key="stats" class=${cx('dstats-wrap', live && 'is-live')} data-live=${live ? live.src || 'prep' : undefined}>
<div class="dstats">
<${LiveTag} live=${live} />
<${Stat} k="生命上限" ...${st.maxHp} />
<${Stat} k="攻击" ...${st.atk} />
<${Stat} k="防御" ...${st.def} />
<${Stat} k="法术抗性" ...${st.res} />
<${Stat} k="攻击间隔" ...${st.interval} />
<${Stat} k="阻挡数" ...${st.blockCnt} />
<${Stat} k="部署费用" v=${s.cost ?? '—'} />
<${Stat} k="再部署" v=${s.respawnTime != null ? `${s.respawnTime}s` : '—'} />
</div>
<div class="drange"><span class="dstat__k">攻击范围</span><${RangeGrid} grid=${cardRangeGrid(live, fr, c)} /></div>
</div>`;
blocks.stats = chessStatsBlock({ rec: fr, chess: c, live });
blocks.skill = sk ? html`<${Section} key="skill" title="技能" micro="SKILL" class="dsec--skill">
<div class="dskill" data-skill=${sk.skillId || ''}>
<${Img} src=${skIcon} class="dskill__icon" fallback=${html`<span class="dskill__icon dskill__icon--empty">${skSlot ? html`<b class="num">${skSlot}</b>` : null}</span>`} />
@@ -415,8 +436,9 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
: !piece && carried.length ? html`<${Section} key="equip" title="装备" micro=${`EQUIP ${carried.length}/2`} class="dsec--equip">
${carried.map((id, i) => html`<${ItemRow} key=${`${i}:${id}`} itemId=${id} carried=${carried} off=${offBonds} />`)}
<//>` : null;
blocks.talents = Array.isArray(fr.talents) && fr.talents.some((t) => t && t.name && !t.hidden) ? html`<${Section} key="talents" title="天赋" micro="TALENT" class="dsec--talent">
${fr.talents.filter((t) => t && t.name && !t.hidden).map((t, i) => html`<div key=${i} class="dtalent"><b>${t.name}</b><${RichText} text=${t.descRaw || t.desc} class="dtext" /></div>`)}
const talents = chessTalents(fr);
blocks.talents = talents.length ? html`<${Section} key="talents" title="天赋" micro="TALENT" class="dsec--talent">
${talents.map((t, i) => html`<div key=${i} class="dtalent"><b>${t.name}</b><${RichText} text=${t.descRaw || t.desc} class="dtext" /></div>`)}
<//>` : null;
blocks.actions = piece && editable && piece.kind !== 'item' ? html`<div key="actions" class="dactions">
<${Button} variant="amber" icon="close" class="dpanel__sell" onClick=${() => onSell(piece, c)}>出售<span class="dsell num">+${sell}</span><//>
+3 -1
View File
@@ -101,7 +101,9 @@ test('a whole-field skill (纯烬艾雅法拉 S3 "攻击范围扩大至整个战
test('the game screen hands the card the live entry it already reads (battle runner / m.unitStats)', () => {
const src = readFileSync(path.join(ROOT, 'public/js/ui/detailPanel.js'), 'utf8');
assert.match(src, /grid=\$\{cardRangeGrid\(live, fr, c\)\}/);
// (the stats block moved into the shared chessStatsBlock — also the 干员调配 screen's 局内数值, GitHub issue #64)
assert.match(src, /grid=\$\{cardRangeGrid\(live, rec, chess\)\}/);
assert.match(src, /blocks\.stats = chessStatsBlock\(\{ rec: fr, chess: c, live \}\)/, 'the card draws it through the shared block');
const game = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
assert.match(game, /battleRunner\.unitStats\(uid, fid\)/);
});
+300
View File
@@ -0,0 +1,300 @@
// 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('<section class="lo-sec lo-sec--mod">');
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'), /<link rel="stylesheet" href="\/css\/screens\/game-panels\.css" \/>/);
});
test('特性 follows the chosen module also when the skill or the module differs from the default (cloned record)', async () => {
const fs = await import('node:fs');
const { chessLoadout } = await import('../../public/js/ui/gameLogic.js');
const { traitText } = await import('../../public/js/ui/detailPanel.js');
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 } }), /攻击空中单位时攻击力提升至/);
// 信仰搅拌机: 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' } }), /能够阻挡三个敌人/);
});
+108
View File
@@ -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();
});
});