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
This commit is contained in:
sganggs committed 2026-10-01 19:08:23 +08:00
1 parent e6628f0cfe
commit 6464e4b46a
138 files changed
+7946 -1379

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
// 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();
}
});
});