feedback3 WA: 联防 阿戈尔 devour counts the operators down since their own combat — they mark and are marked as if they stood (base ATK / block gains), then stay forced out (per players' reports, owner's decision 2026-10-04)

This commit is contained in:
sganggs committed 2026-10-04 17:27:11 +08:00
1 parent 6f4a19a5c5
commit 0e2133de69
2 files changed
+97 -2

No files matched your search

+16 -2
View File
@@ -40,6 +40,7 @@ import { mitigate } from '../../damage.js';
import { spawnYanyou } from '../tokens.js';
import { kjeragColdWind } from '../devices.js';
import * as items from '../items.js';
import { FORCED_EXIT } from '../../constants.js';
const num = S.num;
@@ -343,6 +344,8 @@ function installLaterano(battle, pid, bb, members) {
* cancelled, also when it is back at once (the 5-tier 立刻复活, 不屈's 立刻重新部署, 埃芒加德 / M3茧甲): PRTS 盟约记录 "目标首次被
* 击倒后解除自身被付与但还未触发的【吞噬】效果". A marker off the field gives no further mark (the rule since 0.1.0; one knocked
* out and back in the same pass still gives its marks).
* 联防: the operators down since the end of their own combat (forced out by Battle.start) mark and are marked like
* standing ones, but nothing resolves on or from them (below; per players' reports, owner's decision 2026-10-04).
* Each devoured operator adds its tier to 阿戈尔 once (IN_BATTLE gain, disabled in 联防 / boss fields).
* Tokens / devices / empty tiles are never devoured.
*/
@@ -352,12 +355,22 @@ function devour(battle, pid, bb, members) {
// first — row 0 is the BOTTOM row (DESIGN §3), so the top of the board is the highest row index. The order is a
// board position, independent of the members' directions (only "身前" follows each member's `dir`).
const boardCol = (u) => (u.player && u.player.mirror ? -u.tileC : u.tileC);
const order = members.filter(S.onField).sort((a, b) =>
// 联防: an operator down at the end of its own combat (carryState.down — Battle.start forced it out right before
// battleStart, FORCED_EXIT) still takes part in the marking as if it stood on its tile: it marks in its turn, it is
// "the unit in front" of another (the chain goes on through it when it is a member) and its base ATK / block count
// for its marker — then it stays out: its marks and the marks on it resolve nothing (as for any unit off the field
// during the pass), so it is never knocked out again, revived or devoured. Per players' reports (community report #3,
// GitHub #33 item 3), owner's decision 2026-10-04; until 0.1.2 the forced exit came first and the chain broke there.
const downAtStart = (u) => S.isOp(u) && !u.alive && u.removeReason === FORCED_EXIT && !!u.carry && u.carry.down === true
&& battle.isDown(u);
const order = members.filter((u) => S.onField(u) || downAtStart(u)).sort((a, b) =>
boardCol(a) - boardCol(b) || b.tileR - a.tileR || a.id - b.id);
const opAt = (u) => {
const [r, c] = S.frontTile(u);
const a = S.allyAt(battle, r, c, pid);
return a && S.isOp(a) && a.alive ? a : null;
if (a) return S.isOp(a) && a.alive ? a : null;
const d = battle.downOn(r, c);
return d && d.ownerId === pid && downAtStart(d) ? d : null;
};
const markedBy = new Map(); // marker → [targets]
const marks = [];
@@ -392,6 +405,7 @@ function devour(battle, pid, bb, members) {
for (const [, t] of marks) if (!dep.has(t)) dep.set(t, items.deploymentOf(t));
const knocked = (t) => !t.alive || items.deploymentOf(t) !== dep.get(t);
for (const [m, t] of marks) {
// (a member down since its own combat — downAtStart — is off the field: neither its marks nor the marks on it resolve)
if (knocked(t) || !m.alive) continue;
S.fxOn(battle, 'devour', t, 'bond:egirShip', 'devour', { from: m.id });
if (amount > 0) battle.loseHp(t, mitigate(amount, 'phys', t.s), { source: m, tags: ['bond:egir:devour'] });
+81
View File
@@ -0,0 +1,81 @@
// test/content/feedback3-egir-down.test.js — 阿戈尔 in 联防 (community report #3, GitHub #33 item 3; per players' reports,
// owner's decision 2026-10-04): an operator down at the end of its own combat still takes part in the battle-start devour
// (marking order, the unit in front, base ATK / block gains) and is then forced out — it used to be forced out first,
// which broke the chain at its tile.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, checkInvariants } from '../helpers/battleHarness.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, tier = null, thresholds = [3, 6, 9]) => ({
count, active: count >= thresholds[0], tier: tier ?? thresholds.filter((t) => count >= t).length, 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'));
// ---------------------------------------------------------------------------------------------------------------------
// 阿戈尔 devour in 联防 with an operator down since its own combat
const ULPIA = 'chess_char_5_05_a', GHOST = 'chess_char_2_07_a', GLADY = 'chess_char_4_12_a', FODDER = 'chess_char_1_01_a';
function chain(ghostDown, order = [ULPIA, GHOST, GLADY, FODDER]) {
const h = makeBattle({
kind: 'unite', autoFinish: false, timeLimit: 60, captureNoisy: true, hooks: ['damaged', 'death', 'deploy'],
units: order.map((chessId, i) => ({ chessId, row: 10, col: 3 + i, ...(chessId === GHOST && ghostDown ? { carryState: { down: true } } : {}) })),
bonds: { egirShip: bondOn(3, 0, null, [3, 5]) },
});
h.step();
return h;
}
test('联防 阿戈尔 chain 乌尔比安 → 幽灵鲨 (down) → 歌蕾蒂娅 → fodder: 乌尔比安 gains as if 幽灵鲨 stood, then 幽灵鲨 is forced out', () => {
const up = chain(false);
const down = chain(true);
const [ulpia, ghost, glady, fodder] = [ULPIA, GHOST, GLADY, FODDER].map((id) => down.unit(id));
const ref = buffOf(up.unit(ULPIA), 'bond:egir:devour');
const got = buffOf(ulpia, 'bond:egir:devour');
assert.ok(ref && got, 'both 乌尔比安 devour');
const base = (id) => down.unit(id).base;
close(got.mods.atkFlat, base(GHOST).atk + base(GLADY).atk + base(FODDER).atk, 1e-6, 'the base ATK of 幽灵鲨, 歌蕾蒂娅 and the fodder');
assert.equal(got.mods.blockCnt, base(GHOST).blockCnt + base(GLADY).blockCnt + base(FODDER).blockCnt);
assert.deepEqual(got.mods, ref.mods, 'exactly the gains of the chain with 幽灵鲨 standing');
close(ulpia.s.atk, up.unit(ULPIA).s.atk, 1e-6, '乌尔比安 ATK');
// then she is out: forced out (not knocked out again), never devoured, never revived, her marks resolve nothing
assert.ok(!ghost.alive && ghost.removeReason === FORCED_EXIT && down.b.isDown(ghost), '幽灵鲨 lies on her tile, forced out');
assert.equal(down.hooksOf('death').filter((c) => c.unit === ghost && c.reason === 'killed').length, 0, 'no knock-out');
assert.equal(down.hooksOf('deploy').filter((c) => c.unit === ghost && !c.initial).length, 0, 'no revive');
assert.ok(!devours(down).some((c) => c.target === ghost || c.source === ghost), 'no 物理流失 on or from her');
// standing, 乌尔比安's first mark knocks her out (so her own marks give nothing either); the rest resolves alike
const pairs = (h) => devours(h).map((c) => [c.source.defId, c.target.defId]);
assert.deepEqual(pairs(up), [[ULPIA, GHOST], [ULPIA, GLADY], [ULPIA, FODDER]]);
assert.deepEqual(pairs(down), [[ULPIA, GLADY], [ULPIA, FODDER]]);
assert.equal(glady.alive, up.unit(GLADY).alive);
assert.equal(fodder.alive, up.unit(FODDER).alive);
// her own marks (歌蕾蒂娅, the fodder) still give her their base ATK / block for when she is back
const own = buffOf(ghost, 'bond:egir:devour');
close(own ? own.mods.atkFlat : 0, base(GLADY).atk + base(FODDER).atk, 1e-6, '幽灵鲨 keeps her marking gains (a persistent buff)');
checkInvariants(down.b);
assert.equal(down.result().perPlayer.p1.deaths, up.result().perPlayer.p1.deaths - 1, 'no knock-out counted for her (standing, the devour knocks her out)');
});
test('联防 阿戈尔: a down member at the head of the chain marks and gains, but its marks hit nobody', () => {
// 幽灵鲨 (down) → 歌蕾蒂娅 → fodder, 乌尔比安 on another row facing an empty tile
const h = makeBattle({
kind: 'unite', autoFinish: false, timeLimit: 60, captureNoisy: true, hooks: ['damaged', 'death', 'deploy'],
units: [
{ chessId: GHOST, row: 10, col: 3, carryState: { down: true } }, { chessId: GLADY, row: 10, col: 4 }, { chessId: FODDER, row: 10, col: 5 },
{ chessId: ULPIA, row: 12, col: 3 },
],
bonds: { egirShip: bondOn(3, 0, null, [3, 5]) },
});
h.step();
const [ghost, glady, fodder] = [GHOST, GLADY, FODDER].map((id) => h.unit(id));
const g = buffOf(ghost, 'bond:egir:devour');
close(g.mods.atkFlat, glady.base.atk + fodder.base.atk, 1e-6, 'marks 歌蕾蒂娅 and, through her, the fodder');
assert.deepEqual(devours(h).map((c) => [c.source.defId, c.target.defId]), [[GLADY, FODDER]], 'only 歌蕾蒂娅\'s mark resolves');
close(glady.hp, glady.s.maxHp, 1e-6, '歌蕾蒂娅 takes no 物理流失 from the down 幽灵鲨');
assert.ok(!ghost.alive && ghost.removeReason === FORCED_EXIT);
checkInvariants(h.b);
});