Files
ark-stronghold-protocol/test/sim/playtest5-targeting.test.js
T
sganggs 15291c2293 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
2026-09-30 19:12:36 +08:00

394 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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');
});