Files
ark-stronghold-protocol/test/sim/facing-invariance.test.js
T
sganggs d841d6ac22 feedback5: merge CB3 — community reports (operator kits): 砾's normal attack hits twice at 50 % 伤害倍率 (a 频次 enemy loses 2), 乌尔比安's S3 moves only onto his own board (never behind the one-helper 联防 map's red gate nor onto a boss field's other half), the 「受到攻击时」 counters answer every enemy damage instance (菲莱 S2 on 深溟巢涌者's pulse, 星熊, 泡泡, 刺玫, 余, 玛恩纳, 年, 斩业星熊), an in-place 复活 restarts a deploy-timed skill so a 突袭 member jumps at once, 银灰's normal attack plays its own impact (and 雷蛇 / 初雪 / 圣约送葬人 drop a skill mode's sound); five reports pinned; §25.17 between CB2's §25.16 and CB4's §25.18; data/assets.json lists 银灰's impact (p_imp_spear_n.mp3 fetched); golden: vs feedback5 CB3's own rules — roster-002/004/005/008/010/012/016/024/033/044, bond-indomShip-low/high, bond-kazimierzShip-low/high, bond-lateranoShip-low, boss-boss_2-solo, boss-boss_4-pair/-solo, hidden-boss_8-pair/-solo, hidden-boss_9-solo, diy-051, matches solo-FUNNY-2, coop2-NORMAL-3, coop4-ABYSS-7, coop2-ABYSS-10-boosted; vs CB3 exactly what feedback5 changed since its base (CB2, CB4, CB5)
The merge:
- server/sim/battle/players.js: CB3's onOwnBoard hunk is byte-identical to CB4's (git kept one); its comment and
  docs/SIM.md's onOwnBoard row now name 乌尔比安's S3 【移动】 next to the 突袭 landing (DESIGN §25.17.2 / §25.18).
- test/sim/facing-invariance.test.js: CB3's scenario setup (onOwnBoard off on the open field) and the CB4 merge's
  突袭 test (its enemy inside the board) merged cleanly, both kept.
- data/assets.json: CB3 had regenerated it offline without 银灰's entry (his official impact file was not on disk);
  `node tools/fetch-assets.mjs --add-only` (shared public/assets, add-only) downloaded p_imp_spear_n.mp3 — 8.8 KB,
  the only new file (ok 1, skipped 7968; the 117 misses are the known upstream gaps) — and rewrote the manifest.
  Field-wise JSON compare with CB3's manifest: only audio.sfx.units.char_172_svrash = { hit: p_imp_spear_n } plus hash
  and stats (files / bytes / sfxUnits +1); against feedback5: CB3's four operators (银灰 now the spear impact and no
  S3 swing, 雷蛇 / 初雪 / 圣约送葬人 without the skill-mode impact) plus hash / stats. §25.17.6 says so.
- docs/history/0.2.0.md: §25.17 placed in order, the intro's per-stream list and workstream list name CB3.

Golden moves outside CB3's commit list: coop2-NORMAL-3 fields 砾 from round 3 (her double hit) and 刺玫 in rounds 4-7
(instrumented); solo-FUNNY-1 (in CB3's list through the counters) fields no counter operator on feedback5's path, only
砾, whose two half hits change no kill, leak, LP or end stat there, so its digest stays.

Gate: non-browser suites 5003 / 5003, npm run golden ok, test/golden.test.js 9 / 9, lint 0 errors, check:imports ok,
typecheck ok.
2026-10-07 03:41:48 +08:00

152 lines
9.6 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 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);
}
});