Files
ark-stronghold-protocol/test/content/feedback4-egir-unite.test.js
T
sganggs 8a2e0c7e7f feedback4: 联防 阿戈尔 devours the operator in front whoever owns it, a teammate's standing or down operator too
The owner's decision of 2026-10-05 (GitHub #140 comment 4, #105): PRTS 「战斗开始时依次吞噬身前一格干员」 has no
own-side limit. The devour's front-tile lookup took only the player's own operators.

- devour opAt: the operator on the front tile whoever owns it — a living one, else one lying there since the 联防
  start (carry.down, forced out). The marker gains its base ATK and block count exactly as in a solo battle; a mark
  on a standing teammate's operator resolves (5000 物理流失 less DEF, credited to the marker, the knock-out is its
  owner's: their revive slots), a mark on a down one resolves nothing (it stays forced out, §19.3 kept).
- The chain goes on through a marked 阿戈尔 by S.isMember: for own operators exactly the members, as before; a
  teammate's by its own bonds ([ASSUMED]).
- 联防 still adds no layers (layerGainsEnabled false, unchanged).
- On the current maps the two 联防 helpers stand on board cols 3-9 and 11-17 with the road column 10 between them,
  so a teammate's operator is never in front at the battle start; the tests place one on col 10. The own-board case
  (food down since the own combat) is verified through unite.js's real carry: 联防 gives exactly the own combat's
  atkFlat / blockCnt.
- research 02 §3.6 and its JSON spec; data/bonds.json regenerated offline (only egirShip.spec.assumed[1] changed).
- Tests: test/content/feedback4-egir-unite.test.js (teammate's living and down operator, solo parity of ATK / block /
  物理流失, no layers, the chain through a teammate's 阿戈尔 and her owner's 5-tier slot, the own-board case through
  uniteBattleOpts). The teammate cases fail on the previous code.

Golden: no scenario moved (no corpus field puts a teammate's operator in front of an 阿戈尔).
2026-10-05 23:34:33 +08:00

143 lines
10 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.atkFlat, 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.atkFlat, 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);
});
// ---------------------------------------------------------------------------------------------------------------------
// 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.atkFlat, [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);
});