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:
commit
af68c31d17
7 files changed
+537
-33
No files matched your search
+1
-1
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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
@@ -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><//>
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
@@ -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' } }), /能够阻挡三个敌人/);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user