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.
This commit is contained in:
sganggs committed 2026-10-03 02:01:34 +08:00
1 parent 7f134737ce
commit c92867656d
26 files changed
+789 -73

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
// 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();
}
});
});
+5 -2
View File
@@ -217,6 +217,9 @@ describe('placement mirror (canPlace)', () => {
const board = [];
const tiles = [[9, 3], [9, 4], [9, 5], [9, 7], [9, 8], [9, 9], [10, 5], [10, 7]];
for (const [row, col] of tiles) board.push({ ...piece(MELEE), row, col });
// the 狼群's owner: 伺夜 facing UP on (9,4) — her range (rows 9–12 × cols 3–5) covers the tiles tried below
// ("只能部署在召唤者攻击范围内", player report #9 after 0.1.0)
board[1] = { ...piece('chess_char_3_19_a'), row: 9, col: 4, dir: 'UP' };
const extra = piece(MELEE);
const ctx = ctxFor(privWith({ board, hand: [extra] }));
const full = canPlace(ctx, extra.uid, { area: 'board', row: 11, col: 5 });
@@ -225,7 +228,7 @@ describe('placement mirror (canPlace)', () => {
const onBoard = board[0];
assert.equal(canPlace(ctx, onBoard.uid, { area: 'board', row: 11, col: 5 }).ok, true, 'moving on the board never hits the cap');
// tokens don't use deploy slots
const tok = { uid: ++uid, kind: 'token', id: 'token_10028_vigil_wolf', count: 1, ownerUid: board[0].uid };
const tok = { uid: ++uid, kind: 'token', id: 'token_10028_vigil_wolf', count: 1, ownerUid: board[1].uid };
const ctx2 = ctxFor(privWith({ board, hand: [tok] }));
assert.equal(canPlace(ctx2, tok.uid, { area: 'board', row: 11, col: 5 }).ok, true);
assert.equal(canPlace(ctx2, tok.uid, { area: 'board', row: 10, col: 4 }).ok, false, 'MELEE token not on high ground');
@@ -234,7 +237,7 @@ describe('placement mirror (canPlace)', () => {
const ctx3 = ctxFor(privWith({ board, hand: [orphan] }));
assert.equal(canPlace(ctx3, orphan.uid, { area: 'board', row: 11, col: 5 }).code, 'BAD_TARGET', 'summoner must be deployed');
// a hand chess swapping with a board token takes a deploy slot (cap applies)
const bTok = { uid: ++uid, kind: 'token', id: 'token_10028_vigil_wolf', count: 1, ownerUid: board[0].uid, row: 11, col: 5 };
const bTok = { uid: ++uid, kind: 'token', id: 'token_10028_vigil_wolf', count: 1, ownerUid: board[1].uid, row: 11, col: 5 };
const ctx4 = ctxFor(privWith({ board: [...board, bTok], hand: [extra] }));
assert.equal(canPlace(ctx4, extra.uid, { area: 'board', row: 11, col: 5 }).code, 'BOARD_FULL');
});