feedback5: merge W5M — the nine 0.1.4 PRs (#150 #66 #146 #74 #72 #103 #149 #109 #160) and W4A (高台 per the trait, 阿戈尔 revive slots, 联防 devour) ported into the split layout

Conflicts: server/match/bot.js (W4A's comment + W5K's player data view pgd), test/golden/diy.json (regenerated). data/ equals an offline build of the merged tools/build-data.mjs. Golden regenerated on the merged code: against feedback5 the moved roster / bonds / fields / matches / standins scenarios are exactly W5M's per-PR attribution (#160 skill events, #109 deploy-timed lifecycle, #66 灵巧 sources and hidden layers, 83fc408 歌蕾蒂娅 placement), plus coop2-NORMAL-70-diy (fields operators those PRs touch) and diy-051 / diy-052 (events.skill only: Misery's deploy passives, #160); against fb5-merge4 only feedback5's own additions (the diy kits, coop2-NORMAL-70-diy).
This commit is contained in:
sganggs committed 2026-10-06 00:33:15 +08:00
commit 9ce5435373
74 files changed
+2966 -1338

No files matched your search

+157 -5
View File
@@ -71,6 +71,28 @@ test('迅捷: member skill end +12 SP (p=1 at high L), ≥40 layers every operat
assert.equal(off.s_x, 0);
});
test('迅捷 / 突袭: SP gifts after end do not recharge a zero-SP deployment skill or trigger a ready raid', () => {
const h = makeBattle({
defs: { chess: { t_deploy: chessRec({ id: 't_deploy', bonds: ['swiftShip', 'raidShip'], skill: { spCost: 0 } }) } },
units: [{ chessId: 't_deploy', row: 10, col: 4 }],
bonds: { swiftShip: bond(1, 1000), raidShip: bond(1) }, captureNoisy: true,
kits: { t_deploy: () => ({ skill: {
kind: 'duration', activateOnDeploy: true, duration: 2, spCost: 0, spType: 'none', trigger: 'NEVER',
} }) },
});
h.b.start();
const u = h.unit('t_deploy'), seq = u.deploySeq;
h.run(5);
assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['duration']);
assert.ok(h.hooksOf('spGain').some((c) => c.unit === u), 'swift end handler attempted SP gain');
assert.equal(u.skill.sp, 0);
assert.equal(u.skill.charges, 0);
assert.equal(u.skill.ready, false);
assert.equal(u.skill.activations, 1);
assert.equal(u.deploySeq, seq, 'skill end alone does not meet raid readiness');
checkInvariants(h.b);
});
test('迅捷 / 不屈 proc chances: p = min(1, base + per·L) at each layer count', () => {
const sw = bondBb('swiftShip'), ind = bondBb('indomShip');
close(procChance(sw, 0), 0.20);
@@ -83,7 +105,7 @@ test('迅捷 / 不屈 proc chances: p = min(1, base + per·L) at each layer coun
close(procChance(ind, 1000), 1, 'capped');
});
test('灵巧: aura on members + 4 neighbours (once), 8 tiles at ≥40 layers, follows death and relocation', () => {
test('灵巧: aura on members + 4 neighbours (once), 8 tiles at ≥40 layers, survives death and follows relocation', () => {
const defs = { chess: { k_1: op('k_1', ['skillfulShip']), k_2: op('k_2', ['skillfulShip']), k_n: op('k_n', []), k_d: op('k_d', []), k_f: op('k_f', []) } };
const h = makeBattle({
defs, bonds: { skillfulShip: bond(1, 5) },
@@ -107,7 +129,117 @@ test('灵巧: aura on members + 4 neighbours (once), 8 tiles at ≥40 layers, fo
assert.equal(a('k_n'), 100, 'relocated out of the aura');
h.b.dealDamage(null, h.unit('k_1'), { amount: 1e9, type: 'true' });
h.step(2);
assert.equal(a('k_d'), 100, 'aura source died');
assert.equal(a('k_d'), 150, 'downed aura source still buffs neighbours');
checkInvariants(h.b);
});
for (const layers of [5, 40]) {
test(`灵巧: downed sources keep one aura at ${layers} layers, update live layers and restore it on redeploy`, () => {
const defs = { chess: {
k_1: op('k_1', ['skillfulShip']), k_2: op('k_2', ['skillfulShip']),
k_n: op('k_n', []), k_d: op('k_d', []),
} };
const h = makeBattle({
defs, bonds: { skillfulShip: bond(1, layers) },
units: [
{ chessId: 'k_1', row: 10, col: 4 }, { chessId: 'k_2', row: 10, col: 6 },
{ chessId: 'k_n', row: 10, col: 5 }, { chessId: 'k_d', row: 11, col: 5 },
],
});
h.step();
const first = h.unit('k_1'), second = h.unit('k_2'), neighbour = h.unit('k_n'), diagonal = h.unit('k_d');
assert.equal(neighbour.s.aspd, 110 + layers, 'overlapping sources buff once');
h.b.kill(first);
assert.ok(h.b.isDown(first));
assert.equal(neighbour.s.aspd, 110 + layers, 'no attack speed drop on death');
h.b.kill(second);
assert.ok(h.b.isDown(second));
h.run(1);
assert.equal(neighbour.s.aspd, 110 + layers, 'all sources down: polling keeps the aura');
assert.equal(diagonal.s.aspd, layers >= 40 ? 110 + layers : 100, '4 / 8 tile range still applies');
assert.equal(neighbour.buffs.filter((b) => b.key === 'bond:skillfulShip').length, 1);
gainLayers(h.b, { playerId: 'p1', bonds: 'skillfulShip', n: 40 - layers + 2 });
h.step(2);
assert.equal(neighbour.s.aspd, 152, 'downed sources use live layer values');
assert.equal(diagonal.s.aspd, 152, 'downed sources widen their aura at 40 layers');
h.b.kill(neighbour);
assert.ok(h.b.redeploy(neighbour, { free: true }));
assert.equal(neighbour.s.aspd, 152, 'recipient redeployed next to a downed source');
assert.ok(h.b.redeploy(first, { free: true }));
assert.equal(first.s.aspd, 152, 'source regains its own bonus on redeploy');
assert.equal(neighbour.s.aspd, 152, 'redeploy does not stack the aura');
h.b.retreat(first);
assert.equal(neighbour.s.aspd, 152, 'remaining downed source still covers the recipient');
assert.ok(h.b.redeploy(second, { free: true }));
h.b.retreat(second);
assert.equal(neighbour.s.aspd, 100, 'withdrawn sources no longer provide an aura');
checkInvariants(h.b);
});
}
test('灵巧: a downed source provides its aura around the body tile when it returns home after death', () => {
const defs = { chess: {
k_1: op('k_1', ['skillfulShip']), k_2: op('k_2', ['skillfulShip']),
k_home: op('k_home', []), k_away: op('k_away', []), k_tile: op('k_tile', []),
} };
const h = makeBattle({
defs, bonds: { skillfulShip: bond(1, 5) },
units: [
{ chessId: 'k_1', row: 10, col: 4 }, { chessId: 'k_2', row: 12, col: 3 },
{ chessId: 'k_home', row: 10, col: 5 }, { chessId: 'k_away', row: 10, col: 8 },
{ chessId: 'k_tile', row: 10, col: 7 },
],
});
h.step();
h.b.retreat(h.unit('k_tile'), { permanent: true });
assert.ok(h.b.relocate(h.unit('k_1'), 10, 7));
h.run(0.4);
assert.equal(h.unit('k_home').s.aspd, 100);
assert.equal(h.unit('k_away').s.aspd, 115);
h.b.kill(h.unit('k_1'));
assert.deepEqual(h.unit('k_1').body, [10, 4]);
assert.equal(h.unit('k_home').s.aspd, 115, 'aura follows the downed body back home');
assert.equal(h.unit('k_away').s.aspd, 100, 'last living tile no longer provides an aura');
checkInvariants(h.b);
});
for (const reason of ['retreat', 'forcedExit', 'merchant']) {
test(`灵巧: ${reason} leaves a down model without preserving the killed-source aura`, () => {
const h = makeBattle({
defs: { chess: { k_1: op('k_1', ['skillfulShip']), k_n: op('k_n', []) } },
bonds: { skillfulShip: bond(1, 5) },
units: [{ chessId: 'k_1', row: 10, col: 4 }, { chessId: 'k_n', row: 10, col: 5 }],
});
h.step();
const source = h.unit('k_1'), recipient = h.unit('k_n');
assert.equal(recipient.s.aspd, 115);
h.b.retreat(source, { reason });
assert.ok(h.b.isDown(source), 'a down model is not proof of being killed');
assert.equal(source.removeReason, reason);
assert.equal(recipient.s.aspd, 100, 'aura stops immediately on retreat');
assert.ok(h.b.redeploy(source, { free: true }));
assert.equal(recipient.s.aspd, 115, 'the living source restores its aura');
h.b.kill(source);
assert.equal(recipient.s.aspd, 115, 'a subsequent real kill keeps the aura');
checkInvariants(h.b);
});
}
test('灵巧: unite carry-down forcedExit starts without an aura and restores it on redeploy', () => {
const h = makeBattle({
kind: 'unite',
defs: { chess: { k_1: op('k_1', ['skillfulShip']), k_n: op('k_n', []) } },
bonds: { skillfulShip: bond(1, 40) },
units: [{ chessId: 'k_1', row: 10, col: 4, carryState: { down: true } }, { chessId: 'k_n', row: 11, col: 5 }],
});
h.step();
const source = h.unit('k_1'), recipient = h.unit('k_n');
assert.equal(source.removeReason, 'forcedExit');
assert.ok(h.b.isDown(source));
assert.equal(recipient.s.aspd, 100, 'forcedExit is not a kill in this battle');
assert.ok(h.b.redeploy(source, { free: true }));
assert.equal(recipient.s.aspd, 150, 'the redeployed source covers all eight neighbours');
checkInvariants(h.b);
});
@@ -414,8 +546,9 @@ test('突袭 #51: the most advanced enemy out of reach → the jump goes to the
// GitHub #49: skills.js `ready` is false for every passive skill, so a passive 突袭 member (缄默德克萨斯, 宴 …) only jumped on
// the 10 s idle trigger; the reporter's footage of the official game shows 缄默德克萨斯 jumping within her passive's 10 s.
// raidPoll (only there) counts a passive skill that is on as 技能就绪; the landing rule (#51) still keeps it from hopping.
test('突袭 #49: a passive skill that is on counts as 技能就绪 — 缄默德克萨斯 jumps within a few seconds, then stays while her enemy is in range', () => {
// raidPoll (only there) counts a passive skill that is on as 技能就绪, and — since #109 made her skills deploy-timed
// duration skills — a deploy-timed skill while its window runs; the landing rule (#51) still keeps it from hopping.
test('突袭 #49: a passive skill that is on, or a deploy-timed skill while it runs (#109), counts as 技能就绪 — 缄默德克萨斯 jumps within a few seconds, then stays while her enemy is in range', () => {
const tex = 'chess_char_4_16_a';
const h = makeBattle({
defs: { enemies: DUMMY }, bonds: { raidShip: bond(1, 10) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 8] }],
@@ -423,7 +556,7 @@ test('突袭 #49: a passive skill that is on counts as 技能就绪 — 缄默
});
h.step();
const u = h.unit(tex), e = h.enemy('enemy_addon_dummy');
assert.deepEqual([u.skill.kind, u.skill.ready, u.skill.active], ['passive', false, true], 'her passive: on, and the global `ready` unchanged');
assert.deepEqual([u.skill.kind, !!u.skill.spec.activateOnDeploy, u.skill.ready, u.skill.active], ['duration', true, false, true], 'her deploy-timed skill (#109): on, and the global `ready` false');
assert.ok(!inRange(u, e), 'nothing in her range');
assert.ok(h.runUntil(() => raidJumps(h).length > 0, 3), 'jumped within 3 s (no 10 s idle wait)');
assert.ok(h.b.time < bondBb('raidShip').no_attack_duration - 5, `at ${h.b.time.toFixed(2)} s`);
@@ -433,6 +566,25 @@ test('突袭 #49: a passive skill that is on counts as 技能就绪 — 缄默
checkInvariants(h.b);
});
test('突袭 #49 with #109: a deploy-timed skill counts only while its window runs — once it has ended, only the idle trigger is left', () => {
const tex = 'chess_char_4_16_a';
const idle = bondBb('raidShip').no_attack_duration;
const h = makeBattle({
defs: { enemies: DUMMY }, bonds: { raidShip: bond(1, 10) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 8] }],
units: [{ chessId: tex, row: 12, col: 3 }], hooks: ['deploy', 'death'], autoFinish: false, timeLimit: 60,
});
h.step();
const u = h.unit(tex);
u.skill.end('duration'); // her window over before the first poll, as after its duration
assert.deepEqual([u.skill.active, u.skill.ready], [false, false], 'ended: no charge until the next deployment');
h.run(3);
assert.equal(raidJumps(h).length, 0, 'no 技能就绪 jump once the window has ended');
assert.ok(h.runUntil(() => raidJumps(h).length > 0, idle + 1), 'the idle trigger');
assert.ok(h.b.time >= idle - 1e-6, `at ${h.b.time.toFixed(2)} s`);
assert.ok(u.skill.active, 'the landing is a new deployment: a new window');
checkInvariants(h.b);
});
test('突袭 #49: non-passive members unchanged — a charged skill jumps at once, an uncharged one waits for the idle time; a passive one jumps at once', () => {
const idle = bondBb('raidShip').no_attack_duration;
const defs = {
+132 -78
View File
@@ -374,38 +374,39 @@ test('阿戈尔 devour order: left first, then top first (row 0 is the bottom: t
checkInvariants(h.b);
});
test('阿戈尔 5: the first 3 members by position (left column first, top first) revive at once on their first knock-out, whatever the order of knock-outs; the others never; a second knock-out is final', () => {
test('阿戈尔 5: the 3 slots go to the first 3 members knocked out, wherever they stand; a later first knock-out stays down; a second knock-out is final; 3 members: none', () => {
const list = [];
for (let i = 0; i < 5; i++) list.push([`g${i}_a`, ['egirShip'], { skill: { spCost: 30, initSp: 5, duration: 10 } }]);
// one per row pair so nobody devours anybody: col 3 rows 9–12 (g0 bottom … g3 top), g4 at (9,5). PRTS 盟约记录 阿戈尔
// "前3名【阿戈尔】干员首次被击倒时立刻复活", the first 3 being the first deployed — "更靠左和靠上": g3, g2, g1
// "前3名【阿戈尔】干员首次被击倒时立刻复活"; the owner's decision of 2026-10-05: the first 3 knocked out, in knock-out order
const units = list.map(([id], i) => ({ chessId: id, row: 9 + (i % 4), col: 3 + 2 * Math.floor(i / 4) }));
const h = makeBattle({ defs: defsOf(list), units, bonds: { egirShip: bondOn(5, 0, null, [3, 5]) }, hooks: ['death', 'deploy'] });
h.step(1);
const us = list.map(([id]) => h.unit(id));
const kill = (u) => h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
const redeploys = (u) => h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial).length;
// knocked out first, yet no beneficiary: g0 (bottom of the first column) and g4 (second column) stay down
for (const u of [us[0], us[4]]) {
kill(u);
assert.ok(!u.alive, `${u.defId} is not among the first 3 by position: no revive`);
assert.equal(redeploys(u), 0);
}
us[1].skill.gainSp(20, 'test');
us[0].skill.gainSp(20, 'test');
const tiles = us.map((u) => [u.tileR, u.tileC]);
for (const i of [1, 3, 2]) {
// g4 (second column) and g0 (bottom of the first column) first — the last two by position — then g2
for (const i of [4, 0, 2]) {
const u = us[i];
const before = redeploys(u);
kill(u);
assert.ok(u.alive && u.deployed, `member ${i} back on the field`);
close(u.hp, u.s.maxHp);
assert.deepEqual([u.tileR, u.tileC], tiles[i], 'same tile');
assert.ok(h.hooksOf('death').some((c) => c.unit === u && c.reason === 'killed'), 'it was knocked out (被击倒 triggers fire)');
assert.equal(redeploys(u), before + 1, `member ${i} redeployed`);
assert.equal(redeploys(u), 1, `member ${i} redeployed`);
}
assert.equal(us[1].skill.sp, 5, 'a redeploy restarts from the initial SP');
kill(us[1]);
assert.ok(!us[1].alive, 'second knock-out is final');
assert.equal(us[0].skill.sp, 5, 'a redeploy restarts from the initial SP');
// the 3 are gone: g3 (top-left, first by position) and g1 stay down on their first knock-out
for (const i of [3, 1]) {
kill(us[i]);
assert.ok(!us[i].alive, `member ${i}: no slot left`);
assert.equal(redeploys(us[i]), 0);
}
kill(us[4]);
assert.ok(!us[4].alive, 'a second knock-out is final');
assert.equal(h.eventsOf('fx').filter((x) => x[1] === 'revive').length, 3, 'three revives');
checkInvariants(h.b);
// a member moved off its board tile (突袭 / relocation) revives where it was knocked out; later redeploys go home
const hm = makeBattle({ defs: defsOf(list), units, bonds: { egirShip: bondOn(5, 0, null, [3, 5]) }, hooks: [] });
@@ -424,44 +425,126 @@ test('阿戈尔 5: the first 3 members by position (left column first, top first
assert.ok(!h3.unit('g3_a').alive);
});
test('阿戈尔 5 + the devour chain (GitHub #33): the devour knocks out the food, only the members among the first 3 by position come back; the others keep theirs for the fight (normal and 联防)', () => {
// one row, all facing right: 浊心斯卡蒂 → 乌尔比安 → 幽灵鲨 → 海霓 → 深巡 → 隐现. PRTS 盟约记录 阿戈尔 备注 "标记按付与顺序触发
// 【吞噬】效果,目标首次被击倒后解除自身被付与但还未触发的【吞噬】效果". The 3 revives belong to the first 3 by position
// (浊心斯卡蒂, 乌尔比安, 幽灵鲨 — community reports 「阿戈尔盟约复活有bug,只能复活一个干员」); until 0.1.3 the first 3
// knocked out took them, so the food (幽灵鲨, 海霓, 深巡) spent all 3 at t = 0 and nobody came back in the fight
const ids = ['chess_char_6_04_a', 'chess_char_5_05_a', 'chess_char_2_07_a', 'chess_char_3_09_a', 'chess_char_1_04_a', 'chess_char_1_01_a'];
test('阿戈尔 5 + the devour chain (GitHub #33, #105, #140): the eaten members take the 3 slots in knock-out order, uneaten ones keep none — survivors = uneaten + 3 (normal and 联防)', () => {
// row 10, all facing right: 浊心斯卡蒂 → 幽灵鲨 → 海霓 → 深巡 → 歌蕾蒂娅 → 隐现; 乌尔比安 on row 12 faces an empty tile.
// PRTS 盟约记录 阿戈尔 备注 "标记按付与顺序触发【吞噬】效果,目标首次被击倒后解除自身被付与但还未触发的【吞噬】效果". Until 0.1.3
// the slots belonged to the first 3 by position (浊心斯卡蒂, 乌尔比安, 幽灵鲨: two uneaten members held them, so only 幽灵鲨
// of the food came back — "没被吃的阿戈尔干员也会占用复活名额", "无论食物链怎么吃最终都只有三名阿戈尔人存活")
const ids = ['chess_char_6_04_a', 'chess_char_2_07_a', 'chess_char_3_09_a', 'chess_char_1_04_a', 'chess_char_4_12_a', 'chess_char_1_01_a'];
const ULPIA = 'chess_char_5_05_a';
for (const kind of ['normal', 'unite']) {
const h = makeBattle({
kind, units: ids.map((chessId, i) => ({ chessId, row: 10, col: 2 + i })), bonds: { egirShip: bondOn(5, 0, null, [3, 5]) },
hooks: ['damaged', 'death', 'deploy'], captureNoisy: true, autoFinish: false, timeLimit: 60,
kind, units: [...ids.map((chessId, i) => ({ chessId, row: 10, col: 2 + i })), { chessId: ULPIA, row: 12, col: 3 }],
bonds: { egirShip: bondOn(6, 0, null, [3, 5]) }, hooks: ['damaged', 'death', 'deploy'], captureNoisy: true, autoFinish: false, timeLimit: 60,
});
h.step(1);
const [skadi, ulpia, ghost, hn, deep, fodder] = ids.map((id) => h.unit(id));
// 浊心斯卡蒂 marks the whole row and resolves first; every later mark is on a unit already knocked out (乌尔比安's), or
// comes from one (幽灵鲨's, 海霓's, 深巡's)
const [skadi, ghost, hn, deep, glady, fodder] = ids.map((id) => h.unit(id));
const ulpia = h.unit(ULPIA);
// 浊心斯卡蒂 marks the whole row (through the members) and resolves first; every later mark is on a unit knocked out
assert.deepEqual(tagged(h, 'bond:egir:devour').map((c) => [c.source.defId, c.target.defId]),
[ulpia, ghost, hn, deep, fodder].map((t) => [skadi.defId, t.defId]), kind);
[ghost, hn, deep, glady, fodder].map((t) => [skadi.defId, t.defId]), kind);
const kos = (u) => h.hooksOf('death').filter((c) => c.unit === u && c.reason === 'killed').length;
const revived = (u) => h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial).length;
for (const u of [ghost, hn, deep]) {
assert.equal(h.hooksOf('death').filter((c) => c.unit === u && c.reason === 'killed').length, 1, `${kind}: ${u.defId} knocked out once`);
}
assert.equal(revived(ghost), 1, `${kind}: 幽灵鲨 (third by position) revived at once`);
assert.equal(revived(hn) + revived(deep), 0, `${kind}: 海霓 and 深巡 (fourth, fifth) not revived`);
for (const u of [ghost, hn, deep, glady]) assert.equal(kos(u), 1, `${kind}: ${u.defId} knocked out once`);
for (const u of [ghost, hn, deep]) assert.equal(revived(u), 1, `${kind}: ${u.defId} (knocked out 1st–3rd) revived at once`);
assert.equal(revived(glady), 0, `${kind}: 歌蕾蒂娅 (knocked out 4th) stays down`);
assert.equal(kos(skadi) + kos(ulpia) + revived(skadi) + revived(ulpia), 0, `${kind}: the uneaten members take no slot`);
h.run(3);
for (const u of [skadi, ulpia, ghost]) assert.ok(u.alive && u.deployed && u.hp > 0, `${kind}: ${u.defId} standing`);
for (const u of [hn, deep, fodder]) assert.ok(!u.alive, `${kind}: ${u.defId} down`);
// 浊心斯卡蒂 and 乌尔比安 still hold theirs: their first knock-out in the fight is revived, 幽灵鲨's second is final
for (const u of [skadi, ulpia]) {
const members = [skadi, ghost, hn, deep, glady, ulpia];
assert.deepEqual(members.filter((u) => u.alive).map((u) => u.defId), [skadi, ghost, hn, deep, ulpia].map((u) => u.defId),
`${kind}: survivors = the 2 uneaten + 3`);
assert.ok(!fodder.alive, `${kind}: the fodder (no member) is down`);
// nothing is left for the fight: 浊心斯卡蒂's first knock-out and 幽灵鲨's second stay down
for (const u of [skadi, ghost]) {
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.ok(u.alive && revived(u) === 1, `${kind}: ${u.defId} revived in the fight`);
assert.ok(!u.alive, `${kind}: ${u.defId} stays down`);
}
h.b.dealDamage(null, ghost, { amount: 1e9, type: 'true' });
assert.ok(!ghost.alive, `${kind}: 幽灵鲨 has no revive left`);
checkInvariants(h.b);
}
});
test('阿戈尔 5 beneficiaries: fixed at battle start — a 联防 member entering down keeps the slot, a 调和 member is one, the mirrored boss side counts from its own left', () => {
test('阿戈尔 5: a member devoured by several 阿戈尔 spends one slot at most — its pending marks are cancelled once it is knocked out', () => {
// A (10,3) → B (10,4) → X (10,5) and C (11,5, facing DOWN) → X: three marks on X; D (12,8) faces an empty tile
const list = [['A_a', ['egirShip']], ['B_a', ['egirShip']], ['X_a', ['egirShip']], ['C_a', ['egirShip']], ['D_a', ['egirShip']]];
const defs = defsOf(list.map(([id, b]) => [id, b, { stats: { maxHp: 3000 } }]));
const units = [
{ chessId: 'A_a', row: 10, col: 3 }, { chessId: 'B_a', row: 10, col: 4 }, { chessId: 'X_a', row: 10, col: 5 },
{ chessId: 'C_a', row: 11, col: 5, dir: 'DOWN' }, { chessId: 'D_a', row: 12, col: 8 },
];
const h = makeBattle({ defs, units, bonds: { egirShip: bondOn(5, 0, null, [3, 5]) }, hooks: ['damaged', 'death', 'deploy'], captureNoisy: true, autoFinish: false, timeLimit: 60 });
h.step(1);
const [a, b, x, c, d] = ['A_a', 'B_a', 'X_a', 'C_a', 'D_a'].map((id) => h.unit(id));
const kos = (u) => h.hooksOf('death').filter((e) => e.unit === u && e.reason === 'killed').length;
const revived = (u) => h.hooksOf('deploy').filter((e) => e.unit === u && !e.initial).length;
// marked by A (through B), by B and by C; A's mark knocks it out, B's and C's are cancelled
assert.deepEqual(tagged(h, 'bond:egir:devour').map((e) => [e.source.defId, e.target.defId]), [['A_a', 'B_a'], ['A_a', 'X_a']]);
for (const u of [b, x]) assert.ok(u.alive && kos(u) === 1 && revived(u) === 1, `${u.defId}: one knock-out, one slot`);
close(x.hp, x.s.maxHp, 1e-6, 'X at full HP: no second mark');
// two slots used: D's first knock-out takes the third, C's stays down
h.b.dealDamage(null, d, { amount: 1e9, type: 'true' });
assert.ok(d.alive, 'D: the third slot');
h.b.dealDamage(null, c, { amount: 1e9, type: 'true' });
assert.ok(!c.alive, 'C: none left');
assert.ok(a.alive && kos(a) === 0, 'A never knocked out');
checkInvariants(h.b);
// X with more HP survives A's and B's marks and falls to C's: still one knock-out, one slot
const tough = defsOf(list.map(([id, bd]) => [id, bd, { stats: { maxHp: id === 'X_a' ? 8000 : 3000 } }]));
const t = makeBattle({ defs: tough, units, bonds: { egirShip: bondOn(5, 0, null, [3, 5]) }, hooks: ['damaged', 'death', 'deploy'], captureNoisy: true, autoFinish: false, timeLimit: 60 });
t.step(1);
const tx = t.unit('X_a');
assert.deepEqual(tagged(t, 'bond:egir:devour').map((e) => [e.source.defId, e.target.defId]), [['A_a', 'B_a'], ['A_a', 'X_a'], ['B_a', 'X_a'], ['C_a', 'X_a']]);
assert.equal(t.hooksOf('death').filter((e) => e.unit === tx && e.reason === 'killed').length, 1, 'X knocked out once, by the third mark');
assert.ok(tx.alive && t.eventsOf('fx').filter((e) => e[1] === 'revive').length === 2, 'B and X: two slots');
checkInvariants(t.b);
});
test('阿戈尔 5: an operator\'s own save uses no slot (斯卡蒂\'s DRE-Y, M3茧甲, 埃芒加德 [ASSUMED for the band]); the slot waits for its first real knock-out', () => {
// 斯卡蒂 (elite, module DRE-Y by default) enters 联防 at half HP in front of 乌尔比安: his mark is lethal, DRE-Y saves her
// (PRTS: once per deployment, full HP with max HP −60 %) — no knock-out, so no slot
const SKADI = 'chess_char_3_05_b', ULPIA = 'chess_char_5_05_a', GHOST = 'chess_char_2_07_a', HN = 'chess_char_3_09_a', DEEP = 'chess_char_1_04_a';
const h = makeBattle({
kind: 'unite', autoFinish: false, timeLimit: 60, hooks: ['damaged', 'death', 'deploy'], captureNoisy: true,
units: [
{ chessId: ULPIA, row: 10, col: 3 }, { chessId: SKADI, row: 10, col: 4, dir: 'UP', carryState: { hpPct: 0.5 } },
{ chessId: GHOST, row: 12, col: 3 }, { chessId: HN, row: 12, col: 5 }, { chessId: DEEP, row: 12, col: 7 },
],
bonds: { egirShip: bondOn(5, 0, null, [3, 5]) },
});
h.step(1);
const sk = h.unit(SKADI);
assert.deepEqual(tagged(h, 'bond:egir:devour').map((c) => [c.source.defId, c.target.defId]), [[ULPIA, SKADI]]);
assert.ok(sk.alive && buffOf(sk, 'trait:skadi_tide'), 'saved by her module');
assert.equal(h.hooksOf('death').filter((c) => c.unit === sk).length, 0, 'not knocked out');
assert.equal(h.eventsOf('fx').filter((x) => x[1] === 'revive' && x[4] && x[4].n).length, 0, 'no 阿戈尔 revive');
const kill = (id) => { const u = h.unit(id); h.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); return u.alive; };
// all 3 slots are still there: 幽灵鲨, 海霓 and then 斯卡蒂 (DRE-Y spent in this deployment) come back, 深巡 does not
assert.deepEqual([GHOST, HN, SKADI, DEEP].map(kill), [true, true, true, false]);
checkInvariants(h.b);
const list = [['g0_a', ['egirShip']], ['g1_a', ['egirShip']], ['g2_a', ['egirShip']], ['g3_a', ['egirShip']], ['g4_a', ['egirShip']]];
const units = (it = {}) => list.map(([chessId], i) => ({ chessId, row: 9 + (i % 2) * 3, col: 3 + i, items: it[i] }));
const killed = (b, id) => { const u = b.unit(id); b.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); return u; };
// M3茧甲 (an item 复活, in place): g0 is saved, the 3 slots go to g1, g2, g3; g4 stays down
const hi = makeBattle({ defs: defsOf(list), autoFinish: false, timeLimit: 60, hooks: ['death', 'deploy'], units: units({ 0: ['chess_item_4_12_e_a'] }), bonds: { egirShip: bondOn(5, 0, null, [3, 5]) } });
hi.step(1);
const g0 = killed(hi, 'g0_a');
assert.ok(g0.alive && hi.hooksOf('death').filter((c) => c.unit === g0).length === 0, 'item: saved in place (no knock-out)');
assert.deepEqual(['g1_a', 'g2_a', 'g3_a', 'g4_a'].map((id) => killed(hi, id).alive), [true, true, true, false], 'item: three slots left after the save');
checkInvariants(hi.b);
// 埃芒加德 (命结之秘: the battle's first 3 knock-downs revive in place — its own count, unchanged): g0, g1, g2 saved by it;
// the 阿戈尔 slots then go to g3, g4 and g0's first real knock-out; g1's real one finds none
const hb = makeBattle({ defs: defsOf(list), autoFinish: false, timeLimit: 60, hooks: ['death', 'deploy'], units: units(), bandId: 'band_ermengard', bonds: { egirShip: bondOn(5, 0, null, [3, 5]) } });
hb.step(1);
for (const id of ['g0_a', 'g1_a', 'g2_a']) {
const u = killed(hb, id);
assert.ok(u.alive && hb.hooksOf('death').filter((c) => c.unit === u).length === 0, `band: ${id} saved in place`);
}
assert.deepEqual(hb.eventsOf('fx').filter((x) => x[1] === 'revive' && x[4] && x[4].left != null).map((x) => x[4].left), [2, 1, 0], '埃芒加德 spent its 3');
assert.deepEqual(['g3_a', 'g4_a', 'g0_a', 'g1_a'].map((id) => killed(hb, id).alive), [true, true, true, false], 'band: the 阿戈尔 slots are untouched');
checkInvariants(hb.b);
});
test('阿戈尔 5 slots in 联防 and on the boss field: a member entering down takes none until it is knocked out after standing up; a 调和 member takes one; a mirrored player counts its own knock-outs', () => {
const list = [['g0_a', ['egirShip']], ['g1_a', ['egirShip']], ['g2_a', ['egirShip']], ['g3_a', ['egirShip']], ['g4_a', ['egirShip']], ['mani_a', ['maniShip']]];
// five members in five columns (one per column, nobody in front of anybody): g0 col 3 … g4 col 7
const units = list.slice(0, 5).map(([chessId], i) => ({ chessId, row: 9 + (i % 2) * 3, col: 3 + i }));
@@ -470,8 +553,8 @@ test('阿戈尔 5 beneficiaries: fixed at battle start — a 联防 member enter
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
return u.alive;
};
// 联防: g0 entered down (forced out before battleStart). The slots stay the first 3 by position — g0, g1, g2.
// The forced exit is not a 击倒, so g0's charge waits until they stand back up. g3 is not promoted into it.
// 联防: g0 entered down (forced out before battleStart — not a 击倒, so no slot); g4 and g3 take two slots, g0's first
// knock-out after it stands back up takes the third, g1 finds none
const hu = makeBattle({
kind: 'unite', defs: defsOf(list), autoFinish: false, timeLimit: 60, bonds: { egirShip: bondOn(5, 0, null, [3, 5]) },
units: units.map((u, i) => (i === 0 ? { ...u, carryState: { down: true } } : u)),
@@ -479,59 +562,30 @@ test('阿戈尔 5 beneficiaries: fixed at battle start — a 联防 member enter
hu.step(1);
const g0 = hu.unit('g0_a');
assert.ok(!g0.alive, 'g0 forced out');
assert.deepEqual(['g4_a', 'g3_a', 'g1_a', 'g2_a'].map((id) => revives(hu, id)), [false, false, true, true]);
assert.deepEqual(['g4_a', 'g3_a'].map((id) => revives(hu, id)), [true, true]);
assert.ok(hu.runUntil(() => g0.alive && g0.deployed, 25), 'g0 stands back up inside the redeploy time');
assert.equal(revives(hu, 'g0_a'), true, 'g0 still held the first slot');
hu.b.dealDamage(null, g0, { amount: 1e9, type: 'true' });
assert.ok(!g0.alive, 'g0 has no second revive');
assert.equal(revives(hu, 'g0_a'), true, 'g0: its first knock-out takes the third slot');
assert.equal(revives(hu, 'g1_a'), false, 'g1: none left');
checkInvariants(hu.b);
// 调和 (an active 调和 member enjoys the core bonds) in the first column: one of the 3
// 调和 (an active 调和 member enjoys the core bonds): knocked out first, it takes the first slot
const hm = makeBattle({
defs: defsOf(list), autoFinish: false, timeLimit: 60,
units: [{ chessId: 'mani_a', row: 11, col: 2 }, ...units.slice(0, 4)],
bonds: { egirShip: bondOn(5, 0, null, [3, 5]), maniShip: { count: 1, active: true, tier: 1, layers: 0 } },
});
hm.step(1);
assert.deepEqual(['g2_a', 'mani_a', 'g0_a', 'g1_a'].map((id) => revives(hm, id)), [false, true, true, true]);
// the right-hand boss player (mirrored): its board's left is the field's right — board cols 3, 4, 5 = field cols 17,
// 16, 15 hold the revives (read in field columns from the left they would go to g4, g3, g2)
assert.deepEqual(['mani_a', 'g3_a', 'g2_a', 'g0_a'].map((id) => revives(hm, id)), [true, true, true, false]);
// the right-hand boss player (mirrored): the order of its own knock-outs, wherever they stand
const hb = makeBattle({
kind: 'boss', defs: defsOf(list), autoFinish: false, timeLimit: 60,
players: [{ playerId: 'R1', side: 'R', colOffset: 8, units: units.map((u, i) => ({ ...u, uid: i + 1 })), bonds: { egirShip: bondOn(5, 0, null, [3, 5]) } }],
});
hb.step(1);
assert.deepEqual(['g4_a', 'g3_a', 'g0_a', 'g1_a', 'g2_a'].map((id) => [hb.unit(id).tileC, revives(hb, id)]),
[[13, false], [14, false], [17, true], [16, true], [15, true]]);
[[13, true], [14, true], [17, true], [16, false], [15, false]]);
checkInvariants(hb.b);
});
test('阿戈尔 5: a beneficiary 埃芒加德 or M3茧甲 save in place has used its 阿戈尔 revive [ASSUMED]; the others keep theirs', () => {
const list = [['g0_a', ['egirShip']], ['g1_a', ['egirShip']], ['g2_a', ['egirShip']], ['g3_a', ['egirShip']], ['g4_a', ['egirShip']]];
const units = (it = {}) => list.map(([chessId], i) => ({ chessId, row: 9 + (i % 2) * 3, col: 3 + i, items: it[i] }));
const kill = (h, id) => { const u = h.unit(id); h.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); return u; };
for (const how of ['band', 'item']) {
const h = makeBattle({
defs: defsOf(list), autoFinish: false, timeLimit: 60, hooks: ['death', 'deploy'],
units: units(how === 'item' ? { 0: ['chess_item_4_12_e_a'] } : {}), ...(how === 'band' ? { bandId: 'band_ermengard' } : {}),
bonds: { egirShip: bondOn(5, 0, null, [3, 5]) },
});
h.step(1);
// g0, g1, g2 (columns 3–5) hold the 阿戈尔 revives; g0 is saved in place first
const g0 = kill(h, 'g0_a');
assert.ok(g0.alive && h.hooksOf('death').filter((c) => c.unit === g0).length === 0, `${how}: saved in place (no knock-out)`);
// 埃芒加德 saves the battle's first 3 knock-downs: its other 2 go to g3 and g4 (no 阿戈尔 beneficiaries)
if (how === 'band') for (const id of ['g3_a', 'g4_a']) assert.ok(kill(h, id).alive, 'band revive');
kill(h, 'g0_a');
assert.ok(!g0.alive, `${how}: its 阿戈尔 revive went with the save — its first knock-out is final`);
assert.equal(h.hooksOf('deploy').filter((c) => c.unit === g0 && !c.initial).length, 0);
for (const id of ['g1_a', 'g2_a']) {
const u = kill(h, id);
assert.ok(u.alive && h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial).length === 1, `${how}: ${id} revived by 阿戈尔`);
}
checkInvariants(h.b);
}
});
test('阿戈尔 devour, a unit back during the pass: a marker revived by the 5-tier revive still gives its marks; a target revived in place (埃芒加德) or redeployed (不屈) takes none', () => {
// g1 (10,3) → g2 (10,4, 3000 × 1.35 HP: knocked out by g1's mark, revived) → g3 (10,5) → fodder (10,6); g4 / g5 face empty tiles
const list = [
+142
View File
@@ -0,0 +1,142 @@
// 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);
});
+18
View File
@@ -165,6 +165,24 @@ test('炎国短刀: each skill activation ATK +5 % / +8 % (one multiplier), at m
}
});
test('炎国短刀: timed deployment skill adds one stack per deployment, including carry.skillActive', () => {
const id = 'chess_char_1_19_a';
for (const [item, per] of [[A('3_04'), 0.05], [B('3_04'), 0.08]]) {
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 4, skillIndex: 0, items: [item], carryState: { skillActive: true } }] });
h.b.start();
const u = h.unit(id);
assert.equal(u.skill.activations, 1);
close(u.s.atk, u.base.atk * (1 + per), 'one deployment stack');
h.run(u.skill.duration + 1);
close(u.s.atk, u.base.atk * (1 + per), 'no extra stack on end');
h.b.retreat(u);
assert.ok(h.b.redeploy(u, { free: true }));
assert.equal(u.skill.activations, 2);
close(u.s.atk, u.base.atk * (1 + per * 2), 'one more on redeployment');
checkInvariants(h.b);
}
});
test('迅捷作战粮: on deploy SP +3 / +6, plus as much per other operator sharing a bond', () => {
for (const [id, each] of [[A('3_05'), 3], [B('3_05'), 6]]) {
const ops = { t_op: op('t_op', { bonds: ['swiftShip'] }), t_a: op('t_a', { bonds: ['swiftShip'] }), t_b: op('t_b', { bonds: ['yanShip'] }) };
+54 -4
View File
@@ -444,6 +444,8 @@ test('1_18 宴 S1 分神: no attacks, block 0 (releases), DEF +def, regenerates
assert.equal(u.s.blockCnt, 0);
assert.equal(u.blocking.length, 0);
assert.equal(e.blockedBy, null, 'blocked enemy released');
h.b.dealDamage(u, e, { amount: 100, type: 'phys', isAttack: true });
assert.equal(dealt(h, u).at(-1).type, 'phys', 'active S1 does not inherit S2 arts conversion');
approx(u.s.def, u.base.def * (1 + bb.def), `${id} DEF`);
approx(u.s.hpRegen, u.s.maxHp * bb.hp_recovery_per_sec_by_max_hp_ratio, 'regen');
u.hp = u.s.maxHp * 0.3;
@@ -462,23 +464,71 @@ test('1_18 宴 S1 分神: no attacks, block 0 (releases), DEF +def, regenerates
}
});
test('1_19 野鬃 S1 骑枪刺击 (PASSIVE): ASPD +attack_speed for `duration` s after every deployment', () => {
test('1_18 宴 S2: deployment loses current HP once; ATK and normal-attack arts conversion end with the duration', () => {
for (const id of pair('18')) {
const s = rec(id, 'skchr_utage_2'), bb = s.bb;
const h = run({ defs: { chess: noGarrison(id), enemies: { e: dummy('e') } },
units: [entry(id, s.skillId, { row: 10, col: 4, carryState: { hpPct: 0.5, skillActive: true } })] });
h.b.start();
const u = h.unit(id);
assert.equal(u.skill.kind, 'duration');
assert.equal(u.skill.activations, 1);
assert.equal(u.skill.charges, 0);
approx(u.hp, u.s.maxHp * 0.5 * (1 - bb.hp_ratio), 'one loss from carried current HP');
approx(u.s.atk, u.base.atk * (1 + bb.atk));
const e = h.spawn('e', { pos: [10, 8] });
const hit = () => { h.b.dealDamage(u, e, { amount: 100, type: 'phys', isAttack: true }); return dealt(h, u).at(-1); };
assert.equal(hit().type, 'arts');
assert.ok(!hit().dmg.isSkill, 'normal attack marker retained');
h.run(bb.duration / 2);
approx(u.skill.timeLeft, bb.duration / 2);
h.run(bb.duration / 2 + 0.1);
assert.equal(u.skill.active, false);
assert.equal(u.skill.ready, false);
approx(u.s.atk, u.base.atk);
assert.equal(hit().type, 'phys');
assert.deepEqual(ended(h, u).map((c) => c.reason), ['duration']);
const hp = u.hp;
h.run(3);
approx(u.hp, hp, 'no second loss while staying deployed');
h.b.retreat(u);
assert.ok(h.b.redeploy(u, { free: true }));
assert.equal(u.skill.activations, 2);
approx(u.hp, u.s.maxHp * (1 - bb.hp_ratio), 'new deployment loses current HP once');
done(h);
}
});
test('1_19 野鬃 S1 骑枪刺击: ASPD +attack_speed for `duration` s after every deployment', () => {
for (const id of pair('19')) {
const s = rec(id, 'skchr_wildmn_1');
const h = run({ defs: { chess: noGarrison(id) }, units: [entry(id, s.skillId, { row: 9, col: 5 })] });
const u = sel(h, id, s.skillId);
assert.equal(u.skill.kind, 'passive');
h.step();
h.b.start();
assert.equal(u.skill.kind, 'duration');
assert.equal(u.skill.active, true);
assert.equal(u.skill.activations, 1);
assert.equal(u.skill.charges, 0);
assert.equal(u.skill.ready, false);
assert.equal(started(h, u)[0].reason, 'deploy');
approx(u.s.aspd, u.base.aspd + s.bb.attack_speed, `${id} ASPD after deployment`);
h.run(s.duration - 0.5);
h.run(s.duration / 2);
approx(u.skill.timeLeft, s.duration / 2);
assert.deepEqual(h.snapshot().units.find((t) => t[0] === u.id).slice(5, 7), [s.duration / 2, s.duration]);
h.run(s.duration / 2 - 0.5);
approx(u.s.aspd, u.base.aspd + s.bb.attack_speed, 'still up');
h.run(1);
approx(u.s.aspd, u.base.aspd, `expired after ${s.duration} s`);
assert.equal(u.skill.active, false);
assert.equal(u.skill.ready, false);
assert.deepEqual(ended(h, u).map((c) => c.reason), ['duration']);
h.b.kill(u, null);
h.step();
assert.ok(h.b.redeploy(u, { free: true }));
h.step();
approx(u.s.aspd, u.base.aspd + s.bb.attack_speed, 'again after the redeployment');
assert.equal(u.skill.activations, 2);
assert.equal(ended(h, u).length, 1, 'expired skill does not end again on death');
done(h);
}
});
+8
View File
@@ -342,12 +342,20 @@ test('2_12 砾 S1 影袭: at deployment DEF +def decaying to 0 over `duration` s
h.run(0.5);
approx(u.s.def, u.base.def * (1 + aura + bb.def), `${id} full bonus`);
assert.equal(u.s.shield, 0, 'no 鼠群 barrier');
assert.equal(u.skill.kind, 'duration');
assert.equal(u.skill.active, true);
assert.equal(u.skill.ready, false);
assert.equal(h.snapshot().units.find((t) => t[0] === u.id)[6], bb.duration);
h.run(1);
approx(u.s.def, u.base.def * (1 + aura + bb.def * (bb.duration - 1) / bb.duration), 'one step down after 1 s');
h.run(0.4);
approx(u.s.def, u.base.def * (1 + aura + bb.def * (bb.duration - 1) / bb.duration), 'steps once per second');
h.run(bb.duration);
approx(u.s.def, u.base.def * (1 + aura), 'gone');
assert.equal(u.skill.active, false);
assert.equal(u.skill.ready, false);
assert.equal(u.findBuff('gravel:shadow'), null);
assert.equal(h.hooksOf('skillEnd').filter((c) => c.unit === u && c.reason === 'duration').length, 1);
done(h);
}
});
+21 -6
View File
@@ -369,19 +369,34 @@ test('3_05 斯卡蒂 S1 迅捷打击·γ型 ATK/ASPD; S2 跃浪击 ATK + for `du
const g = makeBattle({ timeLimit: 60, units: [U(id, 'skchr_skadi_2', 10, 4)] });
const v = g.unit(id);
g.step();
assert.equal(v.skill.kind, 'passive');
assert.equal(v.skill.kind, 'duration');
assert.equal(v.skill.active, true);
assert.equal(v.skill.charges, 0);
assert.equal(v.skill.ready, false);
assert.equal(g.hooksOf('skillStart')[0].reason, 'deploy');
approx(v.s.atk, v.base.atk * (1 + b2.atk + own));
if (id.endsWith('_b')) {
const left = v.skill.timeLeft;
g.b.dealDamage(null, v, { type: 'true', amount: 1e7 });
assert.equal(v.alive, true, 'DRE-Y revives in place during the window');
assert.equal(v.skill.activations, 1);
approx(v.skill.timeLeft, left, 1e-9, 'revival preserves the remaining skill time');
}
g.run(b2.duration - 0.5);
approx(v.s.atk, v.base.atk * (1 + b2.atk + own));
g.run(1);
approx(v.s.atk, v.base.atk * (1 + own), 1e-6, 'gone after its duration');
assert.equal(v.skill.active, false);
assert.equal(v.skill.ready, false);
assert.deepEqual(g.hooksOf('skillEnd').map((c) => c.reason), ['duration']);
// redeployed ⇒ again
g.b.dealDamage(null, v, { type: 'true', amount: 1e7 });
if (!v.alive) {
g.b.redeploy(v, { free: true });
g.step();
approx(v.s.atk, v.base.atk * (1 + b2.atk + own), 1e-6, 'every deployment');
}
assert.equal(v.skill.activations, 1, 'DRE-Y in-place revival does not deploy or restart S2');
if (v.alive) g.b.retreat(v);
assert.ok(g.b.redeploy(v, { free: true }));
approx(v.skill.timeLeft, b2.duration);
assert.equal(v.skill.activations, 2);
approx(v.s.atk, v.base.atk * (1 + b2.atk + own), 1e-6, 'every deployment');
done(g);
}
// DRE-X: ×atk_scale on blocked enemies; no DRE-Y revive
+87 -9
View File
@@ -265,6 +265,13 @@ test('伊内丝 S3 独影归途: first deployment leaves a sentry and retreats;
const sk = D(id, 2).skill, bb = sk.bb;
const h = battle([U(id, 10, 5, 2)], { flags: { dpInit: 99, dpPerSec: 0 } });
const u = h.unit(id);
h.b.start();
assert.equal(u.skill.kind, 'duration');
assert.equal(u.skill.active, false, 'first deployment does not activate the skill');
assert.equal(u.skill.activations, 0);
assert.equal(h.hooksOf('skillStart').filter((c) => c.unit === u).length, 0, 'placing a sentry emits no skillStart');
assert.equal(u.skill.timeLeft, 0, 'first deployment places the sentry without a full effect window');
assert.equal(u.findBuff('ines:s3'), null);
const near = [];
for (let i = 0; i < 3; i++) near.push(h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_dummy', { pos: [10, 4] }));
const far = h.spawn('enemy_dummy', { pos: [10, 8] });
@@ -273,9 +280,14 @@ test('伊内丝 S3 独影归途: first deployment leaves a sentry and retreats;
assert.ok(left && left.reason === 'retreat', `${id}: leaves right after the first deployment`);
assert.ok(h.runUntil(() => u.alive && u.deployed, 2), 'redeploy timer refreshed: back at once');
const buff = u.findBuff('ines:s3');
assert.ok(buff, 'passive buff on the redeploy');
assert.ok(buff, 'ATK effect on the redeploy');
approx(buff.mods.atkPct, bb.atk, 1e-9);
approx(buff.timeLeft, sk.duration, 0.1);
approx(u.skill.timeLeft, sk.duration, 0.1);
assert.equal(u.skill.active, true);
assert.equal(u.skill.activations, 1);
assert.equal(h.hooksOf('skillStart').filter((c) => c.unit === u).length, 1, 'the redeployment starts the skill once');
assert.equal(u.skill.ready, false);
assert.equal(h.snapshot().units.find((t) => t[0] === u.id)[6], sk.duration);
const recall = dmgBy(h, u, tagged('sentryRecall'));
assert.equal(recall.length, bb.max_target, `${id}: recall hits ≤ ${bb.max_target} of 6`);
assert.ok(recall.every((c) => near.includes(c.target)) && !recall.some((c) => c.target === far));
@@ -287,6 +299,15 @@ test('伊内丝 S3 独影归途: first deployment leaves a sentry and retreats;
assert.ok(hits > 0);
assert.equal(h.b.getPlayer('p1').dp, dpAt + hits * bb.cost, 'one DP per damage while the passive lasts');
assert.equal(h.hooksOf('death').filter((c) => c.unit === u).length, 1, 'only the first deployment leaves');
h.run(sk.duration);
assert.equal(u.skill.active, false);
assert.equal(u.skill.ready, false);
assert.equal(u.findBuff('ines:s3'), null);
const dpEnd = h.b.getPlayer('p1').dp;
h.run(2);
assert.equal(h.b.getPlayer('p1').dp, dpEnd, 'no skill DP after the actual window ends');
assert.equal(h.hooksOf('death').filter((c) => c.unit === u).length, 1, 'stays deployed after the window');
assert.deepEqual(h.hooksOf('skillEnd').filter((c) => c.unit === u).map((c) => c.reason), ['duration']);
checkInvariants(h.b);
}
});
@@ -611,10 +632,11 @@ test('缄默德克萨斯 S1 细雨无声: passive ATK up for its duration; hits
const h = battle([U(id, 10, 4, 0)]);
h.step();
const u = h.unit(id);
const buff = u.findBuff('texas2:drizzle');
assert.ok(buff, `${id}: passive active on deployment`);
approx(buff.mods.atkPct, bb.atk, 1e-9);
assert.ok(u.findBuff('texas2:rainAtk'), 'talent 德克萨斯传统 ATK during the passive');
assert.equal(u.skill.kind, 'duration');
assert.equal(u.skill.active, true);
assert.equal(u.skill.charges, 0);
approx(u.skill.spec.mods.atkPct, bb.atk + D(id, 0).talents[0].bb.atk, 1e-9, 'skill and 德克萨斯传统 ATK');
assert.equal(h.snapshot().units.find((t) => t[0] === u.id)[6], sk.duration);
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
h.run(3);
assert.ok(e.s.flags.silence, 'hit target silenced (失去特殊能力)');
@@ -623,7 +645,10 @@ test('缄默德克萨斯 S1 细雨无声: passive ATK up for its duration; hits
assert.ok(dot.length >= 2, `a DoT tick every second although she re-hits faster (${dot.length})`);
for (const c of dot) approx(c.amount, bb['attack@texas2_s_1[dot].dot_damage'], 1e-9, 'fixed arts per second (RES 0)');
h.run(sk.duration);
assert.ok(!u.findBuff('texas2:drizzle'), 'ends with the skill duration');
assert.equal(u.skill.active, false, 'ends with the skill duration');
assert.equal(u.skill.ready, false);
assert.equal(h.hooksOf('skillEnd').filter((c) => c.unit === u && c.reason === 'duration').length, 1);
assert.ok(e.s.flags.silence && e.findBuff(`texas2:drizzleDot:${u.id}`), 'applied enemy effects keep their own duration');
h.run(bb['attack@silence'] + 1);
assert.ok(!e.s.flags.silence && !e.findBuff(`texas2:drizzleDot:${u.id}`), 'no new silence / DoT after the passive');
}
@@ -657,8 +682,8 @@ test('缄默德克萨斯 S2 阵雨连绵: deploy burst atk_scale arts + RES down
approx(burst[0].amount, u.s.atk * bb.atk_scale * 0.5, 1e-6, 'arts burst vs RES 50');
approx(e.s.res, 50 * (1 + bb.magic_resistance), 1e-6, 'RES down');
assert.equal(far.s.res, 50);
approx(u.findBuff('texas2:shower').mods.atkPct, bb.atk, 1e-9);
approx(u.findBuff('texas2:shower').timeLeft, sk.duration, 1e-6);
approx(u.skill.spec.mods.atkPct, bb.atk + D(id, 1).talents[0].bb.atk, 1e-9);
approx(u.skill.timeLeft, sk.duration, 1e-6);
h.run(2);
const hits = dmgBy(h, u, (c) => c.dmg.isAttack);
assert.ok(hits.length >= 2 && hits.every((c) => c.type === 'arts'), 'arts attacks');
@@ -666,6 +691,8 @@ test('缄默德克萨斯 S2 阵雨连绵: deploy burst atk_scale arts + RES down
for (const c of hits) per.set(c.dmg.attackId, (per.get(c.dmg.attackId) ?? 0) + 1);
assert.ok([...per.values()].every((v) => v === 2), 'double hits');
h.run(sk.duration);
assert.equal(u.skill.active, false);
assert.equal(u.skill.ready, false);
const n0 = dmgBy(h, u, (c) => c.dmg.isAttack).length;
h.run(3);
const late = dmgBy(h, u, (c) => c.dmg.isAttack).slice(n0);
@@ -673,6 +700,57 @@ test('缄默德克萨斯 S2 阵雨连绵: deploy burst atk_scale arts + RES down
}
});
test('缄默德克萨斯 S1/S2/S3: first kill reopens a full window, including a kill inside the deployment burst', () => {
for (const id of pair('chess_char_4_16_a')) for (const index of [0, 1, 2]) {
const life = [];
const h = battle([U(id, 10, 4, index)], { extra: { setup(b) {
for (const name of ['skillStart', 'skillEnd']) b.on(name, (c) => life.push([name, c.reason]), { priority: 1000 });
} } });
h.b.start();
const u = h.unit(id), sk = u.skill;
h.run(2);
u.hp = u.s.maxHp / 2;
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
h.b.kill(e, u);
assert.equal(u.mem.texasKilled, true);
approx(u.hpRatio, 1);
assert.equal(u.findBuff('texas2:swordplay'), null);
assert.equal(sk.activations, 2);
approx(sk.timeLeft, sk.duration);
assert.equal(sk.charges, 0);
assert.equal(sk.ready, false);
assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['recast']);
h.b.kill(h.spawn('enemy_dummy', { pos: [10, 5] }), u);
assert.equal(sk.activations, 2, 'only first kill recasts');
h.run(sk.duration + 0.1);
assert.equal(sk.active, false);
assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['recast', 'duration']);
assert.deepEqual(h.hooksOf('skillStart').map((c) => c.reason), ['deploy', 'kill']);
h.b.retreat(u);
const lifeAt = life.length;
const weak = h.spawn('enemy_dummy', { pos: [10, 5] });
weak.hp = 1;
assert.ok(h.b.redeploy(u, { free: true }));
if (index === 0) h.b.kill(weak, u); // S1 has no deployment burst
assert.equal(u.mem.texasKilled, true);
assert.equal(sk.activations, 4, 'new deployment and first-kill recast, including synchronous burst reentry');
assert.deepEqual(life.slice(lifeAt), [['skillStart', 'deploy'], ['skillEnd', 'recast'], ['skillStart', 'kill']]);
assert.equal(u.findBuff('texas2:swordplay'), null);
approx(sk.timeLeft, sk.duration);
if (index === 2) {
const target = h.spawn('enemy_dummy', { pos: [10, 5] });
h.run(sk.duration + 0.1);
const waves = new Set(dmgBy(h, u, tagged('swordRain')).filter((c) => c.target === target).map((c) => c.t));
assert.equal(waves.size, sk.duration, 'reentrant deployment keeps a full set of rain waves');
const n = dmgBy(h, u, tagged('swordRain')).length;
h.run(2);
assert.equal(dmgBy(h, u, tagged('swordRain')).length, n, 'rain stops at skill end');
assert.ok(target.alive);
}
checkInvariants(h.b);
}
});
test('缄默德克萨斯 module none: no ATK +10 % when alone (EXE-Y default has it)', () => {
const has = (m) => { const h = battle([U('chess_char_4_16_b', 10, 4, null, m)]); h.run(0.5); return !!h.unit('chess_char_4_16_b').findBuff('texas2:module'); };
assert.equal(has(null), true);
+21 -2
View File
@@ -1018,7 +1018,11 @@ test('6_17 耀骑士临光 S2 逐夜烁光: on deploy ATK + and 3 护盾 layers
const u = h.unit(id);
usesSkill(u, sid);
h.step();
approx(u.findBuff('nearl2:night').mods.atkPct, bb.atk);
approx(u.skill.spec.mods.atkPct, bb.atk);
assert.equal(u.skill.kind, 'duration');
assert.equal(u.skill.active, true);
assert.equal(u.skill.ready, false);
assert.equal(h.snapshot().units.find((t) => t[0] === u.id)[6], dur);
assert.equal(u.findBuff('nearl2:shield').shieldHits, bb.times);
h.run(bb.times * 1.2 + 1);
const taken = h.hooksOf('damaged').filter((c) => c.target === u);
@@ -1028,15 +1032,30 @@ test('6_17 耀骑士临光 S2 逐夜烁光: on deploy ATK + and 3 护盾 layers
assert.ok(h.runUntil(() => !u.alive, dur + 1));
approx(h.b.time, dur, 'withdraws when it ends', 0.05);
approx(u.respawnAt - h.b.time, u.base.respawnTime * bb.respawn_time, 'this redeploy ×1.25', 1e-3);
assert.equal(u.removeReason, 'retreat');
assert.equal(u.skill.active, false);
assert.deepEqual(h.hooksOf('skillEnd').filter((c) => c.unit === u).map((c) => c.reason), ['duration']);
done(h);
const k = run({ defs: { chess: { kaz: plain('kaz', { bonds: ['kazimierzShip'] }) } }, units: [U(id, sid, 10, 4), { chessId: 'kaz', row: 12, col: 4 }] });
const ku = k.unit(id);
k.step(2);
assert.ok(ku.alive && ku.findBuff('nearl2:night'));
assert.ok(ku.alive && ku.skill.active);
assert.ok(k.runUntil(() => !ku.alive, dur + 1));
approx(ku.respawnAt - k.b.time, ku.base.respawnTime, '卡西米尔 before her: no extension', 1e-3);
done(k);
const dead = run({ units: [U(id, sid, 10, 4, { moduleId: 'none' })] });
dead.step();
const du = dead.unit(id);
dead.b.kill(du);
const respawnAt = du.respawnAt;
approx(respawnAt - dead.b.time, du.base.respawnTime, 'early death keeps ordinary redeployment');
assert.equal(du.findBuff('nearl2:shield'), null);
dead.run(dur + 1);
assert.equal(du.respawnAt, respawnAt, 'no delayed retreat or multiplier after death');
assert.deepEqual(dead.hooksOf('skillEnd').filter((c) => c.unit === du).map((c) => c.reason), ['death']);
done(dead);
}
});
+13
View File
@@ -961,6 +961,10 @@ test('2_12 砾: 鼠群 barrier hp_ratio × max HP decaying over `duration` s;
assert.equal(u.base.cost, raw(id).stats.cost + t.cost);
h.step();
const full = u.s.maxHp * bb.hp_ratio;
assert.equal(u.skill.kind, 'duration');
assert.equal(u.skill.active, true);
assert.equal(u.skill.ready, false);
assert.deepEqual(h.snapshot().units.find((t) => t[0] === u.id).slice(5, 7), [Math.round(u.skill.timeLeft * 10) / 10, bb.duration]);
approx(u.s.shield, full, 'full barrier', 1e-2);
h.run(bb.duration / 2 + 0.5);
approx(u.s.shield, full / 2, 'half decayed', 1e-2);
@@ -968,6 +972,9 @@ test('2_12 砾: 鼠群 barrier hp_ratio × max HP decaying over `duration` s;
approx(u.s.shield, full / 2, 'PRTS: the capacity steps once per second', 1e-2);
h.run(bb.duration / 2);
assert.equal(u.s.shield, 0);
assert.equal(u.skill.active, false);
assert.equal(u.skill.ready, false);
assert.equal(u.findBuff('gravel:rats'), null);
done(h);
// PRTS "受到伤害后,剩余容量根据屏障最大容量等比变化": a damaged barrier shrinks in proportion, not to the capacity
const hd = run({ units: [{ chessId: id, row: 9, col: 5 }] });
@@ -979,6 +986,12 @@ test('2_12 砾: 鼠群 barrier hp_ratio × max HP decaying over `duration` s;
hd.run(bb.duration / 2 + 0.5);
approx(ud.s.shield, cap0 * 0.4 / 2, 'remaining × capacity ratio (a plain cap would keep 40 %)', 1e-2);
assert.ok(ud.hp === ud.s.maxHp);
hd.b.retreat(ud);
assert.equal(ud.findBuff('gravel:rats'), null);
assert.equal(ud.skill.active, false);
assert.ok(hd.b.redeploy(ud, { free: true }));
approx(ud.skill.timeLeft, bb.duration);
approx(ud.s.shield, cap0, 'fresh shield on redeployment');
done(hd);
const idb = 'chess_char_2_12_b', tb = tal(idb);
const cheapRec = chessRec({ id: 't_cheap', stats: { cost: tb['cond.cost'] } });
+4 -2
View File
@@ -645,7 +645,8 @@ test('缄默德克萨斯 S3 (passive): deploy burst 2 × 115 % arts + 1.5 s stun
assert.equal(waves.size, D(id).skill.duration, 'one rain per second while the passive lasts');
assert.ok([...waves.values()].every((n) => n === bb.max_target), '≤ 2 distinct targets');
approx(rain[0].amount, u.base.atk * (1 + t0.atk) * bb.atk_scale, 1e-6);
assert.equal(u.findBuff('texas2:rainAtk'), null, 'ATK bonus ends with the passive');
assert.equal(u.skill.active, false, 'ATK bonus ends with the skill');
approx(u.s.atk, u.base.atk);
});
test('缄默德克萨斯: first kill ⇒ full heal + recast; 剑术 buff removed', () => {
@@ -660,7 +661,8 @@ test('缄默德克萨斯: first kill ⇒ full heal + recast; 剑术 buff removed
assert.ok(u.mem.texasKilled);
approx(u.hpRatio, 1, 1e-3, 'full heal');
assert.equal(u.findBuff('texas2:swordplay'), null);
assert.ok(u.findBuff('texas2:rainAtk'), 'passive recast');
assert.equal(u.skill.active, true, 'skill recast');
assert.equal(u.skill.activations, 2);
});
test('星熊 S2 (passive): DEF +13 % and 65 % ATK thorns; 战术装甲 negates ~25 % of hits; 重装 aura DEF +6 %', () => {
+3 -1
View File
@@ -231,7 +231,9 @@ test('the eight 预备干员: every chess record they replace fields the stand-i
const dp0 = h.b.players[0].dp;
if (sk.skillType === 'PASSIVE') {
h.step();
assert.deepEqual(u.findBuff(`generic:passive:${sk.skillId}`)?.mods, statMods(sk.bb), `${where}: deploy buff`);
// a deploy-timed passive runs the duration lifecycle from the deployment (#109): the skill's own mods
assert.deepEqual([u.skill.kind, !!u.skill.spec.activateOnDeploy, u.skill.active], ['duration', true, true], `${where}: deploy-timed skill`);
assert.deepEqual(u.findBuff(`skill:${u.id}`)?.mods, statMods(sk.bb), `${where}: deploy buff`);
} else {
assert.ok(h.runUntil(() => u.skill.activations > 0, 30), `${where}: the skill fires`);
h.step();