Files
ark-stronghold-protocol/test/ui/feedback1-placement.e2e.test.js
T
sganggs c92867656d feedback1 WC: no deployment in the water, tactical points inside the tactician's range
Player reports #3 and #9 after the 0.1.0 release.

#3 On 战场#08(下半) 涨潮控制 (act2autochess_m04) operators could be placed in the
pool and 突袭 members jumped into it. The 深水区 (tile_deepsea) has buildableType
ALL in the level file, but it refuses deployment (PRTS 地形 深水区 "地形机制:拒绝
部署"; the season-1 战场#05 puts a 特制水上平台 - "在水上建立可以部署任意单位的
平台" - on every one of its water tiles).
- grid.js DEPLOY_REFUSED_TILES: the legend's effective build is NONE, so
  canStand (突袭 landing tile, tactical points, summon tiles) never picks the
  water; a hard-blocked 射击台 / mound tile takes no melee unit either.
- build-data: stages.json legend `buildable` is the effective type (the level's
  value kept in `buildableType`), deployTiles exclude the water, a 特制水上平台
  makes its tile deployable (act1 m05, inactive). board.js / gameLogic.js
  deploy maps follow (water platforms included); bots plan on the same map.
- Gate routes are unchanged; the water now counts as non-blockable ground in
  the path tie-break (audit lists updated).

#9 伺夜's 狼群 and 缪尔赛思's 流形 ("只能部署在召唤者攻击范围内", tokens.json
`ownerRange`) could be placed anywhere.
- PlayerState._legal / summonRange: a range-bound summon only on a tile of its
  owner's attack range (loadout grid rotated by the owner's facing); swaps with
  the owner are checked from the owner's new tile; re-orienting (or promoting)
  the owner sends a summon left outside back onto its stack with a toast.
- The client lights and accepts the same tiles (gameLogic.summonRange), the bot
  plans inside them, the invariants check it, the 伺夜 kit re-uses the piece's
  tile only inside her range.

Tests: test/match/feedback1-placement, test/sim/feedback1-water, the opt-in
headless-Chrome test/ui/feedback1-placement.e2e (fastServer hook SP_STAGE);
existing summon / deploy / pathing tests moved off the water tiles.
2026-10-03 02:01:34 +08:00

