Files
ark-stronghold-protocol/test/sim/feedback2-surtr-stealth.test.js
T
sganggs 806a047717 feedback6 R21P: every operator fights at full potential (潜能 6) — the owner's decision of 2026-10-07 (GitHub #252, PR #255; no host slider): tools/build-data.mjs OPERATOR_POTENTIAL adds every character_table potentialRanks attribute step (部署费用 / 再部署时间 / ATK / HP / DEF / RES / ASPD, all ADDITION, before rounding) to every chess (normal and elite), 补位 and 自选 unit form and picks talent / trait / module-talent candidates up to requiredPotentialRank 5 (「天赋效果增强」); a summon picks its talent candidates at its owner's potential (their requiredPotentialRank mirrors the owner's — PRTS 凯尔希: Mon3tr 3.5 s / 1400) and takes none of the owner's attribute steps; the 原型干员 stand-ins have no potential ranks (unchanged). 刺玫 413 / 17 → 435 / 15 as in the tournament video, 砾 18 → 16 s, 至简 80 → 70 s. data/chess.json (256 records), backups.json (the 71 owned picks, 8 summons), tokens.json (5 summons) and the data translations en / ja / ko / zh-TW rebuilt — only potential-driven fields (JSON-compared); the 干员调配 局内数值 and detail cards read the records. op-wang.js reads her stock cap 「最多拥有8(+1)枚」 through the potential step (the pattern fell back to 7: she sat at the cap from the start). Docs: DATA §2 / §18 / tokens, PLAYING (detail card), research 03, kits README, shared/diy.js / standIn.js, the 71 自选 kit headers and the kit comments that quoted potential-0 numbers. Tests: test/full-potential.test.js (new, fails on the parent); data.test.js re-derivation adds the potential steps (+ respawnTime); every test that hard-coded a potential-0 number carries the full-potential one (from the data and the test's own formula; no check loosened); golden: moves almost everywhere — roster 49/49, bonds 46/46, fields 22/22, matches 18/18, diy 133/138 (each battle parts on a stat: roster-001 / roster-020 at tick 1 on 角峰's HP (生命上限+250), bond-arcaneShip-high at 4.73 s and diy-001 at 3.70 s on an enemy's HP from a higher ATK; diy-110 / 111 also by 望's cap; the matches through those battles); unchanged: standins 10/10 and diy-135…138 (no potential ranks), diy-012 (twelve 夜莺 never hit, never redeployed)
2026-10-07 18:35:39 +08:00

387 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// test/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 9 s (8 + the potential step); 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, 9, 'data: surtr_t_2[withdraw].interval (8 s, 9 at full potential)');
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 9 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 9 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 9 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 9 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 9 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);
});