Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed - #2 two-tap confirm on 机变 cards (like the shop) - #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles) - #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band - #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from - #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others) - #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats) - #8 official element gauges on units; burst cooldown (阻回) per PRTS - #9 knocked-down operators stay on their tile with a redeploy countdown ring - #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼) - #11 赫默's drone only from her skill; placeable summons limited to talent tokens - #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel - #13 the occasional giant bond icon (loading texture scaled ×46) fixed - docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
This commit is contained in:
1 parent
0aba602ecf
commit
d4f868cd19
107 files changed
+4510
-1653
No files matched your search
@@ -55,9 +55,12 @@ test('炎: members ATK ×(1+0.23+0.009L), 调和 enjoys it, non-members unchange
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('炎 6 / 9: one / two 炎佑 with 30 % of the real 炎 sums; 9: ATK ×1.5, damage taken ×0.1', () => {
|
||||
test('炎 6 / 9: one / two 炎佑 with their template stats + 30 % of the real 炎 sums; 9: ATK ×1.5, damage taken ×0.1', () => {
|
||||
assert.equal(yanyouShare(), 0.3);
|
||||
const bb = bondBb('yanShip');
|
||||
// PRTS: "登场时使自身攻击力、生命值增加…所有【炎】盟约干员攻击力、生命值的30%(最终加算)" — added to the template
|
||||
const tpl = DATA.tokens.enemy_9012_acloon.stats;
|
||||
assert.deepEqual([tpl.atk, tpl.maxHp], [600, 12000], 'enemy_9012_acloon template');
|
||||
for (const n of [6, 9]) {
|
||||
const list = [];
|
||||
for (let i = 0; i < n - 1; i++) list.push([`y${i}_a`, ['yanShip']]);
|
||||
@@ -72,8 +75,8 @@ test('炎 6 / 9: one / two 炎佑 with 30 % of the real 炎 sums; 9: ATK ×1.5,
|
||||
const sumAtk = (n - 1) * 1000 * (1 + bb.base_atk); // 调和 excluded from the sums
|
||||
const sumHp = (n - 1) * 10000;
|
||||
for (const y of yy) {
|
||||
close(y.s.atk, sumAtk * 0.3 * (n === 9 ? bb.atk : 1), 1e-6, `炎佑 ATK @${n}`);
|
||||
close(y.s.maxHp, sumHp * 0.3, 1e-6, `炎佑 HP @${n}`);
|
||||
close(y.s.atk, (tpl.atk + sumAtk * 0.3) * (n === 9 ? bb.atk : 1), 1e-6, `炎佑 ATK @${n}`);
|
||||
close(y.s.maxHp, tpl.maxHp + sumHp * 0.3, 1e-6, `炎佑 HP @${n}`);
|
||||
close(y.s.dmgTakenMul, n === 9 ? 1 - bb.damage_resistance : 1, 1e-9, `炎佑 dmgTaken @${n}`);
|
||||
}
|
||||
checkInvariants(h.b);
|
||||
|
||||
@@ -79,7 +79,10 @@ test('coverage: every 悬赏 / 战术决策 effect and every 道具补给 / 机
|
||||
const ids = choiceCardIds(DATA);
|
||||
assert.equal(ids.bounty.length, 129, '129 ENEMY_GAIN effects');
|
||||
assert.equal(ids.tactic.length, 43, '43 BUFF_GAIN effects');
|
||||
assert.equal(ids.items.length, 56, '56 shop-eligible normal EQUIP items');
|
||||
// 56 normal EQUIP items − the 5 effect-only ones (4 special 维式重锤, 突变细胞: never sold, user playtest #4)
|
||||
assert.equal(ids.items.length, 51, '51 shop items');
|
||||
for (const id of ['chess_item_2_03_e_a', 'chess_item_3_09_e_a', 'chess_item_3_10_e_a', 'chess_item_4_09_e_a', 'chess_item_5_08_e_a']) assert.ok(!ids.items.includes(id), `${id} is never a supply card`);
|
||||
assert.ok(ids.items.includes('chess_item_1_01_e_a'), 'the plain 维式重锤 is');
|
||||
for (const id of [...ids.bounty, ...ids.tactic, ...ids.items]) {
|
||||
const h = REG.get(`choice:${id}`);
|
||||
assert.ok(h && typeof h.onChoicePick === 'function', `choice:${id}`);
|
||||
|
||||
@@ -713,19 +713,22 @@ test(`${nm('enemy_1042_frostd')}: operators within ${tb('enemy_1042_frostd', 'de
|
||||
assert.equal(h.unit('t_mage').s.aspd, 100);
|
||||
});
|
||||
|
||||
test(`${nm('enemy_1040_bombd')}: bombs splash around the target; speeds up after the first throw`, () => {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 10, col: 6 }] });
|
||||
test(`${nm('enemy_1040_bombd')}: no normal attack; ONE bomb on the target + its 8 tiles, then move speed ×${skb('enemy_1040_bombd', 'boomb').bb.move_speed}`, () => {
|
||||
// PRTS 暴鸰: "不进行普通攻击" · 投弹 "对目标及其周围八格的我方单位造成100%物理伤害 … 技能结束后移速最终提升至200% ※此技能仅能触发一次"
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 10, col: 6 }, { chessId: 't_wall3', row: 12, col: 6 }] });
|
||||
h.step();
|
||||
const e = put(h, 'enemy_1040_bombd', [10, 7], { route: 2 });
|
||||
const v0 = E.enemy_1040_bombd.stats.moveSpeed;
|
||||
h.runUntil(() => e.stats.attacks >= 1, 10);
|
||||
h.run(0.5);
|
||||
assert.ok(h.unit('t_wall').stats.taken > 0 && h.unit('t_wall2').stats.taken > 0);
|
||||
assert.equal(e.base.moveSpeed, 0);
|
||||
approx(e.s.moveSpeed, 0);
|
||||
assert.ok(e.findBuff('ab:bombRun'));
|
||||
approx(e.findBuff('ab:bombRun').mods.moveMul, 1 + skb('enemy_1040_bombd', 'boomb').bb.move_speed);
|
||||
assert.ok(v0 > 0);
|
||||
const s = skb('enemy_1040_bombd', 'boomb');
|
||||
h.run(s.initCooldown + 0.2);
|
||||
const [w1, w2, w3] = ['t_wall', 't_wall2', 't_wall3'].map((id) => h.unit(id));
|
||||
approx(w2.stats.taken, e.s.atk, 1e-6, 'the target (latest deployed in range): 100 % ATK');
|
||||
approx(w1.stats.taken, e.s.atk, 1e-6, 'a tile next to it: splash 100 %');
|
||||
assert.equal(w3.stats.taken, 0, 'two rows away: outside the 3×3');
|
||||
assert.equal(e.stats.attacks, 0, 'never a normal attack');
|
||||
approx(e.findBuff('ab:bombRun').mods.moveMul, s.bb.move_speed, 1e-9, '移速最终提升至200%');
|
||||
h.run(20);
|
||||
approx(w2.stats.taken + w1.stats.taken, 2 * e.s.atk, 1e-6, 'only once');
|
||||
assert.equal(e.stats.attacks, 0);
|
||||
});
|
||||
|
||||
for (const key of ['enemy_10083_hlbird', 'enemy_10084_hlegle', 'enemy_10085_hllevi_2']) {
|
||||
@@ -738,6 +741,7 @@ for (const key of ['enemy_10083_hlbird', 'enemy_10084_hlegle', 'enemy_10085_hlle
|
||||
assert.equal(f.length, 1);
|
||||
approx(f[0].duration, tb(key, 'SelfFear.fear'));
|
||||
assert.ok(e.findBuff('ab:fearRun'));
|
||||
approx(e.findBuff('ab:fearRun').mods.moveMul, tb(key, 'SelfFear.move_speed'), 1e-9, '移动速度最终提升至150%');
|
||||
h.b.dealDamage(null, e, { amount: 1, type: 'true' });
|
||||
assert.equal(statuses(h, e.id, 'fear').length, 1, 'only once');
|
||||
});
|
||||
@@ -774,19 +778,66 @@ test(`${nm('enemy_1407_hummbd')}: death exposes nearby operators (damage taken
|
||||
assert.equal(h.unit('t_wall2').s.dmgTakenMul, 1);
|
||||
});
|
||||
|
||||
test(`${nm('enemy_9009_acfort')}: devours nearby normal flyers for ammo, then fires it all (ATK×1.3 per round)`, () => {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }] });
|
||||
test(`${nm('enemy_9009_acfort')}: grabs ≤ 3 normal flyers within 2.5 × range_radius for ammo, then fires one ATK×1.3 hit per ammo at random allies`, () => {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 12, col: 2 }] });
|
||||
h.step();
|
||||
const e = put(h, 'enemy_9009_acfort', [10, 7], { route: 2 });
|
||||
e.profile.noAttack = true;
|
||||
const y1 = put(h, 'enemy_1005_yokai', [10, 7], { route: 2 });
|
||||
const y2 = put(h, 'enemy_1005_yokai', [11, 7], { route: 2 });
|
||||
const k = skb('enemy_9009_acfort', 'KillOthers');
|
||||
h.run(k.initCooldown + k.bb.duration + 0.1);
|
||||
assert.ok(!y1.alive && !y2.alive);
|
||||
assert.ok(h.unit('t_wall').stats.taken >= 2 * e.s.atk * skb('enemy_9009_acfort', 'FireWeapon').bb.atk_scale - 1e-6 || e.mem.ab.ammo === 2);
|
||||
h.run(skb('enemy_9009_acfort', 'FireWeapon').cooldown + 0.1);
|
||||
approx(h.unit('t_wall').stats.taken, 2 * e.s.atk * skb('enemy_9009_acfort', 'FireWeapon').bb.atk_scale);
|
||||
const k = skb('enemy_9009_acfort', 'KillOthers'), f = skb('enemy_9009_acfort', 'FireWeapon');
|
||||
const R = k.bb.range_radius * 2.5; // PRTS: 半径3.75 (2.5倍可变半径)
|
||||
assert.equal(R, 3.75);
|
||||
// four prey inside 3.75 (the nearest three are taken), one outside
|
||||
const near = [[10, 7], [11, 7], [10, 9], [12, 10]].map((p) => put(h, 'enemy_1005_yokai', p, { route: 2 }));
|
||||
const out = put(h, 'enemy_1005_yokai', [9, 2], { route: 2 });
|
||||
assert.ok(Math.hypot(out.x - e.x, out.y - e.y) > R && Math.hypot(near[3].x - e.x, near[3].y - e.y) <= R);
|
||||
h.run(k.initCooldown + 0.1);
|
||||
assert.ok(e.findBuff('ab:grabbing')?.flags.bind, '黑云 holds still while grabbing');
|
||||
h.run(0.5 + k.bb.duration + 0.1);
|
||||
assert.deepEqual(near.map((y) => y.alive), [false, false, false, true], 'the nearest three are devoured');
|
||||
assert.ok(out.alive, 'out of reach');
|
||||
const w1 = h.unit('t_wall'), w2 = h.unit('t_wall2');
|
||||
// 全弹发射 fires as soon as it has ammo: 3 hits of ATK×1.3, each on a random ally of the whole field
|
||||
assert.ok(h.runUntil(() => w1.stats.taken + w2.stats.taken > 0, f.cooldown + 1));
|
||||
h.run(0.1);
|
||||
approx(w1.stats.taken + w2.stats.taken, 3 * e.s.atk * f.bb.atk_scale, 1e-6, 'one hit per ammo');
|
||||
assert.equal(e.mem.ab.ammo, 0, 'all SP spent');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test(`${nm('enemy_1112_emppnt')} / 中枢: every attack is a shell that lands 3 s later on the target's spot (all allies within 1.2)`, () => {
|
||||
for (const key of ['enemy_1112_emppnt', 'enemy_1112_emppnt_2']) {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }, { chessId: 't_wall2', row: 11, col: 6 }, { chessId: 't_wall3', row: 12, col: 6 }], captureNoisy: true });
|
||||
h.step();
|
||||
const e = put(h, key, [10, 7], { route: 2 });
|
||||
assert.ok(h.runUntil(() => e.stats.attacks >= 1, 10), 'attacks');
|
||||
const [w1, w2, w3] = ['t_wall', 't_wall2', 't_wall3'].map((id) => h.unit(id));
|
||||
const t0 = h.b.time;
|
||||
assert.equal(w1.stats.taken + w2.stats.taken + w3.stats.taken, 0, 'nothing on launch');
|
||||
assert.ok(h.events.some((ev) => ev[0] === 'atk' && ev[1] === e.id && ev[3] === 'mortar'), 'attack event without a projectile');
|
||||
const shell = h.eventsOf('fx').find((ev) => ev[1] === 'bombardShell');
|
||||
assert.ok(shell, 'the shell fx (3 s flight)');
|
||||
h.run(2.8);
|
||||
assert.equal(w1.stats.taken + w2.stats.taken + w3.stats.taken, 0, 'still in the air');
|
||||
h.run(0.3);
|
||||
const hit = [w1, w2, w3].filter((w) => w.stats.taken > 0);
|
||||
// the target (latest deployed in range 2) is t_wall3 or t_wall2; the blast covers every wall within 1.2 of it
|
||||
assert.ok(hit.length >= 2 && hit.every((w) => Math.abs(w.stats.taken - e.s.atk) < 1e-6), `${key}: ${hit.map((w) => w.defId)}`);
|
||||
assert.ok(h.b.time - t0 >= 3 - 1e-6);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${nm('enemy_10084_hlegle')}: never attacks a flying ally (不会攻击飞行单位) — a ground one in range instead`, () => {
|
||||
// a flying ally (the 炎佑 is one) deployed after the wall, so the engine's order (latest deployed) would pick it
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }, { chessId: 't_wall2', row: 10, col: 8 }] });
|
||||
h.step();
|
||||
const w = h.unit('t_wall'), fly = h.unit('t_wall2');
|
||||
fly.motion = 'FLY';
|
||||
assert.ok(fly.isFlying && fly.deploySeq > w.deploySeq);
|
||||
const e = put(h, 'enemy_10084_hlegle', [10, 7], { route: 2 });
|
||||
assert.ok(h.runUntil(() => e.stats.attacks >= 1, 10));
|
||||
h.run(0.5);
|
||||
assert.equal(fly.stats.taken ?? 0, 0, 'the flyer is never hit');
|
||||
assert.ok(w.stats.taken > 0, 'the ground wall is');
|
||||
});
|
||||
|
||||
test(`${nm('enemy_1321_wdarft')} + ${nm('enemy_1269_nhfly')}: the apostle spawns seeds that dive onto operators and self-destruct`, () => {
|
||||
|
||||
@@ -973,21 +973,27 @@ test('圣约送葬人 T1 受选之人: the extra attack consumes no ammo and nev
|
||||
});
|
||||
|
||||
test('元素伤害 uses the engine elemental type: × elemTakenMul, never × trueTakenMul (烛煌 熔点引爆, 妮芙 失魂)', () => {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 'chess_char_5_03_a', row: 9, col: 3 }, { chessId: 'chess_char_5_22_a', row: 11, col: 3 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 30,
|
||||
});
|
||||
const blaze = h.unit('chess_char_5_03_a'), nymph = h.unit('chess_char_5_22_a');
|
||||
h.step();
|
||||
const e = h.enemy('enemy_dummy');
|
||||
h.b.addBuff(e, { key: 't:true', mods: { trueTakenMul: 2 } });
|
||||
h.b.addBuff(e, { key: 't:elem', mods: { elemTakenMul: 1.5 } });
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 1000 });
|
||||
const melt = tagged(h, 'blazeMelt', e);
|
||||
// one battle per burst: after a burst the enemy's gauges stay locked for its 爆发冷却 (damage.js)
|
||||
const arena = () => {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 'chess_char_5_03_a', row: 9, col: 3 }, { chessId: 'chess_char_5_22_a', row: 11, col: 3 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 30,
|
||||
});
|
||||
h.step();
|
||||
const e = h.enemy('enemy_dummy');
|
||||
h.b.addBuff(e, { key: 't:true', mods: { trueTakenMul: 2 } });
|
||||
h.b.addBuff(e, { key: 't:elem', mods: { elemTakenMul: 1.5 } });
|
||||
return { h, e, blaze: h.unit('chess_char_5_03_a'), nymph: h.unit('chess_char_5_22_a') };
|
||||
};
|
||||
const a = arena();
|
||||
a.h.b.dealDamage(null, a.e, { type: 'element', element: 'burn', amount: 1000 });
|
||||
const melt = tagged(a.h, 'blazeMelt', a.e);
|
||||
assert.equal(melt.length, 1);
|
||||
assert.equal(melt[0].type, 'elemental');
|
||||
approx(melt[0].amount, blaze.s.atk * tal(blaze, 0).ep_damage_scale * 1.5, 1e-6, 'meltdown × elemTakenMul only');
|
||||
approx(melt[0].amount, a.blaze.s.atk * tal(a.blaze, 0).ep_damage_scale * 1.5, 1e-6, 'meltdown × elemTakenMul only');
|
||||
clean(a.h);
|
||||
const { h, e, nymph } = arena();
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 1000 });
|
||||
assert.ok(h.runUntil(() => tagged(h, 'nymphSoul', e).length > 0, 6));
|
||||
const soul = tagged(h, 'nymphSoul', e)[0];
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Content tests for user playtest #4 items 11 and 12 (server/sim/content/tokens.js, kits/tier2.js):
|
||||
// #11 赫默's 医疗无人机 appears only when her S2 fires — never on the field at battle start (tools/build-data.mjs:
|
||||
// a skill's summon is not a prep hand card; test/match/loadout.test.js covers the prep side);
|
||||
// #12 the 炎 bond's “炎佑” (PRTS “炎佑” 级别0(卫戍协议)): follows the highest-aggro enemy of the whole field and stays
|
||||
// where it is without one (no fixed return point); 祛恶之焰 [模式乙] locks one target, channels up to 20 s (no normal
|
||||
// attacks meanwhile), ends when the lock is lost or on silence; burn on every damage it deals; 元素脆弱 aura
|
||||
// (radius 1.5); immune to element damage; bond stats = template + 30 % of the 炎 sums.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { hasGeneratedData } from '../../server/sim/simdata.js';
|
||||
import { spawnYanyou, TOKEN_IDS } from '../../server/sim/content/tokens.js';
|
||||
|
||||
const REAL = { skip: !hasGeneratedData() };
|
||||
const dummy = (o = {}) => enemyRec({ key: o.key ?? 'enemy_dummy', hp: 1e7, speed: 0, ...o });
|
||||
const guard = (o = {}) => chessRec({ id: 'test_guard', profession: 'WARRIOR', skill: null, stats: { atk: 0, maxHp: 1e5, def: 0 }, ...o });
|
||||
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const flameRec = (h) => h.b.data.rawToken(TOKEN_IDS.yanyou).skills.find((s) => s.prefabKey === 'Skill_2');
|
||||
|
||||
// =====================================================================================================================
|
||||
// #11 赫默
|
||||
|
||||
test('#11 赫默 (full kit, default S2): no drone at battle start; the 医疗探机 appears when 医疗无人机 fires', REAL, () => {
|
||||
const h = makeBattle({ defs: { chess: { test_guard: guard() } }, units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 3 }, { chessId: 'test_guard', row: 10, col: 4 }], autoFinish: false, timeLimit: 90 });
|
||||
h.step();
|
||||
const hm = h.unit('chess_char_2_02_a');
|
||||
assert.equal(hm.skill.id, 'skchr_silent_2');
|
||||
const drones = () => h.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.healDrone && u.alive);
|
||||
assert.equal(drones().length, 0, 'nothing but the operators at t = 0');
|
||||
h.unit('test_guard').hp = 1000;
|
||||
const cost = hm.skill.spCost;
|
||||
h.run(cost - 2);
|
||||
assert.equal(hm.skill.activations, 0);
|
||||
assert.equal(drones().length, 0, 'no drone before the skill is ready');
|
||||
assert.ok(h.runUntil(() => hm.skill.activations >= 1, 10), 'S2 fires once its SP is full (initSp 0)');
|
||||
h.step();
|
||||
assert.equal(drones().length, 1, 'the drone comes with the skill');
|
||||
assert.equal(drones()[0].ownerUnit, hm);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// =====================================================================================================================
|
||||
// #12 炎佑
|
||||
|
||||
test('#12 炎佑 stays where it is when no enemy is on the field (no fixed return point)', REAL, () => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], enemies: [{ key: 'enemy_dummy', pos: [9, 4] }], autoFinish: false, timeLimit: 90 });
|
||||
h.step();
|
||||
const [y] = spawnYanyou(h.b, 'p1', { atk: 1000, hp: 5000 });
|
||||
const home = { x: y.x, y: y.y };
|
||||
const e = h.enemy('enemy_dummy');
|
||||
assert.ok(h.runUntil(() => dist(y, e) <= 0.3, 30), 'flies over the enemy');
|
||||
h.b.dealDamage(null, e, { amount: 1e9, type: 'true' });
|
||||
assert.equal(e.alive, false);
|
||||
h.step();
|
||||
const at = { x: y.x, y: y.y };
|
||||
assert.ok(Math.hypot(at.x - home.x, at.y - home.y) > 1, 'it had left its spawn point');
|
||||
h.run(10);
|
||||
assert.ok(Math.abs(y.x - at.x) < 1e-9 && Math.abs(y.y - at.y) < 1e-9, `hovers in place (${y.x.toFixed(2)},${y.y.toFixed(2)} vs ${at.x.toFixed(2)},${at.y.toFixed(2)})`);
|
||||
// an empty field from the start: it never moves
|
||||
const h2 = makeBattle({ units: [], autoFinish: false, timeLimit: 30 });
|
||||
h2.step();
|
||||
const [y2] = spawnYanyou(h2.b, 'p1', { atk: 1000, hp: 5000 });
|
||||
const p0 = { x: y2.x, y: y2.y };
|
||||
h2.run(10);
|
||||
assert.deepEqual({ x: y2.x, y: y2.y }, p0);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('#12 炎佑 tracks the highest-aggro enemy of the whole field (least remaining path), not the nearest one', REAL, () => {
|
||||
// two standing enemies on the walk route to the goal (9,2): (9,4) is 2 tiles from it, (12,9) far away
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], enemies: [{ key: 'enemy_dummy', pos: [12, 9] }, { key: 'enemy_dummy', pos: [9, 4] }], autoFinish: false, timeLimit: 90 });
|
||||
h.step();
|
||||
const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 5000 });
|
||||
const [far, lead] = h.enemies();
|
||||
assert.ok(h.b.remainingDistance(lead) < h.b.remainingDistance(far), 'fixture: (9,4) leads');
|
||||
// start next to the far enemy: it still leaves it for the leader
|
||||
y.x = far.x; y.y = far.y;
|
||||
assert.ok(h.runUntil(() => dist(y, lead) <= 0.3, 40), 'flies over the leading enemy');
|
||||
h.run(3);
|
||||
assert.ok(dist(y, lead) <= 0.3, 'and keeps hovering over it');
|
||||
});
|
||||
|
||||
test('#12 祛恶之焰 [模式乙]: locks the normal attack target, no normal attacks while channelling, ends when the target dies', REAL, () => {
|
||||
const burn = [];
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: dummy(), enemy_frail: dummy({ key: 'enemy_frail', hp: 1e7 }) } }, units: [],
|
||||
enemies: [{ key: 'enemy_frail', pos: [10, 6] }, { key: 'enemy_dummy', pos: [10, 6] }], autoFinish: false, timeLimit: 120, captureNoisy: true, hooks: ['damaged'],
|
||||
setup: (b) => b.on('damaged', (c) => { if (c.type === 'element' && c.dmg?.tags?.includes('yanyou')) burn.push({ t: b.time, id: c.target.id }); }),
|
||||
});
|
||||
h.step();
|
||||
const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 50000 }); // ATK 600 (template): no burn burst in the test window
|
||||
const sk = flameRec(h);
|
||||
assert.deepEqual([sk.cooldown, sk.initCooldown, sk.bb.atk_scale, sk.bb.hit_duration, sk.bb.range_radius], [15, 15, 0.6, 20, 1], 'official Skill_2 blackboard');
|
||||
assert.ok(h.runUntil(() => y.skill.active, 25), '祛恶之焰');
|
||||
const t = y.mem.flame.target;
|
||||
assert.ok(t && t.alive, 'locked target');
|
||||
const attacks = y.stats.attacks;
|
||||
for (const e of h.enemies()) e.elem.burn = 0; // the normal attacks' burn so far: keep the gauges unburst
|
||||
const burnBefore = burn.length;
|
||||
h.run(4.05);
|
||||
assert.equal(y.stats.attacks, attacks, 'no normal attack while channelling');
|
||||
assert.equal(y.mem.flame.target, t, 'the lock holds');
|
||||
// 5 ticks (at once, then every second): both enemies (same tile) burn from every flame tick
|
||||
assert.ok(burn.length - burnBefore >= 2 * 5, `burn from the flame ticks (${burn.length - burnBefore})`);
|
||||
assert.ok(y.skill.active);
|
||||
// the locked target dies → the channel ends at once (no retarget), long before its 20 s
|
||||
h.b.dealDamage(null, t, { amount: 1e12, type: 'true' });
|
||||
h.step();
|
||||
assert.equal(y.skill.active, false, 'ends with its target');
|
||||
assert.ok(h.b.time - y.skill.lastStart < sk.bb.hit_duration - 5);
|
||||
assert.equal(y.mem.flame, null);
|
||||
// normal attacks resume; the next flame needs the whole cooldown again
|
||||
const t1 = h.b.time;
|
||||
assert.ok(h.runUntil(() => y.stats.attacks > attacks, 5), 'attacks again');
|
||||
assert.ok(h.runUntil(() => y.skill.activations >= 2, 20));
|
||||
assert.ok(h.b.time - t1 >= sk.cooldown - 0.5, `cooldown after the channel (${(h.b.time - t1).toFixed(2)} s)`);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('#12 祛恶之焰 lasts at most hit_duration on a surviving target, and silence ends it at once', REAL, () => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], autoFinish: false, timeLimit: 120 });
|
||||
h.step();
|
||||
const [y] = spawnYanyou(h.b, 'p1', { atk: 100, hp: 50000 });
|
||||
assert.ok(h.runUntil(() => y.skill.active, 25));
|
||||
const t0 = y.skill.lastStart;
|
||||
assert.ok(h.runUntil(() => !y.skill.active, 25));
|
||||
const len = h.b.time - t0;
|
||||
assert.ok(Math.abs(len - flameRec(h).bb.hit_duration) < 0.1, `20 s channel (${len.toFixed(2)})`);
|
||||
assert.ok(h.runUntil(() => y.skill.active, 20), 'next flame');
|
||||
h.b.applyStatus(y, 'silence', { duration: 3, source: null });
|
||||
h.step();
|
||||
assert.equal(y.skill.active, false, 'silenced: the channel ends');
|
||||
});
|
||||
|
||||
test('#12 炎佑: 元素脆弱 aura within 1.5 of itself, immune to element damage, burn = 20 % ATK on normal hits', REAL, () => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy', pos: [10, 8] }], autoFinish: false, timeLimit: 60 });
|
||||
h.step();
|
||||
const [y] = spawnYanyou(h.b, 'p1', { atk: 400, hp: 5000 });
|
||||
// pin it at (10, 4.8): the enemy at (10,6) is 1.2 away, the one at (10,8) 3.2
|
||||
h.b.addBuff(y, { key: 'test:pin', persist: true, flags: { noMove: true } });
|
||||
y.x = 4.8; y.y = 10;
|
||||
h.run(0.5);
|
||||
const [near, far] = h.enemies();
|
||||
assert.ok(near.findBuff('elemFragile'), '1.2 away: 元素脆弱');
|
||||
assert.ok(!far.findBuff('elemFragile'), '3.2 away: none');
|
||||
assert.ok(Math.abs(near.s.elemTakenMul - 1.2) < 1e-9, 'element damage ×1.2');
|
||||
// element damage on the dragon is cancelled
|
||||
h.b.dealDamage(near, y, { type: 'element', element: 'burn', amount: 800 });
|
||||
assert.equal(y.elem.burn, 0, 'no burn gauge on 炎佑');
|
||||
// normal hits: burn = ATK × 0.2 (× the 1.2 fragility) per hit
|
||||
const before = near.elem.burn;
|
||||
assert.ok(h.runUntil(() => near.elem.burn > before, 5));
|
||||
const tal = h.b.data.rawToken(TOKEN_IDS.yanyou).talents.bb;
|
||||
assert.ok(Math.abs(near.elem.burn - before - y.s.atk * tal['2.ep_damage_ratio'] * tal['2.damage_scale']) < 1e-6, `burn per hit ${near.elem.burn - before}`);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
@@ -463,9 +463,9 @@ test('炎佑: spawnYanyou — flying ally, bond stats, 3 targets with burn + ele
|
||||
assert.equal(ys.length, 2);
|
||||
const y = ys[0];
|
||||
assert.equal(y.motion, 'FLY');
|
||||
approx(y.s.atk, 1500, 1e-9);
|
||||
assert.equal(y.s.maxHp, 6000);
|
||||
assert.equal(y.hp, 6000);
|
||||
approx(y.s.atk, (600 + 1000) * 1.5, 1e-9, 'template 600 + the bond share, ×1.5');
|
||||
assert.equal(y.s.maxHp, 12000 + 6000);
|
||||
assert.equal(y.hp, 18000);
|
||||
approx(y.s.dmgTakenMul, 0.1, 1e-9);
|
||||
assert.equal(y.s.blockCnt, 0);
|
||||
const x0 = y.x, y0 = y.y;
|
||||
|
||||
Reference in new issue
Block a user