Files
ark-stronghold-protocol/test/sim/facing-invariance.test.js
T
sganggs 6ed3aed335 feedback5: merge CB4 — community reports (enemies / bosses): a 突袭 member lands only on its own player's board (never a boss field's hand / 临时整备区 rows nor the other half of a 联防 or boss field), a silenced 萨卡兹枯朽战士 leaves no 污染秽蚀, 萨卡兹穿刺手's acceleration ends with a stun or 束缚 and its first hit after a block adds move speed × 600 as its own instance, 自制投石机 ignores its block when it picks a target (no attack on the 排气格栅's hidden blocker), the briefing types' 7–9 waves, 新硎's 断刃, a freed prisoner in 联防, 鸭爵's 0–2 swaps and the moving leaders' routes pinned, four reports not reproduced; §25.18; golden: vs feedback5 CB4's own rules — roster-016, boss-boss_2/3/4/5/6-pair, hidden-boss_8/10-pair, standin-04/06/08, diy-028, diy-124, matches solo-NORMAL-1, solo-HARD-1, solo-ABYSS-1, coop2-HARD-4, coop4-ABYSS-7, coop2-NORMAL-8-serverrun; vs CB4 exactly what feedback5 changed since its base (CB2's roster 13 / bonds 7 / fields 6, CB5's 18 bot matches)
Merge fix: test/sim/facing-invariance.test.js — the 突袭 rotation check put its enemy on the board's top row (9, 9),
so the facing-RIGHT landing tile it expected, (8, 8), lies above the player's board, where CB4's onOwnBoard rule (the
official 再部署 goes where the player deploys; community reports 40 / 16.3) now refuses it; the member landed on
(10, 8). The enemy stands at (10, 6), inside rows 9–12 / cols 2–10, so all four diagonal tiles are candidates in every
direction and the test checks the facing tie-break as before. It failed on fb5-cb4 itself (the stream did not run the
file). The §25 intro's stream lines joined into one sentence again.

