Files
ark-stronghold-protocol/test/ui/playtest6-summons.e2e.test.js
T
sganggs 6464e4b46a Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #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
2026-10-01 19:08:23 +08:00

137 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
}
});
});