feedback5 WN: 补位 display follows the official game (the owner's recall of 2026-10-06) — a chess the player does not own is shown as its stand-in wherever the player's copy appears: shop and reward cards (the stand-in's portrait, name, class and backup skill; the chess's bonds, tier and price; a small 「替补」 mark), the hand and 临时整备区 pieces (the stand-in's model, a 「替补」 tag; the board already was), the detail card (the stand-in's portrait, name, skill, talents and module with the chess's bonds and 特质, 「银灰的替补」 under the name [ASSUMED]), bond popups (own and a teammate's by UnitInfo standInFor through ownerBoard / ownerOps), the result lineup (m.result standInFor), prep scouting of the bench, the equip-replace dialog, the elite and gift tickers; rules unchanged (bonds, price, tier, merges, pools and 特质 stay the chess's); new strings through t() with English; tests updated (standin-ui 15, standin-ownership 7, watch-bonds wiring) and the e2e now ends on the result screen (fastServer SP_FINISH_AFTER); golden unchanged

This commit is contained in:
sganggs committed 2026-10-06 02:40:38 +08:00
1 parent 78251f499c
commit 0e8004a382
32 files changed
+518 -181

No files matched your search

+133 -24
View File
@@ -1,9 +1,11 @@
// 0.2.0 补位 — client side (the approved plan, owner's decision 2026-10-05): the 干员持有 tab's model (storage, toggles,
// the roster by tier, export / import) and its server sync (room.ownership), and the match UI of a not-owned chess —
// the shop / reward card keeps the chess (name, bonds, price) with a 「替补:X」 badge and the stand-in's skill, the own
// pieces' tags, the board / deploy rules of the stand-in's body (position, range), and the detail card's body (portrait,
// class, skill, talents) from the stand-in for the player's own pieces (m.private.standIns) and any unit carrying
// UnitInfo `standInFor`.
// SHOWN as its stand-in wherever the player's copy appears (the owner's recall of the official mode, 2026-10-06): the
// shop / reward card (the stand-in's portrait, name, class and skill; the chess's bonds and price; a small 「替补」 mark),
// the own pieces (the stand-in's model in the hand, 临时整备区 and on the board, a 「替补」 tag), the detail card (the
// stand-in's portrait, name, skill, talents; the chess's bonds and 特质; 「银灰的替补」), the bond popups (own and a
// teammate's), the result lineup and the equip-replace dialog — while the rules keep the chess (placement and ranges
// by the stand-in's body, as before).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -27,6 +29,10 @@ const { createStore } = await import('../../public/js/store.js');
const G = await import('../../public/js/ui/gameLogic.js');
const { ChessCard } = await import('../../public/js/ui/shopBar.js');
const { ChessDetail, resolveDetail } = await import('../../public/js/ui/detailPanel.js');
const { BondPopup } = await import('../../public/js/ui/bondStrip.js');
const { ownerBoard } = await import('../../public/js/ui/watchBonds.js');
const { replaceRequest } = await import('../../public/js/ui/equipReplace.js');
const { LineupThumb } = await import('../../public/js/screens/result.js');
const { standInPieces } = await import('../../public/js/screens/game/standInTags.js');
const { OwnCard } = await import('../../public/js/screens/ownership.js');
const { data } = await import('../../public/js/data.js');
@@ -49,6 +55,17 @@ function* walk(v) {
}
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
const textOf = (v) => [...walk(v)].flatMap((n) => (Array.isArray(n.props?.children) ? n.props.children : [n.props?.children])).filter((x) => typeof x === 'string' || typeof x === 'number').join('');
/** Render a component tree one level of function components deep at a time (htm vnodes with function types). */
function expand(v, depth = 6) {
if (Array.isArray(v)) return v.map((x) => expand(x, depth));
if (!v || typeof v !== 'object') return v;
if (typeof v.type === 'function' && depth > 0) {
try { return expand(v.type(v.props || {}), depth - 1); } catch { return v; }
}
const children = v.props?.children;
return children === undefined ? v : { ...v, props: { ...v.props, children: expand(children, depth) } };
}
const srcsOf = (v) => [...walk(v)].map((n) => n.props?.src).filter((x) => typeof x === 'string');
describe('干员持有 model (ui/ownershipModel.js)', () => {
test('storage: tolerant parse, clean sorted ids, round trip; hostile keys dropped', () => {
@@ -177,7 +194,10 @@ test('ownership sync: room.ownership after welcome and after edits (debounced);
// ---- match UI -------------------------------------------------------------------------------------------------------------
describe('match UI of a not-owned chess', () => {
const priv = { standIns: [SILVER, MLYSS, SARIA], loadout: {}, board: [], hand: [], temp: [] };
const priv = { playerId: 'p_0', standIns: [SILVER, MLYSS, SARIA], loadout: {}, board: [], hand: [], temp: [] };
const SHARP = 'char_609_acguad';
const sharp = () => BACKUPS.units[SHARP];
test('gameLogic: own pieces by m.private.standIns (normal + elite); the composed record keeps the identity', () => {
assert.deepEqual(G.standInIds(priv), [SILVER, MLYSS, SARIA]);
assert.equal(G.fieldsStandIn(priv, getChess(SILVER)), true);
@@ -185,14 +205,21 @@ describe('match UI of a not-owned chess', () => {
assert.equal(G.fieldsStandIn({ standIns: [] }, getChess(SILVER)), false);
assert.equal(G.fieldsStandIn(null, getChess(SILVER)), false);
const si = G.standInOf(getChess(SILVER), BACKUPS);
assert.deepEqual([si.charId, si.name, si.standInFor, si.chessId, si.price, si.bonds], ['char_609_acguad', 'Sharp', getChess(SILVER).charId, SILVER, getChess(SILVER).price, getChess(SILVER).bonds]);
assert.deepEqual([si.charId, si.name, si.standInFor, si.chessId, si.price, si.bonds, si.garrisonIds], [SHARP, 'Sharp', getChess(SILVER).charId, SILVER, getChess(SILVER).price, getChess(SILVER).bonds, getChess(SILVER).garrisonIds]);
assert.equal(G.standInOf(getChess(SILVER), BACKUPS), si, 'cached');
assert.equal(G.standInOf(si, BACKUPS), si, 'a composed record is its own stand-in');
assert.equal(G.standInOf(getChess(CATHY), BACKUPS), null, 'PRESET');
assert.equal(G.standInLabel(si), '替补:Sharp');
assert.equal(G.ownStandIn(getChess(SILVER), priv, BACKUPS), si);
assert.equal(G.ownStandIn(getChess(SILVER), { standIns: [] }, BACKUPS), null);
assert.equal(G.standInLabel(si), '替补', 'a small mark');
assert.equal(G.standInLabel(getChess(SILVER)), null);
assert.equal(G.standInForText('银灰'), '银灰的替补');
assert.match(G.standInTip(si, '银灰'), /未持有银灰:由替补干员 Sharp 上场/);
const lo = G.standInLoadout(si, getChess, BACKUPS);
assert.equal(lo.skill.skillId, 'skchr_acguad_2', 'its backup skill (S2), never the player\'s loadout');
// the deployed record: the stand-in's for a dropped chess, the loadout's otherwise
assert.equal(G.deployedRecord(getChess(SILVER), priv, getChess, BACKUPS).charId, 'char_609_acguad');
assert.equal(G.deployedRecord(getChess(SILVER), priv, getChess, BACKUPS).charId, SHARP);
assert.equal(G.deployedRecord(si, priv, getChess, BACKUPS).charId, SHARP, 'a composed record deploys as itself');
assert.equal(G.deployedRecord(getChess(SILVER), { standIns: [] }, getChess, BACKUPS).charId, getChess(SILVER).charId);
const eg = getChess(getChess(MLYSS).goldenId);
assert.equal(G.deployedModuleId(eg, priv, getChess, BACKUPS), eg.backup.uniEquipId, 'the backup module (SOL-X)');
@@ -208,58 +235,140 @@ describe('match UI of a not-owned chess', () => {
assert.equal(G.piecePosition(own, { kind: 'chess', id: SARIA }), 'MELEE');
});
test('shop / reward card: the chess\'s name, price and bonds stay, the 「替补:X」 badge and the stand-in\'s skill show', () => {
test('shop / reward card: the stand-in\'s portrait, name, class and skill; the chess\'s bonds and price; a small 「替补」 mark', () => {
const slot = { kind: 'chess', id: SILVER, price: 3, basePrice: 3 };
const card = ChessCard({ slot, idx: 0, priv, onBuy() {}, onDetail() {} });
const nodes = [...walk(card)];
const badge = nodes.find((v) => hasClass(v, 'scard__standin'));
assert.ok(badge, 'badge');
assert.equal(badge.props['data-standin'], 'char_609_acguad');
assert.equal(badge.props.children, '替补:Sharp');
assert.equal(nodes.find((v) => hasClass(v, 'scard__name')).props.children, '银灰', 'the card keeps the chess\'s name');
assert.ok(badge, 'the mark');
assert.deepEqual([badge.props['data-standin'], badge.props['data-for'], badge.props.children], [SHARP, SILVER, '替补']);
assert.match(badge.props.title, /银灰/, 'its title names the chess');
assert.equal(nodes.find((v) => hasClass(v, 'scard__name')).props.children, 'Sharp', 'the stand-in\'s name');
assert.ok(hasClass(card, 'is-standin'));
assert.match(card.props['aria-label'], /^Sharp(银灰的替补),价格 3/);
assert.ok(srcsOf(card).some((u) => u.includes('char_609_acguad_1')), 'the stand-in\'s portrait');
assert.ok(!srcsOf(card).some((u) => u.includes('svrash')), 'not the replaced operator\'s');
assert.ok(textOf(nodes.find((v) => hasClass(v, 'scard__class'))).includes(sharp().subProfessionName), 'the stand-in\'s class (无畏者)');
const bonds = nodes.filter((v) => hasClass(v, 'scard__bond')).map((v) => v.key);
assert.deepEqual(bonds, getChess(SILVER).bonds, 'the chess\'s bonds still count');
const skill = nodes.find((v) => v?.type && v.props?.lo);
assert.equal(skill.props.lo.skill.skillId, 'skchr_acguad_2');
const owned = ChessCard({ slot, idx: 0, priv: { standIns: [] }, onBuy() {}, onDetail() {} });
assert.equal([...walk(owned)].find((v) => hasClass(v, 'scard__standin')), undefined, 'owned: no badge');
assert.equal([...walk(owned)].find((v) => hasClass(v, 'scard__standin')), undefined, 'owned: no mark');
assert.equal([...walk(owned)].find((v) => hasClass(v, 'scard__name')).props.children, '银灰');
});
test('detail card: own piece / card → the stand-in\'s body under the chess\'s name; a unit with standInFor likewise', () => {
test('detail card: the stand-in\'s portrait, name, skill and talents, 「银灰的替补」, the chess\'s bonds and 特质; a unit with standInFor likewise', () => {
const piece = { uid: 41, kind: 'chess', id: SILVER, golden: false, items: [] };
const pieces = new Map([[41, { piece, area: 'hand', idx: 0 }]]);
const r = resolveDetail({ kind: 'piece', uid: 41 }, pieces, { priv, backups: BACKUPS });
assert.equal(r.standIn?.charId, 'char_609_acguad');
assert.equal(r.standIn?.charId, SHARP);
assert.equal(resolveDetail({ kind: 'piece', uid: 41 }, pieces, { priv: { standIns: [] }, backups: BACKUPS }).standIn, null);
assert.equal(resolveDetail({ kind: 'chess', id: SILVER }, new Map(), { priv, backups: BACKUPS }).standIn?.charId, 'char_609_acguad', 'a shop card');
assert.equal(resolveDetail({ kind: 'chess', id: SILVER }, new Map(), { priv, backups: BACKUPS }).standIn?.charId, SHARP, 'a shop card');
const u = resolveDetail({ kind: 'unit', unit: { id: 9, uid: 99, side: 'ally', kind: 'op', defId: SARIA, standInFor: getChess(SARIA).charId } }, new Map(), { priv: null, backups: BACKUPS });
assert.equal(u.standIn?.charId, 'char_613_acmedc', 'a teammate\'s unit says it itself');
// a teammate's bond popup row: its own word (standInFor), never the viewer's list
const mate = resolveDetail({ kind: 'chess', id: SILVER, owner: 'p_1' }, new Map(), { priv, backups: BACKUPS });
assert.equal(mate.standIn, null, 'the viewer\'s 补位 list is not the teammate\'s');
assert.equal(resolveDetail({ kind: 'chess', id: SILVER, owner: 'p_1', standInFor: getChess(SILVER).charId }, new Map(), { priv, backups: BACKUPS }).standIn?.charId, SHARP);
assert.equal(resolveDetail({ kind: 'chess', id: SILVER, foreign: true }, new Map(), { priv, backups: BACKUPS }).standIn, null, 'the banned list: the chess');
const blocks = ChessDetail({ chess: r.chess, piece, editable: false, bonds: [], loadout: null, standIn: r.standIn });
const head = blocks.find((b) => b.key === 'head');
const tag = [...walk(head)].find((v) => hasClass(v, 'dtag-standin'));
assert.equal(tag?.props.children, '替补:Sharp');
assert.equal([...walk(head)].find((v) => hasClass(v, 'dhead__name')).props.children, '银灰');
const hn = [...walk(head)];
const tag = hn.find((v) => hasClass(v, 'dtag-standin'));
assert.equal(tag?.props.children, '替补');
assert.equal(hn.find((v) => hasClass(v, 'dhead__name')).props.children, 'Sharp', 'the stand-in\'s name');
assert.equal(hn.find((v) => hasClass(v, 'dhead__for')).props.children, '银灰的替补', 'the replaced operator, under the name [ASSUMED]');
assert.ok(srcsOf(head).some((x) => x.includes('char_609_acguad_1')), 'the stand-in\'s portrait');
assert.ok(textOf(head).includes(sharp().subProfessionName), 'the stand-in\'s class');
// the chess's bonds (chips) and 特质 (garrison block) still apply
const chips = hn.find((v) => v?.type && Array.isArray(v.props?.bondIds));
assert.deepEqual(chips.props.bondIds, getChess(SILVER).bonds);
const garrison = blocks.find((b) => b.key === 'garrison');
assert.equal(garrison?.props.garrison?.garrisonId ?? garrison?.props.garrison?.id ?? null, getChess(SILVER).garrisonIds[0], 'the chess\'s 特质');
const skill = blocks.find((b) => b.key === 'skill');
assert.ok(JSON.stringify(skill.props).includes(r.standIn.skill.name), 'the stand-in\'s skill');
const talents = blocks.find((b) => b.key === 'talents');
for (const t of r.standIn.talents.filter((x) => x.name && !x.hidden)) assert.ok(JSON.stringify(talents.props).includes(t.name), `talent ${t.name}`);
assert.ok(!JSON.stringify(blocks).includes(getChess(SILVER).skill.name), 'not the replaced operator\'s skill');
// an owned chess: its own name and English name
const plain = ChessDetail({ chess: getChess(SILVER), piece, editable: false, bonds: [], loadout: null, standIn: null });
const ph = [...walk(plain.find((b) => b.key === 'head'))];
assert.equal(ph.find((v) => hasClass(v, 'dhead__name')).props.children, '银灰');
assert.equal(ph.find((v) => hasClass(v, 'dhead__for')), undefined);
});
test('own prep pieces with stand-ins get a tag (hand, temp, board); others none', () => {
test('own prep pieces with stand-ins get a small 「替补」 tag (hand, temp, board); others none', () => {
const p = {
standIns: [SILVER],
hand: [{ uid: 1, kind: 'chess', id: SILVER }, { uid: 2, kind: 'chess', id: CATHY }, null],
temp: [{ uid: 3, kind: 'chess', id: getChess(SILVER).goldenId }],
board: [{ uid: 4, kind: 'chess', id: SILVER, row: 10, col: 4 }, { uid: 5, kind: 'token', id: 'x', row: 9, col: 4 }],
};
assert.deepEqual(standInPieces(p, getChess, BACKUPS).map((x) => [x.uid, x.area, x.label]), [[1, 'hand', '替补:Sharp'], [3, 'temp', '替补:Sharp'], [4, 'board', '替补:Sharp']]);
assert.deepEqual(standInPieces(p, getChess, BACKUPS).map((x) => [x.uid, x.area, x.label, x.name]), [[1, 'hand', '替补', 'Sharp'], [3, 'temp', '替补', 'Sharp'], [4, 'board', '替补', 'Sharp']]);
assert.match(standInPieces(p, getChess, BACKUPS)[0].tip, /未持有银灰/);
assert.deepEqual(standInPieces({ ...p, standIns: [] }, getChess, BACKUPS), []);
});
test('the board model of a dropped chess is the stand-in\'s (render/app.js pieceInfo reads m.private.standIns; the view is rebuilt when its model changes)', () => {
test('bond popups: the own not-owned member and a teammate\'s stand-in unit are drawn as the stand-in (mark, name), their rows open the stand-in', () => {
const bond = getChess(SILVER).bonds[0]; // 谢拉格
const own = { ...priv, board: [{ uid: 4, kind: 'chess', id: SILVER, row: 10, col: 4 }] };
assert.equal(G.memberStandIn(own, getChess(SILVER), BACKUPS, getChess)?.charId, SHARP, 'own: by m.private.standIns');
assert.equal(G.memberStandIn({ ...own, standIns: [] }, getChess(SILVER), BACKUPS, getChess), null);
const seen = [];
const pop = expand(BondPopup({ bondId: bond, entry: { count: 1, active: false, tier: 0, layers: 0 }, priv: own, onClose() {}, onMember: (...a) => seen.push(a) }));
const row = [...walk(pop)].find((v) => hasClass(v, 'bpop__member') && v.props['data-standin'] === SHARP);
assert.ok(row, 'the member row is the stand-in');
assert.ok(hasClass(row, 'is-on'), 'on the board');
assert.equal(textOf([...walk(row)].find((v) => hasClass(v, 'bpop__mname'))), 'Sharp');
assert.ok([...walk(row)].some((v) => hasClass(v, 'uthumb__si')), 'the small 「替补」 mark on the thumbnail');
assert.ok(srcsOf(row).some((x) => x.includes('char_609_acguad')), 'the stand-in\'s avatar');
row.props.onClick();
assert.deepEqual(seen.at(-1), [SILVER, null, getChess(SILVER).charId]);
// a watched teammate: their unit says it is a stand-in (UnitInfo standInFor → ownerBoard); the viewer's list does not count
const field = { units: [{ kind: 'op', ownerId: 'p_1', defId: SILVER, standInFor: getChess(SILVER).charId }, { kind: 'op', ownerId: 'p_1', defId: SARIA }] };
const mate = ownerBoard(field, 'p_1');
assert.equal(mate.board[0].standInFor, getChess(SILVER).charId);
assert.equal(mate.board[1].standInFor, undefined);
assert.equal(G.memberStandIn(mate, getChess(SILVER), BACKUPS, getChess)?.charId, SHARP);
assert.equal(G.memberStandIn(mate, getChess(SARIA), BACKUPS, getChess), null, 'a teammate\'s own 塞雷娅 stays 塞雷娅');
const mpop = expand(BondPopup({ bondId: bond, entry: { count: 1, active: false, tier: 0, layers: 0 }, priv: mate, owner: 'P1', onClose() {} }));
assert.ok([...walk(mpop)].some((v) => hasClass(v, 'bpop__member') && v.props['data-standin'] === SHARP), 'a teammate\'s stand-in row');
});
test('result lineup: a lineup entry with standInFor draws the stand-in with the mark; the elite too; others the chess', () => {
const gd = { chess: getChess, backups: BACKUPS };
const th = expand(LineupThumb({ u: { id: getChess(SILVER).goldenId, golden: true, tier: 4, standInFor: getChess(SILVER).charId }, gd }));
assert.ok([...walk(th)].some((v) => hasClass(v, 'uthumb__si') && v.props['data-standin'] === SHARP), 'the mark');
assert.ok(srcsOf(th).some((x) => x.includes('char_609_acguad')), 'the stand-in\'s avatar');
assert.equal(th.props.title, 'Sharp(银灰的替补)');
const plain = expand(LineupThumb({ u: { id: SILVER, tier: 4 }, gd }));
assert.ok(![...walk(plain)].some((v) => hasClass(v, 'uthumb__si')));
assert.equal(plain.props.title, '银灰');
});
test('equip-replace dialog: the operator is named and drawn as the stand-in it shows', () => {
const items = [{ uid: 11, id: 'a' }, { uid: 12, id: 'b' }];
const target = { uid: 1, kind: 'chess', id: SILVER, items };
const ctx = { pieces: new Map([[1, { piece: target }], [3, { piece: { uid: 3, kind: 'item', id: 'c' } }]]) };
const intent = { t: 'g.equip', confirmReplace: true, fields: { itemUid: 3, targetUid: 1 } };
const shown = (id) => G.ownStandIn(getChess(id), priv, BACKUPS) || getChess(id);
const req = replaceRequest(ctx, intent, shown, () => null);
assert.equal(req.targetName, 'Sharp');
assert.equal(req.targetRec?.charId, SHARP);
assert.equal(replaceRequest(ctx, intent, getChess, () => null).targetRec, null, 'owned: the data\'s record');
});
test('the model of a dropped chess is the stand-in\'s in the hand, the 临时整备区 and on the board (render/app.js pieceInfo reads m.private.standIns)', () => {
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
assert.match(src, /area === 'board' && chess && standInList\.includes\(chess\.baseId \|\| chess\.chessId\) \? data\.standIn\(piece\.id\) : null/);
assert.match(src, /const si = chess && standInList\.includes\(chess\.baseId \|\| chess\.chessId\) \? data\.standIn\(piece\.id\) : null;/);
assert.doesNotMatch(src, /area === 'board' && chess && standInList/, 'not only on the board');
assert.match(src, /standInList = Array\.isArray\(src\.standIns\)/);
assert.match(src, /const sig = `\$\{info\.kind\}\|\$\{info\.defId\}\|\$\{info\.golden \? 1 : 0\}\|\$\{info\.spine \|\| ''\}`/);
assert.match(readFileSync(path.join(ROOT, 'public/js/render/app/info.js'), 'utf8'), /standInFor: typeof u\.standInFor === 'string'/);
// the DOM fallback field draws every own piece of it with the stand-in's avatar and name
const ff = readFileSync(path.join(ROOT, 'public/js/ui/fallbackField.js'), 'utf8');
assert.match(ff, /chessAvatarUrl\(mm, ownSi\(chess\) \|\| ownDiy\(chess\) \|\| chess\)/);
assert.match(ff, /\(ownSi\(chess\) \|\| ownDiy\(chess\) \|\| chess\)\?\.name/);
});
});