Online co-op browser remake of Arknights' Stronghold Protocol: Alliance (client-simulated combat, official waves/bonds/items/strategies, 3D board, facing wheel, operator loadout, official emotes, deployment scripts).
212 lines
14 KiB
JavaScript
212 lines
14 KiB
JavaScript
// 4-direction facing in the engine (DESIGN §3 "Facing (corrected)", research 09 §1.2 / §6.1): server/sim/dir.js math,
|
||
// rotated range grids (+ rangeExtend before rotating), unit dir from the battle input, the Final Assault right-side
|
||
// mirror (RIGHT ↔ LEFT, UP / DOWN kept), UnitInfo.dir, and every engine-level front / side helper (professions,
|
||
// skill trigger grids, token placement grids, support geometry, kit helpers).
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {
|
||
DIRS, DIR_VEC, normDir, rotateOffset, toLocal, hSign, mirrorDir, oppositeDir, perpendicular, dirFromDelta, frontOf, offsetTile,
|
||
} from '../../server/sim/dir.js';
|
||
import { absoluteRangeKeys } from '../../server/sim/targeting.js';
|
||
import { COLS } from '../../server/sim/constants.js';
|
||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||
import { findSummonTile } from '../../server/sim/content/tokens.js';
|
||
import { frontTile, sideTiles, alliesAround, N4 } from '../../server/sim/content/support/index.js';
|
||
import { freeTileAround } from '../../server/sim/content/kits/tier1.js';
|
||
import { DIRS as PROTOCOL_DIRS } from '../../shared/protocol.js';
|
||
|
||
const K = (r, c) => r * COLS + c;
|
||
const tilesOf = (keys) => keys.map((k) => [Math.floor(k / COLS), k % COLS]).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||
const sorted = (list) => list.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||
const dummy = (key = 'e_d', o = {}) => enemyRec({ key, hp: 1e7, speed: 0, def: 0, res: 0, ...o });
|
||
|
||
test('dir.js: the DATA.md rotation RIGHT (dr,dc) · UP (dc,−dr) · LEFT (−dr,−dc) · DOWN (−dc,dr), inverse and helpers', () => {
|
||
assert.deepEqual([...DIRS], ['UP', 'RIGHT', 'DOWN', 'LEFT']);
|
||
assert.deepEqual([...PROTOCOL_DIRS], [...DIRS], 'shared/protocol.js lists the same directions');
|
||
assert.deepEqual(rotateOffset(2, 3, 'RIGHT'), [2, 3]);
|
||
assert.deepEqual(rotateOffset(2, 3, 'UP'), [3, -2]);
|
||
assert.deepEqual(rotateOffset(2, 3, 'LEFT'), [-2, -3]);
|
||
assert.deepEqual(rotateOffset(2, 3, 'DOWN'), [-3, 2]);
|
||
// the forward cell [0, 1] becomes the direction vector (row 0 = bottom: UP = +row)
|
||
for (const d of DIRS) assert.deepEqual(rotateOffset(0, 1, d), [...DIR_VEC[d]], d);
|
||
for (const d of DIRS) for (const [dr, dc] of [[0, 0], [1, 2], [-3, 1], [2, -2]]) {
|
||
const [ar, ac] = rotateOffset(dr, dc, d);
|
||
assert.deepEqual(toLocal(ar, ac, d), [dr, dc], `toLocal inverts rotateOffset (${d})`);
|
||
}
|
||
assert.equal(normDir('up'), 'UP');
|
||
assert.equal(normDir(-1), 'LEFT');
|
||
assert.equal(normDir(1), 'RIGHT');
|
||
assert.equal(normDir('NORTH'), 'RIGHT');
|
||
assert.equal(normDir(undefined, 'DOWN'), 'DOWN');
|
||
assert.deepEqual(DIRS.map(hSign), [1, 1, 1, -1], 'horizontal sign only for sprite flipping');
|
||
assert.deepEqual(DIRS.map(mirrorDir), ['UP', 'LEFT', 'DOWN', 'RIGHT'], 'FA mirror: RIGHT ↔ LEFT, UP / DOWN kept');
|
||
assert.deepEqual(DIRS.map(oppositeDir), ['DOWN', 'LEFT', 'UP', 'RIGHT']);
|
||
assert.ok(perpendicular('UP', 'LEFT') && !perpendicular('UP', 'DOWN'));
|
||
assert.equal(dirFromDelta(1, 0), 'UP');
|
||
assert.equal(dirFromDelta(0, -2), 'LEFT');
|
||
assert.equal(dirFromDelta(-1, 0), 'DOWN');
|
||
assert.equal(dirFromDelta(0, 0, 'LEFT'), 'LEFT');
|
||
assert.deepEqual(frontOf(10, 5, 'DOWN', 2), [8, 5]);
|
||
assert.deepEqual(frontOf(10, 5, 'UP', -1), [9, 5], 'k < 0: behind');
|
||
assert.deepEqual(offsetTile(10, 5, 1, 0, 'UP'), [10, 4], 'the left hand of an UP unit is the left column');
|
||
});
|
||
|
||
test('absoluteRangeKeys rotates the facing-RIGHT grid; rangeExtend goes along +dCol before rotating', () => {
|
||
const grid = [[0, 1], [1, 2]];
|
||
assert.deepEqual(tilesOf(absoluteRangeKeys(grid, 10, 5, 'RIGHT')), sorted([[10, 6], [11, 7]]));
|
||
assert.deepEqual(tilesOf(absoluteRangeKeys(grid, 10, 5, 'UP')), sorted([[11, 5], [12, 4]]));
|
||
assert.deepEqual(tilesOf(absoluteRangeKeys(grid, 10, 5, 'LEFT')), sorted([[10, 4], [9, 3]]));
|
||
assert.deepEqual(tilesOf(absoluteRangeKeys(grid, 10, 5, 'DOWN')), sorted([[9, 5], [8, 6]]));
|
||
// legacy facing signs still work (−1 = LEFT)
|
||
assert.deepEqual(absoluteRangeKeys(grid, 10, 5, -1), absoluteRangeKeys(grid, 10, 5, 'LEFT'));
|
||
// +1 forward tile per row, then rotated: a 2-tile line facing UP / DOWN becomes 3 tiles along the column
|
||
const line = [[0, 0], [0, 1]];
|
||
assert.deepEqual(tilesOf(absoluteRangeKeys(line, 10, 5, 'UP', 1)), [[10, 5], [11, 5], [12, 5]]);
|
||
assert.deepEqual(tilesOf(absoluteRangeKeys(line, 10, 5, 'DOWN', 1)), [[8, 5], [9, 5], [10, 5]]);
|
||
assert.deepEqual(tilesOf(absoluteRangeKeys(line, 10, 5, 'LEFT', 2)), [[10, 2], [10, 3], [10, 4], [10, 5]]);
|
||
});
|
||
|
||
test('units take `dir` from the battle input (default RIGHT): rotated rangeKeys, derived facing sign, UnitInfo.dir', () => {
|
||
const rec = chessRec({ id: 't_sn', profession: 'SNIPER', rangeGrid: [[0, 0], [0, 1], [0, 2]], skill: null });
|
||
const h = makeBattle({
|
||
defs: { chess: { t_sn: rec, t_sn2: { ...rec, chessId: 't_sn2', baseId: 't_sn2' }, t_sn3: { ...rec, chessId: 't_sn3', baseId: 't_sn3' }, t_sn4: { ...rec, chessId: 't_sn4', baseId: 't_sn4' } } },
|
||
units: [
|
||
{ chessId: 't_sn', row: 10, col: 3, dir: 'UP' }, { chessId: 't_sn2', row: 10, col: 6, dir: 'LEFT' },
|
||
{ chessId: 't_sn3', row: 12, col: 8, dir: 'DOWN' }, { chessId: 't_sn4', row: 9, col: 8 },
|
||
],
|
||
});
|
||
h.step();
|
||
const [up, left, down, def] = ['t_sn', 't_sn2', 't_sn3', 't_sn4'].map((id) => h.unit(id));
|
||
assert.deepEqual([up.dir, left.dir, down.dir, def.dir], ['UP', 'LEFT', 'DOWN', 'RIGHT']);
|
||
assert.deepEqual([up.facing, left.facing, down.facing, def.facing], [1, -1, 1, 1], 'facing = horizontal sign only');
|
||
assert.deepEqual(up.fwd, [1, 0]);
|
||
assert.deepEqual(tilesOf(up.rangeKeys), [[10, 3], [11, 3], [12, 3]]);
|
||
assert.deepEqual(tilesOf(left.rangeKeys), [[10, 4], [10, 5], [10, 6]]);
|
||
assert.deepEqual(tilesOf(down.rangeKeys), [[10, 8], [11, 8], [12, 8]]);
|
||
assert.deepEqual(tilesOf(down.baseRangeKeys), [[10, 8], [11, 8], [12, 8]], 'the initial (trigger) range is rotated too');
|
||
const info = h.b.fieldMeta().units.find((u) => u.defId === 't_sn');
|
||
assert.equal(info.dir, 'UP');
|
||
assert.equal(h.b.fieldMeta().units.find((u) => u.defId === 't_sn2').facing, -1);
|
||
checkInvariants(h.b);
|
||
});
|
||
|
||
test('a unit only hits enemies inside its rotated range (DOWN-facing sniper shoots the row below)', () => {
|
||
const rec = chessRec({ id: 't_sn', profession: 'SNIPER', rangeGrid: [[0, 0], [0, 1]], skill: null, stats: { atk: 100 } });
|
||
const run = (dir) => {
|
||
const h = makeBattle({ defs: { chess: { t_sn: rec }, enemies: { e_d: dummy() } }, units: [{ chessId: 't_sn', row: 11, col: 4, dir }], enemies: [{ key: 'e_d', pos: [10, 4] }], autoFinish: false, timeLimit: 10 });
|
||
h.run(3);
|
||
return h.hooksOf('attack').length;
|
||
};
|
||
assert.ok(run('DOWN') > 0, 'facing DOWN: the enemy below is in range');
|
||
assert.equal(run('RIGHT'), 0, 'facing RIGHT: out of range');
|
||
assert.equal(run('UP'), 0, 'facing UP: out of range');
|
||
});
|
||
|
||
test('Final Assault right side: col c → 20 − c with RIGHT ↔ LEFT, UP / DOWN unchanged', () => {
|
||
const rec = chessRec({ id: 't_a', profession: 'SNIPER', rangeGrid: [[0, 0], [0, 1]], skill: null });
|
||
const defs = { chess: { t_a: rec, t_b: { ...rec, chessId: 't_b', baseId: 't_b' }, t_c: { ...rec, chessId: 't_c', baseId: 't_c' } } };
|
||
const units = [{ uid: 1, kind: 'chess', chessId: 't_a', row: 10, col: 4, dir: 'RIGHT' }, { uid: 2, kind: 'chess', chessId: 't_b', row: 11, col: 5, dir: 'UP' }, { uid: 3, kind: 'chess', chessId: 't_c', row: 12, col: 6, dir: 'LEFT' }];
|
||
const h = makeBattle({ kind: 'boss', defs, players: [
|
||
{ playerId: 'L', seat: 0, side: 'L', colOffset: 0, units: units.map((u) => ({ ...u })) },
|
||
{ playerId: 'R', seat: 1, side: 'R', colOffset: 8, units: units.map((u) => ({ ...u, uid: u.uid + 10 })) },
|
||
] });
|
||
h.step();
|
||
const of = (pid, id) => h.b.allyUnits.find((u) => u.ownerId === pid && u.defId === id);
|
||
assert.deepEqual([of('L', 't_a').tileR, of('L', 't_a').tileC, of('L', 't_a').dir], [3, 4, 'RIGHT']);
|
||
assert.deepEqual([of('R', 't_a').tileR, of('R', 't_a').tileC, of('R', 't_a').dir], [3, 16, 'LEFT']);
|
||
assert.deepEqual([of('R', 't_b').tileC, of('R', 't_b').dir], [15, 'UP'], 'UP stays UP');
|
||
assert.deepEqual([of('R', 't_c').tileC, of('R', 't_c').dir], [14, 'RIGHT'], 'LEFT becomes RIGHT');
|
||
assert.deepEqual(tilesOf(of('R', 't_a').rangeKeys), [[3, 15], [3, 16]], 'the mirrored range points at the centre');
|
||
assert.equal(h.b.getPlayer('R').dir, 'LEFT', 'default direction of the mirrored side');
|
||
assert.equal(h.b.mapDir(h.b.getPlayer('R'), 'DOWN'), 'DOWN');
|
||
assert.equal(h.b.mapDir(h.b.getPlayer('R'), 'RIGHT', true), 'RIGHT', 'field coordinates are taken as they are');
|
||
checkInvariants(h.b);
|
||
});
|
||
|
||
test('professions: 领主 (lord) full damage only on its own tile and the tile in FRONT along its direction', () => {
|
||
const rec = chessRec({ id: 't_lord', profession: 'WARRIOR', subProfessionId: 'lord', rangeGrid: [[1, 0], [0, 0], [0, 1], [0, 2], [-1, 0]], skill: null });
|
||
const h = makeBattle({ defs: { chess: { t_lord: rec } }, units: [{ chessId: 't_lord', row: 10, col: 4, dir: 'UP' }] });
|
||
h.step();
|
||
const u = h.unit('t_lord');
|
||
const at = (r, c) => u.profile.dmgMul(h.b, u, { x: c, y: r, blockedBy: null });
|
||
assert.equal(at(11, 4), 1, 'the tile in front (above) of an UP-facing lord');
|
||
assert.equal(at(10, 4), 1, 'its own tile');
|
||
assert.ok(at(10, 5) < 1, 'the tile to its right is not in front');
|
||
assert.ok(at(12, 4) < 1, 'two tiles ahead: ranged');
|
||
});
|
||
|
||
test('professions: 行刑者 (reaperrange) front bonus follows the direction (own line, at or ahead)', () => {
|
||
const rec = chessRec({ id: 't_rr', profession: 'SNIPER', subProfessionId: 'reaperrange', rangeGrid: [[0, 0], [0, 1], [0, 2]], skill: null });
|
||
const h = makeBattle({ defs: { chess: { t_rr: rec } }, units: [{ chessId: 't_rr', row: 11, col: 4, dir: 'DOWN' }] });
|
||
h.step();
|
||
const u = h.unit('t_rr');
|
||
u.profile = { ...u.profile, frontGrid: null };
|
||
const at = (r, c) => u.profile.dmgMul(h.b, u, { x: c, y: r });
|
||
assert.ok(at(10, 4) > 1, 'below a DOWN-facing unit: its front line');
|
||
assert.equal(at(12, 4), 1, 'behind it: no bonus');
|
||
assert.equal(at(11, 5), 1, 'beside it: no bonus');
|
||
});
|
||
|
||
test('skill CUSTOM_RANGE trigger grids are rotated with the unit', () => {
|
||
const rec = chessRec({
|
||
id: 't_trig', profession: 'SNIPER', rangeGrid: [[0, 0]],
|
||
skill: { kind: 'instant', spCost: 1, initSp: 1, durationType: 'NONE', trigger: { rule: 'CUSTOM_RANGE', grid: [[0, 1], [0, 2]] } },
|
||
});
|
||
const run = (dir) => {
|
||
const h = makeBattle({ defs: { chess: { t_trig: rec }, enemies: { e_d: dummy() } }, units: [{ chessId: 't_trig', row: 9, col: 4, dir }], enemies: [{ key: 'e_d', pos: [11, 4] }], autoFinish: false, timeLimit: 5 });
|
||
h.run(2);
|
||
return h.hooksOf('skillStart').length;
|
||
};
|
||
assert.ok(run('UP') > 0, 'UP: the enemy two tiles above triggers the skill');
|
||
assert.equal(run('RIGHT'), 0, 'RIGHT: nothing in the trigger grid');
|
||
});
|
||
|
||
test('token placement grids (findSummonTile) and support geometry follow the owner direction', () => {
|
||
const rec = chessRec({ id: 't_o', profession: 'SUPPORT', rangeGrid: [[0, 0]], skill: null });
|
||
const h = makeBattle({ defs: { chess: { t_o: rec, t_n: { ...rec, chessId: 't_n', baseId: 't_n' }, t_s: { ...rec, chessId: 't_s', baseId: 't_s' } } },
|
||
units: [{ chessId: 't_o', row: 10, col: 4, dir: 'UP' }, { chessId: 't_n', row: 11, col: 4 }, { chessId: 't_s', row: 10, col: 3 }] });
|
||
h.step();
|
||
const o = h.unit('t_o');
|
||
assert.deepEqual(findSummonTile(h.b, o, 'near', { grid: [[0, 2]], at: { x: 4, y: 12 } }), [12, 4], 'grid offset [0,2] = two tiles above');
|
||
assert.deepEqual(frontTile(o), [11, 4]);
|
||
assert.deepEqual(frontTile(o, -1), [9, 4]);
|
||
assert.deepEqual(sideTiles(o), [[10, 3], [10, 5]], 'left hand, right hand');
|
||
assert.deepEqual(alliesAround(h.b, o, [[0, 1]]).map((a) => a.defId), ['t_n'], 'relative [0,1] = in front');
|
||
assert.deepEqual(alliesAround(h.b, o, [[1, 0]]).map((a) => a.defId), ['t_s'], 'relative [1,0] = the left hand');
|
||
assert.equal(alliesAround(h.b, o, N4).length, 2);
|
||
// tier1 freeTileAround: front first
|
||
assert.deepEqual(freeTileAround(h.b, o, (r, c) => !(r === 11 && c === 4 && h.b.unitAt(r, c))), [11, 3], 'front first (occupied by t_n)');
|
||
assert.deepEqual(freeTileAround(h.b, o, (r, c) => !(r === 11 && c === 4)), [11, 3], 'then front-left (its left hand is the lower column)');
|
||
checkInvariants(h.b);
|
||
});
|
||
|
||
test('spawnToken inherits the owner direction unless given; spawnDevice takes opts.dir', () => {
|
||
const rec = chessRec({ id: 't_o', profession: 'SUPPORT', rangeGrid: [[0, 0], [0, 1]], skill: null });
|
||
const tok = { id: 'tok_t', name: 'tok', position: 'ALL', stats: { maxHp: 100, atk: 0, def: 0, res: 0, aspd: 100, bat: 1, blockCnt: 0, cost: 0, respawnTime: 0 }, rangeGrid: [[0, 0], [0, 1]] };
|
||
const h = makeBattle({ defs: { chess: { t_o: rec } }, units: [{ chessId: 't_o', row: 10, col: 4, dir: 'DOWN' }] });
|
||
h.step();
|
||
const o = h.unit('t_o');
|
||
const a = h.b.spawnToken(o, 'tok_t', 11, 6, { def: tok });
|
||
assert.equal(a.dir, 'DOWN');
|
||
assert.deepEqual(tilesOf(a.rangeKeys), [[10, 6], [11, 6]]);
|
||
const b = h.b.spawnToken(o, 'tok_t', 12, 6, { def: tok, dir: 'LEFT' });
|
||
assert.equal(b.dir, 'LEFT');
|
||
const c = h.b.spawnToken(o, 'tok_t', 12, 7, { def: tok, facing: -1 });
|
||
assert.equal(c.dir, 'LEFT', 'legacy facing −1 ⇒ LEFT');
|
||
const d = h.b.spawnDevice('dev_t', 9, 7, { dir: 'UP' });
|
||
assert.equal(d.dir, 'UP');
|
||
checkInvariants(h.b);
|
||
});
|
||
|
||
test('the legacy facing setter maps ±1 to RIGHT / LEFT (content that still assigns facing)', () => {
|
||
const rec = chessRec({ id: 't_o', rangeGrid: [[0, 0]], skill: null });
|
||
const h = makeBattle({ defs: { chess: { t_o: rec } }, units: [{ chessId: 't_o', row: 10, col: 4, dir: 'UP' }] });
|
||
h.step();
|
||
const u = h.unit('t_o');
|
||
u.facing = -1;
|
||
assert.equal(u.dir, 'LEFT');
|
||
u.facing = 1;
|
||
assert.equal(u.dir, 'RIGHT');
|
||
assert.equal(K(10, 4), u.tileR * COLS + u.tileC);
|
||
});
|