Files
ark-stronghold-protocol/test/match/board.test.js
T
sganggs 83fc408a55 feedback4: every melee chess whose trait reads 可以放置于远程位 may stand on a 高台, normal or elite, any module
The owner's decision of 2026-10-05 reverses the one of 2026-10-04 (elite 歌蕾蒂娅 with HOK-Y only) and follows PRTS:
the line is the 钩索师 / 推击手 branch trait (歌蕾蒂娅, 崖心, 见行者, normal and elite) and every module keeps it, so
the loadout no longer matters. Raised with the PRTS source in PR #69 by @sunstricken (its own change covered only
歌蕾蒂娅; its test page is not taken). GitHub #153.

- shared/highGround.js: meleeOnHighGround(rec) reads the trait without a module (traitBase, else trait); no module id.
- board.js positionClass(rec) widens those six records; placeClass(ps, rec) = positionClass(rec) for every server
  check (g.move, swaps, merge tile, evictions, audit, invariants). The client's piecePosition mirrors it.
- bot.js: the existing 高台 preference (a 高台 whose range covers the road) now applies to all six; comments.
- Docs: DESIGN §3 and §6.6, DATA (placement), PLAYING §4, META, research 03 C2, code comments. data/ unchanged
  (build-data --offline gives identical files).
- Tests: board, feedback1-placement (each holder with default / no module / every module on each 高台, a 重装
  refused, client highlights, swap, battle, bots), gameLogic.

Golden: 16 scenarios moved, every one because tools/golden.mjs lays operators out by positionClass and now places
歌蕾蒂娅 (normal, or elite with HOK-X) beside the enemy path like a ranged operator instead of on the busiest melee
tile, which shifts the others: roster-010, roster-012, roster-029, roster-040, roster-041; bond-deputShip-high,
bond-indomShip-low, bond-preciShip-low, bond-preciShip-high, bond-suntShip-low, bond-swiftShip-high; boss-boss_2-pair,
boss-boss_4-pair, boss-boss_6-pair, hidden-boss_8-pair, hidden-boss_10-pair. roster-031 (elite with HOK-Y, already
widened) and all matches are unchanged.
2026-10-05 23:20:56 +08:00

271 lines
18 KiB
JavaScript

