Files
ark-stronghold-protocol/test/content/facing.test.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
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).
2026-09-29 16:21:31 +08:00

266 lines
15 KiB
JavaScript

// 4-direction facing in the content (research 09 §6.1 item 3): every "身前 / 身后 / 左右 / 对着" effect follows the unit's
// direction `dir` (UP|RIGHT|DOWN|LEFT, server/sim/dir.js) — bonds (阿戈尔 front tile, devour order), items (叙拉古正装
// perpendicular pair, 歌利亚头盔 front tile), devices (blower relation by direction equality / opposition: the m01 DOWN
// blowers), garrisons (身前 on the prep board and in battle), bands (最右边 = a board position), kits (push / pull /
// anchor / device placement directions).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { hasGeneratedData } from '../../server/sim/simdata.js';
import { airflowRelationOf } from '../../server/sim/content/devices.js';
import { frontPiece, behindPiece } from '../../server/sim/content/support/meta.js';
const REAL = { skip: !hasGeneratedData() };
const ds = getDefaultSource();
const close = (a, b, msg = '', eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
const dummy = (key = 'e_d', o = {}) => enemyRec({ key, hp: 1e8, speed: 0, def: 0, res: 0, ...o });
const fill = (u) => u.skill.gainSp(u.skill.spCost * u.skill.maxCharges + 1, 'test');
const op = (id, o = {}) => chessRec({
id, profession: o.profession ?? 'WARRIOR', bonds: o.bonds ?? [], tier: o.tier ?? 1, skill: null,
rangeGrid: o.range ?? [[0, 0], [0, 1]],
stats: { maxHp: 10000, atk: 1000, def: 0, res: 0, aspd: 100, bat: 1, blockCnt: 2, respawnTime: 20, ...(o.stats || {}) },
});
const bondOn = (count, thresholds = [3, 5]) => ({ count, active: count >= thresholds[0], tier: thresholds.filter((t) => count >= t).length, layers: 0 });
const tagged = (h, tag) => h.hooksOf('damaged').filter((c) => c.dmg && c.dmg.tags && c.dmg.tags.includes(tag));
// ---------------------------------------------------------------------------------------------------------------------
// bonds
test('阿戈尔 devour: "身前一格" is one step along each member\'s direction (UP devours the operator above)', () => {
const chess = { g1_a: op('g1_a', { bonds: ['egirShip'] }), g2_a: op('g2_a', { bonds: ['egirShip'] }), g3_a: op('g3_a', { bonds: ['egirShip'] }), up_a: op('up_a', { tier: 3 }), right_a: op('right_a', { tier: 2 }) };
const units = [
{ chessId: 'g1_a', row: 10, col: 4, dir: 'UP' }, { chessId: 'up_a', row: 11, col: 4 }, { chessId: 'right_a', row: 10, col: 5 },
{ chessId: 'g2_a', row: 9, col: 8 }, { chessId: 'g3_a', row: 12, col: 8 },
];
const h = makeBattle({ defs: { chess }, units, bonds: { egirShip: bondOn(3) }, hooks: ['damaged'], captureNoisy: true });
h.step(1);
const dv = tagged(h, 'bond:egir:devour').map((c) => [c.source.defId, c.target.defId]);
assert.deepEqual(dv, [['g1_a', 'up_a']], 'the operator above, not the one to the right');
close(h.unit('g1_a').s.atk, 1000 + 1000, 'base ATK of the devoured');
assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, 3, 'layers = the devoured tier');
checkInvariants(h.b);
});
test('阿戈尔 devour order is a board position (left first) whatever the members face; the mirrored FA side counts from its own board', () => {
const chess = { g1_a: op('g1_a', { bonds: ['egirShip'] }), g2_a: op('g2_a', { bonds: ['egirShip'] }), g3_a: op('g3_a', { bonds: ['egirShip'] }), fod_a: op('fod_a') };
// g1 at board col 3 facing LEFT (its front: col 2), g2 at col 5 facing RIGHT — g1 is further left, so it goes first
const units = [
{ uid: 1, kind: 'chess', chessId: 'g1_a', row: 10, col: 3, dir: 'LEFT' }, { uid: 2, kind: 'chess', chessId: 'fod_a', row: 10, col: 2 },
{ uid: 3, kind: 'chess', chessId: 'g2_a', row: 10, col: 5, dir: 'RIGHT' }, { uid: 4, kind: 'chess', chessId: 'fod_a', row: 10, col: 6 },
{ uid: 5, kind: 'chess', chessId: 'g3_a', row: 12, col: 9 },
];
for (const side of ['L', 'R']) {
const h = makeBattle({
kind: side === 'L' ? 'normal' : 'boss', defs: { chess }, hooks: ['damaged'], captureNoisy: true,
players: [{ playerId: 'p1', seat: 0, side, colOffset: side === 'L' ? 0 : 8, units: units.map((u) => ({ ...u })), bonds: { egirShip: bondOn(3) } }],
});
h.step(1);
assert.deepEqual(tagged(h, 'bond:egir:devour').map((c) => c.source.uid), [1, 3], `side ${side}: board col 3 before col 5`);
checkInvariants(h.b);
}
});
// ---------------------------------------------------------------------------------------------------------------------
// items
test('叙拉古正装: the two tiles beside the carrier are perpendicular to ITS direction (an UP carrier: left / right columns)', () => {
const id = 'chess_item_3_01_e_a';
const chess = { t_op: op('t_op'), t_l: op('t_l'), t_r: op('t_r'), t_f: op('t_f'), t_b: op('t_b') };
const h = makeBattle({ defs: { chess }, units: [
{ chessId: 't_op', row: 10, col: 5, items: [id], dir: 'UP' }, { chessId: 't_l', row: 10, col: 4 }, { chessId: 't_r', row: 10, col: 6 },
{ chessId: 't_f', row: 11, col: 5 }, { chessId: 't_b', row: 9, col: 5 },
] });
h.run(1);
const side = h.unit('t_l').s.aspd;
assert.ok(side > 100, 'left column');
assert.equal(h.unit('t_r').s.aspd, side, 'right column');
assert.equal(h.unit('t_f').s.aspd, 100, 'in front (above): no');
assert.equal(h.unit('t_b').s.aspd, 100, 'behind (below): no');
});
test('歌利亚头盔: the tile in front follows the carrier direction (DOWN: the tile below)', () => {
const id = 'chess_item_3_06_e_a';
const chess = { t_op: op('t_op', { stats: { maxHp: 2000 } }), t_f: op('t_f') };
const hp = (units) => { const h = makeBattle({ defs: { chess }, units }); h.step(1); return h.unit('t_op').s.maxHp; };
const free = hp([{ chessId: 't_op', row: 11, col: 4, items: [id], dir: 'DOWN' }]);
const below = hp([{ chessId: 't_op', row: 11, col: 4, items: [id], dir: 'DOWN' }, { chessId: 't_f', row: 10, col: 4 }]);
const right = hp([{ chessId: 't_op', row: 11, col: 4, items: [id], dir: 'DOWN' }, { chessId: 't_f', row: 11, col: 5 }]);
assert.ok(below < free, 'an operator below a DOWN carrier occupies its front tile');
close(right, free, 'the tile to its right is not in front');
});
// ---------------------------------------------------------------------------------------------------------------------
// devices
test('气流 relation by direction: equal / opposite / perpendicular (vertical)', () => {
assert.equal(airflowRelationOf('DOWN', 'DOWN'), 'equal');
assert.equal(airflowRelationOf('UP', 'DOWN'), 'opposite');
assert.equal(airflowRelationOf('RIGHT', 'DOWN'), 'vertical');
assert.equal(airflowRelationOf('LEFT', 'RIGHT'), 'opposite');
});
test('气流 (act2 m01 DOWN blowers): an operator facing DOWN gets ATK +equal, UP +opposite, RIGHT nothing', REAL, () => {
const stage = ds.getStage('act2autochess_m01');
const bb = stage.devices.find((d) => d.role === 'blower').raw.skill.bb;
const atkOf = (dir) => {
const h = makeBattle({ stageId: 'act2autochess_m01', defs: { chess: { t_g: op('t_g') } }, units: [{ chessId: 't_g', row: 11, col: 5, dir }], autoFinish: false, timeLimit: 10 });
h.run(0.5);
return h.unit('t_g').s.atk;
};
close(atkOf('DOWN'), 1000 * (1 + bb['blower_s_character[equal].atk']), 'with the flow');
close(atkOf('UP'), 1000 * (1 + bb['blower_s_character[opposite].atk']), 'against the flow');
close(atkOf('RIGHT'), 1000, 'across the flow');
});
// ---------------------------------------------------------------------------------------------------------------------
// garrisons (prep and battle)
test('prep "身前一格" (support/meta frontPiece / behindPiece) follows the board piece dir', () => {
const board = [
{ uid: 1, kind: 'chess', row: 10, col: 5, dir: 'UP' }, { uid: 2, kind: 'chess', row: 11, col: 5 }, { uid: 3, kind: 'chess', row: 10, col: 6 },
{ uid: 4, kind: 'chess', row: 9, col: 5 }, { uid: 5, kind: 'chess', row: 12, col: 5 },
];
const ctx = { board: () => board };
assert.equal(frontPiece(ctx, board[0]).uid, 2, 'UP: the piece above');
assert.equal(frontPiece(ctx, board[0], 2).uid, 5, 'two tiles in front');
assert.equal(behindPiece(ctx, board[0]).uid, 4, 'behind: below');
assert.equal(frontPiece(ctx, { ...board[0], dir: undefined }).uid, 3, 'no dir ⇒ RIGHT');
assert.equal(frontPiece(ctx, { ...board[0], dir: 'LEFT' }), null);
});
test('魔王 (garrison_59): the operator "in front" is along its direction', () => {
const rec = (id, g) => ({ ...chessRec({ id }), garrisonIds: g });
const run = (mwDir, frontAt) => {
const h = makeBattle({
defs: { chess: { mw: rec('mw', ['garrison_59_a']), fr: rec('fr', ['garrison_42_a']) }, enemies: { e_d: dummy() } },
units: [{ chessId: 'mw', row: 10, col: 4, dir: mwDir }, { chessId: 'fr', row: frontAt[0], col: frontAt[1] }],
bonds: { sargonShip: { count: 3, active: true, tier: 1, layers: 0 } }, autoFinish: false, timeLimit: 30,
});
h.step(1);
const u = h.unit('fr');
h.b.emit('skillStart', { unit: u, skill: u.skill, reason: 'test' });
return h.result().perPlayer.p1.layerGains.sargonShip;
};
const up = run('UP', [11, 4]);
const side = run('UP', [10, 5]);
assert.equal(up, side + 1, 'the operator above an UP-facing 魔王 gets the +1; the one to its right does not');
});
// ---------------------------------------------------------------------------------------------------------------------
// bands
test('桑葚 药枚实验 "最右边" is the right-most board column whatever the units face', () => {
const chess = { t_a: op('t_a', { range: [[0, 0], [0, 1], [0, -1], [1, 0], [-1, 0]] }), t_b: op('t_b', { range: [[0, 0], [0, 1], [0, -1], [1, 0], [-1, 0]] }) };
const h = makeBattle({
defs: { chess, enemies: { e_d: dummy() } }, bandId: 'band_mberry', autoFinish: false, timeLimit: 30,
units: [{ chessId: 't_a', row: 10, col: 7, dir: 'LEFT' }, { chessId: 't_b', row: 11, col: 5, dir: 'RIGHT' }],
enemies: [{ key: 'e_d', pos: [10, 6] }, { key: 'e_d', pos: [11, 6] }],
});
const orig = h.b.rng;
h.b.rng = Object.assign(() => 0, orig);
h.run(3.1);
const sh = (u) => { const b = u.findBuff('band:band_mberry:shield'); return b ? b.shieldHits : 0; };
assert.equal(sh(h.unit('t_a')), 1, 'col 7 (facing LEFT) is the right-most column');
assert.equal(sh(h.unit('t_b')), 0, 'col 5 facing RIGHT is not');
});
// ---------------------------------------------------------------------------------------------------------------------
// kits
test('见行者 (3_07) 惊爆射击 pushes along her direction (DOWN: the enemy below is pushed further down)', () => {
const id = 'chess_char_3_07_a';
const h = makeBattle({
defs: { enemies: { enemy_a: dummy('enemy_a', { mass: 1 }) } }, timeLimit: 60,
units: [{ chessId: id, row: 12, col: 4, dir: 'DOWN' }], enemies: [{ key: 'enemy_a', pos: [11, 4] }],
});
const u = h.unit(id);
h.step();
const e = h.enemy('enemy_a');
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === 1, 5), 'skill cast');
assert.ok(e.y < 10.6, `pushed down (${e.y})`);
close(e.x, 4, 'no sideways push', 1e-3);
checkInvariants(h.b);
});
test('薄绿 (3_08) 聚能涡旋 pulls toward the tile in front along her direction', () => {
const id = 'chess_char_3_08_a';
const run = (dir) => {
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d', { mass: 0 }) } }, timeLimit: 60, units: [{ chessId: id, row: 9, col: 4, dir }], enemies: [{ key: 'enemy_d', pos: [11, 4] }] });
const u = h.unit(id);
h.step();
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 5));
h.run(3);
return h.enemy('enemy_d');
};
const up = run('UP');
assert.ok(up.y < 11 - 0.2 && Math.abs(up.x - 4) < 0.05, `UP: pulled straight down toward (10,4) (${up.x}, ${up.y})`);
const right = run('RIGHT');
assert.ok(right.x > 4.2, `RIGHT: pulled toward (9,5) (${right.x}, ${right.y})`);
});
test('乌尔比安 (5_05) S3 throws the anchor straight ahead along his direction (and triggers on the rotated grid)', () => {
const id = 'chess_char_5_05_a';
const run = (dir) => {
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 60, hooks: ['statusApplied', 'skillStart'],
units: [{ chessId: id, row: 9, col: 4, dir }], enemies: [{ key: 'enemy_d', pos: [11, 4] }] });
const u = h.unit(id);
h.step();
fill(u);
h.run(2);
return { h, u };
};
const { h, u } = run('UP');
assert.ok(h.hooksOf('skillStart').some((c) => c.unit === u), 'an enemy two tiles above is in the rotated trigger range');
const anchor = h.eventsOf('fx').find((e) => e[1] === 'anchor');
assert.ok(anchor, 'anchor fx');
assert.deepEqual([anchor[2], anchor[3]], [4, 11], 'the anchor stops on the enemy above');
assert.ok(h.hooksOf('statusApplied').some((c) => c.status === 'stun' && c.target.defId === 'enemy_d'));
assert.deepEqual([u.tileR, u.tileC], [11, 4], 'he lands on the anchor tile');
const r = run('RIGHT');
assert.ok(!r.h.hooksOf('skillStart').some((c) => c.unit === r.u), 'facing RIGHT: nothing ahead, no cast');
});
test('凯瑟琳 (4_11) places a device behind her operator along ITS direction, facing the operator', () => {
const id = 'chess_char_4_11_a', ally = 'chess_char_1_02_a';
const place = (dir) => {
const h = makeBattle({ units: [{ chessId: id, row: 12, col: 2 }, { chessId: ally, row: 10, col: 5, dir }], timeLimit: 30, hooks: [] });
h.run(0.2);
const d = h.b.allyUnits.find((x) => x.defId === 'token_10041_cathy_catsld' && x.alive && x.mem.tr === 10 && x.mem.tc === 5);
assert.ok(d, `a device serves the ${dir} operator`);
return [d.tileR, d.tileC, d.dir];
};
assert.deepEqual(place('UP'), [9, 5, 'UP'], 'below an UP operator, pointing up at it');
assert.deepEqual(place('RIGHT'), [10, 4, 'RIGHT']);
assert.deepEqual(place('LEFT'), [10, 6, 'LEFT']);
});
test('余 (6_03) S3 fire wall runs through his tile perpendicular to his direction (UP / DOWN: his row; RIGHT / LEFT: his column)', REAL, () => {
const run = (dir, casterAt, enemyAt) => {
const h = makeBattle({
defs: { chess: { t_cst: op('t_cst', { profession: 'CASTER', range: [[0, 0]] }) }, enemies: { e_d: dummy() } },
units: [{ chessId: 'chess_char_6_03_a', row: 10, col: 5, dir }, { chessId: 't_cst', row: casterAt[0], col: casterAt[1] }],
enemies: [{ key: 'e_d', pos: enemyAt }], autoFinish: false, timeLimit: 30, hooks: ['damaged'], captureNoisy: true,
});
h.run(0.2);
const yu = h.unit('chess_char_6_03_a'), c = h.unit('t_cst'), e = h.b.enemies[0];
assert.ok(e && e.alive && c.deployed && yu.deployed);
yu.skill.activate('test', { free: true });
assert.ok(yu.skill.active, 'S3 on');
const wall = h.eventsOf('fx').find((f) => f[1] === 'firewall');
const n = tagged(h, 'firewall').length;
h.b.dealDamage(c, e, { amount: 1000, type: 'arts' });
return { burned: tagged(h, 'firewall').length > n, axis: wall && wall[4] && wall[4].axis };
};
// caster at (12, 3), enemy at (9, 3): same column side, opposite sides of row 10
assert.equal(run('UP', [12, 3], [9, 3]).burned, true, 'UP: arts damage crossing his row burns');
assert.equal(run('DOWN', [12, 3], [9, 3]).axis, 'row');
assert.equal(run('RIGHT', [12, 3], [9, 3]).burned, false, 'RIGHT: the wall is his column — nothing crossed');
// caster at (12, 3), enemy at (12, 8): opposite sides of column 5, same side of row 10
assert.equal(run('RIGHT', [12, 3], [12, 8]).burned, true, 'RIGHT: crossing his column burns (as before)');
assert.equal(run('LEFT', [12, 3], [12, 8]).axis, 'col');
assert.equal(run('UP', [12, 3], [12, 8]).burned, false, 'UP: not crossing his row');
});