Files
ark-stronghold-protocol/test/match/playtest6_summons.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

288 lines
16 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.
// User playtest #6 items 1 and 2 (both from a friend): 凯瑟琳's device and 赫默's drone could not be placed by hand.
// The official rule (PRTS 卫戍协议/帮助 §战斗部署): "如果部署的干员拥有可手动部署的附属召唤物,则该召唤物会立刻加入手牌区 …
// 根据召唤物部署数量上限(非初始持有量),发送等量召唤物至手牌区" — every manually deployable summon (shop state
// tokenDisplayType DEFAULT; 凯瑟琳's 爬行号·防护单元 has no entry and is deployable in the base game) is a hand card as
// soon as its owner stands on the board, a skill's summon only while that skill is equipped; "移动干员时,其所属召唤物全部
// 退场并重置至手牌区" (also when a summon dragged onto its owner swaps the owner away); a summon stack left in the
// 临时整备区 at the prep deadline "会于下一回合返还" (§手牌区). The battle side is test/content/playtest6_summons.test.js.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { checkLoadout } from '../../shared/protocol.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, legalTileFor } from './harness.js';
import { botPrep } from '../../server/match/bot.js';
const SILENCE = 'chess_char_2_02_a';
const CATHY = 'chess_char_4_11_a';
const SHAMARE = 'chess_char_3_15_a';
const DRONE = 'token_10000_silent_healrb';
const DEVICE = 'token_10041_cathy_catsld';
const DOLL = 'token_10006_vodfox_doll';
const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
function prep({ loadout = null, stageId = 'act2autochess_m04', seed = 11 } = {}) {
const seats = [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout }];
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats, seed }).start();
h.toPrep(1);
h.setStage(stageId);
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
return { h, m: h.m, ps };
}
const tokensOf = (ps, id) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter((p) => p && p.kind === 'token' && p.id === id);
const stackOf = (ps, id) => ps.hand.find((p) => p && p.kind === 'token' && p.id === id) ?? null;
const move = (m, uid, to, dir) => m.handle('p_0', { t: 'g.move', uid, to, ...(dir ? { dir } : {}) });
test('tokens.json: the manually deployable summons are hand pieces — 医疗探机, 诅咒娃娃, 爬行号·防护单元 with the talent ones', () => {
const placeable = Object.values(DATA.tokens).filter((t) => t.placeable).map((t) => t.tokenId).sort();
assert.deepEqual(placeable, [DRONE, DOLL, 'token_10017_skadi2_dedant', 'token_10028_vigil_wolf', 'token_10030_mlyss_wtrman', DEVICE].sort());
// HIDDEN shop-state tokens stay battle-only (PRTS: 新约能天使 with 使命必达! provides no 投递坐标 card)
for (const t of Object.values(DATA.tokens)) if (t.displayType === 'HIDDEN') assert.equal(t.placeable, false, t.name);
assert.equal(DATA.tokens.token_10056_angel2_target.placeable, false);
// 凯瑟琳's device: no shop-state entry; the hand gets its deploy limit (2), not the 3 carried
assert.equal(DATA.tokens[DEVICE].displayType, null);
assert.equal(DATA.tokens[DEVICE].deployLimit, 2);
});
test('#2 赫默 on the board sends a 医疗探机 card to the hand; it is placed by hand like any summon (S2 only)', () => {
const { m, ps } = prep();
const hm = give(m, ps, SILENCE);
const at = legalTileFor(m, ps, SILENCE);
assert.deepEqual(move(m, hm.uid, { area: 'board', row: at[0], col: at[1] }), { ok: true });
const stack = stackOf(ps, DRONE);
assert.ok(stack, 'the drone card is in the hand');
assert.equal(stack.ownerUid, hm.uid);
assert.equal(stack.count, 1, 'deploy limit 1');
const deployed = ps.deployCount;
const t = legalTileFor(m, ps, SILENCE);
assert.deepEqual(move(m, stack.uid, { area: 'board', row: t[0], col: t[1] }, 'UP'), { ok: true });
const piece = ps.board.get(`${t[0]},${t[1]}`);
assert.equal(piece.kind, 'token');
assert.equal(piece.id, DRONE);
assert.equal(piece.dir, 'UP');
assert.equal(ps.deployCount, deployed, 'summons use no deploy slot (PRTS 医疗探机 部署占用数 0)');
const input = ps.battleInput({ side: 'L', colOffset: 0 });
assert.ok(input.units.some((u) => u.kind === 'token' && u.tokenId === DRONE && u.ownerUid === hm.uid && u.row === t[0] && u.col === t[1]));
checkInvariants(m);
m.dispose();
// S1 治疗强化·γ型 makes no drone: no card
const lo = checkLoadout({ [SILENCE]: { skill: 0 } }, chess).loadout;
const s1 = prep({ loadout: lo });
const hm1 = give(s1.m, s1.ps, SILENCE, 'board', [9, 3]);
assert.ok(hm1);
assert.equal(tokensOf(s1.ps, DRONE).length, 0, '赫默 S1: no drone card');
s1.m.dispose();
});
test('#1 凯瑟琳 on the board sends her 支援装置 (2 = deploy limit) to the hand; both can be placed and turned', () => {
const { m, ps } = prep();
const cat = give(m, ps, CATHY);
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
const stack = stackOf(ps, DEVICE);
assert.ok(stack, 'the device card is in the hand');
assert.equal(stack.count, 2);
assert.deepEqual(move(m, stack.uid, { area: 'board', row: 10, col: 3 }, 'RIGHT'), { ok: true });
assert.equal(stack.count, 1, 'one left on the card');
assert.deepEqual(move(m, stack.uid, { area: 'board', row: 11, col: 4 }, 'DOWN'), { ok: true });
const devs = [...ps.board.values()].filter((p) => p.kind === 'token' && p.id === DEVICE);
assert.deepEqual(devs.map((p) => p.dir).sort(), ['DOWN', 'RIGHT']);
assert.equal(stackOf(ps, DEVICE), null, 'the whole stack is on the board');
checkInvariants(m);
m.dispose();
});
test('巫恋 (S2 诅咒娃娃) sends a doll card like 赫默', () => {
const { m, ps } = prep();
give(m, ps, SHAMARE, 'board', [9, 3]);
assert.equal(tokensOf(ps, DOLL).length, 1);
m.dispose();
});
test('moving an owner on the board sends its placed summons back to its card (PRTS "移动干员时…重置至手牌区")', () => {
const { m, ps } = prep();
const cat = give(m, ps, CATHY);
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
const stack = stackOf(ps, DEVICE);
assert.deepEqual(move(m, stack.uid, { area: 'board', row: 10, col: 3 }), { ok: true });
assert.deepEqual(move(m, stack.uid, { area: 'board', row: 11, col: 4 }), { ok: true });
assert.equal(ps.board.get('10,3')?.id, DEVICE);
assert.equal(ps.board.get('11,4')?.id, DEVICE);
// re-orienting in place is not a move: the devices stay
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 4 }, 'UP'), { ok: true });
assert.equal([...ps.board.values()].filter((p) => p.kind === 'token').length, 2);
// a real move: both devices leave the board and go back onto one card of 2
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 7 }), { ok: true });
assert.equal([...ps.board.values()].filter((p) => p.kind === 'token').length, 0, 'no device left on the board');
const back = tokensOf(ps, DEVICE);
assert.equal(back.length, 1);
assert.equal(back[0].count, 2);
checkInvariants(m);
m.dispose();
});
test('bots place the summon cards: 赫默\'s drone and 凯瑟琳\'s devices end up on the board, each device facing an operator', () => {
for (const [owner, token] of [[SILENCE, DRONE], [CATHY, DEVICE]]) {
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 21 }).start();
h.toPrep(2);
const bp = h.ps('ai_0');
for (const p of [...bp.board.values(), ...bp.hand.filter(Boolean)]) if (p.kind === 'chess') bp.returnCopies(p);
bp.board.clear();
bp.hand.fill(null);
bp.recompute();
give(h.m, bp, 'chess_char_1_10_a'); // 古米: a blocker for the devices to face
give(h.m, bp, owner);
botPrep(h.m, bp);
const placed = [...bp.board.entries()].filter(([, p]) => p.kind === 'token' && p.id === token);
assert.ok(placed.length >= 1, `${token}: placed by the bot`);
if (token === DEVICE) {
for (const [k, p] of placed) {
const [r, c] = k.split(',').map(Number);
const d = { RIGHT: [0, 1], LEFT: [0, -1], UP: [1, 0], DOWN: [-1, 0] }[p.dir ?? 'RIGHT'];
const front = bp.board.get(`${r + d[0]},${c + d[1]}`);
assert.ok(front && front.kind === 'chess', `device at ${k} faces an operator (${p.dir})`);
}
}
h.m.dispose();
}
});
test('bots re-aim placed devices with every layout: a device left facing an operator the plan moved away is lifted and placed again (QA)', () => {
// the bot arranges twice (before and after its items) and may switch to the rehearsed plan: operators moved without
// their summons left 凯瑟琳's devices facing an empty tile (3.5–8.6 % of devices in QA's bot matches)
const D = { RIGHT: [0, 1], LEFT: [0, -1], UP: [1, 0], DOWN: [-1, 0] };
const others = ['chess_char_1_10_a', 'chess_char_2_02_a', 'chess_char_1_01_a', 'chess_char_1_02_a', 'chess_char_1_03_a', 'chess_char_1_04_a'];
let devices = 0;
for (const seed of [21, 22, 23, 24, 25, 26, 27, 28]) {
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed }).start();
h.toPrep(seed % 2 ? 3 : 5);
const bp = h.ps('ai_0');
for (const p of [...bp.board.values(), ...bp.hand.filter(Boolean)]) if (p.kind === 'chess') bp.returnCopies(p);
bp.board.clear();
bp.hand.fill(null);
bp.recompute();
// a hand-made layout the bot will rearrange: every operator on the first legal tile, both devices facing one of them
for (const id of [CATHY, ...others.slice(0, bp.deployCap - 1)]) {
const p = give(h.m, bp, id);
const t = legalTileFor(h.m, bp, id);
if (t) bp.move(p.uid, { area: 'board', row: t[0], col: t[1] }, 'RIGHT');
}
const [fk] = [...bp.board.entries()].find(([, p]) => p.kind === 'chess' && p.id !== CATHY);
const [fr, fc] = fk.split(',').map(Number);
for (const [dr, dc, dir] of [[0, -1, 'RIGHT'], [1, 0, 'DOWN'], [-1, 0, 'UP'], [0, 1, 'LEFT']]) {
const stack = stackOf(bp, DEVICE);
if (!stack) break;
if (!bp.board.has(`${fr + dr},${fc + dc}`)) bp.move(stack.uid, { area: 'board', row: fr + dr, col: fc + dc }, dir);
}
botPrep(h.m, bp);
for (const [k, p] of bp.board) {
if (p.kind !== 'token' || p.id !== DEVICE) continue;
devices++;
const [r, c] = k.split(',').map(Number);
const d = D[p.dir ?? 'RIGHT'];
const front = bp.board.get(`${r + d[0]},${c + d[1]}`);
assert.ok(front && front.kind === 'chess', `seed ${seed}: the device at ${k} (${p.dir}) faces an operator`);
}
checkInvariants(h.m);
h.m.dispose();
}
assert.ok(devices >= 12, `devices checked: ${devices}`);
});
test('a summon card needs its owner on the board; the owner withdrawn takes its summons along', () => {
const { m, ps } = prep();
const hm = give(m, ps, SILENCE);
assert.deepEqual(move(m, hm.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
const stack = stackOf(ps, DRONE);
assert.deepEqual(move(m, stack.uid, { area: 'board', row: 10, col: 5 }), { ok: true });
const free = ps.hand.findIndex((x) => x == null);
assert.deepEqual(move(m, hm.uid, { area: 'hand', idx: free }), { ok: true });
assert.equal(tokensOf(ps, DRONE).length, 0, 'withdrawn with its owner');
// back on the board: a new card; it cannot be placed once the owner is gone again, and summons are not sold
const at = legalTileFor(m, ps, SILENCE);
assert.deepEqual(move(m, hm.uid, { area: 'board', row: at[0], col: at[1] }), { ok: true });
const card = stackOf(ps, DRONE);
assert.ok(card);
assert.equal(m.handle('p_0', { t: 'g.sell', uid: card.uid }).error, ERR.BAD_TARGET);
checkInvariants(m);
m.dispose();
});
test('a summon dragged onto an operator swaps it away: that operator\'s other placed summons go back onto their card', () => {
const { m, ps } = prep();
const cat = give(m, ps, CATHY);
const hm = give(m, ps, SILENCE);
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
assert.deepEqual(move(m, hm.uid, { area: 'board', row: 10, col: 7 }), { ok: true });
const devCard = stackOf(ps, DEVICE);
assert.deepEqual(move(m, devCard.uid, { area: 'board', row: 10, col: 3 }), { ok: true });
assert.deepEqual(move(m, devCard.uid, { area: 'board', row: 11, col: 4 }, 'UP'), { ok: true });
assert.deepEqual(move(m, stackOf(ps, DRONE).uid, { area: 'board', row: 10, col: 8 }), { ok: true });
// 凯瑟琳's device (10,3) dropped onto 赫默 (10,7): 赫默 takes 10,3, so her drone goes back to her card; 凯瑟琳 did not move
const devA = ps.board.get('10,3');
assert.deepEqual(move(m, devA.uid, { area: 'board', row: 10, col: 7 }), { ok: true });
assert.equal(ps.board.get('10,3')?.uid, hm.uid);
assert.equal(ps.board.get('10,7')?.uid, devA.uid, 'the dragged device is where it was dropped');
assert.equal(ps.board.get('10,8'), undefined, '赫默\'s placed drone left the board');
assert.equal(stackOf(ps, DRONE)?.count, 1, '… back onto her card');
assert.equal(ps.board.get('11,4')?.id, DEVICE, '凯瑟琳\'s other device stays');
// the other device (11,4) dropped onto its own owner (10,4): 凯瑟琳 takes 11,4; the dropped device stays at 10,4, her
// remaining placed device (10,7) goes back onto her card
const devB = ps.board.get('11,4');
assert.deepEqual(move(m, devB.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
assert.equal(ps.board.get('11,4')?.uid, cat.uid);
assert.equal(ps.board.get('10,4')?.uid, devB.uid);
assert.equal(ps.board.get('10,7'), undefined);
assert.equal(stackOf(ps, DEVICE)?.count, 1);
assert.equal(tokensOf(ps, DEVICE).reduce((n, p) => n + (p.count || 1), 0), 2, 'still 2 devices in all');
checkInvariants(m);
m.dispose();
});
/** Plain equipment (no equip / merge side effects) to fill the hand. */
const PLAIN = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && i.kind === 'passive').map((i) => i.itemId ?? i.id).filter(Boolean);
test('a summon stack left in temp at the prep deadline comes back next round (PRTS "干员所属召唤物会于下一回合返还")', () => {
// co-op of 2 humans: a timed prep whose deadline resolves temp (PlayerState.resolveTemp), FakeBattle
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 1601, fake: true }).start();
h.toPrep(1);
h.setStage('act2autochess_m04');
const m = h.m;
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
const cat = give(m, ps, CATHY);
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
const card = stackOf(ps, DEVICE);
assert.deepEqual(move(m, card.uid, { area: 'board', row: 10, col: 3 }), { ok: true });
assert.deepEqual(move(m, card.uid, { area: 'board', row: 11, col: 4 }, 'UP'), { ok: true });
for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, PLAIN[i]);
// moving 凯瑟琳 with a full hand: her devices go back as one card of 2, which overflows into temp and blocks Ready
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 7 }), { ok: true });
const inTemp = ps.temp.find((p) => p && p.kind === 'token' && p.id === DEVICE);
assert.equal(inTemp?.count, 2);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY });
// the player leaves it there: the prep deadline removes the temp card …
h.drive(() => m.phase === PHASE.COMBAT && m.round === 1, { ready: false });
assert.equal(tokensOf(ps, DEVICE).length, 0, 'removed with the temp slot at the deadline');
// … and it comes back at the next round (still a full hand ⇒ temp again, where it can be placed)
h.drive(() => m.phase === PHASE.PREP && m.round === 2, { ready: false });
const back = tokensOf(ps, DEVICE);
assert.equal(back.length, 1);
assert.equal(back[0].count, 2, 'the deploy limit again');
assert.equal(back[0].ownerUid, cat.uid);
assert.deepEqual(move(m, back[0].uid, { area: 'board', row: 10, col: 5 }, 'RIGHT'), { ok: true });
assert.deepEqual(move(m, back[0].uid, { area: 'board', row: 11, col: 7 }, 'UP'), { ok: true });
assert.ok(ps.tempEmpty, 'placing both cleared temp');
// a later round start grants nothing more: both devices are on the board
h.drive(() => m.phase === PHASE.PREP && m.round === 3);
assert.equal(tokensOf(ps, DEVICE).reduce((n, p) => n + (p.count || 1), 0), 2);
checkInvariants(m);
m.dispose();
});