feedback5: merge WE1 — fidelity (sim rules): 秘术师 store at the attack check (深靛; 维伊 / 黑键 plug into it), enemy attacks land at their damage frame so a short stun / freeze / sleep / 浮空 / 缴械 / 恐惧 before it cancels the swing, 武者 heal per damage instance through 禁疗, 庇护 the strongest only, PHY-X 不高于 50 %, 链愈师 jumps in the 3×3, a summon readied at its owner's redeploy, Misery's duration skills, 伺夜 S2 in 战术点形态, centurions hit up to block-count targets; merge fixes: 黑键's kit keeps the up() import WE2's S2 cast needs (WE1 dropped it with its tick store — the first regeneration had stored 9288 swallowed errors in diy-114), the 自选 refusal names 集成战略 / 生息演算, tools/golden.mjs prints a missing value, test/golden.test.js fails on any stored engine error; golden: vs WE1 only the W4B / WE2 scenarios differ (roster-042, bond-egirShip-high, coop2-ABYSS-10-boosted, coop2-NORMAL-70-diy, diy-045 / 078 / 114 / 115 / 116)
This commit is contained in:
commit
e2bb090f44
60 files changed
+6888
-6103
No files matched your search
@@ -143,7 +143,9 @@ describe('#8 深池逐火: a knock-out is a walking 隐匿 ember that a blocker
|
||||
for (const u of dusk) assert.equal(deaths.get(u.id), 1, `${u.defId} #${u.id} dies exactly once`);
|
||||
assert.equal(ember.size, dusk.length, 'every 逐火 went through its ember');
|
||||
assert.deepEqual([...bad], [], 'ember rules');
|
||||
assert.ok(walked > 1, `an ember whose blocker fell walks on (${walked.toFixed(2)} tiles)`);
|
||||
// (until 0.2.0 one of 百炼嘉维尔's embers walked on once she fell; since 0.2.0 she — a 强攻手 — strikes up to her block count
|
||||
// of targets (ai.js targetCount) and holds: an ember whose blocker falls is checked on its own in the test below)
|
||||
assert.ok(walked >= 0);
|
||||
});
|
||||
|
||||
for (const key of EMBERS) {
|
||||
@@ -198,6 +200,20 @@ describe('#8 深池逐火: a knock-out is a walking 隐匿 ember that a blocker
|
||||
assert.equal(h2.b.killed, 1);
|
||||
assert.equal(h2.eventsOf('die').filter((ev) => ev[1] === f.id).length, 1, 'one die event');
|
||||
checkInvariants(h2.b);
|
||||
|
||||
// its blocker falls: the ember walks on (隐匿 again — nobody else can target it)
|
||||
const h3 = arena({ units: [{ chessId: 't_wall', row: 9, col: 7 }], kits: QUIET_GUNS });
|
||||
h3.step();
|
||||
const g = put(h3, key, { mods: { speedMul: 1 } });
|
||||
assert.ok(h3.runUntil(() => !!g.blockedBy, 20), 'blocked');
|
||||
h3.b.kill(g, null);
|
||||
h3.run(HUSK_REBIRTH + 0.1);
|
||||
assert.ok(g.blockedBy, 'an ember held by its blocker');
|
||||
const gx = g.x, gy = g.y;
|
||||
h3.b.kill(h3.unit('t_wall'), null);
|
||||
h3.run(3);
|
||||
assert.ok(g.alive && Math.hypot(g.x - gx, g.y - gy) > 0.5, `an ember whose blocker fell walks on (${Math.hypot(g.x - gx, g.y - gy).toFixed(2)} tiles)`);
|
||||
checkInvariants(h3.b);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
// `attackAnim`, tools/build-data.mjs from the asset manifest; server/sim/ai.js attackStand). It used to stop only
|
||||
// ATTACK_PAUSE (0.35 s) after each strike while the client played the attack clip over the whole walk. An enemy with no
|
||||
// clip known keeps the old 0.35 s (test/sim/combat.test.js); a 「不停止移动」 attacker never stops; a stun cuts the stand.
|
||||
// Since 0.2.0 (GitHub #187 / #170) every attack has its wind-up — the strike comes at the clip's damage frame after the
|
||||
// swing starts, also the first one of an enemy whose cooldown ran out before it had a target (it used to strike at once)
|
||||
// — and a stun before the frame cuts the swing (test/sim/feedback5-enemy-swing.test.js).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -101,9 +104,10 @@ test('a 「不停止移动」 attacker (attackMoves) keeps walking through its a
|
||||
};
|
||||
const moving = run(true), standing = run(false);
|
||||
assert.ok(moving.atks.length >= 3 && standing.atks.length >= 3);
|
||||
for (const t of moving.atks.slice(1, 3)) assert.ok(walked(moving.log, t - 0.5, t + 1) > 0.6, `walks through the attack at ${t.toFixed(2)}`);
|
||||
// (the first attack lands as the target comes into range, its cooldown long over: no wind-up to stand through)
|
||||
for (const t of standing.atks.slice(1, 3)) assert.equal(walked(standing.log, t - 0.5 + 2 * TICK, t + 1 - TICK), 0, 'the same enemy without it stands');
|
||||
for (const t of moving.atks.slice(0, 3)) assert.ok(walked(moving.log, t - 0.5, t + 1) > 0.6, `walks through the attack at ${t.toFixed(2)}`);
|
||||
// (since 0.2.0 the first attack too: its cooldown long over as the target comes into range, it swings from the start —
|
||||
// the whole 0.5 s wind-up stood before the strike)
|
||||
for (const t of standing.atks.slice(0, 3)) assert.equal(walked(standing.log, t - 0.5 + 2 * TICK, t + 1 - TICK), 0, 'the same enemy without it stands');
|
||||
});
|
||||
|
||||
test('no attack clip known: ATTACK_PAUSE after the strike and no wind-up stand; a stun cuts a stand short', () => {
|
||||
@@ -116,8 +120,13 @@ test('no attack clip known: ATTACK_PAUSE after the strike and no wind-up stand;
|
||||
});
|
||||
h.step();
|
||||
const e = h.enemy('enemy_slow');
|
||||
assert.ok(e.lastAttackAt >= 0, 'attacked at once');
|
||||
assert.ok(Math.abs(e.atkStandUntil - (h.b.time - TICK + 2.5)) < 1e-6, 'stands the 2.5 s after the strike');
|
||||
// (0.2.0: a target in range at once, its cooldown over — it swings at once and strikes at the 0.5 s damage frame; it
|
||||
// used to strike in the first tick)
|
||||
assert.equal(e.lastAttackAt, -Infinity, 'swinging, not struck yet');
|
||||
h.runUntil(() => e.lastAttackAt >= 0, 2);
|
||||
assert.ok(Math.abs(e.lastAttackAt - (TICK + 0.5)) < TICK + 1e-6, `struck at the damage frame (${e.lastAttackAt.toFixed(3)})`);
|
||||
h.step();
|
||||
assert.ok(Math.abs(e.atkStandUntil - (e.lastAttackAt + 2.5)) < 1e-6, 'stands the 2.5 s after the strike');
|
||||
h.run(0.5);
|
||||
const x0 = e.x;
|
||||
h.b.applyStatus(e, 'stun', { duration: 0.5 });
|
||||
@@ -156,8 +165,9 @@ test('a ranged enemy with a target in range on a WAIT checkpoint leaves when the
|
||||
assert.equal(free.attacks, 0);
|
||||
assert.ok(sniper.attacks >= 2 && quick.attacks >= 5);
|
||||
assert.ok(Math.abs(sniper.waited - free.waited) < 1e-9 && Math.abs(quick.waited - free.waited) < 1e-9, `the wait ends at ${free.waited.toFixed(2)} s for all three`);
|
||||
// it walks on once the attack clip running at the end of the wait is over (the attack at 2.7 s: until 3.2 s)
|
||||
assert.ok(sniper.moved > free.moved && sniper.moved <= 3.2 + 2 * TICK, `leaves at ${sniper.moved?.toFixed(2)} s (no target: ${free.moved.toFixed(2)} s)`);
|
||||
// it walks on once the attack clip running at the end of the wait is over (since 0.2.0 the first strike at its 0.5 s
|
||||
// damage frame, the next one 2.7 s later at 3.2 s: its clip until 3.7 s; it used to strike at once — until 3.2 s)
|
||||
assert.ok(sniper.moved > free.moved && sniper.moved <= 3.7 + 2 * TICK, `leaves at ${sniper.moved?.toFixed(2)} s (no target: ${free.moved.toFixed(2)} s)`);
|
||||
assert.equal(quick.moved, null, 'an enemy attacking quicker than its clip stands while the target stays in range');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// test/sim/feedback5-block-targets.test.js — "同时攻击阻挡的所有敌人" takes up to the block count of targets (the queued 0.2.0
|
||||
// question; PRTS 分支特性信息 强攻手 / 重剑手 / 推击手 "普通攻击最大目标数等于阻挡数(不会低于1)", PRTS 作战机制 §AOE伤害判定 "锁定人数的无
|
||||
// 弹道AOE攻击(例如近卫分支“强攻手”)…在抬手时选取范围内的全体目标(不超过其攻击目标上限)"; the client's selector flag
|
||||
// _limitedMaxTargetNumToBlockedCnt) — server/sim/ai.js acquireTargets / targetCount (`hitAllBlocked`). Until 0.2.0 it struck
|
||||
// the enemies it blocked only (one in range when it blocked none).
|
||||
// Run: node --test test/sim/feedback5-block-targets.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
// a 强攻手 (block 3) whose range is its tile and the three ahead
|
||||
const CENT = chessRec({ id: 't_cent', subProfessionId: 'centurion', stats: { maxHp: 1e6, atk: 100, def: 1e4, blockCnt: 3, bat: 1 }, skill: null, rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3]] });
|
||||
const dummy = (key) => enemyRec({ key, hp: 1e9, speed: 0, atk: 0 });
|
||||
|
||||
/** The targets of its first attack, with enemies at `at` ([row, col] each). */
|
||||
function firstAttack(at, setup = null) {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_cent: CENT }, enemies: { e: dummy('e') } }, units: [{ chessId: 't_cent', row: 10, col: 5 }],
|
||||
enemies: at.map((pos) => ({ key: 'e', pos })), content: 'none', autoFinish: false, timeLimit: 30, hooks: ['attack'], captureNoisy: true,
|
||||
setup,
|
||||
});
|
||||
const u = h.unit('t_cent');
|
||||
assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 3));
|
||||
const atk = h.hooksOf('attack').find((c) => c.attacker === u);
|
||||
checkInvariants(h.b);
|
||||
return { h, u, targets: atk.targets };
|
||||
}
|
||||
|
||||
test('blocking one enemy with two more in its range: one attack strikes all three (up to its block count, the blocked one first)', () => {
|
||||
const { u, targets } = firstAttack([[10, 5], [10, 6], [10, 7]]);
|
||||
assert.equal(u.blocking.length, 1, 'it blocks the one on its tile');
|
||||
assert.equal(targets.length, 3);
|
||||
assert.equal(targets[0], u.blocking[0], 'the blocked enemy first');
|
||||
});
|
||||
|
||||
test('blocking none: up to its block count from the range; never more than the block count; never fewer than 1', () => {
|
||||
const free = firstAttack([[10, 6], [10, 7], [10, 8]]);
|
||||
assert.equal(free.u.blocking.length, 0);
|
||||
assert.equal(free.targets.length, 3, 'three in range, none blocked: three targets');
|
||||
const crowd = firstAttack([[10, 5], [10, 6], [10, 6], [10, 7], [10, 7], [10, 8]]);
|
||||
assert.equal(crowd.targets.length, 3, 'six in reach: its block count of them');
|
||||
assert.ok(crowd.u.blocking.every((e) => crowd.targets.includes(e)), 'every enemy it blocks among them');
|
||||
const zero = firstAttack([[10, 6], [10, 7]], (b) => {
|
||||
b.on('deploy', (c) => { if (c.unit.defId === 't_cent') b.addBuff(c.unit, { key: 'test:noBlock', mods: { blockCnt: -99 } }); });
|
||||
});
|
||||
assert.equal(zero.targets.length, 1, 'block count 0: still one target (不会低于1)');
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// test/sim/feedback5-chain-heal.test.js — the 链愈师 heal chain (follow-up 27; PRTS 分支特性信息 链愈师 "跳跃范围为x-4,无特殊说明
|
||||
// 的场合一次治疗链不会对已跳跃过的单位重复跳跃", "优先跳跃至范围内生命比例最低>部署时间点最晚的我方单位。可选择满生命我方单位为跳跃目标,
|
||||
// 但仍受禁疗制约") — server/sim/ai.js chainHealNext / doHeal (莎草's profession chain; Mon3tr's kit uses the same links). It used
|
||||
// to jump to the most injured ally within 2.5 tiles (never a full-HP one, ties by list order).
|
||||
// Run: node --test test/sim/feedback5-chain-heal.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
const GRID = []; for (let r = -2; r <= 2; r++) for (let c = 0; c <= 6; c++) GRID.push([r, c]);
|
||||
const HEALER = chessRec({ id: 't_chain', profession: 'MEDIC', subProfessionId: 'chainhealer', stats: { maxHp: 1e4, atk: 100, bat: 1 }, skill: null, rangeGrid: GRID });
|
||||
const wall = (id) => chessRec({ id, stats: { maxHp: 1e4, atk: 0, def: 0 }, skill: null });
|
||||
|
||||
/** The healer on (10, 2) and walls at `at` = { id: [row, col, hpRatio] }; returns the chain of its first heal (ids → wall ids). */
|
||||
function firstChain(at, setup = null) {
|
||||
const ids = Object.keys(at);
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_chain: HEALER, ...Object.fromEntries(ids.map((id) => [id, wall(id)])) } },
|
||||
units: [{ chessId: 't_chain', row: 10, col: 2 }, ...ids.map((id) => ({ chessId: id, row: at[id][0], col: at[id][1] }))],
|
||||
content: 'none', autoFinish: false, timeLimit: 30, seed: 3,
|
||||
});
|
||||
h.step(); // deployed (the healer heals nobody yet: everyone is at full HP)
|
||||
for (const id of ids) { const u = h.unit(id); u.hp = u.s.maxHp * at[id][2]; }
|
||||
if (setup) setup(h);
|
||||
const n0 = h.events.length;
|
||||
for (let i = 0; i < 90 && !h.events.slice(n0).some((e) => e[0] === 'atk'); i++) h.step(); // (step drains the events)
|
||||
const name = new Map(h.b.allyUnits.map((u) => [u.id, u.defId]));
|
||||
const ev = h.events.slice(n0).filter((e) => e[0] === 'atk');
|
||||
checkInvariants(h.b);
|
||||
return { h, main: name.get(ev.find((e) => e[3] !== 'chainHeal')[2]), jumps: ev.filter((e) => e[3] === 'chainHeal').map((e) => [name.get(e[1]), name.get(e[2])]) };
|
||||
}
|
||||
|
||||
test('the jump reaches the 3×3 around the last one healed (x-4), not an ally two tiles away; each jump goes on from where it landed', () => {
|
||||
// A (most injured) on (10, 5); B two tiles right of it (inside 2.5 tiles, outside its 3×3), C on its diagonal
|
||||
const r = firstChain({ t_a: [10, 5, 0.5], t_b: [10, 7, 0.6], t_c: [11, 6, 0.7] });
|
||||
assert.equal(r.main, 't_a');
|
||||
assert.deepEqual(r.jumps, [['t_a', 't_c'], ['t_c', 't_b']], 'A → C (its 3×3) → B (C\'s 3×3); it used to jump A → B → C');
|
||||
});
|
||||
|
||||
test('a full-HP ally is a jump target when no injured one is in reach — healed for nothing, the chain jumps on from it', () => {
|
||||
const r = firstChain({ t_a: [10, 5, 0.5], t_d: [10, 6, 1], t_e: [10, 7, 0.6] });
|
||||
assert.equal(r.main, 't_a');
|
||||
assert.deepEqual(r.jumps, [['t_a', 't_d'], ['t_d', 't_e']], 'A → D (full HP) → E');
|
||||
assert.equal(r.h.unit('t_d').hp, r.h.unit('t_d').s.maxHp);
|
||||
});
|
||||
|
||||
test('ties of the HP ratio: the latest deployed first; a 禁疗 unit is never a jump target', () => {
|
||||
const tie = firstChain({ t_a: [10, 5, 0.5], t_f: [9, 5, 0.8], t_g: [11, 5, 0.8] });
|
||||
const [f, g] = [tie.h.unit('t_f'), tie.h.unit('t_g')];
|
||||
const later = f.aggroSeq > g.aggroSeq ? 't_f' : 't_g';
|
||||
assert.deepEqual(tie.jumps[0], ['t_a', later], 'the later deployed of the two');
|
||||
const banned = firstChain({ t_a: [10, 5, 0.5], t_n: [10, 6, 0.2], t_m: [11, 5, 0.9] }, (h) => {
|
||||
h.b.addBuff(h.unit('t_n'), { key: 'test:healFree', status: 'healFree', flags: { noHeal: true, healFree: true } });
|
||||
});
|
||||
assert.equal(banned.main, 't_a', 'the 禁疗 unit is no main target either');
|
||||
assert.deepEqual(banned.jumps[0], ['t_a', 't_m'], 'the 禁疗 unit is skipped');
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
// test/sim/feedback5-enemy-swing.test.js — an enemy's normal attack strikes at its clip's damage frame, and a stun before the
|
||||
// frame cuts the swing (GitHub #187 point 1 「短轴眩晕无法打断敌人攻击,比如:六卡西米尔、异德三、忍冬三」, #170; PRTS 状态机
|
||||
// ATTACK / COMBAT "每帧检查异常状态,若有则切换到异常状态的状态", PRTS 异常效果 STUNNED / DISARMED "正在进行的普通攻击将被中断") —
|
||||
// server/sim/ai.js updateEnemy / enemyAttack / attackWindup. The damage frame is data/enemies.json `attackAnim.hit` (the
|
||||
// clip's strike frame), shortened with the clip when the attacks come quicker than it; no clip known ⇒ no wind-up.
|
||||
// Run: node --test test/sim/feedback5-enemy-swing.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { TICK } from '../../server/sim/constants.js';
|
||||
import { attackWindup } from '../../server/sim/ai.js';
|
||||
|
||||
const HIT = 0.4; // the clip's strike frame
|
||||
const BAT = 2; // attack interval
|
||||
const near = (a, b, msg, ticks = 2) => assert.ok(Math.abs(a - b) <= ticks * TICK + 1e-9, `${msg}: ${a.toFixed(3)} vs ${b.toFixed(3)}`);
|
||||
|
||||
/** A wall operator and one enemy (melee on the wall's tile — blocked at once —, or ranged 3 tiles away). */
|
||||
function field({ ranged = false, clip = true } = {}) {
|
||||
const rec = {
|
||||
...enemyRec({ key: 'enemy_sw', hp: 1e9, atk: 100, bat: BAT, speed: 0, ...(ranged ? { range: 3 } : null) }),
|
||||
...(clip ? { attackAnim: { clip: 'Attack', dur: 1, hit: HIT } } : null),
|
||||
};
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_wall: chessRec({ id: 't_wall', stats: { maxHp: 1e9, atk: 0, def: 0 }, skill: null }) }, enemies: { enemy_sw: rec } },
|
||||
units: [{ chessId: 't_wall', row: 10, col: 6 }], enemies: [{ key: 'enemy_sw', pos: ranged ? [10, 9] : [10, 6] }],
|
||||
content: 'none', autoFinish: false, timeLimit: 60, hooks: ['attack', 'damaged'], captureNoisy: true,
|
||||
});
|
||||
h.step();
|
||||
return { h, e: h.enemy('enemy_sw'), wall: h.unit('t_wall') };
|
||||
}
|
||||
const strikes = (h, e) => h.hooksOf('attack').filter((c) => c.attacker === e).map((c) => c.t);
|
||||
const hits = (h, e) => h.hooksOf('damaged').filter((c) => c.source === e && c.dmg.isAttack).map((c) => c.t);
|
||||
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
|
||||
|
||||
test('attackWindup: the clip\'s strike frame, shortened with the clip when the attacks come quicker than it; 0 with no clip', () => {
|
||||
const w = (anim, interval) => attackWindup({ def: { attackAnim: anim }, s: { interval } });
|
||||
assert.equal(w({ dur: 1, hit: 0.4 }, 2), 0.4);
|
||||
assert.equal(w({ dur: 2, hit: 1 }, 1), 0.5);
|
||||
assert.equal(w({ dur: 1 }, 2), 0.5, 'no strike frame named: half the clip');
|
||||
assert.equal(w(undefined, 2), 0);
|
||||
});
|
||||
|
||||
test('a blocked enemy swings when its cooldown is over and strikes at the damage frame: the first strike one wind-up after the block (its cooldown long over), then one per attack interval', () => {
|
||||
const { h, e } = field();
|
||||
const t0 = h.b.time; // blocked in the first tick
|
||||
h.run(4.6);
|
||||
const s = strikes(h, e);
|
||||
assert.ok(s.length >= 3, `${s.length} strikes`);
|
||||
near(s[0], t0 + HIT, 'the first strike: the whole wind-up from the block (it used to land at once)');
|
||||
near(s[1] - s[0], BAT, 'then one per interval', 1);
|
||||
near(s[2] - s[1], BAT, 'then one per interval', 1);
|
||||
assert.deepEqual(hits(h, e), s, 'melee: the damage at the strike');
|
||||
done(h);
|
||||
});
|
||||
|
||||
for (const dur of [0.1, 0.2]) {
|
||||
test(`a ${dur} s stun before the damage frame cuts the swing: no strike at that frame, the attack starts again from its wind-up after the stun (卡西米尔 6's pulse, 忍冬 S3)`, () => {
|
||||
const { h, e } = field();
|
||||
assert.ok(h.runUntil(() => strikes(h, e).length === 1, 2));
|
||||
const t1 = strikes(h, e)[0];
|
||||
// its next swing starts at t1 + BAT − HIT; the stun lands 0.2 s into it
|
||||
h.runUntil(() => h.b.time >= t1 + BAT - HIT + 0.2 - 1e-9, 3);
|
||||
assert.ok(e.swing, 'swinging');
|
||||
const tStun = h.b.time;
|
||||
h.b.applyStatus(e, 'stun', { duration: dur });
|
||||
h.run(dur + HIT + 0.3);
|
||||
const s = strikes(h, e);
|
||||
assert.equal(s.filter((t) => t > t1 && t < tStun + dur).length, 0, 'the cut swing never struck');
|
||||
near(s[1], tStun + dur + HIT, 'the next strike: the stun, then the whole wind-up again', 3);
|
||||
assert.ok(s[1] > t1 + BAT + dur + 0.1, `later than a mere pause of the countdown (${(t1 + BAT + dur).toFixed(2)})`);
|
||||
done(h);
|
||||
});
|
||||
}
|
||||
|
||||
test('a stun during the cooldown (before the swing) only pauses it; 缴械 cuts a swing like a stun; a strike already made stays made (a ranged shot in flight lands)', () => {
|
||||
// the cooldown: a 0.5 s stun 0.5 s after a strike delays the next one by 0.5 s
|
||||
const a = field();
|
||||
assert.ok(a.h.runUntil(() => strikes(a.h, a.e).length === 1, 2));
|
||||
const t1 = strikes(a.h, a.e)[0];
|
||||
a.h.run(0.5);
|
||||
a.h.b.applyStatus(a.e, 'stun', { duration: 0.5 });
|
||||
a.h.run(BAT + 0.3);
|
||||
near(strikes(a.h, a.e)[1], t1 + BAT + 0.5, 'paused by the stun, no new wind-up', 3);
|
||||
done(a.h);
|
||||
// 缴械 mid-swing: the swing is cut, a new one starts once it ends
|
||||
const b = field();
|
||||
assert.ok(b.h.runUntil(() => strikes(b.h, b.e).length === 1, 2));
|
||||
const u1 = strikes(b.h, b.e)[0];
|
||||
b.h.runUntil(() => b.h.b.time >= u1 + BAT - HIT + 0.2 - 1e-9, 3);
|
||||
const tD = b.h.b.time;
|
||||
b.h.b.addBuff(b.e, { key: 'test:disarm', duration: 0.1, flags: { disarm: true } });
|
||||
b.h.run(HIT + 0.5);
|
||||
near(strikes(b.h, b.e)[1], tD + 0.1 + HIT, '缴械 "正在进行的普通攻击将被中断": the whole wind-up after it', 3);
|
||||
done(b.h);
|
||||
// ranged: stunned just after the strike — the shot flies on and lands
|
||||
const c = field({ ranged: true });
|
||||
assert.ok(c.h.runUntil(() => strikes(c.h, c.e).length === 1, 3));
|
||||
assert.equal(hits(c.h, c.e).length, 0, 'the shot is in flight');
|
||||
c.h.b.applyStatus(c.e, 'stun', { duration: 1 });
|
||||
c.h.run(0.6);
|
||||
assert.equal(hits(c.h, c.e).length, 1, 'a strike already made is not undone');
|
||||
done(c.h);
|
||||
});
|
||||
|
||||
test('no attack clip known: no wind-up — it strikes as its swing starts, so a stun only pauses its cooldown (the rule before 0.2.0)', () => {
|
||||
const { h, e } = field({ clip: false });
|
||||
assert.equal(attackWindup(e), 0);
|
||||
h.step();
|
||||
assert.equal(strikes(h, e).length, 1, 'struck in its first tick blocked');
|
||||
const t1 = strikes(h, e)[0];
|
||||
h.runUntil(() => h.b.time >= t1 + BAT - 0.1 - 1e-9, 3);
|
||||
h.b.applyStatus(e, 'stun', { duration: 0.1 });
|
||||
h.run(0.5);
|
||||
near(strikes(h, e)[1], t1 + BAT + 0.1, 'paused by the stun', 3);
|
||||
done(h);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// test/sim/feedback5-musha-heal.test.js — the 武者 trait heal on every damage instance (PRTS 分支特性信息 武者 "特性治疗于干员每次
|
||||
// 输出伤害时触发(不局限于攻击)", "常态持有禁疗;通过自身特性/天赋/技能产生的作用于自身的治疗效果会无视自身的禁疗") —
|
||||
// server/sim/professions.js installMushaHeal; 赫拉格's kit no longer patches his double strikes (op-helage.js), 左乐's S1 reads
|
||||
// his HP when it fires (op-zuole.js).
|
||||
// Run: node --test test/sim/feedback5-musha-heal.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
const VALUE = 50;
|
||||
/** A 武者 (trait bb value 50) hitting `hits` times per attack; an enemy that dodges `dodge` of physical hits. */
|
||||
function field({ hits = 1, dodge = 0, kit = null } = {}) {
|
||||
const musha = chessRec({ id: 't_musha', subProfessionId: 'musha', stats: { maxHp: 1e6, atk: 100, bat: 1, def: 0 }, skill: null });
|
||||
musha.trait = { desc: '', bb: { value: VALUE } };
|
||||
const medic = chessRec({ id: 't_medic', profession: 'MEDIC', stats: { maxHp: 1000, atk: 300, bat: 1 }, skill: null, rangeGrid: [[0, 0], [0, -1], [0, -2]] });
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_musha: musha, t_medic: medic }, enemies: { e: { ...enemyRec({ key: 'e', hp: 1e9, speed: 0 }) } } },
|
||||
kits: { t_musha: () => ({ trait: { hits, ...(kit?.trait ?? {}) }, ...(kit?.rest ?? {}) }) },
|
||||
units: [{ chessId: 't_musha', row: 10, col: 5 }, { chessId: 't_medic', row: 10, col: 7 }], enemies: [{ key: 'e', pos: [10, 6] }],
|
||||
content: 'generic', autoFinish: false, timeLimit: 60, hooks: ['heal', 'damaged', 'attack', 'dodge'], captureNoisy: true,
|
||||
setup: (b) => { if (dodge) b.on('enemySpawn', ({ enemy }) => { b.addBuff(enemy, { key: 'test:dodge', mods: { dodgePhys: dodge } }); }); },
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('t_musha');
|
||||
u.hp = u.s.maxHp / 2;
|
||||
return { h, u, e: h.enemies()[0] };
|
||||
}
|
||||
const traitHeals = (h, u) => h.hooksOf('heal').filter((c) => c.source === u && c.target === u && c.opts?.self && !c.opts?.tags);
|
||||
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
|
||||
|
||||
test('a double strike heals twice: one heal of the trait value per damage instance of the attack (it used to heal once per enemy struck)', () => {
|
||||
const { h, u } = field({ hits: 2 });
|
||||
h.run(3.05);
|
||||
const atks = h.hooksOf('attack').filter((c) => c.attacker === u).length;
|
||||
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack).length;
|
||||
assert.ok(atks >= 3 && hits === 2 * atks, `${atks} attacks, ${hits} hits`);
|
||||
assert.equal(traitHeals(h, u).length, hits, 'a heal per hit');
|
||||
assert.ok(traitHeals(h, u).every((c) => c.amount === VALUE));
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('every damage instance it outputs heals, not only attacks; a dodged hit, a 流失, an element 损伤 and a talent / DoT damage do not', () => {
|
||||
const { h, u, e } = field({ dodge: 1 });
|
||||
h.run(2.05);
|
||||
assert.ok(h.hooksOf('dodge').some((c) => c.source === u), 'its hits were dodged');
|
||||
assert.equal(traitHeals(h, u).length, 0, 'a dodged hit deals no damage: no heal');
|
||||
const n0 = traitHeals(h, u).length;
|
||||
h.b.dealDamage(u, e, { amount: 100, type: 'arts', isSkill: true, canDodge: false, tags: ['skill'] });
|
||||
h.b.dealDamage(u, e, { amount: 100, type: 'true', canDodge: false, tags: ['item', 'item:test'] });
|
||||
assert.equal(traitHeals(h, u).length - n0, 2, 'skill and item damage heal');
|
||||
h.b.loseHp(e, 100, { source: u });
|
||||
h.b.dealDamage(u, e, { type: 'element', element: 'burn', amount: 100 });
|
||||
h.b.dealDamage(u, e, { amount: 100, type: 'arts', canDodge: false, tags: ['talent', 'dot'] });
|
||||
assert.equal(traitHeals(h, u).length - n0, 2, 'no heal for a 流失, an element 损伤 or a DoT');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('禁疗 does not stop the trait heal ("会无视自身的禁疗"); another unit\'s heal still does not reach it', () => {
|
||||
const { h, u } = field();
|
||||
h.b.addBuff(u, { key: 'test:healFree', status: 'healFree', flags: { noHeal: true, healFree: true } });
|
||||
const hp0 = u.hp;
|
||||
h.run(1.05);
|
||||
assert.ok(traitHeals(h, u).length >= 1 && u.hp > hp0, 'healed through 禁疗');
|
||||
assert.equal(h.hooksOf('heal').filter((c) => c.target === u && c.source !== u).length, 0, 'the medic never heals it');
|
||||
assert.equal(h.b.heal(h.unit('t_medic'), u, 500), 0);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('宴 (the chess): one heal per hit of her attacks', () => {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { e: enemyRec({ key: 'e', hp: 1e9, speed: 0 }) } }, units: [{ chessId: 'chess_char_1_18_a', row: 10, col: 5 }],
|
||||
enemies: [{ key: 'e', pos: [10, 6] }], autoFinish: false, timeLimit: 60, hooks: ['heal', 'damaged'], captureNoisy: true, seed: 5,
|
||||
});
|
||||
h.run(8);
|
||||
const u = h.unit('chess_char_1_18_a');
|
||||
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.target.side === 'enemy' && c.type !== 'element' && !(c.dmg.tags || []).includes('dot')).length;
|
||||
assert.ok(hits >= 3);
|
||||
assert.equal(traitHeals(h, u).length, hits);
|
||||
done(h);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// test/sim/feedback5-mystic-store.test.js — the 秘术师 energy store at the attack check (GitHub #181; PRTS 分支特性信息 秘术师
|
||||
// "能量储存与攻击占用相同的攻击间隔:在进行攻击判定时,若范围内存在有效目标,则进行普通攻击;若无有效目标且能量储存数未满,则改为储存
|
||||
// 一份攻击能量(属于攻击行为);仅没有有效目标,且能量储存数已满的情况下才进入待机状态", "攻击被打断且弹道未能成功生成的情况下,储存的
|
||||
// 能量不会被消耗"; PRTS 异常效果 缴械 "秘术师储存能量同样无法进行") — server/sim/professions.js installMystic (storeEnergy /
|
||||
// releaseEnergy / hitsFn), server/sim/ai.js updateAlly / performAttack, 深靛's kit (a bound enemy is no valid target).
|
||||
// Run: node --test test/sim/feedback5-mystic-store.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
|
||||
const ds = getDefaultSource();
|
||||
const ID = 'chess_char_1_17_a'; // 深靛 (normal: trait times 3)
|
||||
const ELITE = 'chess_char_1_17_b'; // 深靛 elite (MSC-X: times 4)
|
||||
const TICK = 1 / 30;
|
||||
const approx = (a, b, msg, eps = TICK + 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${msg}: ${a} vs ${b}`);
|
||||
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, ...o });
|
||||
/** Her record without the talent's random bind (柔光缚目 prob 0): the binds of these tests are placed by hand. */
|
||||
const noBind = (id) => {
|
||||
const r = ds.rawChess(id);
|
||||
return { [id]: { ...r, talents: r.talents.map((t) => ({ ...t, bb: { ...t.bb, prob: 0 } })) } };
|
||||
};
|
||||
|
||||
function field({ id = ID, enemies = [], setup } = {}) {
|
||||
return makeBattle({
|
||||
seed: 3, autoFinish: false, timeLimit: 300, hooks: ['attack', 'damaged'], captureNoisy: true, setup,
|
||||
defs: { chess: noBind(id), enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4 }], enemies,
|
||||
});
|
||||
}
|
||||
const attacksOf = (h, u) => h.hooksOf('attack').filter((c) => c.attacker === u);
|
||||
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
|
||||
|
||||
test('no target: her first attack check stores at once (an attack action: the interval restarts), then one energy per attack interval up to the trait times; full ⇒ idle with the attack ready', () => {
|
||||
for (const [id, max] of [[ID, 3], [ELITE, 4]]) {
|
||||
const h = field({ id });
|
||||
const u = h.unit(id);
|
||||
h.step();
|
||||
assert.equal(u.trait.stored, 1, `${id}: the attack is ready at deployment — stored at the first check`);
|
||||
const iv = u.s.interval;
|
||||
approx(u.atkCd, iv, `${id}: storing took the attack interval`, 1e-9);
|
||||
let last = h.b.time;
|
||||
for (let n = 2; n <= max; n++) {
|
||||
assert.ok(h.runUntil(() => u.trait.stored === n, iv + 1), `${id}: energy ${n}`);
|
||||
approx(h.b.time - last, iv, `${id}: energy ${n} one interval after the last`);
|
||||
last = h.b.time;
|
||||
}
|
||||
h.run(3 * iv);
|
||||
assert.equal(u.trait.stored, max, `${id}: at most ${max}`);
|
||||
assert.equal(u.atkCd, 0, `${id}: full — idle, the attack ready`);
|
||||
assert.equal(attacksOf(h, u).length, 0);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('a target in range while she holds energies: attacked at once, the energies leave with that attack and land with its main hit, one arts attack hit each', () => {
|
||||
const h = field({ enemies: [{ key: 'e', pos: [10, 6], time: 12 }] });
|
||||
const u = h.unit(ID);
|
||||
assert.ok(h.runUntil(() => attacksOf(h, u).length === 1, 15));
|
||||
approx(h.b.time, 12, 'attacked in the tick the enemy appeared (the full store idled with the attack ready)', 2 * TICK);
|
||||
assert.equal(u.trait.stored, 0, 'released with the attack (before the bolt lands)');
|
||||
h.run(1);
|
||||
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack);
|
||||
assert.equal(hits.length, 1 + 3, 'the main hit + 3 energies');
|
||||
assert.ok(hits.every((c) => c.type === 'arts' && c.dmg.attackId === hits[0].dmg.attackId), 'arts, one attack');
|
||||
assert.ok(hits.every((c) => Math.abs(c.amount - hits[0].amount) < 1e-6), 'each energy hits as the main hit');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('a bind on her only target is no valid target: her next attack check stores an energy, and the freed enemy waits for the interval the store took (GitHub #181)', () => {
|
||||
const h = field({ enemies: [{ key: 'e', pos: [10, 6] }] });
|
||||
const u = h.unit(ID);
|
||||
assert.ok(h.runUntil(() => attacksOf(h, u).length === 1, 2));
|
||||
const t0 = h.b.time, iv = u.s.interval;
|
||||
assert.equal(u.trait.stored, 0);
|
||||
const [e] = h.enemies();
|
||||
h.b.applyStatus(e, 'bind', { duration: 4 });
|
||||
assert.ok(h.runUntil(() => u.trait.stored === 1, iv + 1));
|
||||
approx(h.b.time - t0, iv, 'stored at her next attack check, during the bind');
|
||||
assert.equal(attacksOf(h, u).length, 1, 'no attack at the bound enemy');
|
||||
assert.ok(h.runUntil(() => attacksOf(h, u).length === 2, iv + 1));
|
||||
approx(h.b.time - t0, 2 * iv, 'the bind ended at +4 s; her next check comes an interval after the store', 2 * TICK + 1e-6);
|
||||
assert.equal(u.trait.stored, 0, 'the energy left with that attack');
|
||||
h.run(1);
|
||||
const n = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack && c.t > t0 + 2 * iv - 0.1).length;
|
||||
assert.equal(n, 2, 'the main hit + the stored energy');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('disarmed (缴械) or stunned: no storing; an attack cancelled before its shot keeps the energies; a redeployment holds none [ASSUMED]', () => {
|
||||
const h = field();
|
||||
const u = h.unit(ID);
|
||||
h.step();
|
||||
assert.equal(u.trait.stored, 1);
|
||||
h.b.addBuff(u, { key: 'test:disarm', duration: 10, flags: { disarm: true } });
|
||||
h.run(9.5);
|
||||
assert.equal(u.trait.stored, 1, 'disarmed: "秘术师储存能量同样无法进行"');
|
||||
h.run(1);
|
||||
assert.equal(u.trait.stored, 2, 'stored at the first check after the disarm');
|
||||
h.b.applyStatus(u, 'stun', { duration: 10 });
|
||||
h.run(9.5);
|
||||
assert.equal(u.trait.stored, 2, 'stunned: no attack check');
|
||||
h.run(4);
|
||||
assert.equal(u.trait.stored, 3);
|
||||
// an attack whose shot never forms (beforeAttack empties it): the energies stay
|
||||
let cancel = true;
|
||||
h.b.on('beforeAttack', (ctx) => { if (ctx.attacker === u && cancel) ctx.targets = []; });
|
||||
h.spawn('e', { pos: [10, 6] });
|
||||
h.run(0.5);
|
||||
assert.equal(attacksOf(h, u).length, 0);
|
||||
assert.equal(u.trait.stored, 3, '"攻击被打断且弹道未能成功生成的情况下,储存的能量不会被消耗"');
|
||||
cancel = false;
|
||||
// knocked out holding them: the redeployment starts empty
|
||||
h.b.retreat(u);
|
||||
h.b.redeploy(u, { free: true });
|
||||
assert.equal(u.trait.stored, 0, 'a redeployment holds no energy');
|
||||
done(h);
|
||||
});
|
||||
Reference in new issue
Block a user