387 lines
22 KiB
JavaScript
387 lines
22 KiB
JavaScript
// test/sim/feedback2-surtr-stealth.test.js — GitHub issues after 0.1.1 (DESIGN §22.7, §22.8):
|
||
// #52 史尔特尔 余烬 (PRTS 天赋备注 "触发本天赋后,获得禁疗与不死"; 异常效果 禁疗 HEAL_FREE "无法成为治疗类能力的目标,且受到
|
||
// 的治疗量变为0", an HP-regen attribute excepted; 技能3 备注 "(无视禁疗)"): v0.1.1 kept healing her during the 8 s —
|
||
// medics picked her as the lowest HP ratio and wasted their heals on a unit that leaves anyway.
|
||
// #43 follow-up — PRTS 作战机制 §隐匿 "对于绝大部分可隐匿的敌人而言,在被我方单位阻挡后会解除隐匿,不被阻挡的3秒后重新进入隐匿"
|
||
// (and the enemy pages' "(解除阻挡N秒后恢复)"): v0.1.1 hid an enemy again 1 tick after its block ended. Our operators'
|
||
// 隐匿 is never lifted by blocking. The 深池逐火 embers: the 重生's 无法阻挡 ends the warrior's block at the knock-out,
|
||
// so the ember stays revealed until 3 s after it (players after 0.1.1: "the stealth monster revives forever").
|
||
|
||
import { test, describe } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||
import * as enemiesMod from '../../server/sim/content/enemies.js';
|
||
import { canTargetEnemy, canTargetAlly, enemyStealthed, stealthOffKey } from '../../server/sim/targeting.js';
|
||
import { flagsOf } from '../../server/sim/snapshot.js';
|
||
import { STEALTH_RESTORE } from '../../server/sim/constants.js';
|
||
import { UF } from '../../shared/constants.js';
|
||
import { statusIconKey } from '../../public/js/render/style.js';
|
||
import { STATUS_KEYS } from '../../public/js/render/textures.js';
|
||
|
||
const { HUSK_REBIRTH } = enemiesMod;
|
||
const clean = (h) => { assert.deepEqual(h.b.errors.map((e) => `${e.label}: ${e.message}`), []); checkInvariants(h.b); };
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// #52 史尔特尔 余烬: 禁疗 + 不死 until the forced exit
|
||
|
||
const SURTR = 'chess_char_5_07_a', MEDIC = 'chess_char_2_02_a' /* 赫默 */, TANK = 'chess_char_1_04_a' /* 深巡 */;
|
||
const DUMMY = enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, atk: 0 });
|
||
const lethal = (h, u) => h.b.dealDamage(null, u, { amount: 1e9, type: 'true', canDodge: false });
|
||
const waitOf = (u) => (u.def.raw.talents || []).find((t) => t.index === 1).bb['surtr_t_2[withdraw].interval'];
|
||
|
||
describe('#52 史尔特尔 余烬: 禁疗 from the lethal blow until she leaves', () => {
|
||
test('a medic beside her heals the other injured operator instead — 0 heals reach her, she is never its target, she still leaves at 8 s; the 禁疗 status is shown', () => {
|
||
const h = makeBattle({
|
||
defs: { enemies: { enemy_dummy: DUMMY } }, autoFinish: false, timeLimit: 200, seed: 2,
|
||
units: [
|
||
{ chessId: SURTR, row: 9, col: 5, dir: 'RIGHT', skillIndex: 0, carryState: { sp: 0 } },
|
||
{ chessId: MEDIC, row: 9, col: 4, dir: 'RIGHT' }, // her range covers (9,5) and (10,5)
|
||
{ chessId: TANK, row: 10, col: 5, dir: 'RIGHT' },
|
||
],
|
||
enemies: [{ key: 'enemy_dummy', pos: [9, 9] }],
|
||
});
|
||
const s = h.unit(SURTR), medic = h.unit(MEDIC), tank = h.unit(TANK);
|
||
const wait = waitOf(s);
|
||
assert.equal(wait, 8, 'data: surtr_t_2[withdraw].interval');
|
||
h.run(1);
|
||
const healed = new Map();
|
||
const picked = new Set();
|
||
h.b.on('heal', (c) => { if (c.amount > 0) healed.set(c.target, (healed.get(c.target) ?? 0) + c.amount); }, { priority: -9999 });
|
||
h.b.on('attack', (c) => { if (c.attacker === medic) for (const t of c.targets) picked.add(t); }, { priority: -9999 });
|
||
tank.hp = tank.s.maxHp * 0.5;
|
||
lethal(h, s);
|
||
const t0 = h.b.time;
|
||
assert.ok(s.alive && s.deployed && s.hp >= 1 && s.hp < 2, '余烬: HP held at 1');
|
||
assert.ok(s.s.flags.noHeal && s.s.flags.healFree, '禁疗: no heal pick, no heal at all');
|
||
const on = h.eventsOf('status').filter((ev) => ev[1] === s.id && ev[2] === 'healFree');
|
||
assert.deepEqual(on.map((ev) => ev[3]), [1], 'the status 禁疗 is reported to the clients');
|
||
assert.equal(statusIconKey('healFree'), 'healFree');
|
||
assert.ok(STATUS_KEYS.includes('healFree'), 'and drawn in the status atlas');
|
||
// 不死: a second lethal blow inside the window changes nothing (and does not restart the 8 s)
|
||
h.run(3);
|
||
lethal(h, s);
|
||
assert.ok(s.alive && s.hp >= 1);
|
||
h.run(wait - 3 - 0.2);
|
||
assert.ok(s.alive && s.deployed, 'still on the field just before 8 s');
|
||
assert.ok(s.hp < 2, 'nobody healed her');
|
||
assert.equal(healed.get(s) ?? 0, 0, '0 heals to her');
|
||
assert.ok(!picked.has(s), 'the medic never picks her');
|
||
assert.ok((healed.get(tank) ?? 0) > 0 && picked.has(tank), 'the medic heals the other injured operator instead');
|
||
h.run(0.4);
|
||
assert.equal(s.deployed, false, 'she leaves at 8 s');
|
||
assert.equal(s.removeReason, 'retreat', '"强制退出战场视为撤回干员"');
|
||
assert.ok(h.b.time - t0 <= wait + 0.25);
|
||
const ev = h.eventsOf('status').filter((e) => e[1] === s.id && e[2] === 'healFree').map((e) => e[3]);
|
||
assert.deepEqual(ev, [1, 0], 'the status ends with her stay');
|
||
clean(h);
|
||
});
|
||
|
||
test('her own heals stop too (休眠子裔), an HP-regen attribute does not, and S3 黄昏\'s start heal ignores 禁疗 (PRTS 技能3 备注) — she still leaves at 8 s', () => {
|
||
const mk = (o = {}) => makeBattle({
|
||
defs: { enemies: { enemy_dummy: DUMMY } }, autoFinish: false, timeLimit: 200, seed: 2,
|
||
units: [{ chessId: SURTR, row: 9, col: 5, dir: 'RIGHT', carryState: { sp: 0 }, ...o }],
|
||
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }],
|
||
});
|
||
// control: 休眠子裔 ("每攻击1个目标,回复自身2%最大生命值") heals her while she is not under 禁疗
|
||
const h0 = mk({ skillIndex: 0, items: ['chess_item_4_06_e_a'] });
|
||
const c = h0.unit(SURTR);
|
||
h0.run(1);
|
||
c.hp = c.s.maxHp * 0.5;
|
||
h0.run(3);
|
||
assert.ok(c.hp > c.s.maxHp * 0.5 + 1, 'control: the item heals her on attack');
|
||
// under 禁疗 it gives nothing; an HP-regen attribute still works
|
||
const h = mk({ skillIndex: 0, items: ['chess_item_4_06_e_a'] });
|
||
const s = h.unit(SURTR);
|
||
h.run(1);
|
||
lethal(h, s);
|
||
const atk0 = s.stats.attacks;
|
||
h.run(3);
|
||
assert.ok(s.stats.attacks > atk0, 'she keeps attacking');
|
||
assert.ok(s.hp < 2, '休眠子裔: no heal under 禁疗');
|
||
h.b.addBuff(s, { key: 'test:regen', mods: { hpRegen: 100 } });
|
||
h.run(2);
|
||
assert.ok(s.hp > 150, `生命回复速度 is no 治疗类能力: regen still applies (${s.hp.toFixed(0)})`);
|
||
// S3 黄昏 starting inside the window: "立即恢复所有生命(无视禁疗)"
|
||
const h3 = mk();
|
||
const u = h3.unit(SURTR);
|
||
assert.equal(u.skill.id, 'skchr_surtr_3');
|
||
h3.run(1);
|
||
lethal(h3, u);
|
||
const t0 = h3.b.time;
|
||
h3.run(1);
|
||
assert.ok(u.hp < 2 && !u.skill.active);
|
||
u.skill.gainSp(1000);
|
||
assert.ok(h3.runUntil(() => u.skill.active, 3), 'S3 starts');
|
||
assert.ok(u.hp > u.s.maxHp - 50, 'healed to full in spite of 禁疗');
|
||
h3.runUntil(() => !u.deployed, waitOf(u) + 1);
|
||
assert.equal(u.removeReason, 'retreat');
|
||
assert.ok(Math.abs(h3.b.time - t0 - waitOf(u)) < 0.1, 'and she still leaves 8 s after the lethal blow');
|
||
clean(h); clean(h3);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// #43 follow-up: an enemy's 隐匿 returns 3 s after its block ends
|
||
|
||
const WALL = (id, block = 3) => chessRec({ id, profession: 'TANK', stats: { atk: 0, maxHp: 1e7, def: 0, res: 0, blockCnt: block }, rangeGrid: [[0, 0]], skill: null });
|
||
const BIG = [];
|
||
for (let dr = -4; dr <= 4; dr++) for (let dc = -12; dc <= 12; dc++) BIG.push([dr, dc]);
|
||
const CHESS = {
|
||
t_wall: WALL('t_wall'), t_wall1: WALL('t_wall1', 1),
|
||
t_gun: chessRec({ id: 't_gun', profession: 'SNIPER', projectile: 'none', stats: { atk: 50, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: BIG, skill: null }),
|
||
};
|
||
const NOATK = () => ({ trait: { noAttack: true } });
|
||
function arena(o = {}) {
|
||
const { kits = {}, ...rest } = o;
|
||
return makeBattle({
|
||
content: 'generic', extraContent: [enemiesMod], seed: 7, autoFinish: false, timeLimit: 600,
|
||
defs: { chess: CHESS }, kits: { t_wall: NOATK, t_wall1: NOATK, ...kits }, ...rest,
|
||
});
|
||
}
|
||
/** A pinned enemy (speed ×0) at a tile. */
|
||
const put = (h, key, pos, o = {}) => h.spawn(key, { pos, routeIndex: 0, mods: { speedMul: 0, ...(o.mods || {}) } });
|
||
const anyGun = { canHitFly: false };
|
||
/** Is `e` hidden by its 隐匿 for every consumer: ally targeting, operator splash and the b.snap stealth bit. */
|
||
function hidden(h, e) {
|
||
const st = enemyStealthed(e);
|
||
assert.equal(!canTargetEnemy(null, e, anyGun), st, 'targeting agrees');
|
||
assert.equal(!h.b.foesInRadius(e.x, e.y, 0.3).includes(e), st, 'operator splash agrees (Battle.foesInRadius)');
|
||
assert.equal(!!(flagsOf(e) & UF.STEALTH), st, 'the stealth bit agrees');
|
||
return st;
|
||
}
|
||
const stun = (h, u, d) => h.b.applyStatus(u, 'stun', { duration: d, force: true });
|
||
|
||
describe('#43: a blocked 隐匿 enemy hides again only 3 s after the block ends (PRTS 作战机制 §隐匿)', () => {
|
||
test(`潜伏者: blocked = revealed; the blocker lets go ⇒ revealed for STEALTH_RESTORE (${STEALTH_RESTORE}) s — targetable, splashed, drawn solid, shot by a ranged operator — then 隐匿 again`, () => {
|
||
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }, { chessId: 't_gun', row: 11, col: 3 }] });
|
||
h.step();
|
||
const wall = h.unit('t_wall');
|
||
const e = put(h, 'enemy_1009_lurker', [9, 5]);
|
||
h.step(2);
|
||
assert.ok(e.blockedBy === wall && !hidden(h, e), 'blocked: revealed');
|
||
stun(h, wall, 30); // a stunned blocker lets go and blocks nobody
|
||
assert.ok(!e.blockedBy);
|
||
const t0 = h.b.time;
|
||
assert.ok(e.findBuff(stealthOffKey('ab:stealth')), 'its 隐匿 source is switched off');
|
||
h.run(1);
|
||
assert.equal(hidden(h, e), false, '+1 s: still revealed');
|
||
const taken = e.stats.taken;
|
||
h.run(STEALTH_RESTORE - 1 - 0.1);
|
||
assert.equal(hidden(h, e), false, `+${STEALTH_RESTORE - 0.1} s: still revealed`);
|
||
assert.ok(e.stats.taken > taken, 'the ranged operator shoots it meanwhile');
|
||
h.run(0.2);
|
||
assert.equal(hidden(h, e), true, `+${STEALTH_RESTORE + 0.1} s: 隐匿 again`);
|
||
assert.ok(Math.abs(h.b.time - t0 - STEALTH_RESTORE - 0.1) < 0.05);
|
||
const after = e.stats.taken;
|
||
h.run(2);
|
||
assert.equal(e.stats.taken, after, 'no ranged hit once it hides');
|
||
clean(h);
|
||
});
|
||
|
||
test('a new block inside the window lifts it again, and the window restarts when that block ends (the first one does not cut it short)', () => {
|
||
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
|
||
h.step();
|
||
const wall = h.unit('t_wall');
|
||
const e = put(h, 'enemy_1009_lurker', [9, 5]);
|
||
h.step(2);
|
||
stun(h, wall, 1.5); // let go at t0 …
|
||
const t0 = h.b.time;
|
||
h.run(1.6);
|
||
assert.equal(e.blockedBy, wall, '… blocked again at +1.6 s (inside the 3 s)');
|
||
assert.equal(hidden(h, e), false);
|
||
h.run(2.5 - 1.6);
|
||
stun(h, wall, 30); // … and let go again at +2.5 s
|
||
const t1 = h.b.time;
|
||
h.run(t0 + 4 - h.b.time);
|
||
assert.equal(hidden(h, e), false, '+4 s: revealed (the first window would have ended at +3 s)');
|
||
h.run(t1 + STEALTH_RESTORE - 0.1 - h.b.time);
|
||
assert.equal(hidden(h, e), false, `${STEALTH_RESTORE - 0.1} s after the second block: revealed`);
|
||
h.run(0.2);
|
||
assert.equal(hidden(h, e), true, `${STEALTH_RESTORE + 0.1} s after it: 隐匿`);
|
||
clean(h);
|
||
});
|
||
|
||
test('a release through the block count (ai.js enforceBlockCapacity) and a 反隐 that ends: the same switch; a 反隐 ending inside the window keeps it revealed', () => {
|
||
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
|
||
h.step();
|
||
const wall = h.unit('t_wall');
|
||
const e = put(h, 'enemy_1009_lurker', [9, 5]);
|
||
h.step(2);
|
||
h.b.addBuff(e, { key: 'test:reveal', duration: 1, flags: { reveal: true } });
|
||
h.b.addBuff(wall, { key: 'test:noblock', mods: { blockCnt: -99 } });
|
||
h.step(1);
|
||
assert.ok(!e.blockedBy, 'over capacity: released');
|
||
h.run(1.5);
|
||
assert.ok(!e.s.flags.reveal && hidden(h, e) === false, 'the 反隐 is over, the block switch still holds it');
|
||
h.run(STEALTH_RESTORE - 1.5);
|
||
assert.equal(hidden(h, e), true);
|
||
// a plain 反隐 (no block): hidden again as soon as it ends
|
||
h.b.addBuff(e, { key: 'test:reveal', duration: 1, flags: { reveal: true } });
|
||
assert.equal(hidden(h, e), false);
|
||
h.run(1.05);
|
||
assert.equal(hidden(h, e), true, '反隐 alone: no 3 s');
|
||
clean(h);
|
||
});
|
||
|
||
test('"(解除阻挡N秒后恢复)" on the enemy pages: 0 s (业余竞演者, 清明\'s veil …), 1 s (家族灭迹人); a veil over its own 隐匿 hides it at once, its own switch takes over when the veil ends', () => {
|
||
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }, { chessId: 't_wall', row: 11, col: 5 }, { chessId: 't_wall', row: 12, col: 7 }] });
|
||
h.step();
|
||
const [w1, w2, w3] = h.allies();
|
||
// (流泪小子 has 0 s too, but its first hit stuns its blocker, which lets it go at once)
|
||
const thief = put(h, 'enemy_10031_cnvsld', [9, 5]);
|
||
const killer = put(h, 'enemy_1283_sgkill', [11, 5]);
|
||
const lurker = put(h, 'enemy_1009_lurker', [12, 7]);
|
||
h.step(2);
|
||
assert.ok(thief.blockedBy && killer.blockedBy && lurker.blockedBy);
|
||
assert.equal(thief.findBuff('ab:stealth').data.stealthRestore, 0);
|
||
assert.equal(killer.findBuff('ab:stealth').data.stealthRestore, 1);
|
||
assert.equal(lurker.findBuff('ab:stealth').data.stealthRestore, undefined, 'a plain 隐匿: the general 3 s');
|
||
// the 清明 veil the same way kitInvisShield gives it, over the lurker's own 隐匿
|
||
h.b.addBuff(lurker, { key: 'ab:veiled', duration: 0.5, flags: { stealth: true }, visible: true, data: { stealthRestore: 0 } });
|
||
for (const w of [w1, w2, w3]) stun(h, w, 30);
|
||
h.step();
|
||
assert.equal(hidden(h, thief), true, '业余竞演者: 0 s');
|
||
assert.equal(hidden(h, lurker), true, 'veiled: 0 s');
|
||
assert.equal(hidden(h, killer), false);
|
||
h.run(0.85);
|
||
assert.equal(hidden(h, killer), false, '家族灭迹人: still revealed at +0.9 s');
|
||
assert.equal(hidden(h, lurker), false, 'the veil is over and its own 隐匿 is still switched off');
|
||
h.run(0.25);
|
||
assert.equal(hidden(h, killer), true, '家族灭迹人: 隐匿 at +1.1 s');
|
||
h.run(STEALTH_RESTORE - 1.1 + 0.1);
|
||
assert.equal(hidden(h, lurker), true, 'its own switch: 3 s after the block');
|
||
clean(h);
|
||
});
|
||
|
||
test('清明 / 堂皇 give their veil a 0 s switch; every other stealthy enemy here keeps the general 3 s unless its PRTS page says otherwise', () => {
|
||
const h = arena({ units: [{ chessId: 't_wall', row: 12, col: 3 }] });
|
||
h.step();
|
||
const lamp = put(h, 'enemy_1209_sfden', [10, 7]);
|
||
const o = put(h, 'enemy_1007_slime', [10, 8]);
|
||
assert.ok(h.runUntil(() => !!o.findBuff('ab:veiled'), 30), 'the veil comes');
|
||
assert.equal(o.findBuff('ab:veiled').data.stealthRestore, 0);
|
||
assert.ok(lamp.alive);
|
||
const ZERO = ['enemy_10031_cnvsld', 'enemy_10034_cnvsax', 'enemy_9008_acbunn', 'enemy_2034_sythef', 'enemy_2034_sythef_2', 'enemy_1389_winbab_2'];
|
||
const ONE = ['enemy_1283_sgkill', 'enemy_1283_sgkill_2'];
|
||
const PLAIN = ['enemy_1009_lurker', 'enemy_1019_jshoot', 'enemy_1019_jshoot_2', 'enemy_1023_jmage', 'enemy_1299_ymkilr', 'enemy_1299_ymkilr_2', 'enemy_1404_msnip', 'enemy_10042_prtrop', 'enemy_10042_prtrop_2', 'enemy_1175_dushdo_2'];
|
||
for (const [keys, n] of [[ZERO, 0], [ONE, 1], [PLAIN, undefined]]) {
|
||
for (const k of keys) assert.equal(put(h, k, [10, 9]).findBuff('ab:stealth').data.stealthRestore, n, k);
|
||
}
|
||
});
|
||
|
||
test('our operators: 隐匿 is never lifted by blocking, and its block ending switches nothing (PRTS "我方干员并不会因为阻挡而解除隐匿")', () => {
|
||
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
|
||
h.step();
|
||
const wall = h.unit('t_wall');
|
||
h.b.applyStatus(wall, 'stealth', { duration: 99 });
|
||
const e = put(h, 'enemy_1007_slime', [9, 5]);
|
||
h.step(2);
|
||
assert.ok(e.blockedBy === wall && flagsOf(wall) & UF.STEALTH, 'blocking and still 隐匿');
|
||
assert.ok(canTargetAlly(e, wall, true), 'the enemy it blocks attacks it');
|
||
h.b.releaseBlocked(wall);
|
||
assert.ok(!wall.buffs.some((b) => b.key.startsWith('stealthOff')) && !e.buffs.some((b) => b.key.startsWith('stealthOff')));
|
||
assert.ok(flagsOf(wall) & UF.STEALTH);
|
||
clean(h);
|
||
});
|
||
|
||
test('山海众头目: its 强击标记 comes with its 隐匿 ("该隐匿每次生效后自身获得强击标记") — a new block inside the 3 s gives no second strong hit, one after it hid again does', () => {
|
||
const key = 'enemy_1299_ymkilr';
|
||
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }], captureNoisy: true, hooks: ['damaged'] });
|
||
h.step();
|
||
const wall = h.unit('t_wall');
|
||
const e = put(h, key, [9, 5]);
|
||
const scale = e.mem.ab.sk.InvisibleCombat.bb.atk_scale;
|
||
const hits = () => h.hooksOf('damaged').filter((c) => c.source === e && c.dmg.isAttack).map((c) => c.amount);
|
||
assert.ok(h.runUntil(() => hits().length >= 1, 20));
|
||
assert.ok(Math.abs(hits()[0] - e.s.atk * scale) < 1e-6, 'the first blocked attack: ×atk_scale (the mark of its spawn)');
|
||
stun(h, wall, 1); // let go, blocked again at +1 s (inside the 3 s)
|
||
h.run(1.2);
|
||
assert.ok(e.blockedBy === wall);
|
||
const n1 = hits().length;
|
||
assert.ok(h.runUntil(() => hits().length > n1, 20));
|
||
assert.ok(Math.abs(hits()[n1] - e.s.atk) < 1e-6, 'never hidden in between: a plain hit');
|
||
stun(h, wall, STEALTH_RESTORE + 1); // let go long enough to hide again
|
||
h.run(STEALTH_RESTORE + 0.5);
|
||
assert.equal(enemyStealthed(e), true);
|
||
const n2 = hits().length;
|
||
assert.ok(h.runUntil(() => hits().length > n2, 20));
|
||
assert.ok(e.blockedBy === wall);
|
||
assert.ok(Math.abs(hits()[n2] - e.s.atk * scale) < 1e-6, 'it hid again: the next blocked attack is ×atk_scale');
|
||
clean(h);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// the 深池逐火 embers: the warrior's block ends at the knock-out (the 重生's 无法阻挡)
|
||
|
||
describe('深池逐火: an ember knocked out while blocked stays revealed until 3 s after the knock-out [ASSUMED order]', () => {
|
||
for (const key of ['enemy_1288_duskls', 'enemy_1288_duskls_2', 'enemy_1292_duskld']) {
|
||
test(`${key}: its blocker takes the next warrior during the 1 s 重生 (无敌, untargetable) — the ember is then targetable by ranged and operator splash until +${STEALTH_RESTORE} s, then 隐匿`, () => {
|
||
const h = arena({ units: [{ chessId: 't_wall1', row: 9, col: 5 }, { chessId: 't_gun', row: 11, col: 3 }], kits: { t_gun: NOATK } });
|
||
h.step();
|
||
const wall = h.unit('t_wall1'), gun = h.unit('t_gun');
|
||
const a = put(h, key, [9, 5]);
|
||
const b = put(h, key, [9, 5.3]); // queued right behind it, in contact
|
||
h.step(2);
|
||
assert.ok(a.blockedBy === wall && !b.blockedBy, 'block 1: the first warrior is blocked');
|
||
h.b.kill(a, gun);
|
||
const t0 = h.b.time;
|
||
assert.ok(a.alive && a.form === 'husk' && !a.blockedBy, 'the knock-out: 重生, its block ends at once');
|
||
assert.ok(a.findBuff(stealthOffKey('ab:ember')), 'the ember\'s 隐匿 starts switched off');
|
||
h.step();
|
||
assert.equal(b.blockedBy, wall, 'the blocker takes the next warrior');
|
||
h.run(HUSK_REBIRTH - 0.2);
|
||
assert.ok(a.s.flags.invulnerable && !canTargetEnemy(gun, a, gun.profile), '重生: 无敌 + untargetable');
|
||
h.run(0.4);
|
||
assert.ok(!a.s.flags.untargetable && !a.blockedBy && a.s.flags.stealth, 'the ember walks, unblocked (the blocker is full)');
|
||
assert.equal(hidden(h, a), false, 'revealed after the 重生');
|
||
assert.ok(canTargetEnemy(gun, a, gun.profile), 'a ranged operator can target it');
|
||
const n = a.hp;
|
||
h.b.dealDamage(gun, a, { amount: 1, type: 'phys', tags: ['test'] });
|
||
assert.equal(a.hp, n - 1, 'and hit it (one of its hits)');
|
||
h.run(t0 + STEALTH_RESTORE - 0.1 - h.b.time);
|
||
assert.equal(hidden(h, a), false, `+${STEALTH_RESTORE - 0.1} s: revealed`);
|
||
h.run(0.2);
|
||
assert.equal(hidden(h, a), true, `+${STEALTH_RESTORE + 0.1} s: 隐匿`);
|
||
clean(h);
|
||
});
|
||
}
|
||
|
||
test('knocked out while NOT blocked: 隐匿 at once when the 重生 ends (the switch only follows a block)', () => {
|
||
const h = arena({ units: [{ chessId: 't_gun', row: 11, col: 3 }], kits: { t_gun: NOATK } });
|
||
h.step();
|
||
const a = put(h, 'enemy_1288_duskls', [9, 6]);
|
||
h.b.kill(a, h.unit('t_gun'));
|
||
assert.ok(!a.buffs.some((x) => x.key.startsWith('stealthOff')));
|
||
h.run(HUSK_REBIRTH + 0.1);
|
||
assert.equal(hidden(h, a), true);
|
||
clean(h);
|
||
});
|
||
});
|
||
|
||
test('#52 a chain healer\'s bounces skip 史尔特尔 in 余烬 (禁疗: no heal target) and go to the next injured ally', () => {
|
||
const SURTR = 'chess_char_5_07_a', PAP = 'chess_char_2_06_a', TANK = 'chess_char_1_04_a', MEDIC = 'chess_char_2_02_a';
|
||
const h = makeBattle({
|
||
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, atk: 0 }) } }, autoFinish: false, timeLimit: 200, seed: 3,
|
||
units: [
|
||
{ chessId: SURTR, row: 9, col: 5, dir: 'RIGHT', skillIndex: 0, carryState: { sp: 0 } },
|
||
{ chessId: PAP, row: 10, col: 3, dir: 'RIGHT', skillIndex: 0, carryState: { sp: 0 } },
|
||
{ chessId: TANK, row: 10, col: 5, dir: 'RIGHT' },
|
||
{ chessId: MEDIC, row: 11, col: 5, dir: 'RIGHT', carryState: { sp: 0 } },
|
||
],
|
||
enemies: [{ key: 'enemy_dummy', pos: [9, 9] }],
|
||
});
|
||
const s = h.unit(SURTR), tank = h.unit(TANK), med = h.unit(MEDIC);
|
||
h.run(1);
|
||
tank.hp = tank.s.maxHp * 0.5; med.hp = med.s.maxHp * 0.7;
|
||
h.b.dealDamage(null, s, { amount: 1e9, type: 'true', canDodge: false });
|
||
assert.ok(s.findBuff('surtr:ember'), '余烬 is on');
|
||
const ev0 = h.events.length;
|
||
h.run(6);
|
||
const bounces = h.events.slice(ev0).filter((ev) => ev[0] === 'atk' && ev[3] === 'chainHeal').map((ev) => ev[2]);
|
||
assert.ok(bounces.length > 0, 'the chain bounced');
|
||
assert.ok(!bounces.includes(s.id), 'no bounce picks her');
|
||
assert.ok(bounces.includes(med.id), 'the injured 赫默 gets the bounces');
|
||
clean(h);
|
||
});
|