// Board placement legality (stage legend), deploy cap, move semantics, placeable summons (DESIGN §3, research 03 C2).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildDeployMap, canPlace, legalTiles, tileKey, positionClass, basePositionClass } from '../../server/match/board.js';
import { applyCard } from '../../server/match/choices.js';
import { ERR } from '../../shared/constants.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier } from './harness.js';
const MELEE = (c) => c.position === 'MELEE';
const RANGED = (c) => c.position === 'RANGED';
function prepMatch(stageId = 'act2autochess_m01', o = {}) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: o.seed ?? 11, ...o }).start();
h.toPrep(1);
h.setStage(stageId);
const ps = h.ps('p_0');
// clean board/hand for deterministic scenarios
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 };
}
test('deploy map from the stage legend equals stages[id].deployTiles.normal for every stage', () => {
for (const [id, st] of Object.entries(DATA.stages)) {
const map = buildDeployMap(st);
const melee = [...map].filter(([, v]) => v === 'melee').map(([k]) => k).sort();
const ranged = [...map].filter(([, v]) => v === 'ranged').map(([k]) => k).sort();
assert.deepEqual(melee, st.deployTiles.normal.melee.map(([r, c]) => tileKey(r, c)).sort(), `${id} melee`);
assert.deepEqual(ranged, st.deployTiles.normal.rangedOnly.map(([r, c]) => tileKey(r, c)).sort(), `${id} ranged`);
}
});
test('glyph rules: melee only on LOW buildable ALL/MELEE; ranged also on HIGH/RANGED; lane/forbidden never; device overrides', () => {
const st = DATA.stages.act2autochess_m01;
const map = buildDeployMap(st);
// (10,4) is 'h' high ground: ranged only; (9,3) road: both; (9,6) '#': none; col 9 lane (10,9) 'f': none
assert.equal(map.get('10,4'), 'ranged');
assert.ok(canPlace(map, 'ranged', 10, 4) && !canPlace(map, 'melee', 10, 4));
assert.ok(canPlace(map, 'melee', 9, 3) && canPlace(map, 'ranged', 9, 3) && canPlace(map, 'all', 9, 3));
assert.ok(!canPlace(map, 'ranged', 9, 6));
assert.ok(!canPlace(map, 'ranged', 10, 9));
assert.ok(!canPlace(map, 'ranged', 8, 4), 'temp row is not the board');
assert.ok(!canPlace(map, 'ranged', 9, 11), 'partner half is not the own board');
// crates block tiles; a terrain override (crate removed) frees them
const m01 = DATA.stages.act1autochess_m01;
const crate = m01.devices.find((d) => d.role === 'crate' && d.pos[0] === 11 && d.pos[1] === 5);
assert.ok(crate);
assert.ok(!canPlace(buildDeployMap(m01), 'melee', 11, 5));
assert.ok(canPlace(buildDeployMap(m01, { deviceOverrides: { [crate.alias]: false } }), 'melee', 11, 5));
assert.ok(!canPlace(buildDeployMap(m01, { tileOverrides: { '12,3': 'none' } }), 'melee', 12, 3));
assert.deepEqual(legalTiles(map, 'melee').length, st.deployTiles.normal.melee.length);
// missing stage data: an open board (degraded mode)
assert.ok(canPlace(buildDeployMap(null), 'melee', 10, 5));
});
test('positionClass: a MELEE chess whose trait reads 「可以放置于远程位」 is widened, normal or elite, whatever the module (owner 2026-10-05)', () => {
assert.equal(positionClass({ position: 'MELEE' }), 'melee');
assert.equal(positionClass({ position: 'MELEE', placement: 'all' }), 'melee', 'the old placement field is ignored');
assert.equal(positionClass({ position: 'MELEE', trait: { desc: '技能可以使敌人产生位移\n可以放置于远程位' } }), 'all', 'the trait line');
assert.equal(positionClass({ position: 'MELEE', trait: { desc: '可以额外部署在远程位' } }), 'melee', 'another wording is not the line');
assert.equal(positionClass({ position: 'RANGED', trait: { desc: '可以放置于远程位' } }), 'ranged', 'a ranged record keeps its class');
assert.equal(basePositionClass({ position: 'MELEE', trait: { desc: '可以放置于远程位' } }), 'melee', 'the battle class stays melee');
assert.equal(positionClass({ position: 'RANGED' }), 'ranged');
assert.equal(positionClass({ position: 'ALL' }), 'all');
assert.equal(positionClass(null), 'all');
const map = buildDeployMap(DATA.stages.act2autochess_m01);
// the six trait holders: 歌蕾蒂娅 / 崖心 (钩索师) and 见行者 (推击手), normal and elite
for (const id of ['chess_char_4_12_a', 'chess_char_4_12_b', 'chess_char_2_03_a', 'chess_char_2_03_b', 'chess_char_3_07_a', 'chess_char_3_07_b']) {
assert.equal(positionClass(DATA.chess[id]), 'all', id);
assert.ok(canPlace(map, positionClass(DATA.chess[id]), 10, 4), `${id} on the 高台`);
assert.ok(canPlace(map, positionClass(DATA.chess[id]), 9, 3), `${id} still on the ground`);
assert.equal(basePositionClass(DATA.chess[id]), 'melee', `${id} blocks like a melee unit`);
assert.equal(DATA.chess[id].placement, undefined);
}
assert.ok(!canPlace(map, positionClass(DATA.chess.chess_char_1_02_a), 10, 4), '角峰 (重装) not');
});
test('g.move: legality per chess position, BAD_TILE outside, deploy cap 8, swaps allowed at cap', () => {
const { h, m, ps } = prepMatch();
const meleeId = chessOfTier(1, MELEE).find((id) => m.pool.has(id));
const rangedId = chessOfTier(1, RANGED).find((id) => m.pool.has(id));
const a = give(m, ps, meleeId);
const b = give(m, ps, rangedId);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: 10, col: 4 } }), { error: ERR.BAD_TILE }, 'melee on high ground');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: b.uid, to: { area: 'board', row: 10, col: 4 } }), { ok: true }, 'ranged on high ground');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: 9, col: 6 } }), { error: ERR.BAD_TILE }, 'forbidden tile');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: 15, col: 4 } }), { error: ERR.BAD_TILE }, 'outside the board');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: 9, col: 3 } }), { ok: true });
// ranged may use a melee tile; board→board swap keeps legality (melee cannot be swapped onto high ground)
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: b.uid, to: { area: 'board', row: 9, col: 3 } }), { error: ERR.BAD_TILE }, 'the melee occupant cannot go to 10,4');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: b.uid, to: { area: 'board', row: 9, col: 4 } }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: 9, col: 4 } }), { ok: true }, 'melee/ranged swap on melee tiles');
assert.equal(ps.board.get('9,4').uid, a.uid);
assert.equal(ps.board.get('9,3').uid, b.uid);
// fill to the cap (8)
const tiles = legalTiles(ps.deployMap(), 'melee').filter(([r, c]) => !ps.board.has(tileKey(r, c)));
const extra = [];
for (const id of chessOfTier(2, MELEE).filter((x) => m.pool.has(x)).slice(0, 7)) extra.push(give(m, ps, id));
for (let i = 0; i < 6; i++) assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: extra[i].uid, to: { area: 'board', row: tiles[i][0], col: tiles[i][1] } }), { ok: true });
assert.equal(ps.deployCount, 8);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: extra[6].uid, to: { area: 'board', row: tiles[6][0], col: tiles[6][1] } }), { error: ERR.BOARD_FULL });
// swapping a hand chess with a board chess is fine at the cap
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: extra[6].uid, to: { area: 'board', row: tiles[0][0], col: tiles[0][1] } }), { ok: true });
assert.equal(ps.deployCount, 8);
assert.ok(ps.hand.some((p) => p && p.uid === extra[0].uid), 'the swapped-out chess went to the hand');
// the 人事部文档 style cap raise
ps.deployCapMin = 9;
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: extra[0].uid, to: { area: 'board', row: tiles[6][0], col: tiles[6][1] } }), { ok: true });
assert.equal(ps.deployCount, 9);
checkInvariants(m);
m.dispose();
});
test('g.move to the hand: withdraw, full-hand swap with a chess, hand↔hand swaps, items stay in the hand', () => {
const { m, ps } = prepMatch();
const ids = chessOfTier(1).filter((x) => m.pool.has(x));
const onBoard = give(m, ps, ids[0], 'board', [9, 3]);
// fill the hand with 9 chess + 1 item
const handChess = [];
for (let i = 1; i <= 9; i++) handChess.push(give(m, ps, ids[i]));
const item = giveItem(m, ps, 'chess_item_1_01_e_a');
assert.equal(ps.hand.filter(Boolean).length, 10);
// hand full: withdraw onto an empty slot impossible → onto a chess = swap (if the chess is legal on the tile)
const target = ps.hand.findIndex((p) => p && p.kind === 'chess' && m.gd.chess(p.id).position === 'MELEE');
if (target >= 0) {
const other = ps.hand[target];
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: onBoard.uid, to: { area: 'hand', idx: target } }), { ok: true });
assert.equal(ps.hand[target].uid, onBoard.uid);
assert.equal(ps.board.get('9,3').uid, other.uid);
}
// onto the item slot with a full hand → HAND_FULL
const itemIdx = ps.hand.findIndex((p) => p && p.kind === 'item');
const boardPiece = ps.board.get('9,3');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: boardPiece.uid, to: { area: 'hand', idx: itemIdx } }), { error: ERR.HAND_FULL });
// items cannot be placed on the board but can move inside the hand
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: item.uid, to: { area: 'board', row: 9, col: 4 } }).error, ERR.BAD_TARGET);
const other = handChess[0];
const oi = ps.hand.findIndex((p) => p && p.uid === other.uid);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: item.uid, to: { area: 'hand', idx: oi } }), { ok: true });
assert.equal(ps.hand[oi].uid, item.uid);
assert.equal(ps.hand[itemIdx].uid, other.uid);
// bad inputs
assert.equal(m.handle('p_0', { t: 'g.move', uid: 999999, to: { area: 'hand', idx: 0 } }).error, ERR.BAD_TARGET);
assert.equal(m.handle('p_0', { t: 'g.move', uid: item.uid, to: { area: 'hand', idx: 10 } }).error, ERR.BAD_TARGET);
checkInvariants(m);
m.dispose();
});
test('placeable summons: owner on board sends a stack to the hand; tokens deploy without a slot; withdrawing removes them', () => {
const { m, ps } = prepMatch('act2autochess_m04');
const ownerId = 'chess_char_3_19_a'; // 伺夜 → 狼群 (DEFAULT, placeable)
assert.equal(DATA.tokens.token_10028_vigil_wolf.displayType, 'DEFAULT');
const owner = give(m, ps, ownerId);
const tile = [9, 3];
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: owner.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
const stack = ps.hand.find((p) => p && p.kind === 'token');
assert.ok(stack, 'token stack sent to the hand');
assert.equal(stack.ownerUid, owner.uid);
assert.equal(stack.count, DATA.tokens.token_10028_vigil_wolf.deployLimit);
const deployedBefore = ps.deployCount;
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: stack.uid, to: { area: 'board', row: 9, col: 4 } }), { ok: true });
assert.equal(ps.deployCount, deployedBefore, 'summons do not use deploy slots');
const tok = ps.board.get('9,4');
assert.equal(tok.kind, 'token');
// the private view carries it with count/ownerUid
const view = ps.privateView();
const tv = view.board.find((p) => p.kind === 'token');
assert.equal(tv.ownerUid, owner.uid);
// back to the hand (re-stacks), then withdraw the owner → tokens gone
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: tok.uid, to: { area: 'hand', idx: 0 } }), { ok: true });
assert.equal(ps.hand.filter((p) => p && p.kind === 'token').length, 1);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: ps.hand.find((p) => p && p.kind === 'token').uid, to: { area: 'board', row: 9, col: 4 } }), { ok: true });
const free = ps.hand.findIndex((x) => x == null);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: owner.uid, to: { area: 'hand', idx: free } }), { ok: true });
assert.ok(![...ps.board.values(), ...ps.hand].some((p) => p && p.kind === 'token'), 'owner withdrawn → its summons removed');
// summons of an owner that is not on the board cannot deploy; tokens are not sellable
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: owner.uid, to: { area: 'board', row: 9, col: 3 } }), { ok: true });
const st2 = ps.hand.find((p) => p && p.kind === 'token');
assert.equal(m.handle('p_0', { t: 'g.sell', uid: st2.uid }).error, ERR.BAD_TARGET);
// selling the owner removes its summons too
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: owner.uid }), { ok: true });
assert.ok(![...ps.board.values(), ...ps.hand].some((p) => p && p.kind === 'token'));
checkInvariants(m);
m.dispose();
});
test('battle input: board units in deploy order with items; tokens carry ownerUid', () => {
const { m, ps } = prepMatch('act2autochess_m04');
const a = give(m, ps, 'chess_char_3_19_a');
m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: 10, col: 5 } });
const b = give(m, ps, chessOfTier(1, MELEE).find((x) => m.pool.has(x)), 'board', [12, 3]);
const it = giveItem(m, ps, 'chess_item_1_01_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: b.uid }), { ok: true });
const tok = ps.hand.find((p) => p && p.kind === 'token');
// (9,6): inside 伺夜's range ("只能部署在召唤者攻击范围内", player report #9 after 0.1.0)
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: tok.uid, to: { area: 'board', row: 9, col: 6 } }), { ok: true });
const input = ps.battleInput();
assert.deepEqual(input.units.map((u) => [u.row, u.col]), [[12, 3], [10, 5], [9, 6]], 'top→bottom then left→right');
assert.deepEqual(input.units[0].items, ['chess_item_1_01_e_a']);
assert.equal(input.units[2].kind, 'token');
assert.equal(input.units[2].ownerUid, a.uid);
assert.equal(input.playerId, 'p_0');
assert.ok(input.bonds && typeof input.bonds === 'object');
m.dispose();
});
test('terrain 机变 card (模拟战场演变·模式二 on 战场 m02): an operator left on a tile it may no longer occupy is withdrawn to the hand (overflow temp); legal pieces stay', () => {
const { m, ps } = prepMatch('act1autochess_m02');
assert.equal(ps.deployMap().get('12,4'), 'melee');
assert.equal(ps.deployMap().get('12,5'), 'melee');
const melees = chessOfTier(1, MELEE).filter((x) => m.pool.has(x));
const ranged = chessOfTier(1, RANGED).find((x) => m.pool.has(x));
const a = give(m, ps, melees[0]);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: 12, col: 4 } }), { ok: true });
const r = give(m, ps, ranged);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: r.uid, to: { area: 'board', row: 12, col: 5 } }), { ok: true });
// a full hand: the withdrawn operator overflows into temp (the player re-places it during the prep)
for (let i = 1; ps.hand.some((p) => p == null); i++) give(m, ps, melees[i % melees.length]);
const card = m.gd.choices.cards.tactic.find((c) => c.effectId === 'map_m02_1');
assert.ok(card, 'data: the m02 terrain card');
applyCard(m, ps, { kind: 'tactic', id: 'map_m02_1', name: card.name, desc: card.desc, team: false, tacticKind: 'terrain' });
assert.equal(ps.deployMap().get('12,4'), 'ranged', 'the barricades became 射击台');
assert.equal(ps.board.get('12,4'), undefined, 'the melee operator left the 射击台');
assert.ok(ps.temp.includes(a), 'hand full → temp');
assert.equal(ps.board.get('12,5'), r, 'a ranged operator stays on the new 射击台');
assert.ok(!ps.battleInput().units.some((u) => u.uid === a.uid));
assert.equal(ps.tempEmpty, false, 'temp blocks Ready until the operator is re-placed');
checkInvariants(m);
m.dispose();
});
test('a terrain change by content (setDeviceActive at the prep end, no recompute) never fields an illegal board', () => {
const { m, ps } = prepMatch('act1autochess_m02');
const a = give(m, ps, chessOfTier(1, MELEE).find((x) => m.pool.has(x)));
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: 12, col: 4 } }), { ok: true });
// what map_m02_1 toggles, applied the way ctx.setDeviceActive does it (invalidateDeployMap + dirty, no recompute)
const eff = m.gd.effect('map_m02_1');
for (const b of eff.buffs) for (const [alias, v] of Object.entries(b.bb || {})) if (alias.includes('#')) { ps.deviceOverrides[alias] = Number(v) !== 0; ps.invalidateDeployMap(); }
const input = ps.battleInput();
assert.ok(!input.units.some((u) => u.uid === a.uid), 'withdrawn before the battle input is built');
assert.ok(ps.hand.includes(a));
checkInvariants(m);
m.dispose();
});
test('withdrawing a deployed summon into a full hand: HAND_FULL like any other card (no overflow into temp)', () => {
const { m, ps } = prepMatch('act2autochess_m04');
// 伺夜 → one 狼群 (deployLimit 1): once deployed no stack is left in the hand to join
const medic = give(m, ps, 'chess_char_3_19_a');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: medic.uid, to: { area: 'board', row: 10, col: 5 } }), { ok: true });
const drone = ps.hand.find((p) => p && p.kind === 'token');
assert.ok(drone && drone.count === 1, 'one 狼群');
// (9,6): inside 伺夜's range ("只能部署在召唤者攻击范围内", player report #9 after 0.1.0)
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'board', row: 9, col: 6 } }), { ok: true });
// golden items never merge with each other: a plain full hand
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(m, ps, 'chess_item_1_02_e_b', 'hand', i);
assert.ok(ps.hand.every(Boolean) && ps.tempEmpty);
assert.equal(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'hand', idx: 0 } }).error, ERR.HAND_FULL, 'a new card for a full hand');
assert.equal(ps.board.get('9,6'), drone, 'the 狼群 stays on the board');
assert.ok(ps.tempEmpty, 'nothing overflowed into temp (Ready stays possible)');
assert.equal(m.handle('p_0', { t: 'g.move', uid: medic.uid, to: { area: 'hand', idx: 0 } }).error, ERR.HAND_FULL, 'the same as withdrawing an operator');
// a free slot: the 狼群 comes back
ps.hand[3] = null;
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'hand', idx: 0 } }), { ok: true });
assert.equal(ps.hand[3], drone);
checkInvariants(m);
m.dispose();
});