Playtest #5 fixes: hovering enemies, target order, blocking hand-over, 联防 knocked-out, elements, huge-boss hit area, maps, gear icon, phone HUD, 至简 price
- #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
This commit is contained in:
1 parent
d4f868cd19
commit
15291c2293
104 files changed
+5539
-662
No files matched your search
+11
-2
@@ -312,10 +312,19 @@ test('boss field with a shared pool: an emptied field (leader leaked) keeps runn
|
||||
});
|
||||
|
||||
test('Final Assault h07_04 (pair): 昆图斯 walking into the objective does not end the field while the pool is full', { skip: !hasGeneratedData() && 'no generated data' }, () => {
|
||||
// Regression: the official pair route of the leader has no wait step; with nobody blocking him he leaks at ~180 s
|
||||
// Regression: the official pair route of the leader has no wait step; with nobody blocking him he leaked at ~180 s
|
||||
// and the field used to finish 'cleared' at once, ending the Final Assault as a defeat with team LP left.
|
||||
// Officially he is 自缚 (PRTS 天赋; content/bosses.js SELF_BOUND, user playtest #5) and never walks it — the engine
|
||||
// rule is still checked by lifting the 自缚 buff.
|
||||
const pool = new LocalBossPool(708750);
|
||||
const h = makeBattle({ kind: 'boss', stageId: 'act2autochess_m02', rect: { r0: 0, r1: 5, c0: 0, c1: 20 }, waveTemplate: 'act1autochess_h07_04', sharedBoss: pool, units: [] });
|
||||
const opts = { kind: 'boss', stageId: 'act2autochess_m02', rect: { r0: 0, r1: 5, c0: 0, c1: 20 }, waveTemplate: 'act1autochess_h07_04', sharedBoss: pool, units: [] };
|
||||
const bound = makeBattle({ ...opts, sharedBoss: new LocalBossPool(708750) });
|
||||
bound.run(400);
|
||||
assert.ok(!bound.hooks.enemyLeak.some((x) => x.enemy.defId === 'enemy_1521_dslily'), '自缚: he stays');
|
||||
assert.equal(bound.b.finished, false);
|
||||
const h = makeBattle(opts);
|
||||
assert.ok(h.runUntil(() => h.b.enemies.some((e) => e.defId === 'enemy_1521_dslily'), 5));
|
||||
for (const e of h.b.enemies) if (e.defId === 'enemy_1521_dslily') h.b.removeBuff(e, 'boss:selfBound');
|
||||
assert.ok(h.runUntil(() => h.hooks.enemyLeak.some((x) => x.enemy.defId === 'enemy_1521_dslily'), 400), 'the leader leaks');
|
||||
h.run(200);
|
||||
assert.equal(h.b.finished, false);
|
||||
|
||||
@@ -118,8 +118,8 @@ test('element bursts on enemies ("·我方" terms): burn 7000 元素伤害 + RES
|
||||
assert.equal(e.findBuff('palsy').stacks, ELEMENT.neural.enemy.palsy);
|
||||
assert.ok(!e.s.flags.stun, 'enemies are not stunned by a neural burst');
|
||||
assert.equal(h.hooksOf('elementBurst').length, 2);
|
||||
// element fragility multiplies 元素伤害
|
||||
h.b.addBuff(e, { key: 'test:ef', mods: { elemTakenMul: 1.5 } });
|
||||
// 元素脆弱 (elementalTakenMul) multiplies 元素伤害; the gauge's 元素损伤倍率 (elemTakenMul) does not
|
||||
h.b.addBuff(e, { key: 'test:ef', mods: { elementalTakenMul: 1.5, elemTakenMul: 3 } });
|
||||
const hp2 = e.hp;
|
||||
h.b.dealDamage(null, e, { amount: 100, type: 'elemental', element: 'burn' });
|
||||
approx(hp2 - e.hp, 150);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// test/sim/downelem.test.js — user playtest #4 items 8 and 9, sim side (server/sim/Battle.js snapshot / fieldMeta,
|
||||
// damage.js elementView): b.snap `down` lists the knocked-out operators waiting to redeploy on their own tile (the
|
||||
// client keeps them on the field knocked down with a redeploy countdown) and `elem` the element gauge each unit shows
|
||||
// (PRTS 元素: the fullest gauge, its 爆发冷却) — entries, states, and what is left out.
|
||||
// (PRTS 元素: the fullest gauge, its 爆发冷却) — entries, states, and what is left out. User playtest #5 item 2: an
|
||||
// operator entering a battle knocked out (联防 carryState `down`, constants.js FORCED_EXIT) is down the same way.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
|
||||
import { DOWN_STATE, ELEMENT, ELEMENT_ORDER } from '../../server/sim/constants.js';
|
||||
import { DOWN_STATE, ELEMENT, ELEMENT_ORDER, FORCED_EXIT } from '../../server/sim/constants.js';
|
||||
import { elementView } from '../../server/sim/damage.js';
|
||||
import { unitStatsEntry } from '../../shared/protocol.js';
|
||||
|
||||
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
|
||||
const op = (id, stats = {}) => chessRec({ id, profession: 'WARRIOR', stats: { atk: 0, maxHp: 1000, def: 0, blockCnt: 0, cost: 12, respawnTime: 6, ...stats }, skill: null });
|
||||
@@ -65,6 +67,79 @@ test('summons, devices, enemies and withdrawn / removed operators are never `dow
|
||||
assert.ok(ids.has(h.unit('t_c').id) && !ids.has(h.unit('t_a').id) && !ids.has(tok.id));
|
||||
});
|
||||
|
||||
test('carryState.down (联防): deployed with everyone, forced out at once — down on its tile without the knock-out hooks — then back after its timer', () => {
|
||||
// PRTS 卫戍协议/帮助 §联防阶段: "部署完成后…上一阶段为退场状态的干员强制退场"
|
||||
const h = makeBattle({
|
||||
kind: 'unite', defs: { chess: { t_a: op('t_a'), t_b: op('t_b') } },
|
||||
units: [{ chessId: 't_a', row: 9, col: 5, carryState: { down: true } }, { chessId: 't_b', row: 10, col: 5, carryState: { hpPct: 0.5, sp: 0 } }],
|
||||
content: 'none', autoFinish: false, timeLimit: 120, flags: { dpInit: 0, dpPerSec: 1, dpMax: 99 },
|
||||
});
|
||||
h.step();
|
||||
const a = h.unit('t_a'), b = h.unit('t_b');
|
||||
assert.ok(!a.alive && h.b.isDown(a), 'down from the start');
|
||||
assert.equal(a.removeReason, FORCED_EXIT);
|
||||
assert.equal(a.hp, 0, 'HP 0 while down, like a knocked-out operator (the live detail card reads unit.hp)');
|
||||
assert.equal(unitStatsEntry(a).hp, 0);
|
||||
assert.deepEqual([a.tileR, a.tileC], [9, 5], 'on its own tile');
|
||||
const d = downOf(h, a.id);
|
||||
approx(d[1], 6, 0.011);
|
||||
approx(d[2], 6, 0.011);
|
||||
assert.equal(d[3], DOWN_STATE.COUNTING, 'its whole redeploy timer, counting');
|
||||
assert.ok(h.b.fieldMeta().units.some((u) => u.id === a.id), 'a client joining shows it down');
|
||||
assert.ok(b.alive && Math.abs(b.hp - 500) < 1e-6, 'the standing operator keeps its HP ratio');
|
||||
// deployed (deploy effects fire), then withdrawn before battleStart; no `kill`, no 'killed' death, no death counted
|
||||
const dep = h.hooksOf('deploy').find((x) => x.unit === a);
|
||||
assert.ok(dep && dep.initial === true, 'initial deploy hook');
|
||||
const deaths = h.hooksOf('death').filter((x) => x.unit === a);
|
||||
assert.deepEqual(deaths.map((x) => x.reason), [FORCED_EXIT]);
|
||||
assert.equal(h.hooksOf('kill').length, 0);
|
||||
assert.equal(h.hooksOf('battleStart').length, 1);
|
||||
assert.deepEqual(h.eventsOf('die').filter((e) => e[1] === a.id), [['die', a.id, FORCED_EXIT]]);
|
||||
assert.equal(h.b.result().perPlayer.p1.deaths, 0, 'not a new knock-out');
|
||||
h.run(6.5);
|
||||
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_DP, 'timer done, DP short of its cost');
|
||||
h.run(6);
|
||||
assert.ok(a.alive && a.deployed, 'redeployed once DP ≥ cost');
|
||||
assert.deepEqual([a.tileR, a.tileC], [9, 5]);
|
||||
assert.equal(a.hp, a.s.maxHp, 'at full HP');
|
||||
assert.equal(downOf(h, a.id), null);
|
||||
// a later knock-out in the 联防 battle is an ordinary one
|
||||
h.b.kill(a);
|
||||
assert.equal(downOf(h, a.id)[3], DOWN_STATE.COUNTING);
|
||||
assert.equal(h.b.result().perPlayer.p1.deaths, 1);
|
||||
});
|
||||
|
||||
test('carryState.down (联防): a redeploy-time effect that starts with the battle (机变 征召) covers the operator forced out', () => {
|
||||
// 征召 "开始作战时若场上至少有一行存在3名干员,所有干员的再部署时间-50%" is switched on by a battleStart handler, after
|
||||
// the forced exit: the forced-out operator's timer is re-read with it (6 → 3 s), like a later knock-out's
|
||||
const run = (card) => {
|
||||
const h = makeBattle({
|
||||
kind: 'unite', defs: { chess: { t_a: op('t_a'), t_b: op('t_b'), t_c: op('t_c'), t_d: op('t_d') } },
|
||||
players: [{
|
||||
playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, bandId: null,
|
||||
playerEffects: card ? [{ key: 'choice:allybuff_select_14', id: 'choice:allybuff_select_14#1', data: { effectId: 'allybuff_select_14' } }] : [],
|
||||
units: [
|
||||
{ uid: 1, kind: 'chess', chessId: 't_a', row: 9, col: 5, dir: 'RIGHT', items: [], carryState: { down: true } },
|
||||
...['t_b', 't_c', 't_d'].map((chessId, i) => ({ uid: 2 + i, kind: 'chess', chessId, row: 10, col: 4 + i, dir: 'RIGHT', items: [], carryState: { hpPct: 1, sp: 0 } })),
|
||||
],
|
||||
}],
|
||||
autoFinish: false, timeLimit: 120,
|
||||
});
|
||||
h.step();
|
||||
const a = h.unit('t_a'), b = h.unit('t_b');
|
||||
const forced = downOf(h, a.id);
|
||||
h.b.kill(b);
|
||||
const later = downOf(h, b.id);
|
||||
return { forced: forced[2], forcedAt: forced[1], later: later[2] };
|
||||
};
|
||||
const off = run(false), on = run(true);
|
||||
approx(off.forced, 6, 0.011);
|
||||
approx(off.later, 6, 0.011);
|
||||
approx(on.forced, 3, 0.011);
|
||||
approx(on.forcedAt, 3, 0.011);
|
||||
approx(on.later, 3, 0.011);
|
||||
});
|
||||
|
||||
test('elementView: the fullest gauge (ties: the official element order), its cooldown while bursting, nothing when empty', () => {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0 }), enemy_lead: enemyRec({ key: 'enemy_lead', hp: 1e6, speed: 0, rank: 'BOSS' }) } },
|
||||
|
||||
@@ -143,11 +143,12 @@ test('attract: the enemy is released, walks (own speed) to the point, waits ther
|
||||
assert.equal(e().blockedBy, null, 'released');
|
||||
assert.ok(e().s.flags.unblockable && e().s.flags.attract);
|
||||
assert.equal(h.hooksOf('statusApplied').at(-1).entered, true);
|
||||
const [x0, y0] = [e().x, e().y];
|
||||
h.run(1);
|
||||
// grid path: back to its tile centre (9,6), then down the column — axis-aligned legs
|
||||
approx(Math.abs(e().x - x0) + Math.abs(e().y - y0), 0.5, 0.02, 'own speed (1 × MOVE_SCALE) along the path');
|
||||
h.run(4.2);
|
||||
// grid path: back to the centre of the tile it stands on, then on to the point (it was blocked 0.71 tile in front of
|
||||
// the guard — the block radius — i.e. on (9,7))
|
||||
let walked = 0, [px, py] = [e().x, e().y];
|
||||
for (let i = 0; i < 30; i++) { h.step(); walked += Math.hypot(e().x - px, e().y - py); [px, py] = [e().x, e().y]; }
|
||||
approx(walked, 0.5, 0.02, 'own speed (1 × MOVE_SCALE) along the path');
|
||||
h.run(5);
|
||||
approx(e().x, 6, 1e-6); approx(e().y, 11, 1e-6, 'arrived');
|
||||
h.run(1);
|
||||
approx(e().y, 11, 1e-6, 'waits there');
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
// test/sim/playtest5-targeting.test.js — user playtest #5 items 1 and 5 (DESIGN §19).
|
||||
// #1 "有近地悬浮的敌人会被只能攻击地面单位的干员打到": a hovering enemy (近地悬浮, PRTS 术语释义 ba.float) is an air unit for
|
||||
// every targeting rule (Unit.isFlying) while it keeps the ground path; it cannot be blocked or displaced (失衡免疫);
|
||||
// 掠海漂移体 / 吉兆飞鳞 lose the float when stunned (their PRTS pages); 浮空 (levitate) is an air unit too.
|
||||
// #5 "敌人优先锁定的干员顺序": PRTS 作战机制 索敌 — 敌方 "阻挡→特殊优先级→仇恨值(更容易被攻击→…→最后部署的目标→不容易被
|
||||
// 攻击)→最早出现"; PRTS 卫戍协议/帮助 — "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物"; a stealthed ally
|
||||
// is attacked only by the enemy it blocks (PRTS 作战机制 §隐匿); an enemy's own rule (只攻击地面单位) filters its
|
||||
// candidates before the order; a special priority (优先攻击防御力最高的…) breaks ties by taunt, then latest deployed.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import * as enemiesMod from '../../server/sim/content/enemies.js';
|
||||
import * as bossesMod from '../../server/sim/content/bosses.js';
|
||||
import { canTargetEnemy } from '../../server/sim/targeting.js';
|
||||
|
||||
const FLOATER = 'enemy_2025_syufo'; // 掠海漂移体 (海嗣, ELEMENT faction)
|
||||
const PARROT = 'enemy_10045_parrot'; // 吉兆飞鳞 (悬赏·飞行II)
|
||||
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
|
||||
/** Damage `src` dealt to `target` (recorded `damaged` hooks; needs captureNoisy). */
|
||||
const dealt = (h, src, target) => h.hooksOf('damaged').filter((c) => c.source === src && c.target === target).reduce((a, c) => a + c.amount, 0);
|
||||
/** A pinned enemy (speed ×0) on a tile; `o.mods` merged. */
|
||||
const put = (h, key, pos, o = {}) => h.spawn(key, { pos, routeIndex: 0, mods: { speedMul: o.move ? 1 : 0, ...(o.mods || {}) } });
|
||||
|
||||
// synthetic allies: a wall that never attacks, a melee blade, a long-range gun
|
||||
const WALL = chessRec({ id: 't_wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
|
||||
const BLADE = chessRec({ id: 't_blade', profession: 'WARRIOR', stats: { atk: 300, maxHp: 1e7, bat: 1, blockCnt: 2 }, rangeGrid: [[0, 0], [0, 1]], skill: null });
|
||||
const BIG = [];
|
||||
for (let dr = -4; dr <= 4; dr++) for (let dc = -12; dc <= 12; dc++) BIG.push([dr, dc]);
|
||||
const GUN = chessRec({ id: 't_gun', profession: 'SNIPER', projectile: 'none', stats: { atk: 300, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: BIG, skill: null });
|
||||
const SHOOTER = enemyRec({ key: 'enemy_t_shooter', hp: 1e7, atk: 10, range: 9, bat: 1, speed: 0 });
|
||||
const SLIME = enemyRec({ key: 'enemy_t_slime', hp: 1e7, atk: 0, speed: 0 });
|
||||
const BAT = enemyRec({ key: 'enemy_t_bat', hp: 1e7, atk: 0, speed: 0, motion: 'FLY' });
|
||||
const SYN = { chess: { t_wall: WALL, t_blade: BLADE, t_gun: GUN }, enemies: { enemy_t_shooter: SHOOTER, enemy_t_slime: SLIME, enemy_t_bat: BAT } };
|
||||
const NOATK = () => ({ trait: { noAttack: true } });
|
||||
|
||||
/** Synthetic arena: generic kits + the enemies module only (other content never interferes). */
|
||||
function arena(o = {}) {
|
||||
return makeBattle({ content: 'generic', extraContent: [enemiesMod], seed: 7, autoFinish: false, timeLimit: 300, defs: SYN,
|
||||
kits: { t_wall: NOATK }, captureNoisy: true, ...o });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// #1 近地悬浮
|
||||
|
||||
test('#1 the user\'s case: ground-only operators never hit a 近地悬浮 enemy (melee 山 / 泡泡, 迷迭香\'s ground-only shots); ranged 能天使 and the lord 银灰 do', () => {
|
||||
const cases = [
|
||||
['chess_char_5_17_a', 10, 5, false], // 山 (fighter, melee)
|
||||
['chess_char_2_08_a', 10, 6, false], // 泡泡 (protector, melee — standing on the floater's tile)
|
||||
['chess_char_6_12_a', 10, 4, false], // 迷迭香 (bombarder: "攻击对小范围的地面敌人造成…" — ranged but ground only)
|
||||
['chess_char_3_01_a', 10, 4, true], // 能天使 (fastshot, ranged)
|
||||
['chess_char_4_22_a', 10, 5, true], // 银灰 (lord: ranged reach, can hit air)
|
||||
];
|
||||
for (const [chessId, row, col, hits] of cases) {
|
||||
const h = makeBattle({ units: [{ chessId, row, col }], seed: 3, autoFinish: false, timeLimit: 120, captureNoisy: true });
|
||||
h.step();
|
||||
const e = put(h, FLOATER, [10, 6], { mods: { hpMul: 100, atkMul: 0 } });
|
||||
assert.ok(e.isFlying && e.motion === 'WALK', 'an air unit on the ground path');
|
||||
h.run(20);
|
||||
const op = h.b.allyUnits.find((u) => u.kind === 'op');
|
||||
const d = dealt(h, op, e);
|
||||
assert.equal(d > 0, hits, `${op.name}: ${d}`);
|
||||
assert.ok(!e.blockedBy, `${op.name}: never blocks it`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('#1 ground-only splash spares a hovering enemy next to its ground target (迷迭香 + aftershocks)', () => {
|
||||
const h = makeBattle({ units: [{ chessId: 'chess_char_6_12_a', row: 10, col: 3 }], seed: 3, autoFinish: false, timeLimit: 120, captureNoisy: true, defs: SYN });
|
||||
h.step();
|
||||
const g = put(h, 'enemy_t_slime', [10, 6]);
|
||||
const e = put(h, FLOATER, [10, 6], { mods: { hpMul: 100, atkMul: 0 } });
|
||||
h.run(15);
|
||||
const op = h.b.allyUnits.find((u) => u.kind === 'op');
|
||||
assert.ok(dealt(h, op, g) > 0, 'the ground enemy is shot');
|
||||
assert.equal(dealt(h, op, e), 0, 'the floater on the same tile takes no splash');
|
||||
});
|
||||
|
||||
test('#1 a 近地悬浮 enemy walks the ground path through blockers, cannot be displaced (失衡免疫) and ignores deep water', () => {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 6 }] });
|
||||
h.step();
|
||||
const e = h.spawn(FLOATER, { routeIndex: 0, mods: { atkMul: 0 } });
|
||||
const ys = new Set();
|
||||
h.b.on('tick', () => { if (e.alive) ys.add(Math.round(e.y)); });
|
||||
h.runUntil(() => e.x < 5.5, 60);
|
||||
assert.ok(e.x < 5.5 && !e.blockedBy, 'passed the wall on (9,6)');
|
||||
assert.deepEqual([...ys], [9], 'stayed on its ground lane');
|
||||
const x0 = e.x;
|
||||
assert.equal(h.b.displace(e, { x: 1, y: 0 }, 2, { force: 3 }), 0);
|
||||
assert.equal(e.x, x0);
|
||||
// 深水 (act2 m04): the floater hovers over the water — no drowning damage or slow
|
||||
const w = makeBattle({ stageId: 'act2autochess_m04', autoFinish: false, timeLimit: 30,
|
||||
routes: [{ motion: 'WALK', start: [11, 6], end: [9, 2], checkpoints: [{ type: 'WAIT', time: 99 }] }],
|
||||
enemies: [{ key: FLOATER, route: { motion: 'WALK', start: [11, 6], end: [9, 2], checkpoints: [{ type: 'WAIT', time: 99 }] } }] });
|
||||
w.run(3);
|
||||
const f = w.enemy(FLOATER);
|
||||
assert.equal(f.hp, f.s.maxHp, 'not in the water');
|
||||
assert.equal(f.findBuff('terrain:deepsea'), null);
|
||||
});
|
||||
|
||||
test('#1 掠海漂移体 stunned ⇒ 爬行模式 for good (PRTS): a ground unit — blocked, hit by melee — that only attacks its blocker', () => {
|
||||
const h = arena({ units: [{ chessId: 't_blade', row: 10, col: 5 }, { chessId: 't_gun', row: 12, col: 3 }] });
|
||||
h.step();
|
||||
const e = put(h, FLOATER, [10, 6], { mods: { hpMul: 100 } });
|
||||
const blade = h.unit('t_blade'), gun = h.unit('t_gun');
|
||||
h.run(3);
|
||||
assert.equal(dealt(h, blade, e), 0, 'floating: the blade cannot hit it');
|
||||
assert.ok(e.stats.attacks > 0, 'floating: attacks at range');
|
||||
assert.ok(h.b.applyStatus(e, 'stun', { duration: 0.2, source: gun }));
|
||||
assert.ok(!e.isFlying && !e.s.flags.unblockable && !e.s.flags.noDisplace, 'crawling: a ground unit');
|
||||
approx(e.findBuff('stun').timeLeft, 0.5, 1e-9, 'the 0.5 s drop stun (the longer one)');
|
||||
assert.ok(e.profile.melee, '仅进行阻挡攻击');
|
||||
h.run(3);
|
||||
assert.ok(dealt(h, blade, e) > 0, 'the blade hits it now');
|
||||
// walking again it is blocked by a ground operator on its way
|
||||
const h2 = arena({ units: [{ chessId: 't_wall', row: 9, col: 6 }] });
|
||||
h2.step();
|
||||
const e2 = h2.spawn(FLOATER, { routeIndex: 0, mods: { atkMul: 0 } });
|
||||
h2.b.applyStatus(e2, 'freeze', { duration: 0.5 });
|
||||
h2.runUntil(() => !!e2.blockedBy || e2.x < 5.5, 60);
|
||||
assert.equal(e2.blockedBy, h2.unit('t_wall'), '冻结 drops it too; then the wall blocks it');
|
||||
});
|
||||
|
||||
test('#1 吉兆飞鳞: a stun grounds it for Stun.duration s (unblockable), then it floats again; a freeze grounds it when it ends', () => {
|
||||
const h = arena();
|
||||
h.step();
|
||||
const e = put(h, PARROT, [10, 7]);
|
||||
const down = e.mem.ab.t['Stun.duration'];
|
||||
assert.ok(down > 0 && e.isFlying);
|
||||
h.b.applyStatus(e, 'stun', { duration: 0.5 });
|
||||
assert.ok(!e.isFlying && e.s.flags.unblockable, '晕眩模式: no float, still unblockable');
|
||||
approx(e.findBuff('stun').timeLeft, down, 1e-9);
|
||||
h.run(down - 0.2);
|
||||
assert.ok(!e.isFlying);
|
||||
h.run(0.4);
|
||||
assert.ok(e.isFlying && e.s.flags.noDisplace, 'floats again (初始模式)');
|
||||
h.b.applyStatus(e, 'freeze', { duration: 1 });
|
||||
h.run(0.5);
|
||||
assert.ok(e.isFlying, 'frozen: still floating');
|
||||
h.run(0.6);
|
||||
assert.ok(!e.isFlying && e.findBuff('stun'), '【失温坠落】: grounded when the freeze ends');
|
||||
// "离开上述异常效果影响后进入初始模式": a sleep that outlasts the mode stun keeps it down until the sleep ends
|
||||
const z = put(h, PARROT, [11, 7]);
|
||||
assert.ok(h.b.applyStatus(z, 'sleep', { duration: down + 3 }));
|
||||
h.run(down + 0.5);
|
||||
assert.ok(!z.isFlying && z.s.flags.sleep && !z.findBuff('stun'), 'asleep after the 8 s stun: still grounded');
|
||||
h.run(3);
|
||||
assert.ok(z.isFlying && !z.s.flags.sleep, 'awake: floats again');
|
||||
});
|
||||
|
||||
test('#1 浮空 (levitate) makes a ground enemy an air unit (melee cannot hit it); data flyers and levitated units refuse it, a 近地悬浮 enemy does not', () => {
|
||||
const h = arena({ units: [{ chessId: 't_blade', row: 10, col: 5 }] });
|
||||
h.step();
|
||||
const g = put(h, 'enemy_t_slime', [10, 6]);
|
||||
const blade = h.unit('t_blade');
|
||||
assert.ok(canTargetEnemy(blade, g, blade.profile));
|
||||
assert.ok(h.b.applyStatus(g, 'levitate', { duration: 3 }));
|
||||
assert.ok(g.isFlying && !canTargetEnemy(blade, g, blade.profile) && canTargetEnemy(blade, g, { canHitFly: true }));
|
||||
// PRTS 行动方式: "行动类型(数据)为飞行的单位、以及已持有浮空异常的单位无法被施加浮空Buff"
|
||||
assert.equal(h.b.applyStatus(g, 'levitate', { duration: 3 }), false, 'already levitated');
|
||||
assert.equal(h.b.applyStatus(put(h, 'enemy_t_bat', [9, 6]), 'levitate', { duration: 3 }), false, 'a data flyer');
|
||||
// 近地悬浮 is WALK in the data: it can be levitated; 浮空 is none of 掠海漂移体's drop triggers (晕眩/无法行动/沉睡/冻结/缚地)
|
||||
const f = put(h, FLOATER, [11, 6]);
|
||||
assert.ok(h.b.applyStatus(f, 'levitate', { duration: 3 }), 'a hovering enemy can be levitated');
|
||||
assert.ok(f.isFlying && f.s.flags.stun && f.findBuff('ab:float'), 'levitated: helpless, still an air unit, still hovering');
|
||||
assert.ok(!f.profile.melee, 'not in 爬行模式');
|
||||
h.run(3.1);
|
||||
assert.ok(!g.isFlying && canTargetEnemy(blade, g, blade.profile), 'back on the ground');
|
||||
assert.ok(f.isFlying && f.findBuff('ab:float') && !f.s.flags.levitate && !f.profile.melee, 'the floater keeps hovering');
|
||||
});
|
||||
|
||||
/**
|
||||
* Chess `id` with skill `skillIndex` at (10, col) next to a pinned floating 掠海漂移体 and a ground bait on (10, 6); the
|
||||
* skill is refilled every second for `secs` s. Returns the damage its side (the operator, its summons) dealt to the bait
|
||||
* and — only while it still hovered — to the floater, per damage tag.
|
||||
*/
|
||||
function floaterDuel(id, skillIndex, { col = 5, secs = 30 } = {}) {
|
||||
const BAIT = enemyRec({ key: 'enemy_t_bait', hp: 1e9, atk: 0, speed: 0 });
|
||||
const h = makeBattle({ units: [{ chessId: id, row: 10, col, skillIndex }], seed: 3, autoFinish: false, timeLimit: 200, captureNoisy: true, defs: { enemies: { enemy_t_bait: BAIT } } });
|
||||
h.step();
|
||||
const f = put(h, FLOATER, [10, 6], { mods: { hpMul: 1e5, atkMul: 0 } });
|
||||
const g = put(h, 'enemy_t_bait', [10, 6]);
|
||||
const out = { floater: 0, bait: 0, floaterTags: new Map() };
|
||||
h.b.on('damaged', (c) => {
|
||||
if (!c.source || c.source.side !== 'ally' || !(c.amount > 0)) return;
|
||||
if (c.target === g) out.bait += c.amount;
|
||||
if (c.target !== f || !f.findBuff('ab:float')) return;
|
||||
out.floater += c.amount;
|
||||
for (const t of c.dmg?.tags || ['-']) out.floaterTags.set(t, (out.floaterTags.get(t) || 0) + c.amount);
|
||||
});
|
||||
const op = h.b.allyUnits.find((u) => u.kind === 'op');
|
||||
for (let t = 0; t < secs; t++) {
|
||||
if (op.skill && !op.skill.active) op.skill.gainSp(op.skill.spCost * (op.skill.maxCharges || 1) + 1, 'test');
|
||||
h.run(1);
|
||||
}
|
||||
assert.deepEqual(h.b.errors.map((e) => `${e.label} ${e.message}`), []);
|
||||
return out;
|
||||
}
|
||||
|
||||
test('#1 ground-only skills and talents spare a hovering enemy (the worst 731a01c offenders + the PRTS "不可对空" notes)', () => {
|
||||
const cases = [
|
||||
['chess_char_6_19_a', 1, 5, '锏 S2 无声的嘲笑'],
|
||||
['chess_char_6_19_a', 2, 5, '锏 S3 归于宁静'],
|
||||
['chess_char_6_07_a', 0, 5, '维娜·维多利亚 S1 重铸晖光'],
|
||||
['chess_char_5_06_a', 2, 5, '隐德来希 S3 灵与欲的惜别'],
|
||||
['chess_char_6_12_a', 2, 4, '迷迭香 S3 “如你所愿”'],
|
||||
['chess_char_5_06_a', 1, 5, '隐德来希 S2 绯红壁合 (PRTS 备注: 血镰 可对空 only on a 起飞 carrier)'],
|
||||
['chess_char_5_13_a', 1, 5, '归溟幽灵鲨 拥抱自我 (PRTS 备注: "伤害与减速不可对空")'],
|
||||
['chess_char_3_04_a', 2, 5, '琳琅诗怀雅 S3 千金一掷 (PRTS 备注: 地面敌方单位, 弹道不可对空)'],
|
||||
['chess_char_5_05_a', 0, 5, '乌尔比安 S1 必须促成的接触 (捕网, like 雪雉\'s "不对空" net)'],
|
||||
];
|
||||
for (const [id, idx, col, name] of cases) for (const cid of [id, id.replace(/_a$/, '_b')]) {
|
||||
const r = floaterDuel(cid, idx, { col });
|
||||
assert.ok(r.bait > 0, `${name} (${cid}): the ground bait is hit`);
|
||||
assert.equal(r.floater, 0, `${name} (${cid}): ${JSON.stringify([...r.floaterTags])}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('#1 skills PRTS marks "可对空" still hit a hovering enemy (德克萨斯 S2 剑雨, 焰尾 S2 “红松林”)', () => {
|
||||
for (const [id, idx, tag, name] of [['chess_char_1_08_a', 1, 'skill', '德克萨斯 S2'], ['chess_char_4_19_a', 1, 'redPine', '焰尾 S2']]) {
|
||||
const r = floaterDuel(id, idx);
|
||||
assert.ok((r.floaterTags.get(tag) || 0) > 0, `${name}: ${JSON.stringify([...r.floaterTags])}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('#1 阿罗玛\'s bubble levitates a hovering 掠海漂移体 (WALK in its data) without dropping it to 爬行模式', () => {
|
||||
const h = makeBattle({ units: [{ chessId: 'chess_char_4_10_a', row: 10, col: 3 }], seed: 3, autoFinish: false, timeLimit: 60 });
|
||||
h.step();
|
||||
const f = put(h, FLOATER, [10, 6], { mods: { hpMul: 100, atkMul: 0 } });
|
||||
h.runUntil(() => h.hooksOf('statusApplied').some((c) => c.target === f && c.status === 'levitate'), 10);
|
||||
const lev = h.hooksOf('statusApplied').find((c) => c.target === f && c.status === 'levitate');
|
||||
assert.ok(lev, 'levitated by her first hit');
|
||||
assert.ok(f.isFlying && f.findBuff('ab:float') && !f.profile.melee, 'still hovering, not crawling');
|
||||
});
|
||||
|
||||
test('#1 HOVER_KEYS lists exactly the enemies that spawn hovering (the match\'s bot counts them as air units)', () => {
|
||||
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
|
||||
const h = arena();
|
||||
h.step();
|
||||
const hov = [];
|
||||
for (const key of Object.keys(E)) {
|
||||
const e = put(h, key, [10, 6]);
|
||||
if (e && e.findBuff('ab:float')) hov.push(key);
|
||||
}
|
||||
assert.deepEqual(hov.sort(), [...enemiesMod.HOVER_KEYS].sort());
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// #5 enemy target order
|
||||
|
||||
/** The ally the latest attack of `enemy` targeted. */
|
||||
function lastTarget(h, enemy) {
|
||||
const ev = h.eventsOf('atk').filter((x) => x[1] === enemy.id).pop();
|
||||
return ev ? h.b.unitById(ev[2]) : null;
|
||||
}
|
||||
|
||||
test('#5 unblocked ranged enemies: highest taunt, then the latest deployed — the bottom row, rightmost (从上到下 > 从左到右)', () => {
|
||||
const units = [[12, 5], [11, 3], [9, 4], [9, 7], [10, 8]].map(([row, col], i) => ({ chessId: 't_wall', row, col, uid: i + 1 }));
|
||||
const h = arena({ units, enemies: [{ key: 'enemy_t_shooter', pos: [10.5, 6] }] });
|
||||
h.step();
|
||||
const order = h.hooksOf('deploy').filter((c) => c.initial).map((c) => [c.unit.tileR, c.unit.tileC]);
|
||||
assert.deepEqual(order, [[12, 5], [11, 3], [10, 8], [9, 4], [9, 7]]);
|
||||
h.run(1.2);
|
||||
const e = h.enemy('enemy_t_shooter');
|
||||
const t = lastTarget(h, e);
|
||||
assert.deepEqual([t.tileR, t.tileC], [9, 7], 'the last deployed');
|
||||
// a taunt level beats deployment order
|
||||
const h2 = arena({ units, enemies: [{ key: 'enemy_t_shooter', pos: [10.5, 6] }] });
|
||||
h2.step();
|
||||
h2.b.addBuff(h2.unit(1), { key: 'test:taunt', mods: { taunt: 1 }, persist: true });
|
||||
h2.run(1.2);
|
||||
assert.equal(lastTarget(h2, h2.enemy('enemy_t_shooter')), h2.unit(1));
|
||||
});
|
||||
|
||||
test('#5 the initial deployment puts every operator before the summons (PRTS 卫戍协议/帮助) — a summon piece draws the fire', () => {
|
||||
const tok = { kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 12, col: 4, uid: 3, def: { name: 'tok', stats: { maxHp: 1e7, atk: 0, blockCnt: 0 } } };
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 12, col: 3, uid: 1 }, { chessId: 't_wall', row: 9, col: 8, uid: 2 }, tok], enemies: [{ key: 'enemy_t_shooter', pos: [10.5, 6] }] });
|
||||
h.step();
|
||||
const order = h.hooksOf('deploy').filter((c) => c.initial).map((c) => c.unit.uid);
|
||||
assert.deepEqual(order, [1, 2, 3], 'operators top→bottom, then the summon');
|
||||
const t = h.unit(3);
|
||||
assert.ok(t.aggroSeq > h.unit(1).aggroSeq && t.aggroSeq > h.unit(2).aggroSeq);
|
||||
h.run(1.2);
|
||||
assert.equal(lastTarget(h, h.enemy('enemy_t_shooter')), t);
|
||||
// a summon an operator brings along at the start ranks after every operator too
|
||||
const h2 = arena({ units: [{ chessId: 't_wall', row: 12, col: 3, uid: 1 }, { chessId: 't_wall', row: 9, col: 8, uid: 2 }], enemies: [{ key: 'enemy_t_shooter', pos: [10.5, 6] }],
|
||||
setup: (b) => b.on('deploy', (c) => { if (c.initial && c.unit.uid === 1) b.spawnToken(c.unit, 'token_10028_vigil_wolf', 12, 4, { def: { name: 'tok', stats: { maxHp: 1e7, atk: 0, blockCnt: 0 } } }); }) });
|
||||
h2.step();
|
||||
const s = h2.b.allyUnits.find((u) => u.kind === 'token');
|
||||
assert.ok(s.deploySeq < h2.unit(2).deploySeq, 'it came in during the first operator\'s deployment');
|
||||
assert.ok(s.aggroSeq > h2.unit(2).aggroSeq, '…but ranks after every operator');
|
||||
h2.run(1.2);
|
||||
assert.equal(lastTarget(h2, h2.enemy('enemy_t_shooter')), s);
|
||||
// a redeployed operator is the latest deployed again
|
||||
h2.b.retreat(h2.unit(1), { reason: 'retreat' });
|
||||
h2.b.redeploy(h2.unit(1), { free: true });
|
||||
assert.ok(h2.unit(1).aggroSeq > s.aggroSeq);
|
||||
});
|
||||
|
||||
test('#5 a blocked ranged enemy attacks its blocker first', () => {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 6, uid: 1 }, { chessId: 't_wall', row: 9, col: 7, uid: 2 }], enemies: [{ key: 'enemy_t_shooter', pos: [10, 6] }] });
|
||||
h.step();
|
||||
h.run(1.2);
|
||||
const e = h.enemy('enemy_t_shooter');
|
||||
assert.equal(e.blockedBy, h.unit(1));
|
||||
assert.equal(lastTarget(h, e), h.unit(1), 'not the later-deployed one beside it');
|
||||
});
|
||||
|
||||
test('#5 隐匿 / 迷彩 allies: only the enemy they block attacks them — blocking does not expose them to other ranged enemies (PRTS 作战机制 §隐匿, 索敌的概念)', () => {
|
||||
const units = [{ chessId: 't_wall', row: 12, col: 5, uid: 1 }, { chessId: 't_wall', row: 9, col: 7, uid: 2 }];
|
||||
const mk = (stealth) => {
|
||||
const h = arena({ units, enemies: [{ key: 'enemy_t_shooter', pos: [10.5, 5] }, { key: 'enemy_t_shooter', pos: [9, 7] }] });
|
||||
h.step();
|
||||
if (stealth) h.b.applyStatus(h.unit(2), 'stealth', { duration: 99 });
|
||||
h.run(1.2);
|
||||
const held = h.b.enemies.find((e) => e.blockedBy === h.unit(2));
|
||||
const free = h.b.enemies.find((e) => e !== held);
|
||||
assert.ok(held && free && !free.blockedBy);
|
||||
return { h, held, free };
|
||||
};
|
||||
const a = mk(true);
|
||||
assert.equal(lastTarget(a.h, a.held), a.h.unit(2), 'the enemy it blocks attacks it ("强行无视对方可选性")');
|
||||
assert.equal(lastTarget(a.h, a.free), a.h.unit(1), 'an unblocked ranged enemy skips it although it blocks ("我方干员并不会因为阻挡而解除隐匿")');
|
||||
const b = mk(false);
|
||||
assert.equal(lastTarget(b.h, b.free), b.h.unit(2), 'control: without stealth the latest deployed is shot');
|
||||
// 排气格栅 (烟雾) uses the same stealth flag: test/content/tokens_devices.test.js
|
||||
});
|
||||
|
||||
test('#5 an enemy\'s own rule filters its candidates first: 萨卡兹枯朽战车 (只攻击地面单位) shoots the ground operator, never idles', () => {
|
||||
// (10,2) is high ground: that operator deploys last (row 10 after row 11) and would be the preferred target
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 11, col: 5, uid: 1 }, { chessId: 't_wall', row: 10, col: 2, uid: 2 }] });
|
||||
h.step();
|
||||
assert.equal(h.unit(2).ground, false);
|
||||
const e = put(h, 'enemy_1272_nhtank', [10, 4], { mods: { atkMul: 0.01 } });
|
||||
h.runUntil(() => e.stats.attacks >= 2, 20);
|
||||
assert.ok(e.stats.attacks >= 2, 'it attacks');
|
||||
const targets = h.eventsOf('atk').filter((x) => x[1] === e.id).map((x) => x[2]);
|
||||
assert.ok(targets.every((id) => id === h.unit(1).id), 'only the ground operator');
|
||||
// PRTS 天赋 "普通攻击只攻击位于低地的我方单位,且不会攻击飞行单位": a flying ally on a low tile (the 炎佑 dragon) is no target
|
||||
const h2 = arena({ units: [{ chessId: 't_wall', row: 11, col: 5, uid: 1 }, { chessId: 't_wall', row: 10, col: 5, uid: 2 }] });
|
||||
h2.step();
|
||||
h2.unit(2).motion = 'FLY';
|
||||
assert.ok(h2.unit(2).ground && h2.unit(2).isFlying);
|
||||
const e2 = put(h2, 'enemy_1272_nhtank', [10, 4], { mods: { atkMul: 0.01 } });
|
||||
h2.runUntil(() => e2.stats.attacks >= 2, 20);
|
||||
assert.ok(e2.stats.attacks >= 2);
|
||||
assert.ok(h2.eventsOf('atk').filter((x) => x[1] === e2.id).every((x) => x[2] === h2.unit(1).id), 'never the flyer');
|
||||
});
|
||||
|
||||
test('#5 “萨科塔之眼” (不会攻击飞行单位) filters its candidates: it shoots the ground ally, never the later-deployed flyer', () => {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 11, col: 5, uid: 1 }, { chessId: 't_wall', row: 10, col: 5, uid: 2 }] });
|
||||
h.step();
|
||||
h.unit(2).motion = 'FLY';
|
||||
const e = put(h, 'enemy_10084_hlegle', [10.5, 5.5], { mods: { atkMul: 0.01 } });
|
||||
h.runUntil(() => e.stats.attacks >= 2, 20);
|
||||
assert.ok(e.stats.attacks >= 2, 'it attacks');
|
||||
assert.ok(h.eventsOf('atk').filter((x) => x[1] === e.id).every((x) => x[2] === h.unit(1).id), 'only the ground ally');
|
||||
});
|
||||
|
||||
test('#5 a shared field (boss / 联防) deploys the players side by side — the i-th operators together, then the summons [ASSUMED]', () => {
|
||||
const W = chessRec({ id: 't_w', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
|
||||
const tok = { kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 12, col: 6, uid: 5, def: { name: 'tok', stats: { maxHp: 1e7, atk: 0, blockCnt: 0 } } };
|
||||
const h = makeBattle({ kind: 'boss', content: 'none', autoFinish: false, defs: { chess: { t_w: W } },
|
||||
players: [
|
||||
{ playerId: 'L1', side: 'L', colOffset: 0, units: [{ uid: 1, chessId: 't_w', row: 12, col: 4 }, { uid: 2, chessId: 't_w', row: 11, col: 4 }, { uid: 3, chessId: 't_w', row: 10, col: 4 }, tok] },
|
||||
{ playerId: 'R1', side: 'R', colOffset: 8, units: [{ uid: 4, chessId: 't_w', row: 12, col: 4 }] },
|
||||
] });
|
||||
h.step();
|
||||
assert.deepEqual(h.hooksOf('deploy').filter((c) => c.initial).map((c) => c.unit.uid), [1, 4, 2, 3, 5],
|
||||
'not every left operator before the right one: at equal taunt the leader does not always prefer one player');
|
||||
const seq = (uid) => h.b.allyUnits.find((u) => u.uid === uid).aggroSeq;
|
||||
assert.ok(seq(5) > Math.max(seq(1), seq(2), seq(3), seq(4)), 'the summon ranks after every operator');
|
||||
});
|
||||
|
||||
test('#5 a special priority breaks ties by taunt, then the latest deployed (假想敌:铳 "优先攻击防御力最高的单位"; PRTS 索敌: 特殊优先级 → 仇恨值)', () => {
|
||||
const pool = { hp: 1e7, maxHp: 1e7, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } };
|
||||
const duel = (taunt) => {
|
||||
const h = makeBattle({ kind: 'boss', sharedBoss: pool, content: 'generic', extraContent: [enemiesMod, bossesMod], seed: 7, autoFinish: false, timeLimit: 600,
|
||||
defs: SYN, kits: { t_wall: NOATK }, units: [{ chessId: 't_wall', row: 10, col: 9, uid: 1 }, { chessId: 't_wall', row: 10, col: 11, uid: 2 }] });
|
||||
h.step();
|
||||
if (taunt) h.b.addBuff(h.unit(1), { key: 'test:taunt', mods: { taunt: 1 }, persist: true });
|
||||
const e = h.spawn('enemy_9017_achunt', { pos: [3, 10], routeIndex: 0, mods: { speedMul: 0 }, tag: 'boss' });
|
||||
h.runUntil(() => e.stats.attacks >= 1, 60);
|
||||
assert.ok(e.stats.attacks >= 1);
|
||||
assert.equal(h.unit(1).s.def, h.unit(2).s.def, 'equal DEF');
|
||||
return { h, t: lastTarget(h, e) };
|
||||
};
|
||||
const a = duel(false);
|
||||
assert.equal(a.t, a.h.unit(2), 'equal DEF: the latest deployed');
|
||||
const b = duel(true);
|
||||
assert.equal(b.t, b.h.unit(1), 'equal DEF: the higher taunt level first');
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
// User playtest #5 item 4 — "如果敌人被卡在两个干员之间的话,靠后的干员死亡敌人会直接走掉,而不是换成靠前面的干员接替阻挡".
|
||||
//
|
||||
// Official rule (PRTS 游戏数据基础 §阻挡半径, 作战机制 §碰撞体积与位置识别 "中点判定 … 案例: 阻挡"): an unblocked ground
|
||||
// enemy whose position lies within the ground block radius 0.7071 (squared 0.49999037) of a ground operator's centre is
|
||||
// blocked by it while the operator has free block capacity — checked continuously, so an enemy that overlaps an
|
||||
// operator when its blocker dies (or when the operator's capacity frees up) is taken over at once. Flying enemies:
|
||||
// air block radius 0.8944 (起飞 / blockFly units); devices (阻隔工事 / 障碍物): 0.4472.
|
||||
//
|
||||
// Flat stage: lower-gate enemies walk row 9 from the gate (9,10) to the goal (9,2), i.e. towards smaller columns.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { BLOCK_RADIUS } from '../../server/sim/constants.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
|
||||
const FRONT = 'test_front_a', BACK = 'test_back_a';
|
||||
const op = (id, blockCnt = 1) => chessRec({ id, stats: { atk: 0, blockCnt, maxHp: 1e6, respawnTime: 999 }, skill: null });
|
||||
const foe = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 1, ...o });
|
||||
|
||||
function lane(units, enemies, defs = {}) {
|
||||
return makeBattle({
|
||||
defs: {
|
||||
chess: { [FRONT]: op(FRONT, defs.frontBlock ?? 1), [BACK]: op(BACK, defs.backBlock ?? 1), ...(defs.chess || {}) },
|
||||
enemies: { enemy_x: foe('enemy_x'), enemy_e: foe('enemy_e'), enemy_heavy: foe('enemy_heavy', { blockCnt: 2 }), ...(defs.enemies || {}) },
|
||||
},
|
||||
units, enemies, timeLimit: 200, content: 'none',
|
||||
});
|
||||
}
|
||||
const kill = (h, u) => h.b.loseHp(u, 1e12);
|
||||
|
||||
test('the official block radii are the PRTS numbers', () => {
|
||||
assert.ok(Math.abs(BLOCK_RADIUS.ground - 0.7071) < 1e-3);
|
||||
assert.ok(Math.abs(BLOCK_RADIUS.fly - 0.8944) < 1e-3);
|
||||
assert.ok(Math.abs(BLOCK_RADIUS.device - 0.4472) < 1e-3);
|
||||
});
|
||||
|
||||
test('#4: the enemy between two operators is taken over by the front one when its blocker (the rear one) dies', () => {
|
||||
// front (9,6) blocks X; E walks through the full front operator and is blocked by the rear one (9,5) — it now
|
||||
// overlaps both. X dies (front has room again), then the rear blocker dies: E must stay, blocked by the front one.
|
||||
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }],
|
||||
[{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
|
||||
const front = h.unit(FRONT), back = h.unit(BACK);
|
||||
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
|
||||
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
|
||||
assert.ok(h.runUntil(() => x()?.blockedBy === front && e()?.blockedBy === back, 30), 'X blocked by the front, E by the rear operator');
|
||||
const ex = e().x;
|
||||
assert.ok(Math.hypot(ex - 6, e().y - 9) < BLOCK_RADIUS.ground, `E overlaps the front operator too (x=${ex.toFixed(3)})`);
|
||||
kill(h, x());
|
||||
h.step(2);
|
||||
assert.equal(e().blockedBy, back, 'E stays with its blocker while the rear operator lives');
|
||||
kill(h, back);
|
||||
h.step(2);
|
||||
assert.equal(e().blockedBy, front, 'the front operator takes E over');
|
||||
h.run(5);
|
||||
assert.equal(e().blockedBy, front);
|
||||
assert.ok(Math.abs(e().x - ex) < 1e-6, 'E did not walk on');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('#4: a withdrawn or stunned blocker hands its enemy to the operator it still touches', () => {
|
||||
for (const how of ['retreat', 'stun']) {
|
||||
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }],
|
||||
[{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
|
||||
const front = h.unit(FRONT), back = h.unit(BACK);
|
||||
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
|
||||
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
|
||||
assert.ok(h.runUntil(() => x()?.blockedBy === front && e()?.blockedBy === back, 30));
|
||||
kill(h, x());
|
||||
if (how === 'retreat') h.b.retreat(back); else h.b.applyStatus(back, 'stun', { duration: 5 });
|
||||
h.step(2);
|
||||
assert.equal(e().blockedBy, front, how);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('#4: the rear blocker dies while the front operator is still full — the enemy walks on (no room)', () => {
|
||||
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }],
|
||||
[{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
|
||||
const front = h.unit(FRONT), back = h.unit(BACK);
|
||||
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
|
||||
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
|
||||
assert.ok(h.runUntil(() => x()?.blockedBy === front && e()?.blockedBy === back, 30));
|
||||
kill(h, back);
|
||||
h.step(2);
|
||||
assert.equal(e().blockedBy, null, 'no room at the front → E walks on');
|
||||
const x0 = e().x;
|
||||
h.run(1);
|
||||
assert.ok(e().x < x0 - 0.2, 'E is walking again');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('#4: an operator whose capacity frees up blocks an enemy that still overlaps it (past its tile, inside 0.7071)', () => {
|
||||
const h = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
|
||||
const front = h.unit(FRONT);
|
||||
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
|
||||
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
|
||||
assert.ok(h.runUntil(() => x()?.blockedBy === front, 30));
|
||||
// E walks through the full operator; free the capacity once E has left the operator's tile (x < 5.5) but is still
|
||||
// within the block radius
|
||||
assert.ok(h.runUntil(() => e() && e().x < 5.42, 30));
|
||||
assert.equal(e().blockedBy, null, 'passing through while full');
|
||||
kill(h, x());
|
||||
h.step(1);
|
||||
assert.equal(e().blockedBy, front, 'taken as soon as the operator has room');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('#4: pass-through when full — an enemy that never finds room walks past the operator', () => {
|
||||
const h = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
|
||||
const front = h.unit(FRONT);
|
||||
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
|
||||
assert.ok(h.runUntil(() => e() && e().x < 4.5, 40), 'E walked past the full operator');
|
||||
assert.equal(e().blockedBy, null);
|
||||
assert.equal(front.blocking.length, 1);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('#4: contact happens at the block radius (the enemy stops ~0.71 tile from the operator, outside its tile)', () => {
|
||||
const h = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_x', time: 0 }]);
|
||||
const front = h.unit(FRONT);
|
||||
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
|
||||
assert.ok(h.runUntil(() => x()?.blockedBy === front, 30));
|
||||
const d = Math.hypot(x().x - 6, x().y - 9);
|
||||
assert.ok(d <= BLOCK_RADIUS.ground + 1e-9 && d > BLOCK_RADIUS.ground - 0.05, `blocked at contact (d=${d.toFixed(3)})`);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('#4: an operator beside the lane (one row off) never touches the enemies walking it', () => {
|
||||
const h = lane([{ chessId: FRONT, row: 10, col: 6 }], [{ key: 'enemy_x', time: 0 }]);
|
||||
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
|
||||
assert.ok(h.runUntil(() => x() && x().x < 4, 40));
|
||||
assert.equal(h.hooksOf('blocked').length, 0);
|
||||
});
|
||||
|
||||
test('#4: block weight — a 2-weight enemy needs 2 free block', () => {
|
||||
const h1 = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_heavy', time: 0 }]);
|
||||
const heavy1 = () => h1.b.enemies.find((e) => e.defId === 'enemy_heavy');
|
||||
assert.ok(h1.runUntil(() => heavy1() && heavy1().x < 4.5, 40), 'block 1 cannot hold a 2-weight enemy');
|
||||
assert.equal(heavy1().blockedBy, null);
|
||||
const h2 = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_heavy', time: 0 }], { frontBlock: 2 });
|
||||
assert.ok(h2.runUntil(() => h2.b.enemies[0]?.blockedBy === h2.unit(FRONT), 30), 'block 2 holds it');
|
||||
});
|
||||
|
||||
test('#4: of two free operators in contact the nearer one blocks', () => {
|
||||
// an enemy standing still between (9,6) and (9,5), nearer to (9,5)
|
||||
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }], [],
|
||||
{ enemies: { enemy_still: foe('enemy_still', { speed: 0 }) } });
|
||||
h.step(1);
|
||||
const e = h.spawn('enemy_still', { pos: [9, 5.4] });
|
||||
h.step(1);
|
||||
assert.equal(e.blockedBy, h.unit(BACK));
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('#4: a blocker attacks the enemy it blocks even when it stands outside its range (可以选择且优先选择阻挡单位)', () => {
|
||||
// facing LEFT (away from the gate): its range (9,6),(9,5) never holds the enemy blocked at x ≈ 6.71 (tile (9,7))
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_back: chessRec({ id: 't_back', stats: { atk: 100, blockCnt: 1, maxHp: 1e6 }, skill: null }) }, enemies: { enemy_x: foe('enemy_x') } },
|
||||
units: [{ chessId: 't_back', row: 9, col: 6, dir: 'LEFT' }], enemies: [{ key: 'enemy_x', time: 0 }], timeLimit: 60,
|
||||
hooks: ['damaged'], captureNoisy: true,
|
||||
});
|
||||
const u = h.unit('t_back');
|
||||
const x = () => h.b.enemies[0];
|
||||
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30));
|
||||
assert.ok(!u.rangeKeySet.has(Math.round(x().y) * 21 + Math.round(x().x)), 'outside its range');
|
||||
h.run(3);
|
||||
assert.ok(h.hooksOf('damaged').some((c) => c.source === u && c.target === x()), 'it hits its blocked enemy');
|
||||
});
|
||||
|
||||
test('#4: a ranged operator on a melee tile blocks, but never attacks or casts for the enemy held behind it (阻挡(近战限定))', () => {
|
||||
// PRTS 索敌的概念: "我方索敌优先级:阻挡(近战限定)"; "远程位干员通常而言不会优先攻击自己阻挡的目标;甚至如果这个目标被阻挡在
|
||||
// 该干员的身后,该干员可能因为敌人未处于自身攻击范围而无法攻击到这个敌人". Facing LEFT, its range (9,6),(9,5) never holds
|
||||
// the enemy blocked at x ≈ 6.71; a melee operator in the same spot hits it and casts its DEFAULT skill (帮助: "敌人被近战
|
||||
// 干员自身阻挡" satisfies the target condition).
|
||||
const run = (id, profession) => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { [id]: chessRec({ id, profession, stats: { atk: 100, blockCnt: 1, maxHp: 1e6 }, skill: { spCost: 1, initSp: 1, duration: 5 } }) }, enemies: { enemy_x: foe('enemy_x') } },
|
||||
units: [{ chessId: id, row: 9, col: 6, dir: 'LEFT' }], enemies: [{ key: 'enemy_x', time: 0 }], timeLimit: 60,
|
||||
content: 'generic', kits: { [id]: () => ({ skill: { kind: 'duration', duration: 5 } }) }, hooks: ['damaged'], captureNoisy: true,
|
||||
});
|
||||
const u = h.unit(id);
|
||||
const x = () => h.b.enemies[0];
|
||||
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30), `${profession} blocks`);
|
||||
assert.ok(!u.rangeKeySet.has(Math.round(x().y) * 21 + Math.round(x().x)), 'outside its range');
|
||||
h.run(3);
|
||||
checkInvariants(h.b);
|
||||
return { hits: h.hooksOf('damaged').filter((c) => c.source === u && c.target === x()).length, casts: u.skill.activations, still: x().blockedBy === u };
|
||||
};
|
||||
const ranged = run('t_rg', 'SNIPER');
|
||||
assert.equal(ranged.hits, 0, 'the ranged blocker never hits the enemy behind it');
|
||||
assert.equal(ranged.casts, 0, 'nor casts its skill for it');
|
||||
assert.ok(ranged.still, 'it still holds the enemy');
|
||||
const melee = run('t_ml', 'WARRIOR');
|
||||
assert.ok(melee.hits > 0, 'a melee blocker hits it');
|
||||
assert.equal(melee.casts, 1, 'and casts its skill');
|
||||
});
|
||||
|
||||
test('#4: "melee" is the deploy position — a 领主 with a ranged attack (银灰) still hits the enemy it holds behind it', () => {
|
||||
// data position MELEE, attackKind ranged (领主 / 哨戒铁卫 / 要塞 / 钩索师 …): a melee unit for the blocked-first selector
|
||||
const id = 'chess_char_4_22_a';
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_x: foe('enemy_x') } }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
|
||||
units: [{ chessId: id, row: 9, col: 6, dir: 'LEFT' }], enemies: [{ key: 'enemy_x', time: 0 }],
|
||||
});
|
||||
const u = h.unit(id);
|
||||
const x = () => h.b.enemies[0];
|
||||
assert.equal(u.def.position, 'MELEE');
|
||||
assert.equal(u.profile.attack, 'ranged', 'a ranged attack profile');
|
||||
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30), 'blocks');
|
||||
assert.ok(!u.rangeKeySet.has(Math.round(x().y) * 21 + Math.round(x().x)), 'outside its range (behind it)');
|
||||
h.run(4);
|
||||
assert.ok(h.hooksOf('damaged').some((c) => c.source === u && c.target === x()), 'it hits the enemy it blocks');
|
||||
});
|
||||
|
||||
test('#4: blocked-first is a melee priority — a ranged blocker shoots the enemy nearest the goal', () => {
|
||||
// range (9,5)–(9,7) facing RIGHT: the walker it blocks stands on (9,7); a flyer passes over the operator towards the
|
||||
// goal. The ranged operator picks by its own order (least remaining distance first), a melee one (able to hit air)
|
||||
// keeps hitting the enemy it blocks.
|
||||
const run = (id, o) => {
|
||||
const h = makeBattle({
|
||||
defs: {
|
||||
chess: { [id]: chessRec({ id, rangeGrid: [[0, -1], [0, 0], [0, 1]], stats: { atk: 1, bat: 0.2, blockCnt: 1, maxHp: 1e6 }, skill: null, ...o }) },
|
||||
enemies: { enemy_x: foe('enemy_x'), enemy_fl: foe('enemy_fl', { motion: 'FLY' }) },
|
||||
},
|
||||
units: [{ chessId: id, row: 9, col: 6 }], enemies: [{ key: 'enemy_x', time: 0 }, { key: 'enemy_fl', time: 5, route: 2 }], timeLimit: 60,
|
||||
content: 'none', hooks: ['damaged'], captureNoisy: true,
|
||||
});
|
||||
const u = h.unit(id);
|
||||
const walker = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
|
||||
const flyer = () => h.b.enemies.find((e) => e.defId === 'enemy_fl');
|
||||
assert.ok(h.runUntil(() => walker()?.blockedBy === u, 30), 'the walker is blocked');
|
||||
assert.ok(h.runUntil(() => flyer() && flyer().x < 4.4, 30), 'the flyer passed');
|
||||
assert.equal(walker().blockedBy, u);
|
||||
return h.hooksOf('damaged').filter((c) => c.source === u && c.target === flyer()).length;
|
||||
};
|
||||
assert.ok(run('t_rg2', { profession: 'SNIPER' }) > 0, 'the ranged blocker shot the flyer while it was nearer the goal');
|
||||
assert.equal(run('t_ml2', { profession: 'WARRIOR', attackKind: 'melee', canHitFly: true }), 0, 'the melee blocker kept to its blocked enemy');
|
||||
});
|
||||
|
||||
test('#4: a skywalker (起飞: blockFly) blocks flyers at the air block radius 0.8944', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sky: chessRec({ id: 't_sky', subProfessionId: 'skywalker', stats: { atk: 0, blockCnt: 2, maxHp: 1e6 }, skill: null }) },
|
||||
enemies: { enemy_fl: foe('enemy_fl', { motion: 'FLY' }) } },
|
||||
units: [{ chessId: 't_sky', row: 9, col: 6 }], enemies: [{ key: 'enemy_fl', route: 2 }], timeLimit: 60,
|
||||
});
|
||||
const sky = h.unit('t_sky');
|
||||
const f = () => h.b.enemies[0];
|
||||
assert.ok(h.runUntil(() => f()?.blockedBy === sky, 30), 'the flyer is blocked');
|
||||
const d = Math.hypot(f().x - 6, f().y - 9);
|
||||
assert.ok(d <= BLOCK_RADIUS.fly + 1e-9 && d > BLOCK_RADIUS.ground, `at the air radius (d=${d.toFixed(3)})`);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('#4: 瑕光 S2 puts the enemy she blocks to sleep although it stands outside her tile (PRTS: 自身这格内…及自身阻挡的敌人)', () => {
|
||||
const ds = getDefaultSource();
|
||||
const id = 'chess_char_3_12_a';
|
||||
const skillIndex = ds.rawChess(id).skills.find((s) => s.skillId === 'skchr_blemsh_2').index;
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_x: foe('enemy_x') } }, timeLimit: 60,
|
||||
units: [{ chessId: id, row: 9, col: 6, skillIndex }], enemies: [{ key: 'enemy_x', time: 0 }],
|
||||
});
|
||||
const u = h.unit(id);
|
||||
const x = () => h.b.enemies[0];
|
||||
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30));
|
||||
assert.notEqual(Math.round(x().x), 6, 'the blocked enemy is on the tile in front of her');
|
||||
u.skill.gainSp(u.skill.spCost * u.skill.maxCharges + 1, 'test');
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||
h.step();
|
||||
assert.ok(x().s.flags.sleep, 'asleep');
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
// User playtest #5 item 10 — "大体型的关底boss实际受击判定范围很小,是不是就一格,跟官方原版不一样,原版是很大的一片区域".
|
||||
//
|
||||
// Official (PRTS enemy pages, 天赋 "巨型单位:受击判定区域为长4.95、宽2.95的长方形,向上偏移1.0"; 假想敌:管 隐秘核心 also
|
||||
// "向右偏移1.0"; 卫戍协议:盟约 下半/PRTS盟约记录: the season's 阿利斯泰尔 / “萨米的意志” have the same 4.95 × 2.95, up 1) and
|
||||
// PRTS 作战机制 "巨型BOSS单位的每一个占据的格子都可以让其本身通过格子判定": a huge boss is hit anywhere on its rectangle —
|
||||
// every tile it occupies counts for grid ranges, radius effects measure to the rectangle. Regular enemies are
|
||||
// unchanged.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { bodyKeys, bodyDist, bodyInKeys, bodyOnTile, bodyTileReach, hitRect, normHitArea } from '../../server/sim/body.js';
|
||||
import { COLS } from '../../server/sim/constants.js';
|
||||
|
||||
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
|
||||
const tiles = (keys) => keys.map((k) => [Math.floor(k / COLS), k % COLS]).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
const block = (r0, r1, c0, c1) => { const o = []; for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) o.push([r, c]); return o; };
|
||||
|
||||
test('data: the huge bosses carry the PRTS hit areas, everything else is a point', () => {
|
||||
const want = {
|
||||
enemy_9013_acstmk: { w: 4.95, h: 2.95, dx: 0, dy: 1 }, enemy_9013_acstmk_2: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
|
||||
enemy_9021_acduml: { w: 4.95, h: 2.95, dx: 0, dy: 1 }, enemy_9021_acduml_2: { w: 4.95, h: 2.95, dx: 1, dy: 1 },
|
||||
enemy_1521_dslily: { w: 4.95, h: 2.95, dx: 0, dy: 1 }, enemy_9032_aclionk: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
|
||||
enemy_9033_acdeer: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
|
||||
};
|
||||
for (const [k, v] of Object.entries(want)) assert.deepEqual(E[k].hitArea, v, k);
|
||||
const others = Object.keys(E).filter((k) => E[k].hitArea && !want[k]);
|
||||
assert.deepEqual(others, [], 'no other enemy is huge (假想敌:铳 / 卢西恩 / the parts are regular)');
|
||||
for (const k of ['enemy_9017_achunt', 'enemy_9017_achunt_2', 'enemy_2016_csphtm', 'enemy_9014_acstma', 'enemy_9022_acdumm']) assert.equal(E[k].hitArea, undefined, k);
|
||||
assert.equal(normHitArea({ w: 0, h: 1 }), null);
|
||||
assert.equal(normHitArea(null), null);
|
||||
});
|
||||
|
||||
test('geometry: 4.95 × 2.95 up 1 at the boss tile (3,10) occupies rows 3–5 × cols 8–12; 管 (隐秘核心) at (3,9) the same block', () => {
|
||||
const boss = { x: 10, y: 3, hitArea: normHitArea(E.enemy_9013_acstmk.hitArea) };
|
||||
assert.deepEqual(tiles(bodyKeys(boss)), block(3, 5, 8, 12));
|
||||
const pipe2 = { x: 9, y: 3, hitArea: normHitArea(E.enemy_9021_acduml_2.hitArea) };
|
||||
assert.deepEqual(tiles(bodyKeys(pipe2)), block(3, 5, 8, 12));
|
||||
assert.ok(bodyOnTile(boss, 5, 12) && bodyOnTile(boss, 3, 8) && !bodyOnTile(boss, 2, 10) && !bodyOnTile(boss, 4, 13));
|
||||
const R = hitRect(boss);
|
||||
assert.deepEqual([R.x0, R.x1, R.y0, R.y1].map((v) => +v.toFixed(3)), [7.525, 12.475, 2.525, 5.475]);
|
||||
assert.equal(bodyDist(boss, 10, 4), 0, 'inside');
|
||||
assert.ok(Math.abs(bodyDist(boss, 13.475, 4) - 1) < 1e-9, '1 tile right of the edge');
|
||||
assert.equal(bodyTileReach(boss, 4, 13), 1);
|
||||
assert.equal(bodyTileReach(boss, 4, 12), 0);
|
||||
// a regular enemy is its position's tile / a point
|
||||
const e = { x: 6.4, y: 9.6 };
|
||||
assert.deepEqual(tiles(bodyKeys(e)), [[10, 6]]);
|
||||
assert.ok(bodyInKeys(e, new Set([10 * COLS + 6])) && !bodyInKeys(e, [10 * COLS + 7]));
|
||||
assert.ok(Math.abs(bodyDist(e, 6.4, 10.6) - 1) < 1e-9);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// battles
|
||||
|
||||
const HUGE = { ...enemyRec({ key: 'enemy_huge', hp: 1e8, speed: 0, rank: 'BOSS' }), hitArea: { w: 4.95, h: 2.95, dx: 0, dy: 1 } };
|
||||
const SMALL = enemyRec({ key: 'enemy_small', hp: 1e8, speed: 0 });
|
||||
const blade = (id, rangeGrid = [[0, 0], [0, 1]]) => chessRec({ id, stats: { atk: 100, blockCnt: 0, maxHp: 1e7 }, rangeGrid, skill: null });
|
||||
|
||||
function bossField(units, key, pos = [3, 10], extra = {}) {
|
||||
const h = makeBattle({
|
||||
kind: 'boss', content: 'none', autoFinish: false, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
|
||||
defs: { chess: { t_edge: blade('t_edge'), t_far: blade('t_far'), t_aoe: extra.aoe ?? blade('t_aoe') }, enemies: { enemy_huge: HUGE, enemy_small: SMALL } },
|
||||
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units: units.map((u, i) => ({ uid: i + 1, kind: 'chess', ...u })), bonds: {} }],
|
||||
});
|
||||
h.step();
|
||||
const e = h.spawn(key, { pos, routeIndex: 0 });
|
||||
return { h, e };
|
||||
}
|
||||
const hitsOn = (h, e, u) => h.hooksOf('damaged').filter((c) => c.target === e && c.source === u).length;
|
||||
|
||||
test('#10: an operator whose range touches only the edge of the boss area hits it (and not a regular enemy there)', () => {
|
||||
// (4,7) facing RIGHT covers (4,7),(4,8) — (4,8) is the left column of the boss body; the boss's own tile (3,10) is
|
||||
// far
|
||||
for (const [key, expectHit] of [['enemy_huge', true], ['enemy_small', false]]) {
|
||||
const { h, e } = bossField([{ chessId: 't_edge', row: 4, col: 7, dir: 'RIGHT' }], key);
|
||||
const u = h.unit('t_edge');
|
||||
h.run(5);
|
||||
assert.equal(hitsOn(h, e, u) > 0, expectHit, `${key}: ${hitsOn(h, e, u)} hits`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('#10: every tile of the 5 × 3 body counts; one tile outside it does not', () => {
|
||||
// a 1-tile range operator (its own tile only) on the boss's corner tiles (5,12) and (3,8) hits; on (2,9) (below it) /
|
||||
// (4,13) (right of it) it does not
|
||||
const one = [[0, 0]];
|
||||
for (const [r, c, want] of [[5, 12, true], [3, 8, true], [5, 8, true], [4, 13, false], [2, 9, false]]) {
|
||||
const h = makeBattle({
|
||||
kind: 'boss', content: 'none', autoFinish: false, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
|
||||
defs: { chess: { t_one: blade('t_one', one) }, enemies: { enemy_huge: HUGE } },
|
||||
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units: [{ uid: 1, kind: 'chess', chessId: 't_one', row: r, col: c }], bonds: {} }],
|
||||
});
|
||||
h.step();
|
||||
const e = h.spawn('enemy_huge', { pos: [3, 10], routeIndex: 0 });
|
||||
h.run(4);
|
||||
assert.equal(hitsOn(h, e, h.unit('t_one')) > 0, want, `(${r},${c})`);
|
||||
}
|
||||
});
|
||||
|
||||
test('#10: radius effects reach the body — an AoE 1 tile beyond the rectangle edge hits it, a regular enemy there is not hit', () => {
|
||||
for (const [key, want] of [['enemy_huge', 1], ['enemy_small', 0]]) {
|
||||
const { h, e } = bossField([], key);
|
||||
const got = h.b.enemiesInRadius(13.475, 4, 1.0).filter((x) => x === e).length;
|
||||
assert.equal(got, want, key);
|
||||
assert.equal(h.b.enemiesInRadius(13.6, 4, 1.0).filter((x) => x === e).length, 0, `${key}: beyond reach`);
|
||||
// splash around a target is a 中点判定 (PRTS 作战机制): by position, the body does not count
|
||||
assert.equal(h.b.enemiesInRadius(13.475, 4, 1.0, true).filter((x) => x === e).length, 0, `${key}: centre-point radius`);
|
||||
assert.equal(h.b.enemiesInRadius(11, 3, 1.0, true).filter((x) => x === e).length, 1, `${key}: its position within r`);
|
||||
}
|
||||
});
|
||||
|
||||
test('#10 (follow-up): an operator\'s splash on an escort next to the huge boss does not reach the boss (中点判定)', () => {
|
||||
// escort at (2,9): 0.525 below the boss rectangle (rows 2.525–5.475), 1.41 from the boss's position (3,10). A splash
|
||||
// caster targeting only the escort (range: its tile) splashes 1.1 around it — by PRTS 作战机制 "中点判定…酒神1天赋的1.3
|
||||
// 溅射半径" the boss (centre 1.41 away) is not hit; a regular enemy 1.0 away from the escort is
|
||||
const aoe = chessRec({ id: 't_aoe', profession: 'CASTER', subProfessionId: 'splashcaster', dmgType: 'arts', stats: { atk: 100, blockCnt: 0, maxHp: 1e7 }, rangeGrid: [[0, 3]], skill: null });
|
||||
const { h, e } = bossField([{ chessId: 't_aoe', row: 2, col: 6, dir: 'RIGHT' }], 'enemy_huge', [3, 10], { aoe });
|
||||
const esc = h.spawn('enemy_small', { pos: [2, 9], routeIndex: 0 });
|
||||
const near = h.spawn('enemy_small', { pos: [2, 10], routeIndex: 0 });
|
||||
const u = h.unit('t_aoe');
|
||||
assert.ok(h.runUntil(() => hitsOn(h, esc, u) > 0, 10), 'the caster hits the escort');
|
||||
h.run(3);
|
||||
assert.ok(hitsOn(h, near, u) > 0, 'a regular enemy 1.0 from the escort takes the splash');
|
||||
assert.equal(hitsOn(h, e, u), 0, 'the huge boss (position 1.41 away, body 0.53 away) takes none');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('#10: the enemy index lists a huge enemy once per query and unitsInGrid sees its body', () => {
|
||||
const { h, e } = bossField([{ chessId: 't_far', row: 4, col: 3, dir: 'RIGHT' }], 'enemy_huge');
|
||||
h.step();
|
||||
const all = [];
|
||||
for (let r = 0; r <= 5; r++) for (let c = 0; c <= 20; c++) all.push(r * COLS + c);
|
||||
const u = h.unit('t_far');
|
||||
assert.deepEqual(h.b.enemiesInKeys(all, u, { canHitFly: true }).map((x) => x.id), [e.id]);
|
||||
assert.deepEqual(h.b.unitsInGrid(u, [[0, 5]]).map((x) => x.id), [e.id], '(4,8) is on the body');
|
||||
assert.deepEqual(h.b.unitsInGrid(u, [[0, 4]]).map((x) => x.id), [], '(4,7) is not');
|
||||
});
|
||||
|
||||
test('#10: the real 假想敌:胄 on the real boss stage — an operator on the 1×3 fence left of the pipe block hits it', () => {
|
||||
// act2autochess_m01 boss field: floor cols 9–11 rows 3–5 framed by fence tiles (b, deployable) at cols 8 and 12;
|
||||
// the boss stands at (3,10). A melee operator on the fence (4,8) facing RIGHT covers (4,8),(4,9) — both on the boss
|
||||
// body.
|
||||
const h = makeBattle({
|
||||
kind: 'boss', stageId: 'act2autochess_m01', content: 'none', autoFinish: false, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
|
||||
defs: { chess: { t_edge: blade('t_edge') } },
|
||||
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units: [{ uid: 1, kind: 'chess', chessId: 't_edge', row: 4, col: 8, dir: 'RIGHT' }], bonds: {} }],
|
||||
});
|
||||
h.step();
|
||||
const boss = h.spawn('enemy_9013_acstmk', { pos: [3, 10], routeIndex: 0, tag: 'boss', mods: { speedMul: 0 } });
|
||||
assert.ok(boss.hitArea, 'the boss unit carries its hit area');
|
||||
const u = h.unit('t_edge');
|
||||
assert.equal(u.tileR, 4);
|
||||
h.run(6);
|
||||
assert.ok(hitsOn(h, boss, u) > 0, 'hits the boss');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 自缚 + 无法被阻挡 — every 巨型单位 page lists "{{异常效果|自缚}} … 不可阻挡 / 无法被阻挡" (PRTS 假想敌:胄 / 假想敌:管 /
|
||||
// 盐风主教昆图斯 / 阿利斯泰尔,帝国余晖 / “萨米的意志”; 异常效果 自缚 = 无法移动, not recognised as 束缚). The hit area moves
|
||||
// with the boss, so a boss that walked would drag its 5 × 3 body off the pipe block (reviewer: the co-op 昆图斯 walked its
|
||||
// route (3,10) → (2,2) and leaked at ≈ 280 game s).
|
||||
|
||||
const HUGE_TEMPLATES = [
|
||||
['act1autochess_h07_01_s', 'enemy_9013_acstmk'], ['act1autochess_h07_03_s', 'enemy_9021_acduml'],
|
||||
['act1autochess_h07_04_s', 'enemy_1521_dslily'], ['act1autochess_h07_06_s', 'enemy_9032_aclionk'],
|
||||
['act1autochess_h07_07_s', 'enemy_9033_acdeer'], ['act1autochess_h08_01_s', 'enemy_9013_acstmk_2'],
|
||||
['act1autochess_h08_03_s', 'enemy_9021_acduml_2'],
|
||||
];
|
||||
const realBoss = (waveTemplate, units = [], timeLimit = 60, two = false) => makeBattle({
|
||||
kind: 'boss', stageId: 'act2autochess_m01', waveTemplate, content: 'full', autoFinish: false, timeLimit, hooks: ['enemyLeak'],
|
||||
players: [
|
||||
{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units, bonds: {} },
|
||||
...(two ? [{ playerId: 'p2', seat: 1, side: 'R', coords: 'field', units: [], bonds: {} }] : []),
|
||||
],
|
||||
});
|
||||
|
||||
test('#10: every huge boss is 自缚 and unblockable (PRTS 天赋) — in its real template, from its spawn', async () => {
|
||||
const { SELF_BOUND } = await import('../../server/sim/content/bosses.js');
|
||||
assert.deepEqual([...SELF_BOUND].sort(), Object.keys(E).filter((k) => E[k].hitArea).sort(), 'the 巨型单位 are exactly the 自缚 leaders');
|
||||
for (const [tpl, key] of HUGE_TEMPLATES) {
|
||||
const h = realBoss(tpl);
|
||||
h.run(2); // 管 spawns at 1 s
|
||||
const e = h.b.enemies.find((x) => x.defId === key);
|
||||
assert.ok(e, `${tpl}: ${key} spawned`);
|
||||
assert.ok(e.s.flags.noMove && e.s.flags.unblockable, `${key}: 自缚 + 无法被阻挡`);
|
||||
assert.ok(!e.s.flags.bind, `${key}: 自缚 is not recognised as 束缚`);
|
||||
}
|
||||
});
|
||||
|
||||
test('#10: the co-op 盐风主教昆图斯 stays on its pipe block (its route would walk it to (2,2)) and never leaks', () => {
|
||||
const h = realBoss('act1autochess_h07_04', [], 900, true);
|
||||
h.run(1);
|
||||
const bosses = h.b.enemies.filter((e) => e.defId === 'enemy_1521_dslily');
|
||||
const at = bosses.map((e) => [e.x, e.y]);
|
||||
h.run(400);
|
||||
const q = h.b.enemies.filter((e) => e.defId === 'enemy_1521_dslily');
|
||||
assert.ok(q.length >= 1 && q.every((e) => e.alive), 'still on the field');
|
||||
for (const [i, e] of bosses.entries()) assert.deepEqual([e.x, e.y], at[i], `copy ${i} did not move`);
|
||||
assert.equal(h.hooksOf('enemyLeak').filter((c) => c.enemy && c.enemy.defId === 'enemy_1521_dslily').length, 0, 'no leak');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('#10: an operator standing on the boss (a free-capacity blocker in contact) does not block it', () => {
|
||||
const op = chessRec({ id: 't_wall', stats: { atk: 10, blockCnt: 3, maxHp: 1e9, def: 5000 }, rangeGrid: [[0, 0]], skill: null });
|
||||
const h = makeBattle({
|
||||
kind: 'boss', stageId: 'act2autochess_m01', content: 'full', autoFinish: false, timeLimit: 60, defs: { chess: { t_wall: op } },
|
||||
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units: [{ uid: 1, kind: 'chess', chessId: 't_wall', row: 4, col: 8, dir: 'RIGHT' }], bonds: {} }],
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('t_wall');
|
||||
const boss = h.spawn('enemy_1521_dslily', { pos: [u.tileR, u.tileC], routeIndex: 0, tag: 'boss' });
|
||||
h.run(3);
|
||||
assert.equal(boss.blockedBy, null);
|
||||
assert.equal(u.blocking.length, 0);
|
||||
assert.deepEqual([boss.y, boss.x], [u.tileR, u.tileC], 'and it does not walk away either');
|
||||
});
|
||||
|
||||
test('docs: SIM.md / DATA.md describe the hit areas and the block radius (user playtest #5)', () => {
|
||||
const SIM = fs.readFileSync(new URL('../../docs/SIM.md', import.meta.url), 'utf8');
|
||||
const DATA_MD = fs.readFileSync(new URL('../../docs/DATA.md', import.meta.url), 'utf8');
|
||||
assert.match(SIM, /body\.js/);
|
||||
assert.match(SIM, /受击判定区域为长4\.95、宽2\.95的长方形,向上偏移1\.0/);
|
||||
assert.match(SIM, /BLOCK_RADIUS`: ground 0\.7071/);
|
||||
assert.match(SIM, /Battle\.blockedTargets/);
|
||||
assert.match(SIM, /自缚 \+ 无法被阻挡[\s\S]{0,80}SELF_BOUND/);
|
||||
assert.match(DATA_MD, /\| `hitArea` \|/);
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
// test/sim/playtest5_elements.test.js — user playtest #5 report 3 (enemy side): "海嗣敌人元素损伤效率有点低 — check every
|
||||
// enemy's element damage and element gauge against the official game". Encodes the official numbers (enemy_database /
|
||||
// PRTS 元素 / 游戏数据基础 / the enemies' PRTS pages / gamedata_const termDescriptionDict) for the gauge pipeline
|
||||
// (server/sim/damage.js) and the enemies' element abilities (content/enemies.js):
|
||||
// · 海嗣 enemies take element damage at the full rate (损伤抵抗 0, 1000 / leader 2000) and deal ATK × ratio per hit;
|
||||
// · 元素损伤倍率 (elemTakenMul) scales the gauge, 元素脆弱 (elementalTakenMul) the 元素伤害 — never each other;
|
||||
// · 损伤抵抗 (epResistance) reduces the gauge, 元素抗性 (epDamageResistance) the 元素伤害 (both with the 5 % floor);
|
||||
// · bursts deal 无来源 damage (no damage-dealt multiplier / penetration of the unit that filled the gauge, no content hook
|
||||
// sees a source — 深巡's 海怪 ×1.5, 精准狙击镜 — while the credit, the kill and damage sharing keep working);
|
||||
// · 麻痹免疫 enemies get no 麻痹 from a 神经 burst;
|
||||
// · 淤困 spreads 1000, 灼藤's first attack covers 3×3, 节日爵士乐手 channels on one locked target;
|
||||
// · target-side 抵挡 (星熊 战术装甲, 拉普兰德 日晷) still negates a 无来源 burst — it has no source condition.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
|
||||
import { ELEMENT, ELEMENT_GAUGE_MAX, ELEMENT_GAUGE_MAX_LEADER } from '../../server/sim/constants.js';
|
||||
import * as enemiesMod from '../../server/sim/content/enemies.js';
|
||||
import * as bossesMod from '../../server/sim/content/bosses.js';
|
||||
|
||||
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
|
||||
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
|
||||
const WALL = (id, o = {}) => chessRec({ id, profession: 'TANK', stats: { atk: 0, maxHp: 1e7, def: 0, res: o.res ?? 0, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
|
||||
const NOATK = () => ({ trait: { noAttack: true } });
|
||||
const CHESS = { t_wall: WALL('t_wall'), t_wall2: WALL('t_wall2'), t_wall3: WALL('t_wall3'), t_wall4: WALL('t_wall4') };
|
||||
const KITS = { t_wall: NOATK, t_wall2: NOATK, t_wall3: NOATK, t_wall4: NOATK };
|
||||
/** Real enemies with their content (enemies.js / bosses.js) against inert synthetic walls. */
|
||||
const arena = (o = {}) => makeBattle({
|
||||
content: 'generic', extraContent: [enemiesMod, bossesMod], seed: 7, autoFinish: false, timeLimit: 600,
|
||||
defs: { chess: CHESS }, kits: KITS, captureNoisy: true, hooks: ['elementBurst', 'damaged'], ...o,
|
||||
});
|
||||
/** Spawn a pinned enemy (speed ×0) at a tile. */
|
||||
const put = (h, key, pos, mods = {}) => h.spawn(key, { pos, routeIndex: 0, mods: { speedMul: 0, ...mods } });
|
||||
const elemHits = (h, target, source = null) => h.hooksOf('damaged').filter((c) => c.type === 'element' && c.target === target && (!source || c.source === source));
|
||||
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0, ...o });
|
||||
const SEA = Object.values(E).filter((e) => e.tags.includes('seamonster'));
|
||||
|
||||
test('海嗣 enemies take element damage at the official rate: 损伤抵抗 0, gauge 1000 (leaders 2000), 元素抗性 0 on the burst', () => {
|
||||
assert.ok(SEA.length >= 10, `${SEA.length} 海嗣 enemies in data`);
|
||||
for (const rec of SEA) {
|
||||
assert.equal(rec.stats.elementRes, 0, `${rec.name}: enemy_database epResistance (损伤抵抗) 0`);
|
||||
assert.equal(rec.stats.elementDmgRes, 0, `${rec.name}: epDamageResistance (元素抗性) 0`);
|
||||
const h = arena();
|
||||
h.step();
|
||||
const e = put(h, rec.key, [11, 8]);
|
||||
assert.equal(e.gaugeMax, rec.rank === 'BOSS' ? ELEMENT_GAUGE_MAX_LEADER : ELEMENT_GAUGE_MAX, rec.name);
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 250 });
|
||||
approx(e.elem.burn, 250, 1e-9, `${rec.name}: no hidden reduction`);
|
||||
if (rec.rank === 'BOSS' || e.bossPool) continue;
|
||||
const hp0 = e.hp;
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: e.gaugeMax });
|
||||
assert.ok(e.findBuff('burnBurst'), `${rec.name} bursts`);
|
||||
approx(hp0 - e.hp, Math.min(hp0, ELEMENT.burn.enemy.elemDamage), 1e-9, `${rec.name}: 7000 元素伤害`);
|
||||
}
|
||||
});
|
||||
|
||||
test('海嗣 element attackers deal ATK × the official ratio per hit (PRTS talents), with the round\'s ATK multiplier', () => {
|
||||
const cases = [
|
||||
['enemy_1148_dssbr', 'neural', 'epdamage.attack@ep_damage_ratio', [9, 5]], // 底海滑动者 普通攻击额外造成攻击力15%的神经损伤
|
||||
['enemy_1148_dssbr_2', 'neural', 'epdamage.attack@ep_damage_ratio', [9, 5]], // 富营养的滑动者 15 %
|
||||
['enemy_2021_syfish', 'erosion', 'EpDamage.attack@ep_damage_ratio', [9, 5]], // 骨海漂流体 攻击附加相当于攻击力20%的侵蚀损伤
|
||||
['enemy_2025_syufo', 'erosion', 'EpDamage.attack@ep_damage_ratio', [9, 7]], // 掠海漂移体 普通攻击附加攻击力50%的侵蚀损伤 (ranged 2.6)
|
||||
['enemy_1234_dsubrl', 'neural', 'EpDamage.ep_damage_ratio', [9, 6]], // 深溟巢涌者 每次输出伤害时,再造成攻击力5%的神经损伤
|
||||
['enemy_1521_dslily', 'neural', 'epdamage.attack@ep_damage_ratio', null], // 盐风主教昆图斯 攻击力20%的神经损伤
|
||||
];
|
||||
for (const [key, el, k, pos] of cases) {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
|
||||
h.step();
|
||||
const e = pos ? put(h, key, pos, { atkMul: 1.331 }) : h.spawn(key, { pos: [9, 7], routeIndex: 0, mods: { speedMul: 0, atkMul: 1.331 } });
|
||||
approx(e.s.atk, E[key].stats.atk * 1.331, 1e-9, `${E[key].name} ATK × round multiplier`);
|
||||
const w = h.unit('t_wall');
|
||||
assert.ok(h.runUntil(() => elemHits(h, w, e).length > 0, 30), `${E[key].name} hits the wall`);
|
||||
const first = elemHits(h, w, e)[0];
|
||||
assert.equal(first.dmg.element, el);
|
||||
approx(first.amount, e.s.atk * E[key].talents.bb[k], 1e-9, `${E[key].name}: ATK × ${E[key].talents.bb[k]}`);
|
||||
}
|
||||
// 元核孽生者 (season override: ATK 400, 1.ep_damage_ratio 0.05): once hurt, every second, radius 2.5
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
|
||||
h.step();
|
||||
const e = put(h, 'enemy_1439_dslntf', [9, 7]);
|
||||
h.b.dealDamage(null, e, { amount: 1, type: 'true' });
|
||||
h.run(3.05);
|
||||
const w = h.unit('t_wall');
|
||||
const hits = elemHits(h, w, e);
|
||||
assert.equal(hits.length, 3, 'one pulse per second');
|
||||
approx(hits[0].amount, 400 * 0.05, 1e-9);
|
||||
});
|
||||
|
||||
test('the user\'s case in numbers: blocking a 底海滑动者 (险境 R5, ATK ×0.968) bursts 神经 on the 25th hit — the official rate', () => {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
|
||||
h.step();
|
||||
const e = put(h, 'enemy_1148_dssbr', [9, 5], { atkMul: 0.968 });
|
||||
const w = h.unit('t_wall');
|
||||
const per = 280 * 0.968 * 0.15;
|
||||
const need = Math.ceil(ELEMENT_GAUGE_MAX / per);
|
||||
assert.equal(need, 25);
|
||||
assert.ok(h.runUntil(() => h.hooksOf('elementBurst').some((c) => c.target === w), 120), 'the wall bursts');
|
||||
assert.equal(elemHits(h, w, e).length, need, `${need} hits of ${per.toFixed(1)}`);
|
||||
approx(h.b.time, need * E.enemy_1148_dssbr.stats.bat, 0.2, '≈ 25 attacks × 2 s (game s)');
|
||||
});
|
||||
|
||||
test('元素损伤倍率 (elemTakenMul) scales the gauge only; 元素脆弱 (elementalTakenMul) the 元素伤害 only', () => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false });
|
||||
h.step();
|
||||
const e = h.enemy('enemy_dummy');
|
||||
h.b.applyStatus(e, 'elemFragile', { duration: 60, value: 0.2 });
|
||||
approx(e.s.elementalTakenMul, 1.2);
|
||||
approx(e.s.elemTakenMul, 1, 1e-9, '元素脆弱 ("受到的元素伤害提升") leaves the gauge alone');
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 500 });
|
||||
approx(e.elem.burn, 500, 1e-9, 'gauge fill not × 元素脆弱');
|
||||
const hp0 = e.hp;
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 500 });
|
||||
approx(hp0 - e.hp, ELEMENT.burn.enemy.elemDamage * 1.2, 1e-9, '7000 元素伤害 × 元素脆弱');
|
||||
// "受到的元素损伤提高至130%" (淤困) / "…降低12%" (operators' talents): the gauge, not 元素伤害
|
||||
const h2 = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false });
|
||||
h2.step();
|
||||
const e2 = h2.enemy('enemy_dummy');
|
||||
h2.b.addBuff(e2, { key: 't:ep', mods: { elemTakenMul: 1.3 } });
|
||||
h2.b.dealDamage(null, e2, { type: 'element', element: 'burn', amount: 500 });
|
||||
approx(e2.elem.burn, 650);
|
||||
const hp1 = e2.hp;
|
||||
h2.b.dealDamage(null, e2, { amount: 100, type: 'elemental', element: 'burn' });
|
||||
approx(hp1 - e2.hp, 100, 1e-9, '元素伤害 not × 元素损伤倍率');
|
||||
});
|
||||
|
||||
test('损伤抵抗 (epResistance) reduces the gauge; 元素抗性 (epDamageResistance) reduces 元素伤害 — both with the 5 % floor (PRTS 游戏数据基础)', () => {
|
||||
const rec = dummy({ key: 'enemy_res' });
|
||||
rec.stats.elementRes = 10;
|
||||
rec.stats.elementDmgRes = 30;
|
||||
const hard = dummy({ key: 'enemy_hard' });
|
||||
hard.stats.elementDmgRes = 100;
|
||||
// "目标受到元素损伤时也可以使用该公式计算,只需要将 D 值改为目标的损伤抵抗即可": max(0.05A, A(1 − 损伤抵抗 %))
|
||||
hard.stats.elementRes = 98;
|
||||
const h = makeBattle({ defs: { enemies: { enemy_res: rec, enemy_hard: hard } }, enemies: [{ key: 'enemy_res', pos: [11, 8] }, { key: 'enemy_hard', pos: [10, 8] }], content: 'none', autoFinish: false });
|
||||
h.step();
|
||||
const e = h.enemy('enemy_res');
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 500 });
|
||||
approx(e.elem.burn, 450, 1e-9, '× (1 − 10 %)');
|
||||
const hp0 = e.hp;
|
||||
h.b.dealDamage(null, e, { amount: 1000, type: 'elemental' });
|
||||
approx(hp0 - e.hp, 700, 1e-9, '元素伤害 × (1 − 30 %)');
|
||||
const x = h.enemy('enemy_hard');
|
||||
const hp1 = x.hp;
|
||||
h.b.dealDamage(null, x, { amount: 1000, type: 'elemental' });
|
||||
approx(hp1 - x.hp, 50, 1e-9, 'at least 5 %');
|
||||
h.b.dealDamage(null, x, { type: 'element', element: 'burn', amount: 100 });
|
||||
approx(x.elem.burn, 5, 1e-9, 'the gauge takes at least 5 % too (损伤抵抗 98 → 5, not 2)');
|
||||
// content that predicts the pipeline (enemies.js expectedFinal — 吸收屏障 etc.) uses the same 元素伤害 terms
|
||||
h.b.addBuff(e, { key: 't:frag', mods: { elementalTakenMul: 1.2 } });
|
||||
const d = h.b.makeDamage({ amount: 1000, type: 'elemental' });
|
||||
const hp2 = e.hp;
|
||||
const f = enemiesMod.expectedFinal(null, e, d);
|
||||
approx(f, 1000 * 0.7 * 1.2, 1e-9, 'expectedFinal: 元素抗性 30 and 元素脆弱 20 %');
|
||||
h.b.dealDamage(null, e, d);
|
||||
approx(hp2 - e.hp, f, 1e-9, '= what the pipeline deals');
|
||||
});
|
||||
|
||||
test('element bursts deal 无来源 damage: the filler\'s damage-dealt multipliers and penetration never apply; it keeps the credit', () => {
|
||||
const op = chessRec({ id: 't_op', profession: 'CASTER', stats: { atk: 0, maxHp: 1e6, def: 0, res: 50, blockCnt: 0 }, skill: null });
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_op: op }, enemies: { enemy_dummy: dummy({ res: 50 }) } }, units: [{ chessId: 't_op', row: 9, col: 5 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false,
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('t_op'), e = h.enemy('enemy_dummy');
|
||||
h.b.addBuff(u, { key: 't:boost', mods: { dmgDealtMul: 2, artsDealtMul: 1.5, resIgnoreFlat: 50 } });
|
||||
h.b.addBuff(e, { key: 't:boost', mods: { dmgDealtMul: 2, artsDealtMul: 1.5, resIgnoreFlat: 50 } });
|
||||
const hp0 = e.hp;
|
||||
h.b.dealDamage(u, e, { type: 'element', element: 'burn', amount: 1000 });
|
||||
approx(hp0 - e.hp, ELEMENT.burn.enemy.elemDamage, 1e-9, '7000, not ×2');
|
||||
approx(u.stats.dmg, ELEMENT.burn.enemy.elemDamage, 1e-9, 'credited to the operator that filled the gauge');
|
||||
const hp1 = u.hp;
|
||||
h.b.dealDamage(e, u, { type: 'element', element: 'burn', amount: 1000 });
|
||||
approx(hp1 - u.hp, ELEMENT.burn.ally.damage * (1 - (50 - ELEMENT.burn.ally.resDown) / 100), 1e-9, '1200 arts through RES 50 − 20, no penetration, not ×3');
|
||||
// apoptosis ticks on enemies too
|
||||
h.run(10.1);
|
||||
const hp2 = e.hp;
|
||||
h.b.dealDamage(u, e, { type: 'element', element: 'apoptosis', amount: 1000 });
|
||||
h.run(3.02);
|
||||
approx(hp2 - e.hp, 3 * ELEMENT.apoptosis.enemy.elemDps, 1e-9);
|
||||
});
|
||||
|
||||
test('无来源 reaches the content hooks too: `hit` / `damaged` get no source, `credit` and the kill keep the filler (PRTS 伤害分类)', () => {
|
||||
const op = chessRec({ id: 't_op', profession: 'CASTER', stats: { atk: 0, maxHp: 1e6, def: 0, res: 0, blockCnt: 0 }, skill: null });
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_op: op }, enemies: { enemy_dummy: dummy(), enemy_small: dummy({ key: 'enemy_small', hp: 5000 }) } },
|
||||
units: [{ chessId: 't_op', row: 9, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_small', pos: [10, 8] }],
|
||||
content: 'none', autoFinish: false, hooks: ['hit', 'damaged', 'kill'], captureNoisy: true,
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('t_op'), e = h.enemy('enemy_dummy'), small = h.enemy('enemy_small');
|
||||
// a content hook keyed on the attacker ("攻击…时攻击力提升至150%" style) — it must not see the burst
|
||||
h.b.on('hit', (c) => { if (c.source === u && c.target.side === 'enemy') c.dmg.mul *= 1.5; });
|
||||
h.b.dealDamage(u, e, { amount: 1000, type: 'true' });
|
||||
approx(1e6 - e.hp, 1500, 1e-9, 'the hook works on the operator\'s own damage');
|
||||
const hp0 = e.hp;
|
||||
h.b.dealDamage(u, e, { type: 'element', element: 'burn', amount: 1000 });
|
||||
approx(hp0 - e.hp, ELEMENT.burn.enemy.elemDamage, 1e-9, '7000, not ×1.5');
|
||||
const burstHit = h.hooksOf('hit').find((c) => c.dmg.sourceless && c.target === e);
|
||||
assert.equal(burstHit.source, null, 'hit: no source');
|
||||
assert.equal(burstHit.credit, u, 'hit: credit');
|
||||
const burstDmg = h.hooksOf('damaged').find((c) => c.dmg && c.dmg.sourceless && c.target === e);
|
||||
assert.equal(burstDmg.source, null, 'damaged: no source');
|
||||
assert.equal(burstDmg.credit, u, 'damaged: credit');
|
||||
const gauge = h.hooksOf('damaged').find((c) => c.type === 'element' && c.target === e);
|
||||
assert.equal(gauge.source, u, 'the 元素损伤 itself keeps its source');
|
||||
// "无来源伤害的击杀也能追溯击杀来源"
|
||||
h.b.dealDamage(u, small, { type: 'element', element: 'burn', amount: 1000 });
|
||||
assert.equal(small.alive, false);
|
||||
assert.equal(h.hooksOf('kill').find((c) => c.victim === small)?.killer, u, 'kill credited to the filler');
|
||||
});
|
||||
|
||||
test('the user\'s pairing: a real 深巡 (garrison "攻击海怪敌人时攻击力提升至150%") bursting a real 海嗣 deals 7000, not 10500', () => {
|
||||
const h = makeBattle({ units: [{ chessId: 'chess_char_1_04_a', row: 10, col: 3 }], content: 'full', autoFinish: false, timeLimit: 600, seed: 3 });
|
||||
h.step();
|
||||
const u = h.allies()[0];
|
||||
assert.deepEqual(u.def.raw.garrisonIds, ['garrison_18_a']);
|
||||
const e = put(h, 'enemy_1439_dslntf', [11, 8]);
|
||||
assert.ok(e.def.tags.includes('seamonster'), '元核孽生者 is a 海怪');
|
||||
const hp0 = e.hp;
|
||||
h.b.dealDamage(u, e, { amount: 100, type: 'true' });
|
||||
approx(hp0 - e.hp, 100 * 1.5 * u.s.dmgDealtMul, 1e-9, 'its own damage vs a 海怪: ×1.5');
|
||||
const hp1 = e.hp;
|
||||
h.b.dealDamage(u, e, { type: 'element', element: 'burn', amount: e.gaugeMax });
|
||||
assert.ok(e.findBuff('burnBurst'));
|
||||
approx(hp1 - e.hp, ELEMENT.burn.enemy.elemDamage, 1e-9, 'the burst is 无来源: 7000');
|
||||
});
|
||||
|
||||
test('精准狙击镜 (×damage_scale at ≥ 3 tiles) boosts its carrier\'s hits, never the burst it causes', () => {
|
||||
const op = chessRec({ id: 't_op', profession: 'SNIPER', stats: { atk: 0, maxHp: 1e6, def: 0, res: 0, blockCnt: 0 }, skill: null });
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_op: op }, enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 't_op', row: 10, col: 3, items: ['chess_item_3_02_e_a'] }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 8] }], content: 'full', autoFinish: false, timeLimit: 600,
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('t_op'), e = h.enemy('enemy_dummy');
|
||||
h.b.dealDamage(u, e, { amount: 1000, type: 'true' });
|
||||
const boosted = (1e6 - e.hp) / u.s.dmgDealtMul;
|
||||
assert.ok(boosted > 1000 + 1e-6, `the scope works at range (${boosted})`);
|
||||
const hp0 = e.hp;
|
||||
h.b.dealDamage(u, e, { type: 'element', element: 'erosion', amount: 1000 });
|
||||
approx(hp0 - e.hp, ELEMENT.erosion.enemy.elemDamage, 1e-9, '5000 侵蚀 burst, not × the scope');
|
||||
});
|
||||
|
||||
test('a 无来源 burst still passes on through content that shares damage: 圣杯 (credited, no source multiplier) and 盲信之誓', () => {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 12, col: 3 }] });
|
||||
h.step();
|
||||
const w = h.unit('t_wall');
|
||||
h.b.addBuff(w, { key: 't:boost', mods: { dmgDealtMul: 2 } });
|
||||
const c = put(h, 'enemy_1430_lrrook', [10, 7]);
|
||||
const o = put(h, 'enemy_1439_dslntf', [10, 6]);
|
||||
const share = E.enemy_1430_lrrook.talents.bb['takeDmg.damage_scale'];
|
||||
const c0 = c.hp, o0 = o.hp;
|
||||
h.b.dealDamage(w, o, { type: 'element', element: 'burn', amount: o.gaugeMax });
|
||||
const burst = ELEMENT.burn.enemy.elemDamage;
|
||||
approx(c0 - c.hp, Math.min(c0, burst * share), 1e-9, 'the chalice takes its share of 7000 (not ×2)');
|
||||
approx(o0 - o.hp, Math.min(o0, burst * (1 - share)), 1e-9);
|
||||
approx(w.stats.dmg, (c0 - c.hp) + (o0 - o.hp), 1e-9, 'both parts credited to the filler');
|
||||
// 盲信之誓: a spring passes PART_TRANSFER of what it takes to 假想敌:铳 — a burst too
|
||||
const hb = arena({ kind: 'boss', sharedBoss: { hp: 1e6, maxHp: 1e6, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } }, units: [{ chessId: 't_wall', row: 12, col: 3 }] });
|
||||
hb.step();
|
||||
const g = hb.spawn('enemy_9017_achunt_2', { pos: [3, 18], routeIndex: 0, mods: { speedMul: 0 }, tag: 'boss' });
|
||||
g.profile.noAttack = true;
|
||||
assert.ok(g.bossPool, '铳 on the shared pool');
|
||||
const sp = hb.spawn('enemy_9020_actrpc', { pos: [3, 4], routeIndex: 0, mods: { speedMul: 0 }, tag: 'part' });
|
||||
const pool0 = hb.b.sharedBoss.hp, sp0 = sp.hp;
|
||||
hb.b.dealDamage(hb.unit('t_wall'), sp, { type: 'element', element: 'burn', amount: sp.gaugeMax });
|
||||
assert.ok(sp0 - sp.hp > 0, 'the spring takes the burst');
|
||||
approx(pool0 - hb.b.sharedBoss.hp, (sp0 - sp.hp) * bossesMod.PART_TRANSFER, 1e-9, '…and passes its share to 铳');
|
||||
});
|
||||
|
||||
test('凋亡 burst on an enemy: 800 元素伤害 per second, the 50 % 虚弱 drops to 50 % × 剩余时间 ÷ 15 s at each tick (PRTS 元素)', () => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy({ atk: 1000 }) } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false });
|
||||
h.step();
|
||||
const e = h.enemy('enemy_dummy');
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 1000 });
|
||||
approx(e.s.atk, 500, 1e-9, '50 % at once');
|
||||
for (const t of [1, 5, 14]) {
|
||||
h.run(t - (h.b.time - 1 / 30) + 0.02);
|
||||
const left = ELEMENT.apoptosis.enemy.duration - t;
|
||||
assert.ok(Math.abs(e.s.atk - 1000 * (1 - 0.5 * left / ELEMENT.apoptosis.enemy.duration)) < 3, `${t} s: ATK ${e.s.atk.toFixed(1)}`);
|
||||
}
|
||||
approx(1e6 - e.hp, 14 * ELEMENT.apoptosis.enemy.elemDps, 1e-9);
|
||||
});
|
||||
|
||||
test('麻痹免疫 enemies get no 麻痹 from a 神经 burst (PRTS 元素 "若单位不具有麻痹免疫") — still the 6000 元素伤害', () => {
|
||||
const imm = dummy({ key: 'enemy_imm', otherImmunities: ['palsy'] });
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy(), enemy_imm: imm } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_imm', pos: [10, 8] }], content: 'none', autoFinish: false });
|
||||
h.step();
|
||||
const a = h.enemy('enemy_dummy'), b = h.enemy('enemy_imm');
|
||||
for (const e of [a, b]) h.b.dealDamage(null, e, { type: 'element', element: 'neural', amount: 1000 });
|
||||
assert.equal(a.findBuff('palsy')?.stacks, ELEMENT.neural.enemy.palsy);
|
||||
assert.equal(b.findBuff('palsy'), null, 'immune');
|
||||
approx(1e6 - b.hp, ELEMENT.neural.enemy.elemDamage);
|
||||
// a real one: 假想敌:铳 (palsyImmune)
|
||||
assert.ok(E.enemy_9017_achunt.stats.otherImmunities.includes('palsy'));
|
||||
});
|
||||
|
||||
test('假想敌:淤困: its host\'s burst deals 1000 of the same element to the allies of the 4 tiles around (PRTS)', () => {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 11, col: 5 }, { chessId: 't_wall3', row: 10, col: 6 }, { chessId: 't_wall4', row: 11, col: 6 }] });
|
||||
h.step();
|
||||
put(h, 'enemy_9007_acelem', [10, 5]);
|
||||
h.run(0.5);
|
||||
const host = h.unit('t_wall');
|
||||
assert.ok(host.findBuff('ab:parasite'), 'parasitised');
|
||||
h.b.dealDamage(null, host, { type: 'element', element: 'neural', amount: 1000 });
|
||||
const spread = (u) => elemHits(h, u).filter((c) => c.dmg.element === 'neural');
|
||||
for (const id of ['t_wall2', 't_wall3']) {
|
||||
const s = spread(h.unit(id));
|
||||
assert.equal(s.length, 1, `${id} (next to the host) takes one spread`);
|
||||
approx(s[0].amount, 1000, 1e-9, `${id}: 1000`);
|
||||
assert.ok(h.unit(id).findBuff('neuralBurst'), `${id} bursts`);
|
||||
}
|
||||
assert.equal(spread(h.unit('t_wall4')).length, 0, 'the diagonal tile is not one of the 4');
|
||||
});
|
||||
|
||||
test('灼藤: its first attack burns and hits every other ally of the target\'s 3×3 (x-4), diagonals included', () => {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 11, col: 6 }, { chessId: 't_wall3', row: 9, col: 5 }, { chessId: 't_wall4', row: 12, col: 5 }] });
|
||||
h.step();
|
||||
const v = put(h, 'enemy_10067_ftsjc', [10, 5]);
|
||||
assert.ok(h.runUntil(() => v.stats.attacks >= 1, 10), 'first attack');
|
||||
const atk = elemHits(h, h.unit('t_wall'), v)[0].amount / E.enemy_10067_ftsjc.talents.bb['pow.attack@ep_damage_ratio'];
|
||||
for (const id of ['t_wall2', 't_wall3']) {
|
||||
const u = h.unit(id);
|
||||
approx(elemHits(h, u, v)[0]?.amount ?? 0, atk * 0.25, 1e-6, `${id}: 25 % burn`);
|
||||
assert.ok(u.stats.taken > 0, `${id}: 100 % arts splash`);
|
||||
}
|
||||
assert.equal(elemHits(h, h.unit('t_wall4'), v).length, 0, 'two rows away: outside');
|
||||
});
|
||||
|
||||
test('节日爵士乐手 狂欢式演奏: channels on ONE locked target (20 % arts + 10 % burn every 0.5 s), not on everyone around', () => {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 10, col: 6 }, { chessId: 't_wall3', row: 11, col: 5 }] });
|
||||
h.step();
|
||||
const e = put(h, 'enemy_10034_cnvsax', [10, 5]);
|
||||
h.run(6);
|
||||
const w = h.unit('t_wall');
|
||||
const hits = elemHits(h, w, e);
|
||||
assert.ok(hits.length >= 4, `the locked target burns (${hits.length})`);
|
||||
approx(hits[0].amount, e.s.atk * 0.1, 1e-9);
|
||||
for (const id of ['t_wall2', 't_wall3']) assert.equal(elemHits(h, h.unit(id), e).length, 0, `${id} is not the target`);
|
||||
});
|
||||
|
||||
test('节日爵士乐手: channels never overlap (cooldown 10 s < channel 10.6 s) — 21 ticks each, no normal attack while channelling', () => {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }] });
|
||||
h.step();
|
||||
const e = put(h, 'enemy_10034_cnvsax', [10, 5]);
|
||||
const w = h.unit('t_wall');
|
||||
const sk = E.enemy_10034_cnvsax.skills.find((x) => x.prefabKey === 'fire');
|
||||
const iv = sk.bb.hit_interval, n = Math.floor(sk.bb['enemy_cnvsax[cd].duration'] / iv + 1e-9);
|
||||
assert.equal(n, 21, 'floor(10.6 / 0.5)');
|
||||
const arts = [], during = [];
|
||||
h.b.on('damaged', (c) => {
|
||||
if (c.target !== w || c.source !== e) return;
|
||||
if (c.type === 'arts') arts.push(h.b.time);
|
||||
else if (c.type === 'phys' && e.findBuff('ab:channel')) during.push(h.b.time);
|
||||
});
|
||||
const skill = e.mem.ab.list.find((a) => a && a.fire && a.cd != null);
|
||||
const casts = [];
|
||||
for (let i = 0; i < 30 * 30; i++) {
|
||||
h.step();
|
||||
if ((skill.casts ?? 0) > casts.length) casts.push(h.b.time);
|
||||
}
|
||||
assert.ok(casts.length >= 2, `cast twice in 30 s (${casts.map((t) => t.toFixed(2))})`);
|
||||
for (let i = 1; i < arts.length; i++) assert.ok(arts[i] - arts[i - 1] >= iv - 0.02, `no doubled tick at ${arts[i].toFixed(2)}`);
|
||||
for (let i = 0; i + 1 < casts.length; i++) {
|
||||
const k = arts.filter((t) => t > casts[i] && t <= casts[i + 1]).length;
|
||||
assert.equal(k, n, `cast ${i + 1}: ${n} ticks`);
|
||||
assert.ok(casts[i + 1] - casts[i] >= n * iv - 1e-6, 'the next cast waits for the channel');
|
||||
}
|
||||
assert.deepEqual(during, [], 'no normal attack while a channel runs');
|
||||
});
|
||||
|
||||
test('抵挡 is target-side: 星熊 (战术装甲) and 拉普兰德 S1 日晷 still negate a 无来源 burst on themselves (source null, credit = the enemy)', async () => {
|
||||
const { getDefaultSource } = await import('../../server/sim/simdata.js');
|
||||
const ds = getDefaultSource();
|
||||
const altIdx = (id) => ds.rawChess(id).skills.find((s) => !s.isDefault).index;
|
||||
const cases = [
|
||||
['chess_char_4_17_a', {}, ['erosion', 'burn']], // 星熊: phys (侵蚀 800) and arts (灼燃 1200)
|
||||
['chess_char_2_16_a', { skillIndex: altIdx('chess_char_2_16_a'), carryState: { sp: 999 } }, ['erosion']], // 日晷: phys only
|
||||
];
|
||||
for (const [id, extra, elements] of cases) {
|
||||
for (const roll of [true, false]) {
|
||||
for (const element of elements) {
|
||||
const h = makeBattle({
|
||||
units: [{ chessId: id, row: 9, col: 5, ...extra }], defs: { enemies: { enemy_dummy: dummy() } },
|
||||
enemies: [{ key: 'enemy_dummy', pos: [9, 5] }], content: 'full', autoFinish: false, timeLimit: 600, seed: 3, // blocked: in range
|
||||
setup: (b) => { b.rng.chance = () => roll; },
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit(id), e = h.enemy('enemy_dummy');
|
||||
if (extra.skillIndex != null) assert.ok(h.runUntil(() => u.skill.active, 3), `${id}: 日晷 on`);
|
||||
const hp0 = u.hp;
|
||||
h.b.dealDamage(e, u, { type: 'element', element, amount: u.gaugeMax });
|
||||
assert.ok(u.findBuff(`${element}Burst`), `${id} ${element}: bursts`);
|
||||
if (roll) assert.equal(u.hp, hp0, `${id} ${element}: the burst is 抵挡-ed`);
|
||||
else assert.ok(u.hp < hp0, `${id} ${element}: a failed roll lands`);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('卢西恩 / 不祥幻影 in their only stage (boss_5 = act2autochess_h07_05 / _s): the level\'s overrides — ATK 700 (solo 600) / 200, 12 % 神经 per hit', () => {
|
||||
const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8'));
|
||||
// level_act2autochess_h07_05(_s).json enemyDbRefs[5] / [7] overwrittenData: atk 700 (600) / 200,
|
||||
// combat.attack@ep_damage_ratio 0.12
|
||||
for (const [tpl, key, atk] of [['act2autochess_h07_05', 'enemy_2016_csphtm', 700], ['act2autochess_h07_05_s', 'enemy_2016_csphtm', 600], ['act2autochess_h07_05', 'enemy_2017_csphts', 200]]) {
|
||||
const h = arena({
|
||||
kind: 'boss', sharedBoss: { hp: 1e7, maxHp: 1e7, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } },
|
||||
units: [{ chessId: 't_wall', row: 10, col: 5 }], setup(b) { b.enemyOverrides = W[tpl].overrides; },
|
||||
});
|
||||
h.step();
|
||||
const w = h.unit('t_wall');
|
||||
// blocked on the wall's tile: its first normal attack lands before the 5 s initial cooldown of its aoe skill (0.2)
|
||||
const e = h.spawn(key, { pos: [w.y, w.x].map(Math.round), routeIndex: 0, mods: { speedMul: 0 }, tag: key === 'enemy_2016_csphtm' ? 'boss' : undefined });
|
||||
assert.equal(e.s.atk, atk, `${tpl} ${E[key].name}: ATK`);
|
||||
assert.ok(h.runUntil(() => elemHits(h, w, e).length > 0, 4.5), `${E[key].name} hits`);
|
||||
const first = elemHits(h, w, e)[0];
|
||||
assert.equal(first.dmg.element, 'neural');
|
||||
approx(first.amount, atk * 0.12, 1e-9, `${tpl} ${E[key].name}: ATK × 0.12 (base data: ${E[key].stats.atk} × ${E[key].talents.bb['combat.attack@ep_damage_ratio']})`);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user