diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js
index 1b70dd3f..3f2d457a 100644
--- a/public/js/ui/detailPanel.js
+++ b/public/js/ui/detailPanel.js
@@ -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,9 +269,9 @@ 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;
@@ -310,6 +312,45 @@ function GarrisonBlock({ garrison, m }) {
`;
}
+/** 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`
+
+
+ <${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` : '—'} />
+
+
攻击范围<${RangeGrid} grid=${cardRangeGrid(live, rec, chess)} />
+
`;
+}
+
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 +358,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 +400,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
`;
blocks.garrison = garrison ? html`<${GarrisonBlock} key="garrison" garrison=${garrison} m=${m} />` : null;
blocks.trait = c.trait?.desc ? html`<${Icon} name="info" /><${RichText} text=${traitText(c, golden, lo)} />
` : 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`
-
-
- <${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` : '—'} />
-
-
攻击范围<${RangeGrid} grid=${cardRangeGrid(live, fr, c)} />
-
`;
+ blocks.stats = chessStatsBlock({ rec: fr, chess: c, live });
blocks.skill = sk ? html`<${Section} key="skill" title="技能" micro="SKILL" class="dsec--skill">
<${Img} src=${skIcon} class="dskill__icon" fallback=${html`
${skSlot ? html`${skSlot}` : null}`} />
@@ -415,8 +434,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`
${t.name}<${RichText} text=${t.descRaw || t.desc} class="dtext" />
`)}
+ const talents = chessTalents(fr);
+ blocks.talents = talents.length ? html`<${Section} key="talents" title="天赋" micro="TALENT" class="dsec--talent">
+ ${talents.map((t, i) => html`
${t.name}<${RichText} text=${t.descRaw || t.desc} class="dtext" />
`)}
/>` : null;
blocks.actions = piece && editable && piece.kind !== 'item' ? html`
<${Button} variant="amber" icon="close" class="dpanel__sell" onClick=${() => onSell(piece, c)}>出售+${sell}/>
diff --git a/test/ui/feedback1e-range.test.js b/test/ui/feedback1e-range.test.js
index 853ca880..3629ca31 100644
--- a/test/ui/feedback1e-range.test.js
+++ b/test/ui/feedback1e-range.test.js
@@ -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\)/);
});