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.
168 lines
12 KiB
JavaScript
168 lines
12 KiB
JavaScript
// 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);
|
||
});
|