feedback5: merge W5G — 补位 end to end: the 干员持有 tab (drop NORMAL chess), room.ownership, stand-ins in every field, 「替补:X」 badges, the stand-in's body on the board and detail card, golden standins family
Golden regenerated on the merged code (the textual merge had no conflict and equals the regenerated files): against feedback5 only the new match coop2-NORMAL-14-standins and the new standins.json (10 battles); against fb5-standin-game exactly the W5F and W5J scenarios (roster-001/002/007/009/016/026/027/028/038/040/041/042/044, bond-egirShip-low / siracusaShip-high / suntShip-high, boss-boss_2-pair / boss_6-pair, solo-NORMAL-1, coop2-NORMAL-3, coop3-ABYSS-5, coop4-FUNNY-6, coop2-NORMAL-8-serverrun); standins unchanged. test/match 536/536, the stand-in / golden / docs suites 175/175.
This commit is contained in:
commit
ce0d85c78a
51 files changed
+2741
-188
No files matched your search
@@ -0,0 +1,265 @@
|
||||
// 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`.
|
||||
import { test, describe } 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)), '../..');
|
||||
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 M = await import('../../public/js/ui/ownershipModel.js');
|
||||
const { installOwnershipSync, SYNC_DEBOUNCE_MS } = await import('../../public/js/ui/loadoutSync.js');
|
||||
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 { 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');
|
||||
const { DATA } = await import('../match/harness.js');
|
||||
|
||||
await data.loadAll('chess', 'bonds', 'assets', 'garrisons', 'items', 'backups');
|
||||
|
||||
const getChess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
|
||||
const BACKUPS = DATA.backups;
|
||||
const SILVER = 'chess_char_4_22_a'; // 银灰 → Sharp (S2 亮剑)
|
||||
const MLYSS = 'chess_char_6_11_a'; // 缪尔赛思 (RANGED) → 郁金香 (MELEE)
|
||||
const SARIA = 'chess_char_5_11_a'; // 塞雷娅 (MELEE) → Touch (RANGED)
|
||||
const CATHY = 'chess_char_4_11_a'; // 凯瑟琳 (PRESET)
|
||||
|
||||
function* walk(v) {
|
||||
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
|
||||
if (!v || typeof v !== 'object') return;
|
||||
yield v;
|
||||
yield* walk(v.props?.children);
|
||||
}
|
||||
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('');
|
||||
|
||||
describe('干员持有 model (ui/ownershipModel.js)', () => {
|
||||
test('storage: tolerant parse, clean sorted ids, round trip; hostile keys dropped', () => {
|
||||
assert.deepEqual(M.parseStoredOwnership(null), []);
|
||||
assert.deepEqual(M.parseStoredOwnership({ v: 1, notOwned: [SILVER, 'bad id', SILVER, '__proto__', 7, SARIA] }), [SILVER, SARIA].sort());
|
||||
assert.deepEqual(M.parseStoredOwnership([MLYSS]), [MLYSS], 'a bare array of an older build');
|
||||
assert.deepEqual(M.toStoredOwnership([SARIA, SILVER]), { v: M.OWNERSHIP_VERSION, notOwned: [SILVER, SARIA].sort() });
|
||||
assert.deepEqual(M.sanitizeNotOwned([SILVER, CATHY, 'chess_char_9_99_a', getChess(SILVER).goldenId], getChess), [SILVER], 'droppable chess only');
|
||||
});
|
||||
test('toggles: setOwned / isOwned return new sorted lists', () => {
|
||||
let list = [];
|
||||
list = M.setOwned(list, SARIA, false);
|
||||
list = M.setOwned(list, SILVER);
|
||||
assert.deepEqual(list, [SILVER, SARIA].sort());
|
||||
assert.equal(M.isOwned(list, SILVER), false);
|
||||
assert.equal(M.isOwned(list, MLYSS), true);
|
||||
assert.deepEqual(M.setOwned(list, SILVER, true), [SARIA]);
|
||||
assert.deepEqual(list, [SILVER, SARIA].sort(), 'input unchanged');
|
||||
});
|
||||
test('roster: the 53 NORMAL chess of the shop by tier (6 / 13 / 17 / 17); each has its stand-in summary', () => {
|
||||
const roster = M.ownershipRoster(data.list('chess'));
|
||||
assert.equal(roster.length, 53);
|
||||
assert.ok(!roster.some((c) => c.chessType !== 'NORMAL' || c.isGolden || !c.visible));
|
||||
assert.deepEqual(M.rosterByTier(roster).map((g) => [g.tier, g.list.length]), [[3, 6], [4, 13], [5, 17], [6, 17]]);
|
||||
for (const c of roster) {
|
||||
const si = M.standInSummary(c, BACKUPS);
|
||||
assert.ok(si && si.charId === c.backup.charId && si.skill.index === c.backup.skillIndex, c.chessId);
|
||||
}
|
||||
assert.equal(M.standInSummary(getChess(SILVER), BACKUPS).name, 'Sharp');
|
||||
assert.equal(M.notOwnedCount([SILVER, CATHY, 'chess_char_6_10_a'], roster), 1, 'PRESET / retired ids do not count');
|
||||
});
|
||||
test('export / import: versioned envelope, bare arrays and text; junk, newer versions and other kinds refused', () => {
|
||||
const text = M.serializeOwnership([SARIA, SILVER], { now: 0 });
|
||||
const env = JSON.parse(text);
|
||||
assert.deepEqual([env.kind, env.v, env.count, env.notOwned], [M.OWNERSHIP_EXPORT_KIND, 1, 2, [SILVER, SARIA].sort()]);
|
||||
assert.deepEqual(M.parseOwnershipImport(text), { ok: true, notOwned: [SILVER, SARIA].sort() });
|
||||
assert.deepEqual(M.parseOwnershipImport([MLYSS]), { ok: true, notOwned: [MLYSS] });
|
||||
assert.deepEqual(M.parseOwnershipImport({ v: 1, notOwned: [] }), { ok: true, notOwned: [] }, 'an empty list = everything owned');
|
||||
assert.equal(M.parseOwnershipImport('').ok, false);
|
||||
assert.equal(M.parseOwnershipImport('{nope').ok, false);
|
||||
assert.equal(M.parseOwnershipImport({ v: 9, notOwned: [] }).ok, false);
|
||||
assert.equal(M.parseOwnershipImport({ kind: 'stronghold.loadout', v: 1, entries: {} }).ok, false);
|
||||
assert.equal(M.parseOwnershipImport('x'.repeat(M.OWNERSHIP_IMPORT_MAX_BYTES + 1)).ok, false);
|
||||
});
|
||||
test('the 干员持有 card is a switch: operator → stand-in; a tap asks for the other state', () => {
|
||||
const calls = [];
|
||||
const card = OwnCard({ m: data.get('assets'), chess: getChess(SILVER), backups: BACKUPS, owned: true, onToggle: (id, owned) => calls.push([id, owned]) });
|
||||
assert.equal(card.props.role, 'switch');
|
||||
assert.equal(card.props['aria-checked'], 'true');
|
||||
assert.ok(textOf(card).includes('银灰') && textOf(card).includes('Sharp') && textOf(card).includes('S2'));
|
||||
card.props.onClick();
|
||||
assert.deepEqual(calls, [[SILVER, false]]);
|
||||
const off = OwnCard({ m: null, chess: getChess(SILVER), backups: BACKUPS, owned: false, onToggle() {} });
|
||||
assert.ok(hasClass(off, 'is-off'));
|
||||
assert.equal(off.props['aria-checked'], 'false');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- sync -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
function fakeNet() {
|
||||
const listeners = new Map();
|
||||
const net = {
|
||||
status: 'online', sent: [], replies: [],
|
||||
on(t, fn) { if (!listeners.has(t)) listeners.set(t, new Set()); listeners.get(t).add(fn); return () => listeners.get(t).delete(fn); },
|
||||
emit(t, msg) { for (const fn of listeners.get(t) || []) fn(msg); },
|
||||
request(t, fields) {
|
||||
net.sent.push({ t, ...fields });
|
||||
const r = net.replies.shift();
|
||||
return r && r.error ? Promise.reject(Object.assign(new Error(r.error), { code: r.error })) : Promise.resolve({ t: 'ok' });
|
||||
},
|
||||
};
|
||||
return net;
|
||||
}
|
||||
function fakeTimers() {
|
||||
let now = 0;
|
||||
let seq = 0;
|
||||
const q = new Map();
|
||||
return {
|
||||
setTimeout: (fn, ms) => { const id = ++seq; q.set(id, { at: now + ms, fn }); return id; },
|
||||
clearTimeout: (id) => q.delete(id),
|
||||
async advance(ms) {
|
||||
const end = now + ms;
|
||||
for (;;) {
|
||||
const next = [...q.entries()].filter(([, t]) => t.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!next) break;
|
||||
q.delete(next[0]);
|
||||
now = next[1].at;
|
||||
next[1].fn();
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
}
|
||||
now = end;
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('ownership sync: room.ownership after welcome and after edits (debounced); in a match stored for the next one (told once)', async () => {
|
||||
const net = fakeNet();
|
||||
const T = fakeTimers();
|
||||
const told = [];
|
||||
const target = createStore({ notOwned: [SILVER], open: false, ownSync: 'idle' });
|
||||
const s = installOwnershipSync({ net, timers: T, target, notify: (t) => told.push(t) });
|
||||
net.emit('welcome', {});
|
||||
await T.advance(100);
|
||||
assert.deepEqual(net.sent, [{ t: 'room.ownership', notOwned: [SILVER] }]);
|
||||
assert.equal(target.get().ownSync, 'synced');
|
||||
target.set({ notOwned: [SILVER, SARIA].sort() });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.deepEqual(net.sent[1], { t: 'room.ownership', notOwned: [SILVER, SARIA].sort() });
|
||||
// a running match: ROOM_STARTED = stored for the next match, not an error; the edit is told once
|
||||
net.replies.push({ error: 'ROOM_STARTED' });
|
||||
target.set({ notOwned: [] });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.equal(target.get().ownSync, 'locked');
|
||||
assert.equal(told.length, 1);
|
||||
assert.match(told[0], /下一局生效/);
|
||||
// back in the lobby: sent again (accepted)
|
||||
net.emit('room.state', { inMatch: false });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.deepEqual(net.sent.at(-1), { t: 'room.ownership', notOwned: [] });
|
||||
assert.equal(target.get().ownSync, 'synced');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
// ---- match UI -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
describe('match UI of a not-owned chess', () => {
|
||||
const priv = { standIns: [SILVER, MLYSS, SARIA], loadout: {}, board: [], hand: [], temp: [] };
|
||||
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);
|
||||
assert.equal(G.fieldsStandIn(priv, getChess(getChess(SILVER).goldenId)), true, 'the elite too');
|
||||
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.equal(G.standInOf(getChess(SILVER), BACKUPS), si, 'cached');
|
||||
assert.equal(G.standInOf(getChess(CATHY), BACKUPS), null, 'PRESET');
|
||||
assert.equal(G.standInLabel(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), { 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)');
|
||||
});
|
||||
|
||||
test('placement: a dropped chess is placed by its stand-in\'s position (缪尔赛思 → 郁金香 melee, 塞雷娅 → Touch ranged)', () => {
|
||||
const stage = null; // (the deploy tiles are not needed for the placement class)
|
||||
const ctx = G.placementContext({ priv, stage, editable: true, getChess, backups: BACKUPS });
|
||||
assert.equal(G.piecePosition(ctx, { kind: 'chess', id: MLYSS }), 'MELEE');
|
||||
assert.equal(G.piecePosition(ctx, { kind: 'chess', id: SARIA }), 'RANGED');
|
||||
const own = G.placementContext({ priv: { standIns: [] }, stage, editable: true, getChess, backups: BACKUPS });
|
||||
assert.equal(G.piecePosition(own, { kind: 'chess', id: MLYSS }), 'RANGED');
|
||||
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', () => {
|
||||
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');
|
||||
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');
|
||||
});
|
||||
|
||||
test('detail card: own piece / card → the stand-in\'s body under the chess\'s name; 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(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');
|
||||
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');
|
||||
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 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');
|
||||
});
|
||||
|
||||
test('own prep pieces with stand-ins get a 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, 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)', () => {
|
||||
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, /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'/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
|
||||
|
||||
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
|
||||
const SILVER = 'chess_char_4_22_a'; // 银灰 (NORMAL) → Sharp (char_609_acguad), S2 亮剑
|
||||
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] } });
|
||||
const P = (await import('puppeteer-core')).default;
|
||||
const c = new Client(P, srv.base, 'standin', { prefix: 'standin' });
|
||||
try {
|
||||
await c.open();
|
||||
await c.enter('替补');
|
||||
// 1) 干员调配 → 干员持有: mark 银灰 未持有 (real clicks), synced with the server, persisted in the browser
|
||||
await c.click('.lobby-screen [data-testid="loadout-open"]');
|
||||
await c.page.waitForSelector('.lo .lo-tab[data-tab="ownership"]', { visible: true, timeout: 15000 });
|
||||
await c.click('.lo .lo-tab[data-tab="ownership"]');
|
||||
await c.page.waitForSelector(`.own-card[data-chess="${SILVER}"]`, { visible: true, timeout: 15000 });
|
||||
const cards = await c.page.evaluate(() => [...document.querySelectorAll('.own-card')].length);
|
||||
assert.equal(cards, 53, 'the 53 NORMAL chess of the shop');
|
||||
await c.click(`.own-card[data-chess="${SILVER}"]`);
|
||||
await c.page.waitForFunction((id) => document.querySelector(`.own-card[data-chess="${id}"]`)?.getAttribute('aria-checked') === 'false', { timeout: 3000 }, SILVER);
|
||||
await c.page.waitForFunction(() => /已同步/.test(document.querySelector('[data-testid="ownership-sync"]')?.textContent || ''), { timeout: 8000 });
|
||||
const card = await c.page.evaluate((id) => document.querySelector(`.own-card[data-chess="${id}"]`)?.textContent || '', SILVER);
|
||||
assert.ok(card.includes('银灰') && card.includes('Sharp') && card.includes('未持有'), card);
|
||||
const stored = await c.page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.ownership') || 'null'));
|
||||
assert.deepEqual(stored, { v: 1, notOwned: [SILVER] });
|
||||
await c.shot('ownership');
|
||||
await c.page.keyboard.press('Escape');
|
||||
await c.page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 3000 });
|
||||
|
||||
// 2) a solo 标准 match: the briefing's m.private carries the list
|
||||
await c.click('.mode-card', '独立模拟');
|
||||
await c.click('.diff-card', '标准模拟');
|
||||
await c.click('.create-box button', '开始独立模拟');
|
||||
await c.waitFor((s) => !!s.room, 'solo room');
|
||||
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
|
||||
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
|
||||
const standIns = await c.page.evaluate(() => globalThis.__SP__.store.get().match.private?.standIns ?? null);
|
||||
assert.deepEqual(standIns, [SILVER], 'the match received the not-owned list');
|
||||
await c.click('.brief__foot .btn--primary', '准备就绪');
|
||||
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
|
||||
await c.click('.dband', null, { nth: 1 });
|
||||
await c.click('.draft-detail__btns .btn--primary', '确认选择');
|
||||
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
|
||||
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;
|
||||
});
|
||||
assert.deepEqual(shopCard, { name: '银灰', badge: '替补:Sharp', standIn: SHARP });
|
||||
await c.shot('shop');
|
||||
await c.click('.scard', '银灰');
|
||||
await sleep(300);
|
||||
await c.click('.scard', '银灰');
|
||||
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');
|
||||
await c.shot('hand');
|
||||
|
||||
// 4) deploy it (drag → direction wheel): the board model becomes Sharp's; the card shows Sharp's body
|
||||
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));
|
||||
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
|
||||
await c.swipe('RIGHT');
|
||||
await c.waitFor((s) => s.board > 0, 'placed', 8000);
|
||||
await c.page.waitForFunction((uid, sharp) => {
|
||||
const v = globalThis.__SP_VIEW__?.raw?.debug?.views?.get(`p:${uid}`);
|
||||
return v && v.info?.spine === sharp && v.info?.standInFor;
|
||||
}, { timeout: 8000 }, piece.uid, SHARP);
|
||||
await sleep(600);
|
||||
let detail = null;
|
||||
for (const at of [0.72, 0.4, 0.88]) {
|
||||
const p = await c.piecePoint(piece.uid, at);
|
||||
if (!p) continue;
|
||||
await c.page.mouse.click(p.x, p.y, { button: 'right' });
|
||||
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 || '',
|
||||
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' });
|
||||
await c.shot('board');
|
||||
await c.page.keyboard.press('Escape');
|
||||
|
||||
// 5) ready → the local battle fields Sharp (spec standIn: true; the sim's def; the view's Spine model)
|
||||
await c.click('.readybtn');
|
||||
await c.waitFor((s) => s.phase === 'COMBAT', 'combat', 60000);
|
||||
const got = await c.page.waitForFunction((uid, sharp) => {
|
||||
const r = globalThis.__SP_RUNNER__;
|
||||
const e = r && [...r._entries.values()].find((x) => x.own && x.battle);
|
||||
if (!e) return false;
|
||||
const u = e.battle.allyUnits.find((x) => x.uid === uid);
|
||||
if (!u) return false;
|
||||
const views = globalThis.__SP_VIEW__?.raw?.debug?.views;
|
||||
const v = views ? [...views.values()].find((x) => x.info?.uid === uid && !String(x.id).startsWith('p:')) : null;
|
||||
if (!v || !v.spineReady) return false;
|
||||
const entry = (e.spec.players || []).flatMap((p) => p.units || []).find((x) => x.uid === uid) || null;
|
||||
return {
|
||||
authoritative: e.authoritative, spec: entry && { chessId: entry.chessId, standIn: entry.standIn ?? null, skillIndex: entry.skillIndex ?? null },
|
||||
charId: u.def?.charId, standInFor: u.def?.standInFor, skill: u.skill?.id ?? null, bonds: u.def?.bonds,
|
||||
viewSpine: v.info.spine, viewStandInFor: v.info.standInFor ?? null, model: String(v._actorEntry?.skel || ''),
|
||||
};
|
||||
}, { timeout: 30000, polling: 150 }, piece.uid, SHARP).then((h) => h.jsonValue());
|
||||
assert.equal(got.authoritative, true, 'the own normal battle is simulated by this browser');
|
||||
assert.deepEqual(got.spec, { chessId: SILVER, standIn: true, skillIndex: null }, 'b.start spec marks the stand-in');
|
||||
assert.equal(got.charId, SHARP, 'the local sim fields Sharp');
|
||||
assert.equal(got.skill, 'skchr_acguad_2', 'with the chess\'s backup skill (S2 亮剑)');
|
||||
assert.equal(got.viewSpine, SHARP);
|
||||
assert.ok(got.viewStandInFor, 'the view knows it is a stand-in');
|
||||
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);
|
||||
assert.deepEqual(problemsOf([c]), []);
|
||||
} finally {
|
||||
if (c.problems.length) console.log(c.problems.slice(0, 20).join('\n'));
|
||||
await c.close();
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user