Golden moves outside CB4's commit list are its rules on feedback5's paths (instrumented): solo-HARD-1 and solo-ABYSS-1
now have silenced 枯朽战士 deaths (8 / 15) and 突袭 candidates the own-board filter refuses; coop2-ABYSS-10-boosted,
coop2-NORMAL-3 and coop2-NORMAL-14-standins (in CB4's list) have no silenced 枯朽战士 death on this path, and their
digests are identical with the own-board filter switched off (the refused tiles were never the chosen ones).

Gate: non-browser suites 4968 / 4968, npm run golden ok, test/golden.test.js 9 / 9, lint 0 errors, check:imports ok,
typecheck ok, docs-consistency + docs-paths 31 / 31.
2026-10-07 03:21:57 +08:00

147 lines
9.3 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.
// Rotation invariance of the 4-direction facing (DESIGN §3 "Facing (corrected)", research 09 §1.2): on an open field an
// operator facing d, with every enemy / helper placed at the same offsets turned by d, must play exactly like the
// RIGHT-facing original — same damage on the same facing-RIGHT offsets, same pushes / pulls in its own frame, summons
// on the same local tiles. Catches any front / side / "first tile" logic that still assumes RIGHT (tie-breaks by the
// absolute tile key, walls along the column, …). Legit exceptions are board-position rules (DESIGN: deployment order
// top→bottom then left→right, "最右边" / "更靠左" orders), which the scenario avoids or lists explicitly.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { rotateOffset, toLocal, localOrder, localBefore } from '../../server/sim/dir.js';
const CHESS = JSON.parse(readFileSync(new URL('../../data/chess.json', import.meta.url), 'utf8'));
const LIST = Array.isArray(CHESS) ? CHESS : Object.values(CHESS);
// an all-road 19×21 field (no gates / paths near the unit: tactical points and summons choose by geometry only)
const ROWS = [];
for (let r = 0; r < 19; r++) ROWS.push('#' + 'r'.repeat(19) + '#');
const OPEN = { id: 'open', name: 'open', rows: ROWS, devices: [] };
const RECT = { r0: 0, r1: 18, c0: 0, c1: 20 };
const FAR_ROUTE = [{ motion: 'FLY', start: [18, 20], end: [18, 19], checkpoints: [] }];
const R0 = 9, C0 = 9;
// enemy offsets in the facing-RIGHT frame (a spread of ranges incl. behind / beside / far ahead); every 4th one flies
const OFFS = [];
for (let dr = -4; dr <= 4; dr++) for (let dc = -4; dc <= 7; dc++) if ((dr || dc) && (dr * dr + dc * dc) % 3 !== 1) OFFS.push([dr, dc]);
// injured helpers (distinct max HP and HP ratio: heal / protect choices never fall back to the deployment order)
const HELP = [[1, 1], [0, -1], [-1, 0]];
const helpers = Object.fromEntries(HELP.map((_, i) => [`t_help${i}`, chessRec({ id: `t_help${i}`, profession: 'SNIPER', rangeGrid: [[0, 0]], skill: null, stats: { maxHp: 4000 + 500 * i, atk: 0, blockCnt: 0 } })]));
// 凯瑟琳 hands out her devices front-most operator first by board column — a board position (DESIGN), not a facing
const BOARD_POSITION_RULES = new Set(['chess_char_4_11_a', 'chess_char_4_11_b']);
const walkRoute = (pos) => ({ motion: 'WALK', start: pos, checkpoints: [{ type: 'WAIT', time: 1e4 }], end: pos });
function scenario(chessId, dir, secs) {
const enemies = OFFS.map(([dr, dc], i) => {
const [ar, ac] = rotateOffset(dr, dc, dir);
const pos = [R0 + ar, C0 + ac];
const fly = i % 4 === 1;
return { key: fly ? 'e_fly' : 'e_dummy', pos, route: fly ? { ...walkRoute(pos), motion: 'FLY' } : walkRoute(pos) };
});
const units = [{ chessId, row: R0, col: C0, dir, abs: true }];
HELP.forEach(([dr, dc], i) => { const [ar, ac] = rotateOffset(dr, dc, dir); units.push({ uid: 100 + i, chessId: `t_help${i}`, row: R0 + ar, col: C0 + ac, dir, abs: true }); });
const h = makeBattle({
stage: OPEN, rect: RECT, seed: 5, routes: FAR_ROUTE, units, enemies, autoFinish: false, timeLimit: secs + 5, hooks: [],
defs: {
chess: helpers,
enemies: {
e_dummy: enemyRec({ key: 'e_dummy', hp: 1e6, speed: 0, def: 100, res: 10 }),
e_fly: enemyRec({ key: 'e_fly', hp: 1e6, speed: 0, motion: 'FLY', def: 50, res: 30 }),
},
},
setup: (b) => b.on('deploy', ({ unit }) => { if (unit.uid >= 100) unit.hp = unit.s.maxHp * (0.3 + 0.1 * (unit.uid - 100)); }),
});
h.run(secs);
checkInvariants(h.b);
const local = (u) => toLocal(u.y - R0, u.x - C0, dir).map((v) => Math.round(v * 10) / 10);
return {
enemies: h.b.enemies.slice(0, OFFS.length).map((e) => [Math.round(1e6 - e.hp), ...local(e)]),
helpers: HELP.map((_, i) => Math.round(h.b.allyUnits.find((u) => u.uid === 100 + i).hp)),
tokens: h.b.allyUnits.filter((u) => u.kind === 'token').map((t) => `${t.defId}@${toLocal(t.tileR - R0, t.tileC - C0, dir)}:${t.alive ? 1 : 0}`).sort(),
extra: h.b.enemies.length - OFFS.length,
};
}
const same = (a, b) => Math.abs(a - b) <= Math.max(2, 0.02 * Math.max(Math.abs(a), Math.abs(b)));
test('rotation invariance: every chess (normal + elite) facing UP / LEFT / DOWN plays like its RIGHT-facing original', () => {
const bad = [];
for (const rec of LIST) {
if (BOARD_POSITION_RULES.has(rec.chessId)) continue;
const base = scenario(rec.chessId, 'RIGHT', 20);
for (const d of ['UP', 'LEFT', 'DOWN']) {
const o = scenario(rec.chessId, d, 20);
const diffs = [];
base.enemies.forEach(([dmg, lr, lc], i) => {
const [dmg2, lr2, lc2] = o.enemies[i];
if (!same(dmg, dmg2) || Math.abs(lr - lr2) > 0.15 || Math.abs(lc - lc2) > 0.15) diffs.push(`enemy ${OFFS[i]}: ${dmg}@${lr},${lc} vs ${dmg2}@${lr2},${lc2}`);
});
base.helpers.forEach((hp, i) => { if (!same(hp, o.helpers[i])) diffs.push(`helper ${HELP[i]}: hp ${hp} vs ${o.helpers[i]}`); });
if (base.tokens.join() !== o.tokens.join()) diffs.push(`summons [${base.tokens}] vs [${o.tokens}]`);
if (base.extra !== o.extra) diffs.push(`extra enemies ${base.extra} vs ${o.extra}`);
if (diffs.length) bad.push(`${rec.chessId} ${rec.name} ${d}: ${diffs.slice(0, 3).join(' | ')}`);
}
}
assert.deepEqual(bad, []);
});
test('localOrder / localBefore: the facing-RIGHT frame tie-break equals the tile-key order for RIGHT and turns with the direction', () => {
// RIGHT: (row, col) order = tile-key order
const offs = [[-1, 0], [0, -1], [0, 1], [1, 0], [1, 1], [-1, -1]];
const byKey = offs.slice().sort((a, b) => (a[0] * 21 + a[1]) - (b[0] * 21 + b[1]));
const byLocal = offs.slice().sort((a, b) => (localBefore(localOrder(a[0], a[1], 'RIGHT'), localOrder(b[0], b[1], 'RIGHT')) ? -1 : 1));
assert.deepEqual(byLocal, byKey);
// the first of the 4 neighbours is the right hand (local (−1, 0)) whatever the direction
for (const d of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
const n4 = [[1, 0], [-1, 0], [0, 1], [0, -1]];
let best = null, bo = null;
for (const [r, c] of n4) { const o = localOrder(r, c, d); if (localBefore(o, bo)) { bo = o; best = [r, c]; } }
assert.deepEqual(best, rotateOffset(-1, 0, d), d);
}
});
test('战术家 援军 (伺夜): the tactical point is the tile in FRONT along the direction (not the one beside it)', () => {
for (const dir of ['RIGHT', 'UP', 'DOWN', 'LEFT']) {
const h = makeBattle({ stage: OPEN, rect: RECT, routes: FAR_ROUTE, units: [{ chessId: 'chess_char_3_19_a', row: R0, col: C0, dir, abs: true }], autoFinish: false, timeLimit: 30 });
h.run(2);
const wolf = h.b.allyUnits.find((u) => u.kind === 'token' && u.alive);
assert.ok(wolf, `${dir}: the pack is summoned`);
assert.deepEqual([wolf.tileR - R0, wolf.tileC - C0], rotateOffset(0, 1, dir), `${dir}: in front of 伺夜`);
assert.equal(wolf.dir, dir, 'the summon takes the owner direction');
}
});
test('突袭 (raidShip): the landing tile beside the enemy turns with the member direction (equal candidates)', () => {
// a diagonal-only range: two equally near landing tiles cover the enemy — the tie resolves in the member's frame. The
// enemy stands inside the player's own board (rows 9–12, cols 2–10): a member lands only there since 0.2.0
// (Battle.onOwnBoard, DESIGN §25.18), so all four diagonal tiles around (10, 6) are candidates in every direction
const ER = 10, EC = 6;
const rec = chessRec({ id: 'r_diag', bonds: ['raidShip'], profession: 'WARRIOR', skill: null, rangeGrid: [[1, 1], [-1, 1]] });
const land = (dir) => {
const h = makeBattle({
stage: OPEN, rect: RECT, routes: FAR_ROUTE, defs: { chess: { r_diag: rec }, enemies: { e_d: enemyRec({ key: 'e_d', hp: 1e6, speed: 0 }) } },
bonds: { raidShip: { count: 1, active: true, tier: 1, layers: 0 } },
units: [{ chessId: 'r_diag', row: 15, col: 3, dir, abs: true }],
enemies: [{ key: 'e_d', pos: [ER, EC], route: walkRoute([ER, EC]) }], autoFinish: false, timeLimit: 60,
});
const u = h.unit('r_diag');
assert.ok(h.runUntil(() => u.alive && u.deployed && Math.max(Math.abs(u.tileR - ER), Math.abs(u.tileC - EC)) <= 2, 30), `${dir}: jumped next to the enemy`);
return toLocal(u.tileR - ER, u.tileC - EC, dir);
};
const right = land('RIGHT');
assert.deepEqual(right, [-1, -1], 'RIGHT: the lower of the two diagonal tiles behind the enemy (tile-key order, as before)');
for (const d of ['UP', 'LEFT', 'DOWN']) assert.deepEqual(land(d), right, d);
});
test('findSummonTile: equal candidates resolve in the owner\'s facing-RIGHT frame (RIGHT: the tile-key order, as before)', async () => {
const { findSummonTile } = await import('../../server/sim/content/tokens.js');
const rec = chessRec({ id: 't_own', profession: 'SUPPORT', rangeGrid: [[0, 0]], skill: null });
for (const dir of ['RIGHT', 'UP', 'DOWN', 'LEFT']) {
const h = makeBattle({ stage: OPEN, rect: RECT, routes: FAR_ROUTE, defs: { chess: { t_own: rec } }, units: [{ chessId: 't_own', row: R0, col: C0, dir, abs: true }], autoFinish: false, timeLimit: 10 });
h.step();
const o = h.unit('t_own');
// no enemies: the 4 orthogonal neighbours tie on distance — the owner's right hand (local (−1, 0)) comes first
const [r, c] = findSummonTile(h.b, o, 'adjacent');
assert.deepEqual([r - R0, c - C0], rotateOffset(-1, 0, dir), dir);
}
});