Files
ark-stronghold-protocol/test/sim/facing-invariance.test.js
T
sganggs cfd3c67281 feedback6 R21A: a 突袭 member may land on a teammate's half when both halves of the field are taken — the two-helper 联防 field and the Final Assault / Hidden Core pair field (the owner's decision of 2026-10-07): Battle.onFieldBoard (onOwnBoard of any player of the battle) read by raidTile only; a lone 联防 helper's empty half, a solo boss field's other half and the hand / 临时整备区 rows stay closed, every other landing rule kept, 乌尔比安's S3 【移动】 keeps onOwnBoard; golden: 8 moved, each parting from the parent at a member's first landing on its teammate's half (checked tick by tick with the parent instrumented) — fields boss-boss_2 / 3 / 4 / 5 / 6-pair and hidden-boss_8 / 10-pair (the right player's 缄默德克萨斯 / 伊内丝 / 仇白 / 耀骑士临光 on the left player's half, 1–7 landings per battle, the first 0.3–39.8 s in), matches coop4-ABYSS-7 (round 6's two-helper 联防: ai_2's 斯卡蒂 on ai_1's half at 52 s; only ai_1 / ai_2 dmgDealt move, 8263 between them); unite-2 unchanged (its two landings stay on their own half)
Tests: test/sim/feedback5-raid-own-board.test.js — the pair field (the right player's member onto the left telin's board tile (2,3), the left player's onto the mirrored right (2,17), the hand rows closed) and the two-helper 联防 (each helper onto the other's half; 乌尔比安 beside a teammate stays on his own) fail on the parent; the solo boss field's hand rows and the lone helper unchanged.
Docs: docs/history/0.2.1.md (new, §26; §26.1 cites the owner's decision and PRTS 卫戍协议/帮助 §联防阶段), the DESIGN index row and versions line, a pointer from §25.18.1, docs/SIM.md (onFieldBoard; onOwnBoard now 乌尔比安's only).
2026-10-07 17:09:39 +08:00

153 lines
9.7 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)); });
// the open field has no boards: "a tile of the player's own board" (Battle.onOwnBoard — 乌尔比安 S3's landing) is a
// board-position rule (DESIGN), not a facing one — every tile counts here, so the anchor's landing turns with him
b.onOwnBoard = () => true;
},
});
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 on a board of its field since
// 0.2.0 (Battle.onFieldBoard — on a one-player field its own; DESIGN §25.18, §26.1), 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);
}
});