feedback5 WM: merge feedback4 at 13808e3 (W4A) — 高台 for every 「可以放置于远程位」 trait holder, the 5-阿戈尔 revive slots, the 联防 devour of whoever stands in front

Ported by hand into the split layout:
- 83fc408 board.js placeClass: the player's loadout no longer counts, as in 83fc408. feedback5's 补位 rule stays: a
  chess the player fields as its stand-in is placed by the stand-in's record (ps.fieldRecord). positionClass(rec)
  takes no module.
- 83fc408 client piecePosition → public/js/ui/gameLogic/placement.js: meleeOnHighGround(rec) on the stand-in or
  chess record, no module. The gameLogic.js shim header and deployedModuleId's comment follow; deployedModuleId is
  kept, still exported and tested.
- 83fc408 PlayerState._placementOf comment → server/match/player/placement.js.
- 83fc408 tools/golden.mjs layout: positionClass without the module, on feedback5's stand-in body (recOf) and in
  diyLayout.
- bonds/core.js (6ab9a06, 8a2e0c7) applied cleanly; it equals 13808e3's plus feedback5's i18n toast. shared/highGround.js
  equals 13808e3's.
- data/: only bonds.json (the 阿戈尔 spec text). node tools/build-data.mjs --offline on the merged tree rewrites all
  15 files byte-identical.

Golden regenerated: 16 scenarios moved, all by 83fc408. Each fields 歌蕾蒂娅 (normal, or elite with HOK-X / no
module), whom tools/golden.mjs now lays out beside the enemy path like a ranged operator instead of on the busiest
melee tile: 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. This is the list in 83fc408's own message. With the
阿戈尔 commits reverted the digests are the same, so those two move nothing in this corpus. The standins, diy and
matches families are unchanged: no stand-in or 自选 piece there is 歌蕾蒂娅, 崖心 or 见行者.
This commit is contained in:
sganggs committed 2026-10-06 00:06:41 +08:00
commit c5c52eed34
26 files changed
+1095 -871

No files matched your search

