- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player; placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call) - #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿 - #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text - #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool, and the team LP reaching 0 first is a defeat (PRTS) - #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones - #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10) - #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range - #9 official per-enemy model scales (威龙) - #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding - #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider - #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes - #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20 - #18 炎佑 is 孤立: no heals or ally buffs - #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators - DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites green; dev recordings regenerated
163 lines
10 KiB
JavaScript
163 lines
10 KiB
JavaScript
// Content tests for user playtest #4 items 11 and 12 (server/sim/content/tokens.js, kits/tier2.js):
|
||
// #11 赫默's 医疗无人机 comes with her S2 — since user playtest #6 it is a hand piece the player places, it deploys
|
||
// once on that tile at the battle start (PRTS 卫戍协议/帮助 §作战阶段; settled by the user after playtest #6 —
|
||
// shared/constants.js SKILL_SUMMON_START_DEPLOY) and re-appears there each time S2 fires
|
||
// (test/content/playtest6_summons.test.js, the prep side test/match/playtest6_summons.test.js);
|
||
// #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): the placed 医疗探机 deploys once at battle start (10 s), then again when 医疗无人机 fires', REAL, () => {
|
||
const h = makeBattle({
|
||
defs: { chess: { test_guard: guard() } },
|
||
units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 3, uid: 1 }, { chessId: 'test_guard', row: 10, col: 4, uid: 2 }, { kind: 'token', tokenId: TOKEN_IDS.healDrone, ownerUid: 1, row: 9, col: 4, uid: 3 }],
|
||
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, 1, 'the free start deploy (PRTS: "作战开始时无视持有状态自动部署1个")');
|
||
assert.deepEqual([drones()[0].tileR, drones()[0].tileC], [9, 4], 'on its piece\'s tile');
|
||
h.unit('test_guard').hp = 1000;
|
||
h.run(10.5);
|
||
assert.equal(hm.skill.activations, 0);
|
||
assert.equal(drones().length, 0, 'gone after its 10 s; no second one before the skill');
|
||
assert.ok(h.runUntil(() => hm.skill.activations >= 1, 30), '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);
|
||
});
|