// 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_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 “红松林”, 锏 S3 归于宁静)', () => { // 锏 S3: PRTS 备注 "※可对空。不会拖拽自身中心半径0.6708范围内的敌人" (user playtest #6, WF audit — it used to sit in the // ground-only list above) for (const [id, idx, tag, name] of [['chess_char_1_08_a', 1, 'skill', '德克萨斯 S2'], ['chess_char_4_19_a', 1, 'redPine', '焰尾 S2'], ['chess_char_6_19_a', 2, 'slash', '锏 S3']]) { 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 rightmost column, its bottom (从上到下 > 从左到右: down each column, the columns left to right)', () => { const units = [[12, 5], [11, 3], [9, 4], [9, 7], [10, 8], [12, 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, [[11, 3], [9, 4], [12, 5], [9, 7], [12, 8], [10, 8]]); h.run(1.2); const e = h.enemy('enemy_t_shooter'); const t = lastTarget(h, e); assert.deepEqual([t.tileR, t.tileC], [10, 8], '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], 'the operators (by column from the left), 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'); });