feedback3: merge WG — nest enemies pulse instead of attacking, children carry no bounty, a 隐匿 blocker is skipped by areas, stacked cold freezes for the longer duration

This commit is contained in:
sganggs committed 2026-10-04 21:25:40 +08:00
commit 3500ef2d32
13 files changed
+514 -70

No files matched your search

+80
View File
@@ -159,6 +159,18 @@ test(`${nm('enemy_10034_cnvsax')}: never attacks while stealthed; once blocked i
assert.ok(w.elem.burn > 0, '狂欢式演奏 burns its target (the blocker)');
});
test(`${nm('enemy_10034_cnvsax')}: revealed (反隐) and not blocked it makes no normal attack and walks on — 反击模式 "仅进行阻挡攻击"`, () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }] });
h.step();
h.b.addBuff(h.unit('t_wall'), { key: 'test:noBlock', persist: true, flags: { noBlock: true } });
const e = put(h, 'enemy_10034_cnvsax', [9, 8], { move: true, route: { motion: 'WALK', start: [9, 8], end: [9, 1], checkpoints: [] } });
h.b.addBuff(e, { key: 'test:reveal', persist: true, flags: { reveal: true } });
const x0 = e.x;
h.run(4);
assert.equal(e.stats.attacks, 0, 'no ranged attack (until 0.1.3 it shot the wall and stood still)');
approx(x0 - e.x, 4 * e.s.moveSpeed * 0.5, 0.02, 'walked the whole 4 s');
});
test(`${nm('enemy_9008_acbunn')}: attacks several targets at once while stealthed`, () => {
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 6 }, { chessId: 't_mage', row: 11, col: 6 }, { chessId: 't_blade', row: 10, col: 7 }], captureNoisy: true, hooks: ['attack'] });
h.step();
@@ -585,8 +597,55 @@ for (const key of ['enemy_1234_dsubrl', 'enemy_1234_dsubrl_2']) {
approx(e.findBuff('stun').timeLeft, 2);
assert.equal(h.b.applyStatus(e, 'sluggish', { duration: 4 }), false);
});
// community report 「有个持续造成范围伤害的海嗣敌人错误的设置了攻击时不移动导致卡在原地」: PRTS 天赋 "不进行普通攻击", "未处于消失状态
// 时,令攻击范围内的所有我方单位每秒受到攻击力100%的无途径法术伤害", "每次输出伤害时,再造成攻击力5%的神经损伤"
test(`${nm(key)}: no normal attack — walks on past an unblocking operator while it pulses 100 % ATK arts + 5 % neural each second; no pulse while hidden`, () => {
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 6 }], hooks: ['damaged'], captureNoisy: true });
h.step();
const g = h.unit('t_gun');
h.b.addBuff(g, { key: 'test:noBlock', persist: true, flags: { noBlock: true } });
g.profile.noAttack = true;
const e = put(h, key, [9, 8], { move: true, route: { motion: 'WALK', start: [9, 8], end: [9, 1], checkpoints: [] } });
const x0 = e.x;
h.run(4);
assert.ok(e.profile.noAttack && e.stats.attacks === 0, 'never attacks');
approx(x0 - e.x, 4 * e.s.moveSpeed * 0.5, 0.02, 'walked the whole 4 s (MOVE_SCALE 0.5) with the operator in range');
const mine = h.hooksOf('damaged').filter((c) => c.source === e && c.target === g);
const arts = mine.filter((c) => c.dmg.type === 'arts');
assert.ok(arts.length >= 3 && arts.every((c) => !c.dmg.isAttack && c.dmg.tags.includes('nestPulse')), `pulses, not attacks (${arts.length})`);
approx(arts[0].dmg.amount, e.s.atk, 1e-9, '100 % ATK');
const neural = mine.filter((c) => c.dmg.type === 'element');
assert.equal(neural.length, arts.length, 'one 神经 per pulse');
approx(neural[0].dmg.amount, e.s.atk * tb(key, 'EpDamage.ep_damage_ratio'), 1e-9, '5 % ATK 神经');
const times = arts.map((c) => c.t ?? null).filter((t) => t != null);
if (times.length > 1) approx(times[1] - times[0], 1, 1e-6, 'one per second');
// 消失 (a DISAPPEAR leg): no pulse
const n0 = arts.length;
h.b._setHidden(e, true);
e.x = g.x + 1; e.y = g.y;
h.run(2.05);
assert.equal(h.hooksOf('damaged').filter((c) => c.source === e && c.dmg.type === 'arts').length, n0, 'hidden: no pulse');
});
}
test('GitHub #93: 深溟巢涌者 keeps walking with the 炎佑 dragon in range; only its pulse (no attack) reaches the dragon', () => {
const h = makeBattle({ seed: 7, autoFinish: false, timeLimit: 120, defs: { chess: { t_wall: WALL('t_wall') } }, kits: { t_wall: NOATK },
units: [{ chessId: 't_wall', row: 12, col: 1 }], hooks: ['damaged'], captureNoisy: true });
h.step();
const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 50000 });
assert.ok(y && y.isFlying, 'a flying ally');
const e = h.spawn('enemy_1234_dsubrl', { pos: [Math.round(y.y), Math.round(y.x) + 2], routeIndex: 0, mods: { speedMul: 1 },
route: { motion: 'WALK', start: [Math.round(y.y), Math.round(y.x) + 2], end: [Math.round(y.y), 0], checkpoints: [] } });
const x0 = e.x;
h.run(5);
assert.equal(e.stats.attacks, 0, 'no normal attack');
assert.ok(x0 - e.x >= 5 * e.s.moveSpeed * 0.5 - 0.05, `walked on (${(x0 - e.x).toFixed(2)} tiles in 5 s)`);
const onDragon = h.hooksOf('damaged').filter((c) => c.source === e && c.target === y);
assert.ok(onDragon.length > 0, 'the dragon in range is pulsed');
assert.ok(onDragon.every((c) => !c.dmg.isAttack && (c.dmg.type === 'element' || c.dmg.tags.includes('nestPulse'))), 'pulse and its 神经 only');
});
for (const key of ['enemy_1267_nhpbr', 'enemy_1267_nhpbr_2']) {
test(`${nm(key)}: death releases 污染秽蚀 — ground allies within ${tb(key, 'PollutedDie.projectile_range')} lose ${tb(key, 'PollutedDie.polluted_damage_low')} HP/s`, () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }] });
@@ -1277,6 +1336,24 @@ test(`${nm('enemy_10116_ymgtop')}: spinning phase deals ATK×${tb('enemy_10116_y
assert.equal(w.stats.taken, t0, 'no more spin damage');
});
test(`${nm('enemy_10116_ymgtop')}: 漩涡形态 — no normal attack on its blocker while it spins; no spin damage while stunned (阻止攻击)`, () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 7 }] });
h.step();
const e = put(h, 'enemy_10116_ymgtop', [10, 7]); // blocked by the wall
const icd = skb('enemy_10116_ymgtop', 'SwitchModeTrigger').initCooldown;
h.run(icd - 1);
assert.ok(e.blockedBy && e.stats.attacks > 0, 'its blocked attack before the spin (初始形态 仅进行阻挡攻击)');
h.run(1.5);
const a0 = e.stats.attacks;
h.run(4);
assert.ok(e.profile.noAttack && e.stats.attacks === a0, `no normal attack while spinning (${e.stats.attacks - a0})`);
const w = h.unit('t_wall');
h.b.applyStatus(e, 'stun', { duration: 3, force: true });
const t0 = w.stats.taken;
h.run(2.5);
assert.equal(w.stats.taken, t0, 'stunned: no spin damage');
});
test(`${nm('enemy_10118_ymgprc')}: double hits; after ${skb('enemy_10118_ymgprc', 'PowerAttack').spCost} attacks the next double hit is ×${skb('enemy_10118_ymgprc', 'PowerAttack').bb.atk_scale}`, () => {
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }], captureNoisy: true, hooks: ['damaged'] });
h.step();
@@ -1797,6 +1874,8 @@ test('失衡: 弧光锋卫 bleeds per tile pushed; 冒失的小弟 is stunned;
approx(statuses(h, g.id, 'stun')[0].duration, tb('enemy_10112_ymgds', 'StunAfterUnbalance.stun'));
assert.ok(p.s.flags.unblockable);
assert.equal(p.findBuff('ab:noEgg').mods.moveMul, 1 + tb('enemy_10141_xdpeng_2', 'speed.move_speed'));
assert.ok(p.profile.noAttack, '拥霜羽兽 失去蛋的模式: 不进行普通攻击 (no attack, so no stand for its clip)');
assert.equal(typeof p.profile.canTarget === 'function' && p.profile.canTarget({ isFlying: true }), false, '拥霜羽兽: 不会攻击飞行单位');
// 雪孩子: pushed into high ground (row 12 col 2 is 'h' on the flat stage) ⇒ hitWall.value
const sn = put(h, 'enemy_10138_xdsnow', [10, 3]);
const sn2 = put(h, 'enemy_10138_xdsnow', [11, 6]);
@@ -2374,6 +2453,7 @@ test('假想敌:铳 (隐秘核心): damage ×0.2 while springs live; 盲信之
const s2 = W.act1autochess_h08_02.overrides.enemy_9017_achunt_2.skills.find((s) => s.prefabKey === '2');
h.run(s2.initCooldown - h.b.time + 0.2);
assert.ok(sp.s.flags.invulnerable && sp.mem.ab.dash);
assert.ok(sp.s.flags.disarm, '追逐模式: 不进行普通攻击');
});
test('“碎铳之簧” 法术护盾 (9018): barrier absorbs arts, physical ×0.1 with a counter; damage passes to 铳', () => {
+5 -2
View File
@@ -55,7 +55,7 @@ test('stunned operator neither attacks nor casts but keeps its natural SP recove
assert.equal(u.skill.activations, 0, 'silenced: no cast');
});
test('cold on cold ⇒ freeze 3 s (res −15), frozen-immune enemies only get cold', () => {
test('cold on cold ⇒ freeze for the longer cold (res −15), not a fixed 3 s; frozen-immune enemies only get cold', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy({ res: 30 }), enemy_icy: enemyRec({ key: 'enemy_icy', speed: 0, hp: 1e6, immunities: { frozen: true } }) } },
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_icy', pos: [10, 8] }], content: 'none',
@@ -70,8 +70,11 @@ test('cold on cold ⇒ freeze 3 s (res −15), frozen-immune enemies only get co
assert.equal(e.s.flags.freeze, true);
assert.equal(e.s.flags.stun, true);
assert.equal(e.s.res, 15);
assert.ok(Math.abs(e.findBuff('freeze').timeLeft - 5) < 1e-6, 'freeze lasts the longer cold (5 s), not 3 s');
h.run(3.1);
assert.ok(!e.s.flags.freeze, 'freeze lasts 3 s');
assert.ok(e.s.flags.freeze, 'still frozen past the old fixed 3 s');
h.run(2);
assert.ok(!e.s.flags.freeze, 'freeze ends with that cold');
h.b.applyStatus(icy, 'cold', { duration: 5 });
h.b.applyStatus(icy, 'cold', { duration: 5 });
assert.ok(!icy.s.flags.freeze);
+17 -13
View File
@@ -12,10 +12,11 @@
// attack; a normal attack on every operator in range skips 迷彩 too).
// Cause: the enemy side's area effects (content/enemies.js, content/bosses.js) took every ally in the area; only the
// operator side skipped an unblocked 隐匿 enemy (Battle.foesInRadius, 0.1.1).
// Now: targeting.js areaSelectable / enemies.js areaAllies, areaAlliesInTiles, fieldAllies — no 隐匿 ally unless it blocks
// the enemy, no untargetable or sleeping one, no airborne 起飞 one for a ground enemy; 迷彩 is not checked. Buff auras:
// Now: targeting.js areaSelectable / enemies.js areaAllies, areaAlliesInTiles, fieldAllies — no 隐匿 ally, even the one
// blocking the source (GitHub #97, owner 2026-10-04; the blocked enemy's own attack still lands via canTargetAlly),
// no untargetable or sleeping one, no airborne 起飞 one for a ground enemy; 迷彩 is not checked. Buff auras:
// auraSelectable / auraAllies (the same without 对地规避). A locked target is hit as a direct pick (targetAndArea).
// (DESIGN §22.12.)
// (DESIGN §22.12 recorded the 0.1.2 blocker exception; §23 withdraws it for areas and auras.)
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -54,7 +55,7 @@ function countFx(h, kind) {
return n;
}
test('areaSelectable: no 隐匿 ally unless it blocks the source, no untargetable / sleeping one, no 起飞 one for a ground source; 迷彩 is selected', () => {
test('areaSelectable: no 隐匿 ally even when it blocks the source, no untargetable / sleeping one, no 起飞 one for a ground source; 迷彩 is selected', () => {
const h = makeBattle({
seed: 2, autoFinish: false, timeLimit: 60,
defs: { enemies: { enemy_g: enemyRec({ key: 'enemy_g', hp: 1e7, speed: 0 }), enemy_f: enemyRec({ key: 'enemy_f', hp: 1e7, speed: 0, motion: 'FLY' }) } },
@@ -68,15 +69,15 @@ test('areaSelectable: no 隐匿 ally unless it blocks the source, no untargetabl
assert.ok(f.isFlying && !g.isFlying);
for (const u of [jf, gm, hd]) assert.ok(areaSelectable(g, u) && areaSelectable(f, u) && areaSelectable(null, u), `${u.def.name}: plain ⇒ selected`);
stealth(h, jf); stealth(h, gm);
assert.equal(areaSelectable(g, jf), true, '隐匿 but blocking the source: selected');
assert.equal(areaSelectable(g, jf), false, '隐匿 blocker: an area still skips it');
assert.equal(areaSelectable(f, jf), false, '隐匿, blocking another enemy: not selected');
assert.equal(areaSelectable(g, gm), false, '隐匿, not blocking: not selected');
assert.equal(areaSelectable(null, gm), false, '隐匿 vs an effect with no selecting enemy');
assert.deepEqual(areaAllies(h.b, g, g.x, g.y, 1.01).map((u) => u.def.name).sort(), [jf, hd].map((u) => u.def.name).sort(), 'areaAllies: the 隐匿 bystander is left out');
assert.deepEqual(areaAllies(h.b, g, g.x, g.y, 1.01).map((u) => u.def.name).sort(), [hd.def.name], 'areaAllies: the 隐匿 blocker and the 隐匿 bystander are left out');
camou(h, hd);
assert.equal(areaSelectable(g, hd), true, '迷彩: still selected by an area effect ("无法躲避溅射类攻击")');
assert.equal(auraSelectable(g, gm), false, 'a buff aura: 隐匿, not blocking — not taken');
assert.equal(auraSelectable(g, jf), true, 'a buff aura: 隐匿 blocker — taken');
assert.equal(auraSelectable(g, jf), false, 'a buff aura: 隐匿 blocker — not taken');
h.b.addBuff(gm, { key: 'test:liftoff', persist: true, flags: { liftoff: true } });
h.b.removeBuff(gm, 'stealth');
assert.equal(areaSelectable(g, gm), false, '起飞 vs a ground source (对地规避)');
@@ -140,7 +141,7 @@ test('#32.6 碎骨: 伪装服\'s 隐匿 (its carrier\'s first damage) spares the
done(r.h);
});
test('#32.6 “庞贝”\'s self-blast while blocked hits its 隐匿 blocker and a 迷彩 bystander, not a 隐匿 bystander', REAL, () => {
test('#32.6 “庞贝”\'s self-blast while blocked misses its 隐匿 blocker and a 隐匿 bystander, and hits a 迷彩 bystander', REAL, () => {
const h = makeBattle({
seed: 3, timeLimit: 120, autoFinish: false,
units: [{ chessId: JF, row: 9, col: 6, dir: 'RIGHT' }, { chessId: GM, row: 10, col: 6, dir: 'RIGHT' }, { chessId: HD, row: 9, col: 7, dir: 'RIGHT' }],
@@ -155,7 +156,8 @@ test('#32.6 “庞贝”\'s self-blast while blocked hits its 隐匿 blocker and
assert.equal(p.blockedBy, jf, 'blocked by the 隐匿 角峰');
assert.equal(blasts.v, 1, 'one self-blast in 12 s');
const blast = (u) => log.filter((x) => x.target === u && x.skill && !x.attack).length;
assert.equal(blast(jf), 1, '隐匿 blocker: hit (a blocked enemy ignores its blocker\'s selectability)');
assert.equal(blast(jf), 0, '隐匿 blocker: the blocked enemy\'s blast does not select it');
assert.ok(log.some((x) => x.target === jf && x.attack), 'the blocked enemy\'s own attack still hits that 隐匿 blocker');
assert.equal(blast(hd), 1, '迷彩 bystander: hit');
assert.equal(blast(gm), 0, '隐匿 bystander (distance 1, not blocking): spared');
done(h);
@@ -328,7 +330,8 @@ test('#32.6 review: the 斩胄之剑 / 破胄之锤 hover attack skips 隐匿 an
assert.ok(log.some((x) => x.target === h.unit(HD) && !x.attack), '迷彩 红豆: shot inside the zone (in 0.1.1 never)');
done(h);
}
// 深溟巢涌者: its pulse rides on its attack — with the plain 古米 in range the 迷彩 红豆 is hit by every pulse; alone it is never
// 深溟巢涌者: its pulse is a talent aura (no attack, 0.1.3) — the 迷彩 红豆 is hit by every pulse, with the plain 古米 in range
// or alone (until 0.1.3 the pulse rode on its attack, which needed a target: alone it was never hit)
for (const alone of [false, true]) {
const units = [{ chessId: HD, row: 9, col: 6, dir: 'RIGHT' }];
if (!alone) units.push({ chessId: GM, row: 10, col: 7, dir: 'RIGHT' });
@@ -337,9 +340,10 @@ test('#32.6 review: the 斩胄之剑 / 破胄之锤 hover attack skips 隐匿 an
camou(h, h.unit(HD));
const log = damageLog(h, 'enemy_1234_dsubrl');
h.run(10);
const on = (id) => log.filter((x) => x.target === h.unit(id) && x.attack).length;
if (alone) assert.equal(on(HD), 0, '迷彩 alone in range: no attack, so no pulse [the engine attack needs a target]');
else { assert.ok(on(GM) >= 5, `plain 古米 pulsed (${on(GM)})`); assert.equal(on(HD), on(GM), '迷彩 红豆: every pulse too (in 0.1.1 none)'); }
const on = (id) => log.filter((x) => x.target === h.unit(id) && x.tags.includes('nestPulse')).length;
assert.equal(log.filter((x) => x.attack).length, 0, 'no normal attack');
assert.ok(on(HD) >= 9, `迷彩 红豆 pulsed every second (${on(HD)}; alone: ${alone})`);
if (!alone) assert.equal(on(HD), on(GM), '迷彩 红豆: every pulse the plain 古米 takes');
done(h);
}
// “萨科塔昂首”'s 【祈祷邀约】 (whole field, "无视迷彩", no 无视无法选择): 角峰 slowed, the 隐匿 古米 not
+144
View File
@@ -0,0 +1,144 @@
// GitHub #67 / #89-2: a split or summoned child must not carry the kill bounty. The main body does.
// Owner 2026-10-04: the bounty stays on the body only.
//
// spawnChildren used to copy parent.mods, bountyId and bountyCoins included, so a leaked child paid the
// card again in 联防 (planUnite reads those fields). planUnite also paid any leak whose bountyId matched a
// card, even when the enemy was not the card's enemy.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec } from '../helpers/battleHarness.js';
import { spawnChildren } from '../../server/sim/content/enemies.js';
import { planUnite } from '../../server/match/unite.js';
import { hasGeneratedData } from '../../server/sim/simdata.js';
const REAL = { skip: !hasGeneratedData() };
function parentBattle() {
return makeBattle({
content: 'none', autoFinish: false, timeLimit: 30,
defs: {
enemies: {
enemy_parent: enemyRec({ key: 'enemy_parent', hp: 100, speed: 0 }),
enemy_child: enemyRec({ key: 'enemy_child', hp: 100, speed: 0 }),
},
},
enemies: [{
key: 'enemy_parent', pos: [10, 6],
mods: { hpMul: 2, atkMul: 1.5, bountyId: 'b1', bountyCoins: 8 },
bounty: { coins: 8, ownerPlayerId: 'p1' },
}],
});
}
const coinsOf = (h) => h.eventsOf('bounty').reduce((s, e) => s + e[2], 0);
test('spawnChildren keeps the stat mods and drops bountyId and bountyCoins', () => {
const h = parentBattle();
h.step();
const parent = h.enemy('enemy_parent');
const kids = spawnChildren(h.b, parent, 'enemy_child', 2);
assert.equal(kids.length, 2);
assert.equal(parent.mods.bountyId, 'b1', 'the body keeps its card id');
assert.equal(parent.mods.bountyCoins, 8);
for (const c of kids) {
assert.equal(c.mods.hpMul, 2);
assert.equal(c.mods.atkMul, 1.5);
assert.equal(c.mods.bountyId, undefined);
assert.equal(c.mods.bountyCoins, undefined);
assert.equal(c.bounty, null, 'kill payout reads unit.bounty, which a child does not get');
assert.notEqual(c.mods, parent.mods);
}
const explicit = spawnChildren(h.b, parent, 'enemy_child', 1, { mods: { bountyId: 'b9', bountyCoins: 3, defMul: 4 } });
assert.equal(explicit[0].mods.defMul, 4);
assert.equal(explicit[0].mods.bountyId, undefined);
assert.equal(explicit[0].mods.bountyCoins, undefined);
assert.equal(parent.mods.bountyId, 'b1', 'an explicit child mods object is not the parent\'s');
// opts.mods null falls through to the parent, then the bounty fields are stripped
const fallen = spawnChildren(h.b, parent, 'enemy_child', 1, { mods: null });
assert.equal(fallen[0].mods.hpMul, 2);
assert.equal(fallen[0].mods.bountyId, undefined);
});
test('killing the body pays its bounty once; killing the children pays nothing', () => {
const h = parentBattle();
h.step();
const parent = h.enemy('enemy_parent');
const kids = spawnChildren(h.b, parent, 'enemy_child', 2);
h.b.kill(parent, null);
for (const c of kids) h.b.kill(c, null);
assert.equal(coinsOf(h), 8);
});
test('磨砻 on death spawns children that do not inherit the bounty; the body still pays', REAL, () => {
const h = makeBattle({
autoFinish: false, timeLimit: 30,
enemies: [{
key: 'enemy_1195_sfyin', pos: [10, 6],
mods: { hpMul: 1, bountyId: 'card-yin', bountyCoins: 5 },
bounty: { coins: 5, ownerPlayerId: 'p1' },
}],
});
h.step();
const parent = h.enemy('enemy_1195_sfyin');
assert.ok(parent, '磨砻 spawned');
h.b.kill(parent, null);
const kids = h.b.enemies.filter((e) => e !== parent && e.defId !== 'enemy_1195_sfyin');
assert.ok(kids.length >= 1, `death children spawned (${kids.length})`);
for (const c of kids) {
assert.ok(!c.mods || (c.mods.bountyId == null && c.mods.bountyCoins == null), `${c.defId} still carries a bounty field`);
assert.equal(c.bounty, null);
}
assert.equal(coinsOf(h), 5);
});
function seat(id, seatNo, o = {}) {
return {
playerId: id, seat: seatNo, deployCount: o.deployCount ?? 1, bonds: {}, layers: {},
board: new Map(), bounties: o.bounties ?? [],
};
}
function uniteOf(leaks, bounties) {
const leaker = seat('L', 1, { bounties });
const helper = seat('H', 0, { deployCount: 3 });
const m = {
isSolo: false,
alivePlayers: () => [leaker, helper],
gd: {
unite: { maxHelpers: 2 },
enemy: (k) => (k === 'enemy_parent' || k === 'enemy_child' ? { key: k } : null),
},
};
const results = new Map([
['L', { perfect: false, leaked: leaks }],
['H', { perfect: true, leaked: [], unitsEnd: [] }],
]);
return planUnite(m, results);
}
const CARD = { id: 'b1', card: { payout: 'kill', coin: 8, enemyKey: 'enemy_parent' } };
test('planUnite pays the card only when the leaked enemy is the card\'s enemy', () => {
const plan = uniteOf([
{ enemyKey: 'enemy_parent', mods: { bountyId: 'b1', hpMul: 2 }, counted: true, lpr: 1 },
{ enemyKey: 'enemy_child', mods: { bountyId: 'b1', bountyCoins: 8, hpMul: 2 }, counted: true, lpr: 1 },
{ enemyKey: 'enemy_child', mods: { bountyCoins: 4 }, counted: true, lpr: 1 },
{ enemyKey: 'enemy_parent', mods: { bountyId: 'missing' }, counted: true, lpr: 1 },
], [CARD]);
assert.ok(plan);
assert.deepEqual(plan.leaked.map((l) => [l.enemyKey, l.bounty ? l.bounty.coins : null]), [
['enemy_parent', 8],
['enemy_child', null],
['enemy_child', 4],
['enemy_parent', null],
]);
});
test('planUnite does not pay a perfect-clear card or a card with no coin', () => {
const leaks = [{ enemyKey: 'enemy_parent', mods: { bountyId: 'b1' }, counted: true, lpr: 1 }];
const perfect = uniteOf(leaks, [{ id: 'b1', card: { payout: 'perfect', coin: 8, enemyKey: 'enemy_parent' } }]);
assert.equal(perfect.leaked[0].bounty, null);
const zero = uniteOf(leaks, [{ id: 'b1', card: { payout: 'kill', coin: 0, enemyKey: 'enemy_parent' } }]);
assert.equal(zero.leaked[0].bounty, null);
});
+135
View File
@@ -0,0 +1,135 @@
// A second 寒冷 used to apply a fixed 3 s 冻结 (COLD_FREEZE_DURATION) whatever the two colds lasted.
// PRTS 术语释义 寒冷 (id ba.cold): 友方寒冷 pairs into 友方冻结, 「持续时间取双方之中最高」. The cold that remains is
// already max(remaining, incoming) via addBuff refresh 'extend'; the freeze in the same moment must match it.
// 抵抗 shortens the incoming duration before that max. A frozen-immune enemy still gets no freeze.
// [ASSUMED] the engine's one cold uses that 友方 sentence for an enemy-applied cold too (PRTS states the max on
// the 友方 line only). gamedata_const ba.cold names the freeze and not its length.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec } from '../helpers/battleHarness.js';
import { hasGeneratedData } from '../../server/sim/simdata.js';
const REAL = { skip: !hasGeneratedData() };
const close = (a, b, eps, msg = '') => assert.ok(Math.abs(a - b) <= eps, `${msg} expected ${b} ± ${eps}, got ${a}`);
function coldBattle(extra = {}) {
return makeBattle({
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0, res: 30 }) } },
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }],
content: 'none', autoFinish: false, timeLimit: 60,
...extra,
});
}
test('cold 10s then immediately another cold 1s → freeze lasts about 10s, not 3', () => {
const h = coldBattle();
h.step();
const e = h.enemy('enemy_dummy');
h.b.applyStatus(e, 'cold', { duration: 10 });
h.b.applyStatus(e, 'cold', { duration: 1 });
close(e.findBuff('cold').timeLeft, 10, 1e-6, 'the shorter cold does not replace the longer one');
close(e.findBuff('freeze').timeLeft, 10, 1e-6, 'freeze');
assert.equal(e.s.res, 15, 'frozen: RES −15');
h.run(3.2);
assert.ok(e.s.flags.freeze, 'still frozen past the old fixed 3 s');
h.run(7);
assert.ok(!e.s.flags.freeze, 'ends with the 10 s cold');
assert.equal(e.s.res, 30);
});
test('cold 1s then cold 8s → freeze lasts about 8s, not 3', () => {
const h = coldBattle();
h.step();
const e = h.enemy('enemy_dummy');
h.b.applyStatus(e, 'cold', { duration: 1 });
h.b.applyStatus(e, 'cold', { duration: 8 });
close(e.findBuff('cold').timeLeft, 8, 1e-6);
close(e.findBuff('freeze').timeLeft, 8, 1e-6);
h.run(3.2);
assert.ok(e.s.flags.freeze, 'still frozen past 3 s');
h.run(5);
assert.ok(!e.s.flags.freeze, 'ends with the 8 s cold');
});
test('a shorter second cold does not shorten a longer remaining cold\'s freeze', () => {
const h = coldBattle();
h.step();
const e = h.enemy('enemy_dummy');
h.b.applyStatus(e, 'cold', { duration: 10 });
h.run(6);
const remain = e.findBuff('cold').timeLeft;
assert.ok(remain > 3.5 && remain < 4.5, `about 4 s left, got ${remain}`);
h.b.applyStatus(e, 'cold', { duration: 1 });
close(e.findBuff('cold').timeLeft, remain, 1e-6, 'cold');
close(e.findBuff('freeze').timeLeft, remain, 1e-6, 'freeze follows the remaining cold, not 1 s and not 3 s');
h.run(3.2);
assert.ok(e.s.flags.freeze, 'a 1 s or 3 s freeze would have ended');
h.run(1.2);
assert.ok(!e.s.flags.freeze);
});
test('抵抗 still shortens the incoming duration before the max', () => {
const h = coldBattle({ hooks: ['statusApplied'] });
h.step();
const e = h.enemy('enemy_dummy');
h.b.applyStatus(e, 'cold', { duration: 2 });
h.b.applyStatus(e, 'resist', { value: 0.5 });
h.b.applyStatus(e, 'cold', { duration: 10 });
// incoming 10 × (1 − 0.5) = 5, remaining 2 → both cold and freeze are 5, not 10 and not 2.5
close(e.findBuff('cold').timeLeft, 5, 1e-6, 'cold');
close(e.findBuff('freeze').timeLeft, 5, 1e-6, 'freeze');
const freeze = h.hooksOf('statusApplied').filter((c) => c.target === e && c.status === 'freeze');
assert.equal(freeze.length, 1);
close(freeze[0].duration, 5, 1e-9, 'the reported freeze duration');
h.run(3.2);
assert.ok(e.s.flags.freeze, 'not halved a second time down to 2.5 s');
h.run(2);
assert.ok(!e.s.flags.freeze, 'not the unresisted 10 s');
});
test('a frozen-immune enemy gets the second cold and no freeze', () => {
const h = makeBattle({
defs: { enemies: { enemy_icy: enemyRec({ key: 'enemy_icy', hp: 1e6, speed: 0, immunities: { frozen: true } }) } },
enemies: [{ key: 'enemy_icy', pos: [10, 8] }],
content: 'none', autoFinish: false,
});
h.step();
const icy = h.enemy('enemy_icy');
h.b.applyStatus(icy, 'cold', { duration: 10 });
h.b.applyStatus(icy, 'cold', { duration: 1 });
assert.ok(icy.s.flags.cold);
close(icy.findBuff('cold').timeLeft, 10, 1e-6, 'the colds still take the longer duration');
assert.equal(icy.findBuff('freeze'), null);
assert.ok(!icy.s.flags.freeze);
});
test('吉兆飞鳞: the stun when a freeze ends follows the longer or shorter cold, not a fixed 3 s', REAL, () => {
const run = (first, second) => {
const h = makeBattle({
autoFinish: false, timeLimit: 40, hooks: ['statusApplied'],
enemies: [{ key: 'enemy_10045_parrot', pos: [10, 6], mods: { speedMul: 0, hpMul: 100 } }],
});
h.step();
const e = h.enemy('enemy_10045_parrot');
h.b.applyStatus(e, 'cold', { duration: first });
h.b.applyStatus(e, 'cold', { duration: second });
const want = Math.max(first, second);
close(e.findBuff('freeze').timeLeft, want, 1e-6, `freeze ${first} then ${second}`);
h.run(3.2);
const early = h.hooksOf('statusApplied').filter((c) => c.target === e && c.status === 'stun');
if (want > 3.2) {
assert.ok(e.s.flags.freeze, 'still frozen at 3.2 s');
assert.equal(early.length, 0, '失温坠落 has not fired at the old 3 s mark');
}
h.run(want - 3.2 + 0.3);
assert.ok(!e.s.flags.freeze, 'the freeze has ended');
const stuns = h.hooksOf('statusApplied').filter((c) => c.target === e && c.status === 'stun');
// the 0.05 s 【失温坠落】 stun is what the freeze's end applies; its own status hook then grounds the bird
// (Stun.duration, 8 s). The harness hears the 8 s one first because that hook runs inside the 0.05 s emit.
assert.ok(stuns.some((c) => Math.abs(c.duration - 0.05) < 1e-6), `【失温坠落】 0.05 s stun, got ${stuns.map((c) => c.duration)}`);
return h;
};
run(1, 1); // shorter than the old 3 s: the stun is not held until 3 s
run(10, 1); // longer than 3 s: no stun at 3 s, stun when the 10 s freeze ends
});