feedback2 WG: tests for 余烬's 禁疗 and the 3 s 隐匿 window (embers included); flag list, DESIGN §5 / §21.4 and SIM.md follow

This commit is contained in:
sganggs committed 2026-10-03 23:43:28 +08:00
1 parent b32c510069
commit e66a1a8ddb
4 files changed
+385 -16

No files matched your search

+360
View File
@@ -0,0 +1,360 @@
// 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);
});
});