+13 -14
View File
@@ -55,27 +55,26 @@ test('glyph rules: melee only on LOW buildable ALL/MELEE; ranged also on HIGH/RA
assert.ok(canPlace(buildDeployMap(null), 'melee', 10, 5));
});
test('positionClass: melee stays ground-only; only elite 歌蕾蒂娅 + HOK-Y is widened (owner 2026-10-04)', () => {
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(basePositionClass({ position: 'MELEE', isGolden: true, charId: 'char_474_glady' }), 'melee');
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 HOK_Y = 'uniequip_003_glady';
const gladE = DATA.chess.chess_char_4_12_b;
const map = buildDeployMap(DATA.stages.act2autochess_m01);
assert.ok(!canPlace(map, positionClass(DATA.chess.chess_char_4_12_a, HOK_Y), 10, 4), 'normal 歌蕾蒂娅, even with the id, not');
assert.ok(!canPlace(map, positionClass(gladE), 10, 4), 'elite with no module id (default is not passed) not');
assert.ok(!canPlace(map, positionClass(gladE, 'uniequip_002_glady'), 10, 4), 'HOK-X not');
assert.ok(!canPlace(map, positionClass(gladE, 'none'), 10, 4), 'no module not');
assert.ok(canPlace(map, positionClass(gladE, HOK_Y), 10, 4), 'elite + HOK-Y on the 高台');
assert.ok(canPlace(map, positionClass(gladE, HOK_Y), 9, 3), 'and still on the ground');
assert.ok(!canPlace(map, positionClass(DATA.chess.chess_char_2_03_b, HOK_Y), 10, 4), '崖心 not');
assert.ok(!canPlace(map, positionClass(DATA.chess.chess_char_3_07_b, HOK_Y), 10, 4), '见行者 not');
// 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');
assert.equal(DATA.chess.chess_char_4_12_a.placement, undefined);
assert.equal(gladE.placement, undefined);
});
test('g.move: legality per chess position, BAD_TILE outside, deploy cap 8, swaps allowed at cap', () => {
+91 -107
View File
@@ -11,13 +11,15 @@
// nowhere to go); moving the owner sends it back anyway (PRTS 卫戍协议/帮助 "移动干员时,其所属召唤物全部退场并重置
// 至手牌区") — with no room it leaves the board until the next round start.
// The sim side (突袭 landing tile, tactical point) is test/sim/feedback1-water.test.js.
// Owner's decision 2026-10-04 reverses DESIGN §22.6: the trait 「可以放置于远程位」 is not trusted. Only elite
// 歌蕾蒂娅 carrying HOK-Y 淡金坠饰 (uniequip_003_glady) may stand on a 高台. The battle keeps position MELEE
// (on a 高台 she attacks but blocks nothing). A bot may plan that one loadout on a 高台; everyone else stays on the ground.
// 高台: the owner's decision of 2026-10-05 (reversing the one of 2026-10-04, elite 歌蕾蒂娅 with HOK-Y only) follows
// PRTS — every MELEE chess whose trait reads 「可以放置于远程位」 (the 钩索师 / 推击手 branch trait: 歌蕾蒂娅, 崖心, 见行者,
// normal and elite) may stand on a 高台, with any module or none; raised with the PRTS source in PR #69 by
// @sunstricken. The battle keeps position MELEE (on a 高台 they attack but block nothing). The bots plan them on a 高台
// that covers the enemy road; every other melee unit stays on the ground.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildDeployMap, canPlace as boardCanPlace, legalTiles, positionClass, placeClass, tileKey, ownerRangeKeys } from '../../server/match/board.js';
import { GLADIIA_HOK_Y } from '../../shared/highGround.js';
import { PLACE_ON_RANGED, meleeOnHighGround } from '../../shared/highGround.js';
import { ERR } from '../../shared/constants.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier } from './harness.js';
import { botPrep, planLayout, fieldModel } from '../../server/match/bot.js';
@@ -305,75 +307,67 @@ test('#9 bots place 狼群 / 流形 only inside their owner\'s range', () => {
});
// ---------------------------------------------------------------------------------------------------------------------
// 高台: only elite 歌蕾蒂娅 + HOK-Y (owner's decision 2026-10-04)
// 高台: every melee chess whose trait reads 「可以放置于远程位」, any module (the owner's decision of 2026-10-05)
const HIGH_STAGE = 'act2autochess_m01';
const HIGH = ['10,4', '11,4', '12,4']; // 战场#01(下半)'s ranged-only (高台) tiles
const GLAD_N = 'chess_char_4_12_a';
const GLAD_E = 'chess_char_4_12_b'; // the golden record
const CLIFF_N = 'chess_char_2_03_a';
const CLIFF_E = 'chess_char_2_03_b';
const FORCER_N = 'chess_char_3_07_a';
const FORCER_E = 'chess_char_3_07_b';
const HOLDERS = [GLAD_N, GLAD_E, CLIFF_N, CLIFF_E, FORCER_N, FORCER_E];
const HOK_X = 'uniequip_002_glady';
const HOK_Y = 'uniequip_003_glady';
const TANK = (c) => c.profession === 'TANK' && c.position === 'MELEE';
const rc = (k) => k.split(',').map(Number);
const toHand = (m, ps, p) => move(m, p.uid, { area: 'hand', idx: ps.hand.findIndex((x) => x == null) });
const equip = (ps, module) => {
assert.equal(ps.setLoadout({ [GLAD_N]: { module } }), true, module);
};
/**
* The loadouts each holder is tried with: its default (null), no module, and every module it has. 崖心 and 见行者 are
* hidden this season (never in the pool): a player cannot set their loadout (shared/protocol.js checkLoadout), so they
* are tried with their defaults (elite: HOK-X / PUS-X).
*/
const loadoutsOf = (id) => (DATA.chess[DATA.chess[id].baseId].isHidden ? [null]
: [null, 'none', ...(DATA.chess[DATA.chess[id].baseId].goldenId ? DATA.chess[DATA.chess[DATA.chess[id].baseId].goldenId].modules : []).map((x) => x.uniEquipId)]);
test('高台 data: no chess stores placement; HOK-Y is 淡金坠饰 on the elite 歌蕾蒂娅 only', () => {
test('高台 data: no chess stores placement; 「可以放置于远程位」 is the trait of exactly the six 钩索师 / 推击手 records, and every module keeps it', () => {
assert.equal(Object.values(DATA.chess).some((c) => c.placement !== undefined), false);
for (const t of Object.values(DATA.tokens)) assert.equal(t.placement, undefined, t.tokenId);
const e = DATA.chess[GLAD_E];
const y = e.modules.find((m) => m.uniEquipId === GLADIIA_HOK_Y);
assert.equal(e.charId, 'char_474_glady');
assert.equal(e.isGolden, true);
assert.equal(y.typeName, 'HOK-Y');
assert.equal(y.name, '淡金坠饰');
assert.equal(e.modules.find((m) => m.isDefault).uniEquipId, HOK_X, 'the default module is HOK-X, ground-only');
assert.equal(DATA.chess[GLAD_N].isGolden, false);
assert.ok(!DATA.chess[CLIFF_E].modules.some((m) => m.uniEquipId === GLADIIA_HOK_Y), '崖心 has no HOK-Y');
assert.equal(DATA.chess[GLAD_N].position, 'MELEE');
assert.equal(e.position, 'MELEE');
const widened = Object.values(DATA.chess).filter((c) => meleeOnHighGround(c)).map((c) => c.chessId).sort();
assert.deepEqual(widened, HOLDERS.slice().sort());
for (const id of HOLDERS) {
const c = DATA.chess[id];
assert.equal(c.position, 'MELEE', id);
assert.ok(['钩索师', '推击手'].includes(c.subProfessionName), id);
for (const x of c.modules || []) assert.ok(x.traitOverride.desc.includes(PLACE_ON_RANGED), `${id} ${x.typeName} keeps the line`);
}
// the module text never adds the line to anybody else (教官 Y "可以额外部署在远程位" is a talent, not the trait)
for (const c of Object.values(DATA.chess)) {
if (HOLDERS.includes(c.chessId)) continue;
for (const x of c.modules || []) assert.ok(!(x.traitOverride?.desc || '').includes(PLACE_ON_RANGED), `${c.chessId} ${x.typeName}`);
}
assert.equal(DATA.chess[GLAD_E].modules.find((x) => x.uniEquipId === HOK_Y).typeName, 'HOK-Y');
});
test('高台 table: (operator, module, tile) → allowed only for elite 歌蕾蒂娅 + HOK-Y on a 高台; ranged unchanged', () => {
test('高台 table: every trait holder, normal and elite, with its default, no module or any module, may take each 高台; a 重装 is refused; ranged unchanged', () => {
const { m, ps } = prep(HIGH_STAGE);
assert.deepEqual([...ps.deployMap()].filter(([, cls]) => cls === 'ranged').map(([k]) => k).sort(), HIGH);
// rows: chess id, module set on the 歌蕾蒂娅 loadout (null = leave the default), expect 高台
const rows = [
[GLAD_E, GLADIIA_HOK_Y, true],
[GLAD_E, HOK_X, false],
[GLAD_E, 'none', false],
[GLAD_N, GLADIIA_HOK_Y, false],
[GLAD_N, null, false],
[CLIFF_E, GLADIIA_HOK_Y, false],
[FORCER_N, null, false],
[FORCER_E, null, false],
];
const owned = new Map();
for (const [id, module, allowHigh] of rows) {
if (module) equip(ps, module);
else ps.setLoadout({});
let p = owned.get(id);
if (!p) { p = give(m, ps, id); owned.set(id, p); }
else if (ps.find(p.uid)?.area === 'board') assert.deepEqual(toHand(m, ps, p), { ok: true });
for (const k of HIGH) {
const [r, c] = rc(k);
const res = move(m, p.uid, board(r, c), 'DOWN');
if (allowHigh) {
assert.deepEqual(res, { ok: true }, `${id} ${module} → ${k}`);
for (const id of HOLDERS) {
const p = give(m, ps, id);
for (const module of loadoutsOf(id)) {
assert.equal(ps.setLoadout(module ? { [DATA.chess[id].baseId]: { module } } : {}), true, `${id} ${module}`);
for (const k of HIGH) {
const [r, c] = rc(k);
assert.deepEqual(move(m, p.uid, board(r, c), 'DOWN'), { ok: true }, `${id} ${module || 'default'} → ${k}`);
assert.equal(ps.board.get(k), p);
checkInvariants(m);
assert.deepEqual(toHand(m, ps, p), { ok: true });
} else {
assert.equal(res.error, ERR.BAD_TILE, `${id} ${module || 'default'} → ${k}`);
}
assert.deepEqual(move(m, p.uid, board(9, 3)), { ok: true }, `${id}: the ground stays legal`);
assert.equal(placeClass(ps, DATA.chess[id]), 'all', id);
assert.deepEqual(toHand(m, ps, p), { ok: true });
}
assert.deepEqual(move(m, p.uid, board(9, 3)), { ok: true }, `${id}: the ground stays legal`);
assert.equal(placeClass(ps, DATA.chess[id]) === 'all', allowHigh, id);
assert.deepEqual(toHand(m, ps, p), { ok: true });
ps.setLoadout({});
}
const tank = give(m, ps, chessOfTier(1, TANK).find((x) => m.pool.has(x)));
@@ -390,43 +384,41 @@ test('高台 table: (operator, module, tile) → allowed only for elite 歌蕾
m.dispose();
});
test('高台 client: drag highlights follow the loadout; a 重装 keeps "近战单位只能部署在地面"', () => {
test('高台 client: drag highlights = the server for every trait holder under any module; a 重装 keeps "近战单位只能部署在地面"', () => {
const { m, ps } = prep(HIGH_STAGE);
equip(ps, GLADIIA_HOK_Y);
const glad = give(m, ps, GLAD_E);
const normal = give(m, ps, GLAD_N);
const cliff = give(m, ps, CLIFF_E);
const holders = HOLDERS.map((id) => give(m, ps, id));
const tank = give(m, ps, chessOfTier(1, TANK).find((x) => m.pool.has(x)));
const ranged = give(m, ps, chessOfTier(1, RANGED).find((x) => m.pool.has(x)));
const ctx = clientCtx(m, ps);
const map = ps.deployMap();
for (const p of [glad, normal, cliff, tank, ranged]) {
const lit = boardTargets(ctx, p.uid).legal.map(([r, c]) => tileKey(r, c)).sort();
assert.deepEqual(lit, legalTiles(map, placeClass(ps, DATA.chess[p.id])).map(([r, c]) => tileKey(r, c)).sort(), `${p.id}: highlights = the server`);
}
for (const k of HIGH) {
const [r, c] = rc(k);
assert.equal(clientCanPlace(ctx, glad.uid, board(r, c)).ok, true, k);
for (const p of [normal, cliff, tank]) {
const res = clientCanPlace(ctx, p.uid, board(r, c));
assert.equal(res.ok, false, p.id);
for (const loadout of [{}, { [GLAD_N]: { module: HOK_Y } }, { [GLAD_N]: { module: HOK_X } }, { [GLAD_N]: { module: 'none' } }]) {
assert.equal(ps.setLoadout(loadout), true);
const ctx = clientCtx(m, ps);
for (const p of [...holders, tank, ranged]) {
const lit = boardTargets(ctx, p.uid).legal.map(([r, c]) => tileKey(r, c)).sort();
assert.deepEqual(lit, legalTiles(map, placeClass(ps, DATA.chess[p.id])).map(([r, c]) => tileKey(r, c)).sort(), `${p.id}: highlights = the server`);
}
for (const k of HIGH) {
const [r, c] = rc(k);
for (const p of holders) assert.equal(clientCanPlace(ctx, p.uid, board(r, c)).ok, true, `${p.id} → ${k}`);
const res = clientCanPlace(ctx, tank.uid, board(r, c));
assert.equal(res.ok, false);
assert.equal(res.reason, '近战单位只能部署在地面');
assert.equal(clientCanPlace(ctx, ranged.uid, board(r, c)).ok, true);
}
assert.equal(clientCanPlace(ctx, ranged.uid, board(r, c)).ok, true);
}
m.dispose();
});
test('高台 swaps: elite 歌蕾蒂娅 + HOK-Y trades with a ranged operator, not with a 重装', () => {
test('高台 swaps: 歌蕾蒂娅 (normal, no module) trades with a ranged operator, not with a 重装', () => {
const { m, ps } = prep(HIGH_STAGE);
equip(ps, GLADIIA_HOK_Y);
const glad = give(m, ps, GLAD_E);
const glad = give(m, ps, GLAD_N);
const tank = give(m, ps, chessOfTier(1, TANK).find((x) => m.pool.has(x)));
const ranged = give(m, ps, chessOfTier(1, RANGED).find((x) => m.pool.has(x)));
assert.deepEqual(move(m, glad.uid, board(10, 4), 'DOWN'), { ok: true });
assert.deepEqual(move(m, tank.uid, board(9, 3)), { ok: true });
assert.deepEqual(move(m, ranged.uid, board(11, 4)), { ok: true });
let ctx = clientCtx(m, ps);
// swapping 歌蕾蒂娅 (高台) with the 重装 (ground) would put the 重装 on the 高台
assert.equal(clientCanPlace(ctx, glad.uid, board(9, 3)).ok, false);
assert.equal(move(m, glad.uid, board(9, 3)).error, ERR.BAD_TILE);
assert.equal(clientCanPlace(ctx, tank.uid, board(10, 4)).ok, false);
@@ -444,16 +436,14 @@ test('高台 swaps: elite 歌蕾蒂娅 + HOK-Y trades with a ranged operator, no
m.dispose();
});
test('高台 battle input: elite 歌蕾蒂娅 + HOK-Y is fielded on (10,4) and blocks nothing there; on the ground she blocks', () => {
test('高台 battle input: 歌蕾蒂娅 is fielded on (10,4) and blocks nothing there; on the ground she blocks', () => {
const { m, ps } = prep(HIGH_STAGE);
equip(ps, GLADIIA_HOK_Y);
const glad = give(m, ps, GLAD_E);
const glad = give(m, ps, GLAD_N);
assert.deepEqual(move(m, glad.uid, board(10, 4), 'DOWN'), { ok: true });
const u0 = ps.battleInput().units.find((u) => u.uid === glad.uid);
assert.deepEqual([u0.row, u0.col, u0.dir, u0.moduleId], [10, 4, 'DOWN', GLADIIA_HOK_Y]);
assert.deepEqual([u0.row, u0.col, u0.dir], [10, 4, 'DOWN']);
m.dispose();
// position stays MELEE either way: the normal record (the same body the 0.1.2 measurement used) attacks the lane
// from the 高台 and blocks nothing there. HOK-Y's own range is not what this asserts.
// position stays MELEE either way: she attacks the lane from the 高台 and blocks nothing there
for (const [r, c, dir, high] of [[10, 4, 'DOWN', true], [9, 5, 'RIGHT', false]]) {
const h = makeBattle({
stageId: HIGH_STAGE, seed: 3, timeLimit: 60, autoFinish: false,
@@ -473,42 +463,36 @@ test('高台 battle input: elite 歌蕾蒂娅 + HOK-Y is fielded on (10,4) and b
}
});
test('高台 bots: elite 歌蕾蒂娅 + HOK-Y takes a 高台 that covers the road while the ground is still free', () => {
test('高台 bots: every trait holder, normal and elite, takes a 高台 that covers the road while the ground is still free; a plain melee stays on the road', () => {
const { m, ps } = prep(HIGH_STAGE, 11);
equip(ps, GLADIIA_HOK_Y);
const elite = give(m, ps, GLAD_E);
const k = planLayout(m, ps, [elite]).get(elite.uid);
assert.equal(ps.deployMap().get(k), 'ranged', `planned on a 高台 with the road open (${k})`);
const [r, c] = rc(k);
assert.deepEqual(move(m, elite.uid, board(r, c)), { ok: true });
m.dispose();
});
test('高台 bots: everyone else stays on the road; elite 歌蕾蒂娅 + HOK-Y may take a 高台 when the ground is full', () => {
const { m, ps } = prep(HIGH_STAGE, 11);
const pieces = ['chess_char_4_02_a', GLAD_N, FORCER_N, 'chess_char_2_03_a'].map((id) => give(m, ps, id));
const plan = planLayout(m, ps, pieces);
const map = ps.deployMap();
const road = fieldModel(m, ps).ground;
for (const p of pieces.slice(1)) {
const k = plan.get(p.uid);
assert.equal(map.get(k), 'melee', `${p.id} on a ground tile (${k})`);
assert.ok(road.has(k), `${p.id} on an enemy road tile (${k})`);
assert.equal(move(m, p.uid, board(10, 4)).error, ERR.BAD_TILE, `${p.id} deploy refused`);
for (const id of HOLDERS) {
const p = give(m, ps, id);
for (const module of loadoutsOf(id)) {
assert.equal(ps.setLoadout(module ? { [DATA.chess[id].baseId]: { module } } : {}), true);
const plan = planLayout(m, ps, [p]);
const k = plan.get(p.uid);
assert.equal(map.get(k), 'ranged', `${id} ${module || 'default'}: planned on a 高台 with the road open (${k})`);
const [r, c] = rc(k);
const cover = ownerRangeKeys(DATA.chess[id].rangeGrid, r, c, plan.dirOf(p.uid));
assert.ok([...cover].some((t) => road.has(t)), `${id}: its range from ${k} covers the road`);
assert.deepEqual(move(m, p.uid, board(r, c), plan.dirOf(p.uid)), { ok: true });
assert.deepEqual(toHand(m, ps, p), { ok: true });
}
}
const elite = give(m, ps, GLAD_E);
equip(ps, GLADIIA_HOK_Y);
const melee = [...map].filter(([, cls]) => cls === 'melee').map(([k]) => k);
const highPlan = planLayout(m, ps, [elite], undefined, { occupied: new Set(melee) });
const hk = highPlan.get(elite.uid);
assert.equal(map.get(hk), 'ranged', `HOK-Y planned on a 高台 (${hk})`);
const [hr, hc] = rc(hk);
assert.deepEqual(move(m, elite.uid, board(hr, hc)), { ok: true });
ps.setLoadout({});
// a plain melee operator (a 重装) stays on an enemy road tile, and a 高台 is no fallback for it once the ground is full
const tank = give(m, ps, chessOfTier(1, TANK).find((x) => m.pool.has(x)));
const k = planLayout(m, ps, [tank]).get(tank.uid);
assert.equal(map.get(k), 'melee', `the 重装 on a ground tile (${k})`);
assert.ok(road.has(k), `the 重装 on an enemy road tile (${k})`);
assert.equal(move(m, tank.uid, board(10, 4)).error, ERR.BAD_TILE, 'the 重装 deploy refused');
const melee = new Set([...map].filter(([, cls]) => cls === 'melee').map(([key]) => key));
assert.equal(planLayout(m, ps, [tank], undefined, { occupied: melee }).get(tank.uid), undefined, 'no ground left: no plan');
const glad = give(m, ps, GLAD_E);
const hk = planLayout(m, ps, [glad], undefined, { occupied: melee }).get(glad.uid);
assert.equal(map.get(hk), 'ranged', `elite 歌蕾蒂娅 still gets a 高台 (${hk})`);
checkInvariants(m);
assert.deepEqual(toHand(m, ps, elite), { ok: true });
equip(ps, 'none');
const refused = planLayout(m, ps, [elite], undefined, { occupied: new Set(melee) });
assert.equal(refused.get(elite.uid), undefined, 'no module: a 高台 is not a fallback');
assert.equal(move(m, elite.uid, board(10, 4)).error, ERR.BAD_TILE);
m.dispose();
});