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:
1 parent
78251f499c
commit
0e8004a382
32 files changed
+518
-181
No files matched your search
+133
-24
@@ -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/);
|
||||
});
|
||||
});
|
||||
+45
-24
@@ -1,15 +1,18 @@
|
||||
// Real-server browser E2E of 0.2.0 补位 end to end (the approved plan, owner's decision 2026-10-05): the 干员持有 tab of
|
||||
// the 干员调配 overlay marks 银灰 as not owned (real clicks; persisted in localStorage; room.ownership → session → seat →
|
||||
// the next match's m.private.standIns); in the match the shop card keeps 银灰 (name, price) with 「替补:Sharp」; bought,
|
||||
// the hand piece carries the tag; deployed, the board model is Sharp's; the detail card shows Sharp's skill under
|
||||
// 银灰's name; and in the browser's own battle (client-side combat) the unit is the stand-in — the b.start spec marks it
|
||||
// `standIn: true`, the local sim fields char_609_acguad with its kit, and the battle view draws Sharp's Spine model.
|
||||
// Real-server browser E2E of 0.2.0 补位 end to end (the approved plan, owner's decision 2026-10-05; the display follows
|
||||
// the owner's recall of the official mode, 2026-10-06): the 干员持有 tab of the 干员调配 overlay marks 银灰 as not owned
|
||||
// (real clicks; persisted in localStorage; room.ownership → session → seat → the next match's m.private.standIns); in
|
||||
// the match the shop card shows the stand-in Sharp (name, portrait, a small 「替补」 mark; 银灰's bonds and price);
|
||||
// bought, the hand piece is Sharp's model with the 「替补」 tag; deployed, the board model is Sharp's and the detail
|
||||
// card shows Sharp (name, skill) with 「银灰的替补」; in the browser's own battle (client-side combat) the unit is the
|
||||
// stand-in — the b.start spec marks it `standIn: true`, the local sim fields char_609_acguad with its kit, and the
|
||||
// battle view draws Sharp's Spine model; the match then ends (the fast server's finishAfter hook) and the result
|
||||
// screen's lineup shows Sharp with the 「替补」 mark.
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/standin.e2e.test.js
|
||||
//
|
||||
// The server is test/e2e/fastServer.mjs with its starter-kit hooks (no starter operators, 银灰 in the first shop slot,
|
||||
// 20 funds); everything else is the real match engine and the real UI driven by real mouse input.
|
||||
// Screenshots: test/e2e/out/standin-*.png.
|
||||
// 20 funds) and SP_FINISH_AFTER 1 (the match ends once round 1 has settled); everything else is the real match engine
|
||||
// and the real UI driven by real mouse input. Screenshots: test/e2e/out/standin-*.png.
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
@@ -21,8 +24,8 @@ const SILVER = 'chess_char_4_22_a'; // 银灰 (NORMAL) → Sharp (char_609_acgua
|
||||
const SHARP = 'char_609_acguad';
|
||||
|
||||
describe('0.2.0 补位 — a not-owned operator fights as its stand-in (real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
|
||||
test('干员持有: drop 银灰 → buy it (badge) → deploy (Sharp model, Sharp card) → the local battle fields Sharp', { timeout: 6 * 60 * 1000 }, async () => {
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 2, startRound: 1, kit: 0, shop: [SILVER] } });
|
||||
test('干员持有: drop 银灰 → the shop shows Sharp → buy (the hand shows Sharp) → deploy (Sharp card) → the local battle fields Sharp → the result shows Sharp', { timeout: 6 * 60 * 1000 }, async () => {
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 2, startRound: 1, kit: 0, shop: [SILVER], finishAfter: 1 } });
|
||||
const P = (await import('puppeteer-core')).default;
|
||||
const c = new Client(P, srv.base, 'standin', { prefix: 'standin' });
|
||||
try {
|
||||
@@ -62,28 +65,34 @@ describe('0.2.0 补位 — a not-owned operator fights as its stand-in (real ser
|
||||
await c.waitFor((s) => s.phase === 'PREP' && !s.ready && s.funds >= 3, 'prep with funds', 60000);
|
||||
await sleep(1800); // camera flight
|
||||
|
||||
// 3) the shop card: 银灰 with 「替补:Sharp」; two taps buy it
|
||||
// 3) the shop card shows the stand-in: Sharp (name, portrait) with a small 「替补」 mark, 银灰's bond; two taps buy it
|
||||
await c.page.waitForSelector('.scard .scard__standin', { visible: true, timeout: 10000 });
|
||||
const shopCard = await c.page.evaluate(() => {
|
||||
const el = document.querySelector('.scard .scard__standin')?.closest('.scard');
|
||||
return el ? { name: el.querySelector('.scard__name')?.textContent, badge: el.querySelector('.scard__standin')?.textContent, standIn: el.querySelector('.scard__standin')?.dataset.standin } : null;
|
||||
return el ? {
|
||||
name: el.querySelector('.scard__name')?.textContent, badge: el.querySelector('.scard__standin')?.textContent,
|
||||
standIn: el.querySelector('.scard__standin')?.dataset.standin, for: el.querySelector('.scard__standin')?.dataset.for,
|
||||
art: el.querySelector('.scard__art')?.getAttribute('src') || '', bonds: [...el.querySelectorAll('.scard__bond > span:last-child')].map((b) => b.textContent),
|
||||
} : null;
|
||||
});
|
||||
assert.deepEqual(shopCard, { name: '银灰', badge: '替补:Sharp', standIn: SHARP });
|
||||
assert.ok(shopCard, 'the stand-in card');
|
||||
assert.deepEqual([shopCard.name, shopCard.badge, shopCard.standIn, shopCard.for], ['Sharp', '替补', SHARP, SILVER]);
|
||||
assert.match(shopCard.art, /char_609_acguad/, 'Sharp\'s portrait');
|
||||
assert.deepEqual(shopCard.bonds, ['谢拉格'], '银灰\'s bond still counts');
|
||||
await c.shot('shop');
|
||||
await c.click('.scard', '银灰');
|
||||
await c.click('.scard', 'Sharp');
|
||||
await sleep(300);
|
||||
await c.click('.scard', '银灰');
|
||||
await c.click('.scard', 'Sharp');
|
||||
await c.waitFor((s) => s.hand > 0, 'bought', 8000);
|
||||
await sleep(900);
|
||||
const [piece] = (await c.handPieces('chess')).filter((p) => p.id === SILVER);
|
||||
assert.ok(piece, '银灰 is in the hand');
|
||||
// the hand piece keeps 银灰's model, tagged 「替补:Sharp」
|
||||
await c.page.waitForFunction((uid) => document.querySelector(`.sitag[data-uid="${uid}"]`)?.textContent === '替补:Sharp', { timeout: 5000 }, piece.uid);
|
||||
const handModel = await c.page.evaluate((uid) => globalThis.__SP_VIEW__?.raw?.debug?.views?.get(`p:${uid}`)?.info?.spine ?? null, piece.uid);
|
||||
assert.notEqual(handModel, SHARP, 'the hand shows the original operator');
|
||||
assert.ok(piece, 'the 银灰 piece is in the hand');
|
||||
// the hand piece is Sharp's model, tagged 「替补」
|
||||
await c.page.waitForFunction((uid) => document.querySelector(`.sitag[data-uid="${uid}"]`)?.textContent === '替补', { timeout: 5000 }, piece.uid);
|
||||
await c.page.waitForFunction((uid, sharp) => globalThis.__SP_VIEW__?.raw?.debug?.views?.get(`p:${uid}`)?.info?.spine === sharp, { timeout: 5000 }, piece.uid, SHARP);
|
||||
await c.shot('hand');
|
||||
|
||||
// 4) deploy it (drag → direction wheel): the board model becomes Sharp's; the card shows Sharp's body
|
||||
// 4) deploy it (drag → direction wheel): the board model stays Sharp's; the card shows Sharp, 「银灰的替补」
|
||||
const tile = await c.freeTileFor(piece.uid);
|
||||
assert.ok(tile, 'a legal tile');
|
||||
await c.drag(await c.piecePoint(piece.uid), await c.tilePoint(tile.row, tile.col));
|
||||
@@ -103,12 +112,13 @@ describe('0.2.0 补位 — a not-owned operator fights as its stand-in (real ser
|
||||
detail = await c.page.waitForSelector('.dpanel .dtag-standin', { timeout: 2500 }).then(() => c.page.evaluate(() => ({
|
||||
tag: document.querySelector('.dpanel .dtag-standin')?.textContent || '',
|
||||
name: document.querySelector('.dpanel .dhead__name')?.textContent || '',
|
||||
for: document.querySelector('.dpanel .dhead__for')?.textContent || '',
|
||||
skill: document.querySelector('.dpanel .dskill')?.dataset.skill || '',
|
||||
})), () => null);
|
||||
if (detail) break;
|
||||
}
|
||||
assert.ok(detail, 'the detail card opens for the deployed piece');
|
||||
assert.deepEqual(detail, { tag: '替补:Sharp', name: '银灰', skill: 'skchr_acguad_2' });
|
||||
assert.deepEqual(detail, { tag: '替补', name: 'Sharp', for: '银灰的替补', skill: 'skchr_acguad_2' });
|
||||
await c.shot('board');
|
||||
await c.page.keyboard.press('Escape');
|
||||
|
||||
@@ -140,8 +150,19 @@ describe('0.2.0 补位 — a not-owned operator fights as its stand-in (real ser
|
||||
assert.match(got.model, /char_609_acguad/, 'the battle view draws Sharp\'s Spine model');
|
||||
await sleep(800);
|
||||
await c.shot('combat');
|
||||
// the battle ends and the round settles normally (the result upload is accepted)
|
||||
await c.waitFor((s) => (s.phase === 'PREP' && s.round >= 2) || !!s.result, 'next prep', 180000);
|
||||
// 6) the battle ends, the round settles (the result upload is accepted) and the match ends (finishAfter 1): the
|
||||
// result screen's lineup shows Sharp with the 「替补」 mark
|
||||
await c.waitFor((s) => !!s.result, 'result', 180000);
|
||||
await c.page.waitForSelector('.rcard.is-self .rcard__lineup .uthumb', { visible: true, timeout: 15000 });
|
||||
const lineup = await c.page.evaluate(() => [...document.querySelectorAll('.rcard.is-self .rcard__lineup .uthumb')].map((el) => ({
|
||||
title: el.getAttribute('title') || '', mark: el.querySelector('.uthumb__si')?.textContent || '',
|
||||
standIn: el.querySelector('.uthumb__si')?.dataset.standin || '', art: el.querySelector('img')?.getAttribute('src') || '',
|
||||
})));
|
||||
assert.equal(lineup.length, 1, 'the one operator fielded');
|
||||
assert.deepEqual([lineup[0].title, lineup[0].mark, lineup[0].standIn], ['Sharp(银灰的替补)', '替补', SHARP]);
|
||||
assert.match(lineup[0].art, /char_609_acguad/, 'Sharp\'s avatar');
|
||||
await sleep(400);
|
||||
await c.shot('result');
|
||||
assert.deepEqual(problemsOf([c]), []);
|
||||
} finally {
|
||||
if (c.problems.length) console.log(c.problems.slice(0, 20).join('\n'));
|
||||
|
||||
@@ -357,8 +357,8 @@ test('§20.15 wiring: the game screen feeds the strip, the popup and the detail
|
||||
assert.match(src, /onOpen=\$\{\(id\) => openBond\(id, strip\.ownerId, 'strip'\)\}/, 'the strip opens its owner\'s bond');
|
||||
assert.match(src, /const detailOwner = detailBondOwner\(detailTarget, \{ pub, myId, stripOwnerId: strip\.ownerId \}\)/);
|
||||
assert.match(src, /onBond=\$\{\(id\) => openBond\(id, detailOwner, 'detail'\)\}/, 'a card\'s chip opens the same owner\'s bond as its chips show');
|
||||
assert.match(src, /onMember=\$\{\(id, items\) => setDetail\(\{ kind: 'chess', id, owner: bondPop\.ownerId, items: items \|\| null \}\)\}/,
|
||||
'a member card keeps the popup\'s player (and a 变形同构体 row\'s items)');
|
||||
assert.match(src, /onMember=\$\{\(id, items, standInFor\) => setDetail\(\{ kind: 'chess', id, owner: bondPop\.ownerId, items: items \|\| null, standInFor: standInFor \|\| null \}\)\}/,
|
||||
'a member card keeps the popup\'s player (and a 变形同构体 row\'s items, a 补位 row\'s standInFor)');
|
||||
assert.match(src, /openId=\$\{bondPop && bondPop\.ownerId === strip\.ownerId \? bondPop\.bondId : null\}/, 'the strip marks only its own owner\'s popup');
|
||||
assert.match(src, /setBondOpen\(\(b\) => \(b && b\.from === 'strip' \? null : b\)\)/, 'a strip popup closes when the strip changes hands');
|
||||
assert.ok(!/stripBonds\.find\(\(b\) => b\.bondId === bondOpen\)/.test(src), 'no popup entry read from the strip regardless of its owner');
|
||||
|
||||
Reference in new issue
Block a user