Files
ark-stronghold-protocol/test/content/feedback4-egir-unite.test.js
T
sganggs 2b0dfceabd feedback5 R19D: 5-阿戈尔 revives — the members the battle-start devour knocks out revive once every devour pass is over, by board position (columns from the left, bottom first: the [ASSUMED] reading of the community report's 「左下到右上」), not in knock-out order; later knock-outs take the slots left in time order, uneaten members none (community report relayed 2026-10-07); golden: unchanged
The report's steps 1-2 were verified and hold: the bond HP is on every member before the devour (an elite 深巡,
2904 HP / DEF 655, stands its 4345 from 15 layers on), and each mark is one 5000 物理流失 less DEF (PRTS), so a member
in the middle of a chain is marked by every 阿戈尔 behind it — that is what knocked out the reporter's 蓝蒂 / 小鲨.
Golden unchanged: the corpus's only 5-阿戈尔 battle (bond-egirShip-high, 999 layers) has no devour knock-out.
DESIGN §25.22.5.
2026-10-07 12:32:33 +08:00

168 lines
12 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.
// test/content/feedback4-egir-unite.test.js — 阿戈尔's battle-start devour in 联防 (GitHub #105, #140 comment 4; the
// owner's decision of 2026-10-05). PRTS 盟约记录 阿戈尔: "战斗开始时【阿戈尔】干员依次吞噬身前一格干员" — no own-side limit:
// the operator on the front tile is devoured whoever owns it, a teammate's living operator or a teammate's operator
// that entered 联防 knocked out (carry.down, forced out right after the deployment), and the marker gains its base ATK
// and block count exactly as in a solo battle. 联防 still adds no layers ("该阶段不能叠加层数"). The remake's two 联防
// helpers stand on board cols 3–9 and 11–17 (colOffset 8) with the road column 10 between them, so these fields put the
// teammate's operator on col 10 (a LOW tile the sim deploys on) to be in front. The last test runs the own-board case
// through unite.js's carry: a chain whose food is down since the own combat gives 联防 the gains of the own combat.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, checkInvariants } from '../helpers/battleHarness.js';
import { uniteBattleOpts } from '../../server/match/unite.js';
import { makeMatch } from '../match/harness.js';
import { FORCED_EXIT } from '../../server/sim/constants.js';
const close = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps, `${msg} expected ${b}, got ${a}`);
const bondOn = (count, layers = 0) => ({ count, active: count >= 3, tier: count >= 5 ? 2 : count >= 3 ? 1 : 0, layers });
const buffOf = (u, key) => u.buffs.find((b) => b.key === key) ?? null;
const devours = (h) => h.hooksOf('damaged').filter((c) => c.dmg && c.dmg.tags && c.dmg.tags.includes('bond:egir:devour'));
const ULPIA = 'chess_char_5_05_a', GHOST = 'chess_char_2_07_a', GLADY = 'chess_char_4_12_a', DEEP = 'chess_char_1_04_a';
const HN = 'chess_char_3_09_a', MIZUKI = 'chess_char_4_09_a', FODDER = 'chess_char_1_01_a';
/** p1's 3 阿戈尔: 乌尔比安 on (10,9) facing right — its front tile is field (10,10) — and two facing empty tiles. */
const p1Units = () => [
{ uid: 1, kind: 'chess', chessId: ULPIA, row: 10, col: 9, dir: 'RIGHT' },
{ uid: 2, kind: 'chess', chessId: GLADY, row: 12, col: 3, dir: 'RIGHT' },
{ uid: 3, kind: 'chess', chessId: DEEP, row: 12, col: 5, dir: 'RIGHT' },
];
const player = (playerId, seat, colOffset, units, bonds) => ({ playerId, seat, side: 'L', colOffset, units, bonds, bandId: null, playerEffects: [] });
/**
* A battle where `front` (chess ids, left to right) stands on field (10,10), (10,11) …: owned by p1 itself (`owner`
* 'own', board cols 10, 11 …) or by the teammate p2 (`owner` 'mate', colOffset 8: board cols 2, 3 …).
*/
function field({ kind = 'unite', owner = 'own', front = [FODDER], extra = {}, p2Bonds = {}, p2Units = [] } = {}) {
const lane = front.map((chessId, i) => ({ uid: 100 + i, kind: 'chess', chessId, row: 10, dir: 'RIGHT', ...(extra[i] || {}) }));
const players = owner === 'own'
? [player('p1', 0, 0, [...p1Units(), ...lane.map((u, i) => ({ ...u, col: 10 + i }))], { egirShip: bondOn(3, 10) })]
: [player('p1', 0, 0, p1Units(), { egirShip: bondOn(3, 10) }),
player('p2', 1, 8, [...lane.map((u, i) => ({ ...u, col: 2 + i })), ...p2Units], p2Bonds)];
const h = makeBattle({ kind, players, autoFinish: false, timeLimit: 60, hooks: ['damaged', 'death', 'deploy', 'kill', 'layerGain'], captureNoisy: true });
h.step();
return h;
}
test('联防: an 阿戈尔 devours a teammate\'s living operator in front — the same base ATK and block count as an own one, the 物理流失 lands, no layers', () => {
const solo = field({ kind: 'normal' });
const own = field();
const mate = field({ owner: 'mate' });
const ref = buffOf(solo.unit(1), 'bond:egir:devour');
const fodderBase = solo.unit(100).base;
assert.ok(ref, 'solo: 乌尔比安 devours the operator in front');
close(ref.mods.atkFinal, fodderBase.atk, 1e-9, 'its base ATK');
assert.equal(ref.mods.blockCnt, fodderBase.blockCnt, 'its block count');
for (const [tag, h] of [['own', own], ['teammate\'s', mate]]) {
const ulpia = h.unit(1), t = h.unit(100);
assert.deepEqual(buffOf(ulpia, 'bond:egir:devour')?.mods, ref.mods, `${tag}: the solo gains`);
close(ulpia.s.atk, own.unit(1).s.atk, 1e-6, `${tag}: 乌尔比安's ATK`);
assert.equal(ulpia.s.blockCnt, solo.unit(1).s.blockCnt, `${tag}: the block count`);
const d = devours(h);
assert.deepEqual(d.map((c) => [c.source.uid, c.target.uid]), [[1, 100]], `${tag}: one mark`);
close(d[0].amount, devours(solo)[0].amount, 1e-6, `${tag}: the same 物理流失`);
assert.ok(!t.alive && h.hooksOf('kill').some((c) => c.victim === t && c.killer === ulpia), `${tag}: knocked out, credited to 乌尔比安`);
assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, 10, `${tag}: 联防 adds no layers`);
assert.equal(h.hooksOf('layerGain').length, 0, `${tag}: no layer gain at all`);
checkInvariants(h.b);
}
assert.equal(solo.b.getPlayer('p1').bonds.egirShip.layers, 10 + 1, 'solo: the devoured tier as layers');
assert.equal(mate.unit(100).ownerId, 'p2');
});
test('联防: a teammate\'s operator that entered 联防 down is devoured as if it stood — the same gains, nothing lands on it, it stays forced out', () => {
const up = field({ owner: 'mate' });
const down = field({ owner: 'mate', extra: { 0: { carryState: { down: true } } } });
const ownDown = field({ extra: { 0: { carryState: { down: true } } } });
const t = down.unit(100);
assert.ok(!t.alive && t.removeReason === FORCED_EXIT && down.b.isDown(t), 'the teammate\'s operator lies on (10,10), forced out');
for (const [tag, h] of [['teammate\'s', down], ['own', ownDown]]) {
assert.deepEqual(buffOf(h.unit(1), 'bond:egir:devour')?.mods, buffOf(up.unit(1), 'bond:egir:devour').mods, `${tag} down: the gains of the standing one`);
assert.equal(devours(h).length, 0, `${tag} down: no 物理流失 on it`);
assert.equal(h.hooksOf('death').filter((c) => c.unit === h.unit(100) && c.reason === 'killed').length, 0, `${tag} down: no knock-out`);
assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, 10, `${tag} down: no layers`);
checkInvariants(h.b);
}
});
test('联防: the chain goes on through a teammate\'s 阿戈尔 — the gains equal the same chain of own operators; its knock-out spends its owner\'s 5-tier slot', () => {
const own = field({ front: [GHOST, FODDER] });
// p2 holds 5 阿戈尔 (5-tier active): 幽灵鲨 in front of 乌尔比安, four more on its board facing empty tiles
const p2Units = [HN, MIZUKI, GLADY, DEEP].map((chessId, i) => ({ uid: 200 + i, kind: 'chess', chessId, row: 12, col: 3 + 2 * i, dir: 'RIGHT' }));
const mate = field({ owner: 'mate', front: [GHOST, FODDER], p2Units, p2Bonds: { egirShip: bondOn(5) } });
const ref = buffOf(own.unit(1), 'bond:egir:devour');
close(ref.mods.atkFinal, own.unit(100).base.atk + own.unit(101).base.atk, 1e-9, 'own chain: 幽灵鲨 and, through her, the fodder');
assert.deepEqual(buffOf(mate.unit(1), 'bond:egir:devour')?.mods, ref.mods, 'the teammate\'s chain gives the same');
assert.deepEqual(devours(mate).map((c) => [c.source.uid, c.target.uid]), devours(own).map((c) => [c.source.uid, c.target.uid]));
const ghost = mate.unit(100);
assert.equal(mate.hooksOf('death').filter((c) => c.unit === ghost && c.reason === 'killed').length, 1, '幽灵鲨 knocked out by p1\'s mark');
assert.ok(ghost.alive && mate.hooksOf('deploy').some((c) => c.unit === ghost && !c.initial), 'p2\'s 5-tier revives her at once');
assert.ok(!own.unit(100).alive, 'p1 has no 5-tier: its own 幽灵鲨 stays down');
assert.equal(mate.b.getPlayer('p1').bonds.egirShip.layers, 10, 'no layers');
checkInvariants(mate.b);
});
test('联防: a member a teammate\'s devour knocks out waits for every devour pass, then takes its owner\'s slot by position (community report, 2026-10-07; DESIGN §25.22.5)', () => {
// p1's 乌尔比安 → p2's 幽灵鲨 (field 10,10) → p2's fodder (10,11); on p2's board 海霓 (12,3) → 水月 (12,4); p2 holds 5
// 阿戈尔. p1's pass runs first (players' order): it knocks 幽灵鲨 out; p2's pass knocks 水月 out; then p2's revives —
// 幽灵鲨 (field col 10) before 水月 (col 12). Until 0.2.0 she stood again right after p1's mark, before p2's pass.
const seq = [];
const p2Units = [
{ uid: 200, kind: 'chess', chessId: HN, row: 12, col: 3, dir: 'RIGHT' }, { uid: 201, kind: 'chess', chessId: MIZUKI, row: 12, col: 4, dir: 'RIGHT' },
{ uid: 202, kind: 'chess', chessId: GLADY, row: 12, col: 7, dir: 'RIGHT' }, { uid: 203, kind: 'chess', chessId: DEEP, row: 12, col: 9, dir: 'RIGHT' },
];
const lane = [GHOST, FODDER].map((chessId, i) => ({ uid: 100 + i, kind: 'chess', chessId, row: 10, col: 2 + i, dir: 'RIGHT' }));
const h = makeBattle({
kind: 'unite', autoFinish: false, timeLimit: 60, hooks: ['death', 'deploy'],
players: [player('p1', 0, 0, p1Units(), { egirShip: bondOn(3, 10) }), player('p2', 1, 8, [...lane, ...p2Units], { egirShip: bondOn(5) })],
setup: (b) => {
b.on('damaged', (c) => { if (c.dmg?.tags?.includes('bond:egir:devour')) seq.push(`mark ${c.source.uid}→${c.target.uid}`); }, { priority: -1000 });
b.on('deploy', (c) => { if (!c.initial) seq.push(`revive ${c.unit.uid}`); }, { priority: -1000 });
},
});
h.step();
assert.deepEqual(seq, ['mark 1→100', 'mark 1→101', 'mark 200→201', 'revive 100', 'revive 201']);
assert.ok(h.unit(100).alive && h.unit(201).alive, 'both stand again, on p2\'s slots');
assert.equal(h.eventsOf('fx').filter((x) => x[1] === 'revive' && x[4]?.src === 'bond:egirShip').length, 2);
checkInvariants(h.b);
});
// ---------------------------------------------------------------------------------------------------------------------
// the own board through unite.js: a chain whose food is down since the own combat
let gdCache = null;
const gd = () => (gdCache ??= makeMatch({ mode: 'coop', humans: 3, seed: 1, fake: true }).start().m.gd);
test('联防 through unite.js: food knocked out by the own combat\'s devour enters down, and 乌尔比安 gets exactly the own combat\'s base ATK and block count', () => {
// 乌尔比安 → 幽灵鲨 → 歌蕾蒂娅 → fodder on row 10 (3 阿戈尔: no revive), 30 s own combat
const ids = [ULPIA, GHOST, GLADY, FODDER];
const units = ids.map((chessId, i) => ({ uid: i + 1, kind: 'chess', chessId, row: 10, col: 3 + i, dir: 'RIGHT', items: [] }));
const bonds = { egirShip: bondOn(3, 10) };
const own = makeBattle({ players: [player('p1', 0, 0, units, bonds)], autoFinish: false, timeLimit: 30 });
own.runToEnd(60);
const gain = buffOf(own.unit(1), 'bond:egir:devour').mods;
const unitsEnd = own.result().perPlayer.p1.unitsEnd;
assert.deepEqual(unitsEnd.filter((u) => !u.alive).map((u) => u.uid).sort(), [2, 3, 4], 'the food is down at the end');
// the helper's 联防 input as unite.js builds it from that result
const ps = {
playerId: 'p1', seat: 0,
board: new Map(units.map((u) => [`${u.row},${u.col}`, { uid: u.uid, kind: 'chess' }])),
battleInput: ({ side, colOffset, carry }) => ({
...player('p1', 0, colOffset, units.map((u) => ({ ...u, ...(carry.has(u.uid) ? { carryState: carry.get(u.uid) } : {}) })), bonds), side,
}),
};
const m = { gd: gd(), round: 3, lastResults: new Map([['p1', { unitsEnd }]]), dispatch() {} };
const { players } = uniteBattleOpts(m, { helpers: [ps], leaked: [] }, 60);
assert.deepEqual(players[0].units.filter((u) => u.carryState?.down).map((u) => u.uid), [2, 3, 4]);
const u = makeBattle({ kind: 'unite', players, autoFinish: false, timeLimit: 60 });
u.step();
const ulpia = u.unit(1);
assert.deepEqual(buffOf(ulpia, 'bond:egir:devour')?.mods, gain, 'the own combat\'s gains');
close(gain.atkFinal, [2, 3, 4].reduce((s, uid) => s + u.unit(uid).base.atk, 0), 1e-9, '幽灵鲨 + 歌蕾蒂娅 + fodder base ATK');
assert.equal(gain.blockCnt, [2, 3, 4].reduce((s, uid) => s + u.unit(uid).base.blockCnt, 0));
for (const uid of [2, 3, 4]) assert.ok(!u.unit(uid).alive && u.unit(uid).removeReason === FORCED_EXIT, `${uid} forced out`);
assert.equal(u.b.getPlayer('p1').bonds.egirShip.layers, 10, 'no layers in 联防');
checkInvariants(u.b);
});