feedback5 WK: 自选编队 in the browser — the 干员调配 overlay's third tab 自选编队 (5阶 ×2, 6阶 ×2 slots; the slot picker lists the kitted picks of the tier with avatar, name, class, derived bonds and a 原型 / 已持有 tag; an owned pick chooses its skill and module — 集成战略 modules left out —, a prototype shows its locked skill; sp.pref.diy, export / import, 全部清空, synced as room.diy through the shared engine, welcome.diyKitted kept), the operator on shop / reward cards, own pieces (「自选」 tag, its model on bench and board), the detail card (pick's skill and module), placement and range previews, bond popups (own and a teammate's 自选 member), the result screen; new UI strings through t() with English; the e2e fast server's SP_START_LEVEL hook
Bonds of 自选 pieces checked everywhere membership is decided (the owner's reminder of 2026-10-05): the match's counts through the player's data view (煌 in 炎 and 维多利亚, one member per slot, 绝技 elites), the sim's bond effects through def.bonds (叙拉古 on deployment, 协防 ×1.2 / elite ×1.4), the client's strip, popups, cards and detail card. Golden unchanged. Tests: test/ui/diy-ui.test.js (15), test/match/diy-bonds.test.js (4), test/i18n.test.js pins screens/diy.js; test/ui/diy.e2e.test.js (SP_E2E=1, run once: pass).
This commit is contained in:
1 parent
e480b1cb26
commit
0def7babfd
31 files changed
+1637
-78
No files matched your search
@@ -56,6 +56,7 @@ export async function startRealServer(opts = {}) {
|
||||
if (opts.fast.autoPlace) env.SP_AUTO_PLACE = '1';
|
||||
if (opts.fast.eliminate?.length) env.SP_ELIMINATE = opts.fast.eliminate.join(',');
|
||||
if (opts.fast.stage) env.SP_STAGE = String(opts.fast.stage);
|
||||
if (opts.fast.level) env.SP_START_LEVEL = String(opts.fast.level);
|
||||
}
|
||||
const child = spawn(process.execPath, [entry], { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const logs = [];
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
// eliminated spectator from the first round on (test/ui/watch-bonds.e2e.test.js, the Final Assault).
|
||||
// SP_STAGE=<stageId>: every match is played on this stage instead of the drawn one (test/ui/feedback1-placement.e2e.test.js:
|
||||
// 战场#08's pool).
|
||||
// SP_START_LEVEL=<1..6>: the starter kit also puts every human's 调度中心 at this level (and rerolls its shop there, before
|
||||
// SP_START_SHOP stocks its first slots) — test/ui/diy.e2e.test.js: a tier-5 自选 piece is sold from level 5.
|
||||
// Not a test file (node --test runs it as a no-op module when NODE_TEST_CONTEXT is set).
|
||||
|
||||
import { startServer } from '../../server/index.js';
|
||||
@@ -46,6 +48,7 @@ if (!process.env.NODE_TEST_CONTEXT) {
|
||||
const autoPlace = process.env.SP_AUTO_PLACE === '1';
|
||||
const eliminate = String(process.env.SP_ELIMINATE || '').split(',').map((x) => x.trim()).filter(Boolean).map(Number);
|
||||
const forcedStage = String(process.env.SP_STAGE || '').trim();
|
||||
const startLevel = Math.max(0, Math.min(6, Number(process.env.SP_START_LEVEL) || 0));
|
||||
|
||||
class FastMatch extends Match {
|
||||
constructor(opts) {
|
||||
@@ -152,6 +155,11 @@ if (!process.env.NODE_TEST_CONTEXT) {
|
||||
if (slot < 0 || !this.gd.item(itemId)) continue;
|
||||
try { ps.hand[slot] = ps.newPiece('item', itemId); } catch { /* best effort */ }
|
||||
}
|
||||
if (startLevel > 0) {
|
||||
ps.shop.level = Math.min(startLevel, this.gd.maxShopLevel);
|
||||
ps.shop.upgradePrice = this.gd.upgradeBase(ps.shop.level) ?? 0;
|
||||
ps.rollShop({ keepFrozen: false });
|
||||
}
|
||||
kitShop.forEach((id, i) => {
|
||||
if (i < ps.shop.slots.length && this.gd.chess(id)) ps.shop.slots[i] = { kind: 'chess', id, basePrice: this.gd.chessPrice(id), frozen: false, sold: false };
|
||||
});
|
||||
|
||||
@@ -117,6 +117,7 @@ test('public/i18n/en.json: valid, English values, placeholders kept, the pilot s
|
||||
|
||||
test('every msgid of the pilot screens, the shell and the server messages is in public/i18n/en.json', async () => {
|
||||
const files = ['public/js/screens/title.js', 'public/js/screens/lobby.js', 'public/js/screens/room.js', 'public/js/ui/settings.js',
|
||||
'public/js/screens/diy.js',
|
||||
'public/js/main.js', 'public/js/ui/toasts.js', 'server/match/match/messaging.js', 'server/match/player/placement.js',
|
||||
'server/match/player/basics.js', 'server/match/player/acquire.js', 'server/match/effectsMeta.js', 'server/match/match/settle.js',
|
||||
'server/match/match/platform.js', 'server/match/match/unitePhase.js', 'server/match/match/bossRounds.js',
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// test/match/diy-bonds.test.js — 0.2.0 自选编队: a 自选 piece is a member of each of its derived bonds (data/backups.json
|
||||
// diy.operators[charId].bonds — its factions, else 协防; "甄选加入的干员会根据其实际阵营所属分配核心盟约…一个或数个", PRTS 卫戍协议:
|
||||
// 盟约 下半/PRTS盟约记录) in every place membership is decided: the match's bond counts (bondsMeta through the player's
|
||||
// data view — BOARD / BOARD_AND_DECK distinct pieces: the normal and elite copies of one slot count once; 煌 counts for
|
||||
// 炎 and 维多利亚; 绝技 counts a 自选 elite like any elite), the views (m.private / m.public bonds), the battle input's
|
||||
// bond snapshot, and the sim's bond effects (they read the unit's def.bonds: 叙拉古 on deployment, 协防 ×1.2 / elite ×1.4).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { KITTED_CHARS } from '../../server/sim/content/kits/index.js';
|
||||
import { hasGeneratedData } from '../../server/sim/simdata.js';
|
||||
import { makeBattle, checkInvariants as checkBattle } from '../helpers/battleHarness.js';
|
||||
import { DATA, makeMatch, legalTileFor } from './harness.js';
|
||||
|
||||
const REAL = { skip: !hasGeneratedData() };
|
||||
const T5A = 'chess_char_5_diy1_a';
|
||||
const T5B = 'chess_char_5_diy2_a';
|
||||
const T6A = 'chess_char_6_diy1_a';
|
||||
const HUANG = 'char_017_huang'; // 煌: 炎 + 维多利亚 (both from her 隐藏势力)
|
||||
const CROSLY = 'char_1502_crosly'; // 弑君者: 叙拉古
|
||||
const SHARP = 'char_609_acguad'; // a prototype: 协防
|
||||
const OPS = DATA.backups?.diy?.operators ?? {};
|
||||
|
||||
function soloMatch() {
|
||||
const seats = [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true }];
|
||||
const h = makeMatch({ mode: 'solo', seats, seed: 21 }).start();
|
||||
h.toPrep(1);
|
||||
return h;
|
||||
}
|
||||
/** Slot `picks` for p_0 now (kit list widened for operators whose kit is still being written) and give it stock. */
|
||||
function slot(h, picks) {
|
||||
const p0 = h.ps('p_0');
|
||||
assert.equal(p0.setDiy(picks, { kitted: [...KITTED_CHARS, HUANG, CROSLY] }), true);
|
||||
p0.initDiyStock(new Set());
|
||||
p0.recompute();
|
||||
return p0;
|
||||
}
|
||||
function deploy(h, ps, id) {
|
||||
const p = ps.acquireChess(id, { source: 'buy' });
|
||||
const t = legalTileFor(h.m, ps, p.id);
|
||||
assert.ok(t, `a tile for ${id}`);
|
||||
assert.deepEqual(h.m.handle(ps.playerId, { t: 'g.move', uid: p.uid, to: { area: 'board', row: t[0], col: t[1] } }), { ok: true });
|
||||
return p;
|
||||
}
|
||||
|
||||
test('the derived bonds of the data: 协防 39 owned + every prototype, 炎 14, 维多利亚 8, 煌 two', REAL, () => {
|
||||
const owned = DATA.backups.diy.ownedPool;
|
||||
// (research 0.2.0 §2.4: 协防 39, 炎 14, 维多利亚 8, 萨尔贡 4, 叙拉古 4, 拉特兰 1, 卡西米尔 1, 炎 + 维多利亚 1 — 煌)
|
||||
const only = (b) => owned.filter((id) => OPS[id].bonds.length === 1 && OPS[id].bonds[0] === b).length;
|
||||
assert.deepEqual([only('emptyShip'), only('yanShip'), only('victoriaShip'), only('sargonShip'), only('siracusaShip'), only('lateranoShip'), only('kazimierzShip')], [39, 14, 8, 4, 4, 1, 1]);
|
||||
assert.deepEqual(owned.filter((id) => OPS[id].bonds.length > 1), [HUANG]);
|
||||
assert.deepEqual(OPS[HUANG].bonds, ['yanShip', 'victoriaShip']);
|
||||
assert.deepEqual(OPS.char_300_phenxi.bonds, ['lateranoShip']);
|
||||
assert.deepEqual(OPS.char_4098_vvana.bonds, ['kazimierzShip']);
|
||||
for (const id of [...DATA.backups.diy.prototypes[5], ...DATA.backups.diy.prototypes[6]]) assert.deepEqual(OPS[id].bonds, ['emptyShip'], id);
|
||||
});
|
||||
|
||||
test('煌 counts for 炎 and 维多利亚; its normal and elite copies count once; views and the battle input agree', REAL, () => {
|
||||
const h = soloMatch();
|
||||
const p0 = slot(h, { [T5A]: { charId: HUANG, skillIndex: 0 } });
|
||||
const before = { yan: p0.bonds.yanShip?.count ?? 0, vic: p0.bonds.victoriaShip?.count ?? 0 };
|
||||
deploy(h, p0, T5A);
|
||||
assert.equal(p0.bonds.yanShip.count, before.yan + 1, '炎');
|
||||
assert.equal(p0.bonds.victoriaShip.count, before.vic + 1, '维多利亚');
|
||||
// a second copy in the hand: still one distinct member (BOARD counts the slot once)
|
||||
p0.acquireChess(T5A, { source: 'buy' });
|
||||
assert.equal(p0.bonds.yanShip.count, before.yan + 1);
|
||||
h.m.flush(true);
|
||||
const priv = h.lastTo('p_0', 'm.private');
|
||||
assert.equal(priv.bonds.find((b) => b.bondId === 'yanShip').count, before.yan + 1);
|
||||
assert.equal(priv.bonds.find((b) => b.bondId === 'victoriaShip').count, before.vic + 1);
|
||||
const pub = h.lastBc('m.public');
|
||||
assert.equal(pub.players[0].bonds.find((b) => b.bondId === 'victoriaShip').count, before.vic + 1, 'm.public (a teammate\'s strip)');
|
||||
const input = p0.battleInput();
|
||||
assert.equal(input.bonds.yanShip.count, before.yan + 1, 'the battle input\'s bond snapshot');
|
||||
h.invariants();
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('协防: two 自选 prototypes (a tier-5 and a tier-6 slot) enable it; a preset 协防 chess with one too; 绝技 counts 自选 elites', REAL, () => {
|
||||
const h = soloMatch();
|
||||
const p0 = slot(h, { [T5B]: { charId: SHARP }, [T6A]: { charId: SHARP } });
|
||||
assert.ok(!p0.bonds.emptyShip?.active);
|
||||
deploy(h, p0, T5B);
|
||||
assert.equal(p0.bonds.emptyShip.count, 1);
|
||||
deploy(h, p0, T6A);
|
||||
assert.equal(p0.bonds.emptyShip.count, 2, 'two slots = two chess (like any operator who is two chess) [ASSUMED]');
|
||||
assert.equal(p0.bonds.emptyShip.active, true);
|
||||
// 绝技 (BOARD_ALL_CHESS): every elite on the board — a 自选 elite included
|
||||
const sunt0 = p0.bonds.suntShip?.count ?? 0;
|
||||
deploy(h, p0, 'chess_char_5_diy2_b');
|
||||
assert.equal(p0.bonds.suntShip.count, sunt0 + 1);
|
||||
h.invariants();
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('sim: a 叙拉古 自选 piece gets 叙拉古\'s deployment buff; a 协防 one the ×1.2 damage (elite ×1.4)', REAL, () => {
|
||||
const bonds = { siracusaShip: { count: 3, active: true, tier: 1, layers: 10 }, emptyShip: { count: 2, active: true, tier: 1, layers: 0 } };
|
||||
const h = makeBattle({
|
||||
units: [
|
||||
{ diy: { slot: 5, charId: CROSLY, skillIndex: 0 }, row: 10, col: 4 },
|
||||
{ diy: { slot: T5B, charId: SHARP }, row: 10, col: 6 },
|
||||
{ diy: { slot: 6, charId: SHARP }, elite: true, row: 11, col: 6 },
|
||||
],
|
||||
bonds,
|
||||
timeLimit: 10,
|
||||
});
|
||||
h.run(1);
|
||||
const crosly = h.allies().find((u) => u.def?.charId === CROSLY);
|
||||
const [normal, elite] = [h.allies().find((u) => u.def?.charId === SHARP && !u.def.golden), h.allies().find((u) => u.def?.charId === SHARP && u.def.golden)];
|
||||
assert.ok(crosly && normal && elite, 'the three 自选 pieces are fielded');
|
||||
assert.deepEqual(crosly.def.bonds, ['siracusaShip']);
|
||||
assert.ok(crosly.hasBuff('bond:siracusa'), '叙拉古: ASPD on deployment');
|
||||
assert.ok(!normal.hasBuff('bond:siracusa'), 'not for a non-member');
|
||||
const scale = (u) => u.buffs.filter((b) => /empty/i.test(b.key)).map((b) => b.mods?.dmgDealtMul).find((x) => x != null);
|
||||
const bb = DATA.bonds.emptyShip;
|
||||
assert.ok(bb);
|
||||
assert.equal(scale(normal), 1.2, '协防 member ×1.2');
|
||||
assert.equal(scale(elite), 1.4, '协防 elite member ×1.4');
|
||||
assert.equal(scale(crosly), undefined, '弑君者 is no 协防 member');
|
||||
checkBattle(h.b);
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
// 0.2.0 自选编队 — client side (research 0.2.0 §2; the owner's decisions of 2026-10-05): the 自选编队 tab's model (storage,
|
||||
// the slots, the picker's operators with the kit list, skill / module choices without 集成战略 modules, export / import)
|
||||
// and its server sync (room.diy; welcome.diyKitted), the tab's components (four slots, the picker, 确认), and the match
|
||||
// UI of a 自选 piece — the shop / reward card and the detail card show the operator (name, art, class, derived bonds,
|
||||
// the pick's skill and module) with a 「自选」 badge, the own pieces get a tag, placement reads the operator's position, and
|
||||
// the bond popup lists the 自选 member (own and a teammate's).
|
||||
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/diyModel.js');
|
||||
const { installDiySync, 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 { BondPopup } = await import('../../public/js/ui/bondStrip.js');
|
||||
const { ownerBoard } = await import('../../public/js/ui/watchBonds.js');
|
||||
const { diyPieces } = await import('../../public/js/screens/game/standInTags.js');
|
||||
const { DiyPanelView, DiyPickerView } = await import('../../public/js/screens/diy.js');
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
const { DATA } = await import('../match/harness.js');
|
||||
const { KITTED_CHARS } = await import('../../server/sim/content/kits/index.js');
|
||||
|
||||
await data.loadAll('chess', 'bonds', 'assets', 'garrisons', 'items', 'backups');
|
||||
|
||||
const D = { chess: DATA.chess, backups: DATA.backups };
|
||||
const getChess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
|
||||
const T5A = 'chess_char_5_diy1_a';
|
||||
const T5B = 'chess_char_5_diy2_a';
|
||||
const T6A = 'chess_char_6_diy1_a';
|
||||
const T6B = 'chess_char_6_diy2_a';
|
||||
const SIEGE = 'char_112_siege';
|
||||
const SHARP = 'char_609_acguad';
|
||||
const KALTS = 'char_003_kalts'; // 凯尔希: an ISW-A module at both elite stages
|
||||
const SIEGE_PICK = { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
|
||||
const KIT = [...KITTED_CHARS];
|
||||
|
||||
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);
|
||||
/** 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 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/diyModel.js)', () => {
|
||||
test('storage: tolerant parse, junk and hostile keys dropped, round trip', () => {
|
||||
assert.deepEqual(M.parseStoredDiy(null), {});
|
||||
const raw = { v: 1, picks: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP }, __proto__x: 1, bad: { charId: 'a b' }, [T6B]: { charId: SIEGE, skillIndex: 99 } } };
|
||||
assert.deepEqual(M.parseStoredDiy(raw), { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP }, [T6B]: { charId: SIEGE } });
|
||||
assert.deepEqual(M.toStoredDiy({ [T5A]: SIEGE_PICK }), { v: M.DIY_VERSION, picks: { [T5A]: SIEGE_PICK } });
|
||||
assert.equal(M.diyCount({ [T5A]: SIEGE_PICK, [T6A]: null }), 1);
|
||||
assert.deepEqual(M.setPick({ [T5A]: SIEGE_PICK }, T5A, null), {});
|
||||
assert.deepEqual(M.setPick({}, T6A, { charId: SHARP }), { [T6A]: { charId: SHARP } });
|
||||
});
|
||||
|
||||
test('the four slots by tier, with the 调度中心 level that sells them', () => {
|
||||
assert.deepEqual(M.diySlotList(D).map((s) => [s.slotId, s.tier, s.shopLevel]), [[T5A, 5, 5], [T5B, 5, 5], [T6A, 6, 6], [T6B, 6, 6]]);
|
||||
});
|
||||
|
||||
test('the picker lists only kitted operators (prototypes first), marks one another slot holds', () => {
|
||||
const opts = M.pickOptions(T5A, {}, D, KIT);
|
||||
const ids = opts.map((o) => o.charId);
|
||||
assert.ok(ids.includes(SIEGE) && ids.includes(SHARP) && ids.includes('char_601_cguard'));
|
||||
assert.ok(ids.every((id) => KIT.includes(id)), 'no operator without a kit');
|
||||
assert.ok(ids.indexOf(SHARP) < ids.indexOf(SIEGE), 'prototypes first');
|
||||
assert.deepEqual(opts.find((o) => o.charId === SIEGE).bonds, ['victoriaShip']);
|
||||
assert.equal(opts.find((o) => o.charId === SHARP).proto, true);
|
||||
assert.ok(!M.pickOptions(T6A, {}, D, KIT).some((o) => o.charId === 'char_601_cguard'), 'a 4★ reserve is a tier-5 pick only');
|
||||
// taken: an owned operator in any other slot; a prototype in the other slot of its tier (not across tiers)
|
||||
const roster = { [T5B]: { charId: SHARP }, [T6A]: SIEGE_PICK };
|
||||
const at5 = M.pickOptions(T5A, roster, D, KIT);
|
||||
assert.equal(at5.find((o) => o.charId === SIEGE).taken, T6A);
|
||||
assert.equal(at5.find((o) => o.charId === SHARP).taken, T5B);
|
||||
assert.equal(M.pickOptions(T6B, roster, D, KIT).find((o) => o.charId === SHARP).taken, null, 'a prototype may fill a tier-5 and a tier-6 slot');
|
||||
assert.deepEqual(M.pickOptions(T5A, {}, D, []), [], 'no kit list (not connected): nothing to pick');
|
||||
});
|
||||
|
||||
test('choices: three skills, the elite form\'s modules without the 集成战略 (ISW-A) ones; a prototype\'s locked selection', () => {
|
||||
const ch = M.pickChoices(SIEGE, T6A, D);
|
||||
assert.deepEqual(ch.skills.map((s) => s.index), [0, 1, 2]);
|
||||
assert.deepEqual(ch.modules.map((x) => x.uniEquipId), ['uniequip_002_siege', 'uniequip_003_siege']);
|
||||
assert.equal(ch.stage, 3);
|
||||
assert.equal(M.pickChoices(SIEGE, T5A, D).stage, 1);
|
||||
const kalts = M.pickChoices(KALTS, T6A, D);
|
||||
assert.ok(kalts.elite.modules.some((x) => x.typeName === 'ISW-A'), 'the form has one');
|
||||
assert.ok(!kalts.modules.some((x) => x.rec.typeName === 'ISW-A'), 'never offered');
|
||||
const proto = M.pickChoices(SHARP, T5A, D);
|
||||
assert.equal(proto.proto, true);
|
||||
assert.deepEqual(proto.locked, { skillIndex: 2, uniEquipId: 'uniequip_002_acguad' });
|
||||
assert.deepEqual(M.defaultPick(SIEGE, T5A, D), { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' }, 'S3 and the first module');
|
||||
assert.deepEqual(M.defaultPick(SHARP, T5A, D), { charId: SHARP });
|
||||
});
|
||||
|
||||
test('sanitize keeps what the server keeps; export / import envelopes', () => {
|
||||
assert.deepEqual(M.sanitizeDiyPicks({ [T5A]: SIEGE_PICK, [T6A]: { charId: SIEGE, skillIndex: 0 } }, D, KIT), { [T5A]: SIEGE_PICK });
|
||||
const text = M.serializeDiy({ [T5A]: SIEGE_PICK }, { now: 0 });
|
||||
const env = JSON.parse(text);
|
||||
assert.deepEqual([env.kind, env.v, env.count, env.picks], [M.DIY_EXPORT_KIND, 1, 1, { [T5A]: SIEGE_PICK }]);
|
||||
assert.deepEqual(M.parseDiyImport(text), { ok: true, picks: { [T5A]: SIEGE_PICK } });
|
||||
assert.deepEqual(M.parseDiyImport({ v: 1, picks: {} }), { ok: true, picks: {} });
|
||||
for (const bad of ['', '{nope', { v: 9, picks: {} }, { kind: 'stronghold.ownership', v: 1, notOwned: [] }, 'x'.repeat(M.DIY_IMPORT_MAX_BYTES + 1), [1]]) {
|
||||
assert.equal(M.parseDiyImport(bad).ok, false, JSON.stringify(bad).slice(0, 30));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 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(([, x]) => x.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('diy sync: welcome brings the kit list and sends room.diy; edits are debounced; in a match stored for the next one (told once)', async () => {
|
||||
const net = fakeNet();
|
||||
const T = fakeTimers();
|
||||
const told = [];
|
||||
const target = createStore({ diy: { [T5A]: SIEGE_PICK }, diyKitted: null, open: false, diySync: 'idle' });
|
||||
const s = installDiySync({ net, timers: T, target, notify: (x) => told.push(x) });
|
||||
net.emit('welcome', { diyKitted: [SIEGE, SHARP, 7] });
|
||||
assert.deepEqual(target.get().diyKitted, [SIEGE, SHARP]);
|
||||
await T.advance(100);
|
||||
assert.deepEqual(net.sent, [{ t: 'room.diy', picks: { [T5A]: SIEGE_PICK } }]);
|
||||
assert.equal(target.get().diySync, 'synced');
|
||||
target.set({ diy: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } } });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.deepEqual(net.sent[1], { t: 'room.diy', picks: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } } });
|
||||
net.replies.push({ error: 'ROOM_STARTED' });
|
||||
target.set({ diy: {} });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.equal(target.get().diySync, 'locked');
|
||||
assert.deepEqual(told, ['自选编队是局外设置,修改将在下一局生效']);
|
||||
net.emit('room.state', { inMatch: false });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.deepEqual(net.sent.at(-1), { t: 'room.diy', picks: {} });
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
// ---- the tab --------------------------------------------------------------------------------------------------------------
|
||||
|
||||
describe('自选编队 tab (screens/diy.js)', () => {
|
||||
test('four slots by tier: an empty slot to fill, a filled one shows the operator, its tag, skill and module', () => {
|
||||
const tree = expand(DiyPanelView({ m: data.get('assets'), picks: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } }, legal: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } }, kitted: KIT, onSet() {}, picking: null, onPicking() {} }));
|
||||
const slots = [...walk(tree)].filter((v) => hasClass(v, 'diy-slot'));
|
||||
assert.deepEqual(slots.map((v) => v.props['data-slot']), [T5A, T5B, T6A, T6B]);
|
||||
const siege = slots[0];
|
||||
assert.equal(siege.props['data-char'], SIEGE);
|
||||
const txt = textOf(siege);
|
||||
assert.ok(txt.includes(DATA.backups.units[SIEGE].name) && txt.includes('已持有') && txt.includes('S3') && txt.includes('SOL-X'), txt);
|
||||
assert.ok(textOf(slots[2]).includes('原型'));
|
||||
assert.ok(hasClass(slots[1], 'is-empty') && textOf(slots[1]).includes('选择干员'));
|
||||
});
|
||||
|
||||
test('the picker: the slot\'s operators; choosing one and its skill / module confirms the pick', () => {
|
||||
const done = [];
|
||||
let draft = { ...SIEGE_PICK };
|
||||
const view = () => expand(DiyPickerView({ m: null, slot: { slotId: T5A, tier: 5, shopLevel: 5 }, picks: { [T5A]: SIEGE_PICK }, kitted: KIT, onDone: (p) => done.push(p), onClose() {},
|
||||
filter: 'all', query: '', draft, onFilter() {}, onQuery() {}, onDraft: (d) => { draft = d; } }));
|
||||
let tree = view();
|
||||
const opts = [...walk(tree)].filter((v) => hasClass(v, 'diy-opt'));
|
||||
assert.ok(opts.some((v) => v.props['data-char'] === SIEGE) && opts.some((v) => v.props['data-char'] === SHARP));
|
||||
// the current pick is shown with its three skills and its modules (none + X + Y)
|
||||
const skills = [...walk(tree)].filter((v) => v.props?.role === 'radio' && v.props['data-skill'] != null);
|
||||
assert.deepEqual(skills.map((v) => v.props['data-skill']), [0, 1, 2]);
|
||||
assert.equal(skills.find((v) => v.props['aria-checked'] === 'true').props['data-skill'], 2);
|
||||
const mods = [...walk(tree)].filter((v) => v.props?.role === 'radio' && v.props['data-module'] != null);
|
||||
assert.deepEqual(mods.map((v) => v.props['data-module']), ['none', 'uniequip_002_siege', 'uniequip_003_siege']);
|
||||
// another skill and no module, then 确认
|
||||
skills.find((v) => v.props['data-skill'] === 0).props.onClick();
|
||||
tree = view();
|
||||
[...walk(tree)].find((v) => v.props?.role === 'radio' && v.props['data-module'] === 'none').props.onClick();
|
||||
tree = view();
|
||||
[...walk(tree)].find((v) => v.props?.['data-testid'] === 'diy-confirm').props.onClick();
|
||||
assert.deepEqual(done, [{ charId: SIEGE, skillIndex: 0, uniEquipId: null }]);
|
||||
// a prototype: its locked skill only; 确认 sends just the operator (the server completes the locked selection)
|
||||
[...walk(tree)].find((v) => hasClass(v, 'diy-opt') && v.props['data-char'] === SHARP).props.onClick();
|
||||
tree = view();
|
||||
assert.deepEqual([...walk(tree)].filter((v) => v.props?.role === 'radio' && v.props['data-skill'] != null).map((v) => [v.props['data-skill'], v.props.disabled]), [[2, true]]);
|
||||
[...walk(tree)].find((v) => v.props?.['data-testid'] === 'diy-confirm').props.onClick();
|
||||
assert.deepEqual(done.at(-1), { charId: SHARP });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- match UI -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
describe('match UI of a 自选 piece', () => {
|
||||
const priv = { diy: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP, skillIndex: 2, uniEquipId: 'uniequip_002_acguad' } }, loadout: {}, standIns: [], board: [], hand: [], temp: [] };
|
||||
test('gameLogic: the own pick of a slot (normal + elite), the composed record, the own getter', () => {
|
||||
assert.deepEqual(G.ownDiyPick(priv, getChess(T5A)), SIEGE_PICK);
|
||||
assert.deepEqual(G.ownDiyPick(priv, getChess('chess_char_5_diy1_b')), SIEGE_PICK, 'the elite too');
|
||||
assert.equal(G.ownDiyPick(priv, getChess(T5B)), null, 'a slot not filled');
|
||||
assert.equal(G.ownDiyPick(priv, getChess('chess_char_4_22_a')), null);
|
||||
const rec = G.ownDiyRecord(getChess(T5A), priv, D);
|
||||
assert.deepEqual([rec.name, rec.charId, rec.diyFor, rec.bonds, rec.tier, rec.price], [DATA.backups.units[SIEGE].name, SIEGE, T5A, ['victoriaShip'], 5, 4]);
|
||||
assert.equal(G.ownDiyRecord(getChess(T5A), priv, D), rec, 'cached');
|
||||
const get = G.diyGetter(getChess, priv, D);
|
||||
assert.equal(get(T5A), rec);
|
||||
assert.equal(get('chess_char_4_22_a'), getChess('chess_char_4_22_a'));
|
||||
assert.equal(G.chessLoadout(get('chess_char_5_diy1_b'), priv.loadout, get).skill.skillId, 'skchr_siege_3', 'the pick\'s skill');
|
||||
assert.equal(G.deployedModuleId(get('chess_char_5_diy1_b'), priv, get, D.backups), 'uniequip_002_siege', 'the pick\'s module on the elite');
|
||||
assert.equal(G.cardDiy(getChess(T5A), { unit: { diy: { charId: SHARP, skillIndex: 2, uniEquipId: 'uniequip_002_acguad' } }, data: D }).charId, SHARP, 'a teammate\'s unit says it itself');
|
||||
});
|
||||
|
||||
test('placement reads the operator\'s position (推进之王 melee) through the own getter', () => {
|
||||
const get = G.diyGetter(getChess, priv, D);
|
||||
const ctx = G.placementContext({ priv, stage: null, editable: true, getChess: get, backups: D.backups });
|
||||
assert.equal(G.piecePosition(ctx, { kind: 'chess', id: T5A }), 'MELEE');
|
||||
});
|
||||
|
||||
test('shop / reward card: the operator\'s name and bonds, the 「自选」 badge, the pick\'s skill', () => {
|
||||
const card = ChessCard({ slot: { kind: 'chess', id: T5A, price: 4, basePrice: 4 }, idx: 0, priv, onBuy() {}, onDetail() {} });
|
||||
const nodes = [...walk(card)];
|
||||
const badge = nodes.find((v) => hasClass(v, 'scard__diy'));
|
||||
assert.ok(badge);
|
||||
assert.equal(badge.props['data-diy'], SIEGE);
|
||||
assert.equal(badge.props.children, '自选');
|
||||
assert.equal(nodes.find((v) => hasClass(v, 'scard__name')).props.children, DATA.backups.units[SIEGE].name);
|
||||
assert.ok(nodes.some((v) => v.props?.bondId === 'victoriaShip'), 'the derived bond');
|
||||
assert.equal(nodes.find((v) => v?.type && v.props?.lo).props.lo.skill.skillId, 'skchr_siege_3');
|
||||
const other = ChessCard({ slot: { kind: 'chess', id: T5A, price: 4, basePrice: 4 }, idx: 0, priv: { diy: {} }, onBuy() {}, onDetail() {} });
|
||||
assert.equal([...walk(other)].find((v) => hasClass(v, 'scard__diy')), undefined);
|
||||
});
|
||||
|
||||
test('detail card: own piece / card / a unit carrying diy → the operator, the 「自选」 tag, the pick\'s skill and module', () => {
|
||||
const piece = { uid: 7, kind: 'chess', id: 'chess_char_5_diy1_b', golden: true, items: [] };
|
||||
const pieces = new Map([[7, { piece, area: 'board', row: 10, col: 4 }]]);
|
||||
const r = resolveDetail({ kind: 'piece', uid: 7 }, pieces, { priv, backups: D.backups });
|
||||
assert.equal(r.chess.charId, SIEGE);
|
||||
assert.deepEqual(r.diy, SIEGE_PICK);
|
||||
assert.equal(resolveDetail({ kind: 'chess', id: T5A }, new Map(), { priv, backups: D.backups }).chess.charId, SIEGE, 'a shop card');
|
||||
assert.equal(resolveDetail({ kind: 'chess', id: T5A, foreign: true }, new Map(), { priv, backups: D.backups }).diy, undefined, 'the banned list / another player\'s card');
|
||||
const u = resolveDetail({ kind: 'unit', unit: { id: 3, uid: 33, side: 'ally', kind: 'op', defId: T6A, diy: { charId: SHARP, skillIndex: 2, uniEquipId: 'uniequip_002_acguad' } } }, new Map(), { priv: null, backups: D.backups });
|
||||
assert.equal(u.chess.charId, SHARP);
|
||||
const blocks = ChessDetail({ chess: r.chess, piece, editable: false, bonds: [], loadout: priv.loadout, diy: r.diy });
|
||||
const head = blocks.find((b) => b.key === 'head');
|
||||
assert.equal([...walk(head)].find((v) => hasClass(v, 'dtag-diy'))?.props.children, '自选');
|
||||
assert.equal([...walk(head)].find((v) => hasClass(v, 'dhead__name')).props.children, DATA.backups.units[SIEGE].name);
|
||||
const skill = blocks.find((b) => b.key === 'skill');
|
||||
assert.ok(JSON.stringify(skill.props).includes('skchr_siege_3'));
|
||||
const mod = blocks.find((b) => b.key === 'module');
|
||||
assert.ok(JSON.stringify(mod.props).includes('uniequip_002_siege'), 'the pick\'s module (elite)');
|
||||
assert.ok(!blocks.some((b) => b.key === 'garrison'), 'no 特质');
|
||||
});
|
||||
|
||||
test('own prep pieces of a filled slot get the 「自选」 tag', () => {
|
||||
const p = { ...priv, hand: [{ uid: 1, kind: 'chess', id: T5A }, { uid: 2, kind: 'chess', id: 'chess_char_4_22_a' }], temp: [], board: [{ uid: 4, kind: 'chess', id: 'chess_char_6_diy1_b', row: 10, col: 4 }] };
|
||||
assert.deepEqual(diyPieces(p, getChess, D).map((x) => [x.uid, x.area, x.label, x.name]), [[1, 'hand', '自选', DATA.backups.units[SIEGE].name], [4, 'board', '自选', DATA.backups.units[SHARP].name]]);
|
||||
assert.deepEqual(diyPieces({ ...p, diy: {} }, getChess, D), []);
|
||||
});
|
||||
|
||||
test('bonds: the popup lists the own 自选 member (煌-style several bonds count each) and a teammate\'s by its unit\'s pick', () => {
|
||||
const get = G.diyGetter(getChess, priv, D);
|
||||
const own = { ...priv, board: [{ uid: 4, kind: 'chess', id: T5A, row: 10, col: 4 }], hand: [{ uid: 5, kind: 'chess', id: T6A }] };
|
||||
const vic = G.bondMembers(DATA.bonds.victoriaShip, own, [], get);
|
||||
const row = vic.find((x) => x.diy);
|
||||
assert.deepEqual([row.id, row.name, row.onBoard, row.owned, row.rec.charId], [T5A, DATA.backups.units[SIEGE].name, true, true, SIEGE]);
|
||||
const empty = G.bondMembers(DATA.bonds.emptyShip, own, [], get);
|
||||
assert.deepEqual(empty.filter((x) => x.diy).map((x) => [x.id, x.inHand]), [[T6A, true]], 'the prototype in the hand counts for 协防');
|
||||
assert.equal(G.memberHeadCount(vic.filter((x) => x.diy)), 1);
|
||||
// a watched teammate: its board from the field units, their picks carried along (watchBonds ownerBoard)
|
||||
const field = { units: [{ kind: 'op', ownerId: 'p_2', defId: T5B, diy: { charId: 'char_017_huang', skillIndex: 0, uniEquipId: null } }] };
|
||||
const mate = ownerBoard(field, 'p_2');
|
||||
assert.deepEqual(mate.board[0].diy, { charId: 'char_017_huang', skillIndex: 0, uniEquipId: null });
|
||||
const pieceRecord = (p) => (p.diy ? G.diyRecordFor(getChess(p.id), p.diy, D) : null) || getChess(p.id);
|
||||
for (const b of ['yanShip', 'victoriaShip']) {
|
||||
const rows = G.bondMembers(DATA.bonds[b], mate, [], getChess, () => null, pieceRecord);
|
||||
assert.ok(rows.some((x) => x.diy && x.rec.charId === 'char_017_huang'), `煌 is a ${b} member`);
|
||||
}
|
||||
// the popup itself renders the 自选 row with its tag
|
||||
const pop = expand(BondPopup({ bondId: 'victoriaShip', entry: { count: 1, active: false, tier: 0, layers: 0 }, priv: own, onClose() {} }));
|
||||
const mrow = [...walk(pop)].find((v) => hasClass(v, 'bpop__member') && textOf(v).includes(DATA.backups.units[SIEGE].name));
|
||||
assert.ok(mrow, 'the 自选 member row');
|
||||
assert.ok([...walk(mrow)].some((v) => hasClass(v, 'bpop__diy')), 'tagged 自选');
|
||||
});
|
||||
|
||||
test('the renderer draws the operator (pieceInfo reads m.private.diy; UnitInfo diy is kept)', () => {
|
||||
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
|
||||
assert.match(src, /const pick = chess && chess\.isDiy \? diyPicks\[chess\.baseId \|\| chess\.chessId\] : null;/);
|
||||
assert.match(src, /diyPicks = src\.diy && typeof src\.diy === 'object' \? src\.diy : \{\};/);
|
||||
assert.match(readFileSync(path.join(ROOT, 'public/js/render/app/info.js'), 'utf8'), /diy: u\.diy && typeof u\.diy === 'object'/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
// Real-server browser E2E of 0.2.0 自选编队 end to end (research 0.2.0 §2; the owner's decisions of 2026-10-05): the
|
||||
// 自选编队 tab of the 干员调配 overlay fills the first tier-5 slot with 推进之王 (real clicks: the slot, the operator, S3, the
|
||||
// SOL-X module, 确认; persisted in localStorage; room.diy → session → seat → the next match's m.private.diy); in the
|
||||
// match the 调度中心 is at level 5 and the shop sells the slot as 推进之王 with the 「自选」 badge; bought, the hand piece is
|
||||
// tagged 「自选」 and draws 推进之王's model; deployed, the detail card shows 推进之王 with the pick's skill; and in the
|
||||
// browser's own battle (client-side combat) the unit is the operator — the b.start spec carries the pick (`diy`), the
|
||||
// local sim fields char_112_siege with its operator kit, and the battle view draws its Spine model.
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/diy.e2e.test.js
|
||||
//
|
||||
// The server is test/e2e/fastServer.mjs with its starter-kit hooks (no starter operators, the 调度中心 at level 5 —
|
||||
// SP_START_LEVEL —, the tier-5 DIY slot 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/diy-*.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 SLOT = 'chess_char_5_diy1_a';
|
||||
const SIEGE = 'char_112_siege';
|
||||
const PICK = { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
|
||||
|
||||
describe('0.2.0 自选编队 — a slotted operator in the own shop and battle (real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
|
||||
test('自选编队: slot 推进之王 → level-5 shop card (自选) → buy → deploy → the local battle fields 推进之王', { timeout: 6 * 60 * 1000 }, async () => {
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 2, startRound: 1, kit: 0, level: 5, shop: [SLOT] } });
|
||||
const P = (await import('puppeteer-core')).default;
|
||||
const c = new Client(P, srv.base, 'diy', { prefix: 'diy' });
|
||||
try {
|
||||
await c.open();
|
||||
await c.enter('自选');
|
||||
// 1) 干员调配 → 自选编队: fill the first tier-5 slot (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="diy"]', { visible: true, timeout: 15000 });
|
||||
await c.click('.lo .lo-tab[data-tab="diy"]');
|
||||
await c.page.waitForSelector(`.diy-slot[data-slot="${SLOT}"] .diy-slot__fill`, { visible: true, timeout: 15000 });
|
||||
const slots = await c.page.evaluate(() => [...document.querySelectorAll('.diy-slot')].map((el) => el.dataset.slot));
|
||||
assert.deepEqual(slots, ['chess_char_5_diy1_a', 'chess_char_5_diy2_a', 'chess_char_6_diy1_a', 'chess_char_6_diy2_a']);
|
||||
await c.shot('tab');
|
||||
await c.click(`.diy-slot[data-slot="${SLOT}"] .diy-slot__fill`);
|
||||
await c.page.waitForSelector(`[data-testid="diy-picker"] .diy-opt[data-char="${SIEGE}"]`, { visible: true, timeout: 8000 });
|
||||
const listed = await c.page.evaluate(() => [...document.querySelectorAll('[data-testid="diy-picker"] .diy-opt')].map((el) => el.dataset.char));
|
||||
assert.ok(listed.includes('char_609_acguad') && listed.includes('char_601_cguard'), 'the tier-5 prototypes are listed');
|
||||
await c.click(`[data-testid="diy-picker"] .diy-opt[data-char="${SIEGE}"]`);
|
||||
await c.page.waitForSelector('[data-testid="diy-picker"] .diy-choice[data-skill="2"]', { visible: true, timeout: 5000 });
|
||||
await c.click('[data-testid="diy-picker"] .diy-choice[data-skill="2"]');
|
||||
await c.click('[data-testid="diy-picker"] .diy-choice[data-module="uniequip_002_siege"]');
|
||||
await c.shot('picker');
|
||||
await c.click('[data-testid="diy-confirm"]');
|
||||
await c.page.waitForFunction((slot, id) => document.querySelector(`.diy-slot[data-slot="${slot}"]`)?.dataset.char === id, { timeout: 4000 }, SLOT, SIEGE);
|
||||
await c.page.waitForFunction(() => /已同步/.test(document.querySelector('[data-testid="diy-sync"]')?.textContent || ''), { timeout: 8000 });
|
||||
const card = await c.page.evaluate((slot) => document.querySelector(`.diy-slot[data-slot="${slot}"]`)?.textContent || '', SLOT);
|
||||
assert.ok(card.includes('推进之王') && card.includes('已持有') && card.includes('SOL-X'), card);
|
||||
const stored = await c.page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.diy') || 'null'));
|
||||
assert.deepEqual(stored, { v: 1, picks: { [SLOT]: PICK } });
|
||||
await c.shot('slotted');
|
||||
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 picks
|
||||
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 diy = await c.page.evaluate(() => globalThis.__SP__.store.get().match.private?.diy ?? null);
|
||||
assert.deepEqual(diy, { [SLOT]: PICK }, 'the match received the picks');
|
||||
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 >= 4 && s.level >= 5, 'prep at 调度中心 level 5', 60000);
|
||||
await sleep(1800); // camera flight
|
||||
|
||||
// 3) the shop card at level 5: 推进之王 with 「自选」 (only this player's shop sells it); two taps buy it
|
||||
await c.page.waitForSelector('.scard .scard__diy', { visible: true, timeout: 10000 });
|
||||
const shopCard = await c.page.evaluate(() => {
|
||||
const el = document.querySelector('.scard .scard__diy')?.closest('.scard');
|
||||
return el ? { name: el.querySelector('.scard__name')?.textContent, badge: el.querySelector('.scard__diy')?.textContent, diy: el.querySelector('.scard__diy')?.dataset.diy, bonds: el.querySelector('.scard__bonds')?.textContent } : null;
|
||||
});
|
||||
assert.deepEqual([shopCard?.name, shopCard?.badge, shopCard?.diy], ['推进之王', '自选', SIEGE]);
|
||||
assert.ok(shopCard.bonds.includes('维多利亚'), `derived bond: ${shopCard.bonds}`);
|
||||
const level = await c.page.evaluate(() => globalThis.__SP__.store.get().match.private?.shop?.level);
|
||||
assert.ok(level >= 5, 'sold from 调度中心 level 5');
|
||||
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 === SLOT);
|
||||
assert.ok(piece, 'the slot\'s piece is in the hand');
|
||||
await c.page.waitForFunction((uid) => document.querySelector(`.sitag[data-uid="${uid}"]`)?.textContent === '自选', { 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.equal(handModel, SIEGE, 'the hand draws the operator');
|
||||
await c.shot('hand');
|
||||
|
||||
// 4) deploy it (drag → direction wheel); the detail card shows 推进之王 with the pick's skill and the 「自选」 tag
|
||||
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 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-diy', { timeout: 2500 }).then(() => c.page.evaluate(() => ({
|
||||
tag: document.querySelector('.dpanel .dtag-diy')?.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: '自选', name: '推进之王', skill: 'skchr_siege_3' });
|
||||
await c.shot('board');
|
||||
await c.page.keyboard.press('Escape');
|
||||
|
||||
// 5) ready → the local battle fields 推进之王 (the spec's diy pick; the sim's def and kit; 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, siege) => {
|
||||
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, diy: entry.diy ?? null, skillIndex: entry.skillIndex ?? null },
|
||||
charId: u.def?.charId, diyFor: u.def?.diyFor, skill: u.skill?.id ?? null, bonds: u.def?.bonds, generic: !!u.kit?.generic,
|
||||
viewSpine: v.info.spine, viewDiy: v.info.diy?.charId ?? null, model: String(v._actorEntry?.skel || ''), siege,
|
||||
};
|
||||
}, { timeout: 30000, polling: 150 }, piece.uid, SIEGE).then((h) => h.jsonValue());
|
||||
assert.equal(got.authoritative, true, 'the own normal battle is simulated by this browser');
|
||||
assert.deepEqual(got.spec, { chessId: SLOT, diy: PICK, skillIndex: null }, 'b.start spec carries the pick');
|
||||
assert.equal(got.charId, SIEGE, 'the local sim fields 推进之王');
|
||||
assert.equal(got.diyFor, SLOT);
|
||||
assert.equal(got.skill, 'skchr_siege_3', 'with the picked S3');
|
||||
assert.deepEqual(got.bonds, ['victoriaShip']);
|
||||
assert.equal(got.generic, false, 'its operator kit');
|
||||
assert.equal(got.viewSpine, SIEGE);
|
||||
assert.equal(got.viewDiy, SIEGE, 'the view knows the pick');
|
||||
assert.match(got.model, /char_112_siege/, 'the battle view draws 推进之王\'s Spine model');
|
||||
await sleep(800);
|
||||
await c.shot('combat');
|
||||
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