137 lines
8.0 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 check of two player reports after the 0.1.0 release (placement legality, headless Chrome):
// #3 "有水池的那张图干员可以错误的被部署到水里" — on 战场#08(下半) 涨潮控制 (act2autochess_m04, test/e2e/fastServer.mjs hook
// SP_STAGE) the drag highlights of an operator never light the 深水区 (board (10–12, 6)) and a drop there is refused.
// #9 "干员伺夜这样的战术家的战术点能被错误的被放到攻击范围之外" — while 伺夜's 狼群 card is dragged only the free legal
// tiles of her attack range light up; a drop outside is refused, one inside opens the wheel; re-orienting her so the
// pack falls outside her range sends it back to the hand.
// Real mouse drags (press, move, read the 'legal' highlight group of the view, release). Zero console errors.
//
// SP_E2E=1 node --test test/ui/feedback1-placement.e2e.test.js
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
import { ownerRangeKeys } from '../../server/match/board.js';
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
const STAGE = 'act2autochess_m04';
const VIGIL = 'chess_char_3_19_a', GUMMY = 'chess_char_1_10_a', WOLF = 'token_10028_vigil_wolf';
const WATER = ['10,6', '11,6', '12,6'];
const json = (f) => JSON.parse(readFileSync(path.join(ROOT, 'data', f), 'utf8'));
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);
}
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);
const legalLit = (c) => c.page.evaluate(() => {
const hl = globalThis.__SP_VIEW__?.raw?.debug?.tiles?.highlights?.get('legal');
return hl ? hl.tiles.map((t) => (Array.isArray(t) ? `${t[0]},${t[1]}` : `${t.row},${t.col}`)) : [];
});
const wheelOpen = (c) => c.page.evaluate(() => !!document.querySelector('.fwheel__dia'));
/** Press on hand piece `uid`, move over `hover` and read the legal highlights mid-drag, then release on `drop`. */
async function dragRead(c, uid, hover, drop, shot = null) {
const from = await c.piecePoint(uid);
const over = await c.tilePoint(hover[0], hover[1]);
const to = await c.tilePoint(drop[0], drop[1]);
assert.ok(from && over && to, `piece ${uid} and tiles on screen`);
const m = c.page.mouse;
await m.move(from.x, from.y);
await m.down();
for (let i = 1; i <= 12; i++) { await m.move(from.x + ((over.x - from.x) * i) / 12, from.y + ((over.y - from.y) * i) / 12); await sleep(14); }
await sleep(250);
const lit = (await legalLit(c)).sort();
if (shot) await c.shot(shot);
for (let i = 1; i <= 8; i++) { await m.move(over.x + ((to.x - over.x) * i) / 8, over.y + ((to.y - over.y) * i) / 8); await sleep(14); }
await sleep(200);
await m.up();
await sleep(500);
return lit;
}
describe('player reports #3 / #9 after 0.1.0: placement highlights (real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome + assets) to run' }, () => {
test('the pool is never lit nor accepted; 狼群 lights and accepts only 伺夜\'s range', { timeout: 240000 }, async () => {
const puppeteer = (await import('puppeteer-core')).default;
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 4, startRound: 2, chess: [VIGIL, GUMMY], stage: STAGE } });
const c = new Client(puppeteer, srv.base, 'feedback1', { prefix: 'fb1' });
try {
await soloToPrep(c);
assert.equal(await c.page.evaluate(() => globalThis.__SP__.store.get().match.public.stageId), STAGE, 'SP_STAGE hook');
const melee = new Set(json('stages.json')[STAGE].deployTiles.normal.melee.map(([r, col]) => `${r},${col}`));
for (const k of WATER) assert.ok(!melee.has(k), `data: ${k} not deployable`);
// 伺夜 onto (10,4) facing RIGHT through the wheel
const vigil = await handOf(c, VIGIL);
assert.ok(vigil, '伺夜 in the hand');
const lit0 = await dragRead(c, vigil.uid, [9, 3], [10, 4]);
for (const k of WATER) assert.ok(!lit0.includes(k), `伺夜: ${k} not lit (${lit0.join(' ')})`);
assert.ok(lit0.includes('11,5') && lit0.includes('10,7'), 'the dry tiles around the pool are lit');
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
await c.swipe('RIGHT');
await sleep(600);
assert.deepEqual((await boardOf(c, VIGIL)).map((p) => [p.row, p.col, p.dir]), [[10, 4, 'RIGHT']]);
// #3: 古米 (melee) — no water lit, a drop into the pool is refused (no wheel, still in the hand)
const gummy = await handOf(c, GUMMY);
const lit1 = await dragRead(c, gummy.uid, [9, 3], [11, 6], 'pool-drag');
for (const k of WATER) assert.ok(!lit1.includes(k), `古米: ${k} not lit`);
assert.deepEqual(lit1, [...melee].sort(), 'every melee tile (伺夜\'s too: a swap), no water');
assert.equal(await wheelOpen(c), false, 'no wheel for a drop into the water');
assert.ok(await handOf(c, GUMMY), '古米 is still in the hand');
await c.shot('pool-refused');
// #9: the pack card lights exactly the free melee tiles of her range (rows 9–11 × cols 4–7 minus her tile and the pool)
const wolf = await handOf(c, WOLF);
assert.ok(wolf, 'the 狼群 card is in the hand');
const range = ownerRangeKeys(json('chess.json')[VIGIL].rangeGrid, 10, 4, 'RIGHT');
const want = [...melee].filter((k) => range.has(k) && k !== '10,4').sort();
const lit2 = await dragRead(c, wolf.uid, [11, 5], [12, 3], 'wolf-drag');
assert.deepEqual(lit2, want, `狼群 highlights = her range (${lit2.join(' ')})`);
for (const k of ['12,3', '12,4', '10,3', '9,8']) assert.ok(!lit2.includes(k), `${k} outside her range is not lit`);
assert.equal(await wheelOpen(c), false, 'a drop outside her range opens no wheel');
assert.ok(await handOf(c, WOLF), 'the pack stays in the hand');
// inside: the wheel opens and the pack is placed
await dragRead(c, wolf.uid, [11, 5], [11, 5]);
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
await c.swipe('RIGHT');
await sleep(600);
assert.deepEqual((await boardOf(c, WOLF)).map((p) => [p.row, p.col]), [[11, 5]], 'placed inside her range');
await c.shot('wolf-in-range');
// re-orient 伺夜 to LEFT in place: (11,5) leaves her range → the pack goes back to the hand
const v = (await boardOf(c, VIGIL))[0];
const from = await c.piecePoint(v.uid);
const to = await c.tilePoint(10, 4);
await c.drag(from, to);
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
await c.swipe('LEFT');
await sleep(800);
assert.deepEqual((await boardOf(c, VIGIL)).map((p) => p.dir), ['LEFT']);
assert.equal((await boardOf(c, WOLF)).length, 0, 'the pack left the board');
assert.ok(await handOf(c, WOLF), '… back to the hand');
await c.shot('wolf-lifted');
assert.deepEqual(problemsOf([c]), []);
} finally {
await c.close();
await srv.stop();
}
});
});