- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them; PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air - #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included - #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed - #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the blocker dies, withdraws or is stunned; melee blockers attack what they block - #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右, operators before summons; special priorities break ties by taunt then deploy order - #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views - #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages audited against the official level files - #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones - #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable - #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects - DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and every browser suite green; dev recordings regenerated
157 lines
9.5 KiB
JavaScript
157 lines
9.5 KiB
JavaScript
// 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.elementalTakenMul - 1.2) < 1e-9, '元素伤害 ×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 per hit — the 1.2 元素脆弱 (2.damage_scale) raises 元素伤害, never the gauge fill
|
||
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']) < 1e-6, `burn per hit ${near.elem.burn - before}`);
|
||
checkInvariants(h.b);
|
||
});
|