- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player; placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call) - #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿 - #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text - #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool, and the team LP reaching 0 first is a defeat (PRTS) - #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones - #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10) - #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range - #9 official per-enemy model scales (威龙) - #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding - #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider - #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes - #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20 - #18 炎佑 is 孤立: no heals or ally buffs - #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators - DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites green; dev recordings regenerated
137 lines
7.9 KiB
JavaScript
137 lines
7.9 KiB
JavaScript
// Real-server browser E2E of user playtest #6 items 1 and 2 (from a friend): 凯瑟琳's device and 赫默's drone could not be
|
||
// placed by hand. A solo prep (test/e2e/fastServer.mjs hook SP_START_ROUND 2 + SP_START_CHESS: 古米, 凯瑟琳, 赫默 in the
|
||
// hand) is driven with real mouse drags: the three operators go onto the board through the direction wheel, their
|
||
// summon cards appear in the hand (赫默's 医疗探机 ×1, 凯瑟琳's 支援装置 ×2), the drone is dropped on a free tile and a
|
||
// device next to 古米 facing it — then the battle starts with both on the field: the device fights from the start and
|
||
// the placed drone deploys once at the battle start (PRTS 卫戍协议/帮助 §作战阶段; settled by the user after playtest #6 —
|
||
// shared/constants.js SKILL_SUMMON_START_DEPLOY), then comes back with each S2 (sim/content/tokens.js). Zero console
|
||
// errors.
|
||
//
|
||
// SP_E2E=1 node --test test/ui/playtest6-summons.e2e.test.js
|
||
|
||
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 GUMMY = 'chess_char_1_10_a', CATHY = 'chess_char_4_11_a', SILENCE = 'chess_char_2_02_a';
|
||
const DRONE = 'token_10000_silent_healrb', DEVICE = 'token_10041_cathy_catsld';
|
||
|
||
async function soloToPrep(c) {
|
||
await c.open();
|
||
await c.enter('煌');
|
||
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);
|
||
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((x) => x.phase === 'PREP' && !x.ready, 'prep', 60000);
|
||
await sleep(1800);
|
||
}
|
||
|
||
/** Drag hand piece `uid` onto (row, col) and pick `dir` on the wheel; returns the board piece. */
|
||
async function place(c, uid, tile, dir) {
|
||
const from = await c.piecePoint(uid);
|
||
const to = await c.tilePoint(tile.row, tile.col);
|
||
assert.ok(from && to, `piece ${uid} and tile ${tile.row},${tile.col} on screen`);
|
||
await c.drag(from, to);
|
||
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
|
||
await c.swipe(dir);
|
||
await sleep(500);
|
||
}
|
||
|
||
const handOf = (c, id) => c.page.evaluate((id) => (globalThis.__SP__.store.get().match.private?.hand || []).find((p) => p && p.id === id) || null, id);
|
||
const boardOf = (c, id) => c.page.evaluate((id) => (globalThis.__SP__.store.get().match.private?.board || []).filter((p) => p.id === id), id);
|
||
|
||
/**
|
||
* A free deploy tile orthogonally next to one of the board's operators and the direction that faces it from there
|
||
* (board coordinates): { row, col, dir } or null.
|
||
*/
|
||
function spotNextToOperator(c) {
|
||
return c.page.evaluate(() => {
|
||
const { store, data } = globalThis.__SP__;
|
||
const s = store.get();
|
||
const st = data.lookup('stages', s.match.public.stageId);
|
||
const dt = st.deployTiles.normal;
|
||
const ok = new Set([...dt.rangedOnly, ...dt.melee].map(([a, b]) => `${a},${b}`));
|
||
const used = new Set(s.match.private.board.map((p) => `${p.row},${p.col}`));
|
||
for (const op of s.match.private.board.filter((p) => p.kind === 'chess')) {
|
||
for (const [dr, dc, dir] of [[0, -1, 'RIGHT'], [1, 0, 'DOWN'], [-1, 0, 'UP'], [0, 1, 'LEFT']]) {
|
||
const k = `${op.row + dr},${op.col + dc}`;
|
||
if (ok.has(k) && !used.has(k)) return { row: op.row + dr, col: op.col + dc, dir };
|
||
}
|
||
}
|
||
return null;
|
||
});
|
||
}
|
||
|
||
describe('user playtest #6: summons placed by hand (real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome + assets) to run' }, () => {
|
||
let puppeteer = null;
|
||
test('赫默\'s drone and 凯瑟琳\'s devices are hand cards, placed with the wheel; both are on the field at the battle start', { timeout: 240000 }, async () => {
|
||
puppeteer = puppeteer || (await import('puppeteer-core')).default;
|
||
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 4, startRound: 2, chess: [GUMMY, CATHY, SILENCE] } });
|
||
const c = new Client(puppeteer, srv.base, 'summons', { prefix: 'pt6' });
|
||
try {
|
||
await soloToPrep(c);
|
||
await c.hookRequests();
|
||
// the three operators onto the board (melee first: 古米 and 凯瑟琳 take melee tiles)
|
||
for (const id of [GUMMY, CATHY, SILENCE]) {
|
||
const p = await handOf(c, id);
|
||
assert.ok(p, `${id} in the starter kit`);
|
||
const tile = await c.freeTileFor(p.uid);
|
||
await place(c, p.uid, tile, 'RIGHT');
|
||
assert.equal((await boardOf(c, id)).length, 1, `${id} placed`);
|
||
}
|
||
// their summon cards
|
||
const drone = await handOf(c, DRONE);
|
||
const dev = await handOf(c, DEVICE);
|
||
assert.ok(drone, '赫默\'s 医疗探机 card is in the hand');
|
||
assert.ok(dev && dev.count === 2, `凯瑟琳's 支援装置 card ×2 (${dev && dev.count})`);
|
||
await c.shot('hand-cards');
|
||
// the drone on a free tile, facing UP
|
||
const dt = await c.freeTileFor(drone.uid, { prefer: 'back' });
|
||
await place(c, drone.uid, dt, 'UP');
|
||
const [dp] = await boardOf(c, DRONE);
|
||
assert.ok(dp && dp.row === dt.row && dp.col === dt.col && dp.dir === 'UP', `drone placed (${JSON.stringify(dp)})`);
|
||
// a device next to an operator, facing it
|
||
const spot = await spotNextToOperator(c);
|
||
assert.ok(spot, 'a free tile next to an operator');
|
||
await place(c, dev.uid, spot, spot.dir);
|
||
const devs = await boardOf(c, DEVICE);
|
||
assert.equal(devs.length, 1);
|
||
assert.deepEqual([devs[0].row, devs[0].col, devs[0].dir], [spot.row, spot.col, spot.dir], 'device placed facing the operator');
|
||
assert.equal((await handOf(c, DEVICE))?.count, 1, 'one device left on the card');
|
||
assert.ok((await c.requests('g.move')).length >= 5, 'every placement went through g.move');
|
||
// the rest of the stack (one device) is drawn back on its bench slot at once (not left over the placed copy)
|
||
await sleep(450);
|
||
const slot = await c.page.evaluate((id) => (globalThis.__SP__.store.get().match.private?.hand || []).findIndex((p) => p && p.id === id), DEVICE);
|
||
const home = await c.tilePoint(7, slot);
|
||
const at = await c.piecePoint(dev.uid, 0.5);
|
||
assert.ok(home && at && Math.hypot(at.x - home.x, at.y - home.y) < 90, `the device card is back on its bench slot (${JSON.stringify({ home, at })})`);
|
||
await c.shot('placed');
|
||
// the battle: the device deploys with the board, and so does the placed drone, once (then with each S2)
|
||
await c.click('.readybtn');
|
||
await c.waitFor((s) => s.phase === 'COMBAT', 'combat', 30000);
|
||
// the battle views (render/app.js: unit id → view with its UnitInfo) once the board has deployed
|
||
const onField = () => c.page.evaluate(() => [...(globalThis.__SP_VIEW__?.raw?.debug?.views?.values() || [])].filter((v) => v?.info && v.alive !== false).map((v) => v.info.defId));
|
||
await c.page.waitForFunction((id) => [...(globalThis.__SP_VIEW__?.raw?.debug?.views?.values() || [])].some((v) => v?.info?.defId === id), { timeout: 30000 }, GUMMY);
|
||
await sleep(600);
|
||
const start = await onField();
|
||
assert.ok(start.includes(DEVICE), `the device is on the field from the start (${start.join(', ')})`);
|
||
assert.ok(start.includes(DRONE), `the placed drone deploys once at the battle start (${start.join(', ')})`);
|
||
await c.shot('battle');
|
||
assert.deepEqual(problemsOf([c]), []);
|
||
} finally {
|
||
await c.close();
|
||
await srv.stop();
|
||
}
|
||
});
|
||
});
|