Files
ark-stronghold-protocol/server/sim/content/bosses.js
T
Cloudnyco ae22335cfe feedback7: the battle sim uses engine-independent math — Chrome, Firefox, Safari and the server agree bit for bit (PR #373 by @Cloudnyco)
- server/sim/detmath.js: hypot (sqrt of the squares, rescaled outside 2^±500), powi (integer powers in double-double,
  one rounding), sin / cos (fdlibm 5.3) and atan2 (fdlibm / FreeBSD msun) built only from correctly rounded IEEE
  operations; the sim's Math.hypot / sin / cos / atan2 / pow calls go through it (65 files); eslint.config.js forbids
  the approximated Math functions under server/sim and test/sim/detmath.test.js scans for them.
- sin / cos / atan2 are the algorithms V8 runs in Node, so the server's results stay; only hypot differs from V8's.
  Golden: 2 moved — diy-027 (a hypot distance, damage +80) and coop3-ABYSS-5 (a later card pick from round 9, same end).
- Applied unchanged after review (Grok static review: MERGE; the diff read in full: no network, no new dependency).
- Tests: broad node --test 5155 / 5155 with the new file tracked (detmath 7 / 7, package 10 / 10); lint 0 errors,
  typecheck, check:imports.
2026-10-08 18:35:55 +08:00

1162 lines
70 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.
// server/sim/content/bosses.js — scripted leaders (boss_1…boss_10) and their parts (DESIGN §7, research 05 §5, 06 §10.1).
//
// install(battle) ensures the enemy dispatch hooks (enemies.js) and attaches a boss kit to every spawned leader / part
// listed in BOSS_KITS. Damage dealt TO a leader routes to the shared boss pool through the engine (tag 'boss' +
// sharedBoss); parts that "传递伤害" push the damage they take into the pool with a 无来源 battle.loseHp(leader, …)
// credited to the attacker (PART_TRANSFER = 1, "等量"). Numbers come from the (template-overridden) talent / skill blackboards; the rest are named
// constants ([ASSUMED] or PRTS-sourced). Boss HP rules ("<20 %", "≤50 %") read the leader's HP ratio, which the engine
// syncs from the shared pool, so both mirrored copies switch phase together.
//
// Wave template: branches / extraRoutes / devices / patrol steps are read from the template the battle was built
// from — `opts.waveTemplate|template|templateId|waveId` when the match passes it, else inferred from data/waves.json
// (the template whose leader spawn matches and whose route list equals battle.routes). Without a template the
// fallbacks spawn summons at the leader and route them to the nearest goal. Branch spawns keep their literal key
// unless the match passes the round's placeholder replacements as `opts.slotKeys` ({ NF: key, N: key, … }).
// · patrol: a leader / part walking `patrol` steps loops them (never reaches the goal: 卢西恩 lpr 30, 铳, 碎铳之簧).
// · 'boss_battle_multi_player' (铳, 卢西恩): with ≥2 players on the field the mirrored copy is spawned from the branch
// (skipped if the match already spawned a second copy, or opts.bossMirror === false).
//
// Leaders (see each kit for details):
// boss_1/8 假想敌:胄 random arts beam (range 8); 灭顶之灾 one 刺胄之弹 at the highest-ATK operator in range (8-hit
// flying shell, 3×3 stun + phys DoT on arrival); <20 %: damage ×0.5 (no extra shell: PRTS
// 能力修正); 死亡集群 drones — a drone that dies costs the leader 2 % of its max HP = the shared
// pool's (DRONE_LINK_BASE, [ASSUMED] reading), past 限伤 (a share, no hit). boss_8 adds 斩胄之剑 /
// 破胄之锤.
// 斩胄之剑 / 破胄之锤 初始模式: hover invulnerable next to 胄, AoE attacks (锤 150 % ATK). 出击模式: blink to the
// level's blink route, fly at the lowest-ATK operator (3×3 stun + DoT); 15 hits shoot it
// down (瘫痪: stun 10 s, ground unit, damage ×1.3). Either way a new 初始模式 copy with its HP
// replaces it at home. Every damage it takes costs 胄 as much (PART_TRANSFER, 无来源, 等量) —
// grounded per both texts, during the dive per the PRTS talent [ASSUMED]. The shell, 剑 and
// 锤 are 失衡免疫 + 静态刚体 (PRTS 天赋): no push or pull moves them.
// boss_2/9 假想敌:铳 unblockable; highest-DEF target in range; erosion; ASPD ramp (+80 × 5) on the same target
// (floor 20); 最终之罚 charge at the highest-DEF ground unit (disabled by the h07_02 override).
// boss_9: damage ×0.2 while springs live, 盲信之誓 links (100 phys/s on the lines), 末日布道 dash.
// 碎铳之簧 a/b/c arts barrier (absorbs arts after RES; phys ×0.1, counter phys + erosion) / element shield
// (phys+arts ×0.1, broken by its own element burst; every (spCost+1)-th attack a bouncing erosion
// shot) / 5-hit shield (every (spCost+1)-th attack a ten-hit combo); unblockable while shielded;
// shield back 25 s after breaking; every damage taken costs 铳 as much (PART_TRANSFER, 无来源)
// and, split, the other springs.
// boss_3/10 假想敌:管 summons 余音 (dark) every 40 s (20 s < 50 %); normal attacks strike dark 余音 (their pulse hurts
// operators); 裂管之奏 3 strikes on every dark 余音 (AoE arts + apoptosis). boss_10 + 假想敌:弦
// (invulnerable, gold 余音, 断弦之奏 AoE + flips them dark; leaves with 管).
// 余音 10-hit unit, block ≥ 2 only; gold (ASPD +50) / dark (ATK +50 %, slower) forms that swap after
// 10 strikes; pulses on every hit taken; 合奏 every 5 s.
// boss_4 盐风主教昆图斯 2 highest-DEF targets (+ neural); 3 growth stages (33 % HP lost or 75 s / 200 s): 大潮 global
// arts + neural, 崩坍 delayed strikes on 2/4/8 highest-DEF units, 断裂生殖 tentacles (stun),
// 物种爆发 LP loss (stage 3).
// boss_5 卢西恩 evade 40 % while unblocked, DEF pen 40 %, neural on hit; AoE skill (radius 2); blinks past its
// blocker leaving a 不祥幻影 (same kit minus blink). Template overrides (0.2 / 0.12) apply.
// boss_6 阿利斯泰尔 王权号令 (SP 1/s, plus-shaped 200 % strike + random equipment drop); 莫非王土 animates the
// latest equipment on the field (2 s after it appears, then once per 王权号令 cycle) (sword strike / directional vest + barrier / wand disarm); ≤50 %: DEF +100 %,
// RES +45, animates 2; 斥退 at 999 s.
// boss_7 “萨米的意志” 冰凌 (its normal attack) hits a whole column; 自然涌动 stun + arts DoT; <50 %: damage taken
// ×(1−0.6), 2 targets; Doom at 600 s (LP −30). Targets via fairOrder (two players alternate).
// The huge leaders (SELF_BOUND: 胄 ×2, 管 ×2, 昆图斯, 阿利斯泰尔, 萨米的意志 — the 巨型单位 with a data `hitArea`) are
// 自缚 + 无法被阻挡 (PRTS 天赋): a persistent noMove + unblockable buff from spawn, so they never walk their route.
// Every leader (tag boss) ignores 侵蚀 gauge damage ("最终攻势中,敌方领袖不会受到侵蚀损伤").
// An airborne (起飞) operator is no selection of a ground leader or part (对地规避: canTargetAlly, the damage pipeline,
// the area selectors); still reach it (`ignoreSelect`): the 刺胄之弹 / 剑 / 锤 blasts (flying units, 无来源 DoT; 掷剑 /
// 掷锤 pick their operator "(无视无法选择)"), the 盲信之誓 chains ("无视无法选择"), the 法术护盾 counter on its attacker
// (a direct pick) and the ticks of a debuff already on it (【自然涌动】: a tick selects nobody).
// Every area effect of a leader or part — pulses, strikes around an echo, blasts, charges and tramples, crosses, columns,
// whole-field skills — selects with enemies/helpers.js areaAllies / areaAlliesInTiles / fieldAllies (targeting.js
// areaSelectable): no 隐匿 operator, the one blocking the unit included (GitHub #97), no untargetable or sleeping one, no 起飞 one for a
// ground unit; 迷彩 is not checked (splash-type, 中点判定 / 格子判定 or "无视迷彩" on PRTS; the rest [ASSUMED], DESIGN
// §22.12). Only 【盲信之誓】 ("无视无法选择、迷彩") takes everyone on its lines.
// LP effects ('lpLoss' hook + result.lpLoss) must be applied by the match (see the report of this module's owner).
// fx kinds: 'beam' 'shell' 'explode' 'telegraph' 'charge' 'link' 'dash' 'column' 'tide' 'rockfall' 'tentacle' 'equip'
// 'sword' 'vest' 'blink' 'summon' 'grow' 'phase' 'lpLoss' (x, y + extra {id, r, tiles, kind, tx, ty …}).
import { MOVE_SCALE } from '../constants.js';
import { aggroCmp, areaSelectable } from '../targeting.js';
import { compileRoute } from '../ai.js';
import { normalizeRoute } from '../simdata.js';
import {
ensureInstalled, abOf, attach, T, elem, hurt, targetsNear, allTargets, byPriority, areaAllies, areaAlliesInTiles, fieldAllies,
remainingRoute, stayRoute, stepToward, setHits, hitCount, lpLoss, blinkForward, canCast, absorbArts, nthOf,
} from './enemies.js';
import { hypot, powi } from '../detmath.js';
// ---------------------------------------------------------------------------------------------------------------
// constants
/**
* Share of the damage a 剑/锤 or a 碎铳之簧 takes that its leader loses: 1, 等量 — PRTS talents (“斩胄之剑” / “破胄之锤”
* "受到伤害时令假想敌:胄受到等量的无来源生命流失"; 碎铳之簧 (all three) "…令全场范围内仇恨值最高的1名假想敌:铳受到等量的无来源
* 生命流失"); the enemy data carries no ratio blackboard (the handbook's "以一定比例" is that 1). The loss is 无来源 (hooks see
* no source; the attacker keeps the credit — stats, the per-player pool tally) and follows EVERY damage taken: a
* 剑/锤 only takes damage outside its invulnerable 初始模式 (its 出击模式 dive and 【瘫痪】). The two official texts
* disagree on the dive: the PRTS talent says "受到伤害时" (any damage), the in-game handbook (data/enemies.json abilities,
* PRTS 能力) "被击落时受到伤害以一定比例传递" (only once shot down) — the dive hits passing on is [ASSUMED] (the talent
* text; ≤ 15 hits per dive). A loss passed on to a leader is one hit for 限伤 (sim/damage.js leaderHitCancelled, via
* Battle.loseHp). v2.5: 0.5 [ASSUMED], and a 剑/锤 passed damage on only while grounded (DESIGN §20.10, §20.13).
*/
export const PART_TRANSFER = 1;
/** The 斩胄之剑 / 破胄之锤 share — the same PRTS 等量 as PART_TRANSFER (kept as a name for the blade kit and its tests). */
export const BLADE_TRANSFER = PART_TRANSFER;
/** 破胄之锤 normal attack: 150 % ATK (PRTS 天赋 "普通攻击对攻击范围内的所有我方单位造成攻击力150%的物理普通伤害"; 斩胄之剑 100 %). */
const BLADE_ATK_SCALE = Object.freeze({ enemy_9015_acstmb: 1.5 });
/**
* Level branches of each part (act1autochess_h08_01 / _s): 斩胄之剑 spawns on the start of `left_hand_origin` (3,12),
* 破胄之锤 on `right_hand_origin` (3,8). `<hand>_blink` = the preset route 掷剑 blinks to (its start; PRTS "将自身路径改为
* 关卡预设路径并闪现至路径起点"), `<hand>_origin` = the route the new 初始模式 copy is summoned on ("以关卡预设路径召唤").
*/
const BLADE_HAND = Object.freeze({ enemy_9014_acstma: 'left_hand', enemy_9015_acstmb: 'right_hand' });
/**
* 死亡集群: a drone that dies (PRTS 能力修正 "该无人机单位死亡时", whoever kills it) costs 胄 `hp_ratio` (2 %) of "最大生命值"
* (PRTS 假想敌:胄 "该妖怪死亡时令假想敌:胄受到最大生命值2%的真实伤害"). WHICH max HP is not documented [ASSUMED reading]:
* 'pool' (default, = v2.5): the leader's max HP as the battle shows it — the shared pool (the engine syncs the unit's
* HP to it). It is the same HP the "自身生命值低于20%" talent reads, and that one must read the pool (the
* unit's data 600 000 is above the co-op 标准 pool 247 500 and above every solo pool, where a fixed 600 000
* would never drop below 20 %). A drone = 2 % of the leader bar in every mode and difficulty (终极 72 000).
* 'unit': the in-battle unit's data max HP (600 000 / hidden 1 200 000, 不死) — 12 000 / 24 000 per drone at every
* difficulty: 0.33 % of the 终极 bar but 19 % of the solo 标准 bar (61 875), so drones decide solo fights.
* Round 2 of the boss-HP review tried 'unit'; the review measured the solo regression, so the default is back to 'pool'.
* 限伤 (shared/constants.js BOSS_HIT_LIMIT): the share is no hit and passes the limit (Battle.loseHp `noHitLimit`).
* Since the pool counts every player alive at the fight's start (DESIGN §25.13.4, the owner's decision of 2026-10-06),
* the hidden 胄 终极 pool reaches 21 600 000 / 28 800 000 with 3 / 4 players — a drone 432 000 / 576 000, which the
* limit (ceil ≥ 300000) would cancel, so drones would stop counting exactly in the largest fights. [ASSUMED]: the
* official client checks every damage modifier on a leader (research 11 §2.1) and PRTS calls the drone's 2 % 真实伤害,
* but which max HP it reads is not documented; the remake reads the pool and keeps the 2 % share.
*/
export const DRONE_LINK_BASE = 'pool';
/** 卢西恩 / 不祥幻影 AoE radius (PRTS "半径2"). */
const LUCIEN_AOE_RADIUS = 2;
/** 余音 on-hit pulse radius (PRTS 1.6) and 合奏 radii per form (PRTS gold 0.8 / dark 1.6). */
const ECHO_PULSE_RADIUS = 1.6;
const ECHO_ENSEMBLE_RADIUS = Object.freeze({ gold: 0.8, dark: 1.6 });
/** "只能被阻挡数大于等于2的单位阻挡". */
const ECHO_BLOCK_WEIGHT = 2;
/** 裂管之奏 strike spacing (s). */
const PIPE_STRIKE_GAP = 0.4;
/** 断裂生殖 tentacle: stun duration and reach around its device tile [ASSUMED]. */
const TENTACLE_STUN = 5, TENTACLE_RADIUS = 1;
/** 崩坍 delay (PRTS 1 s), targets per growth stage (PRTS 2/4/8) and 物种爆发 delay (PRTS 4 s). */
const ROCKFALL_DELAY = 1, ROCKFALL_TARGETS = [2, 4, 8], DOOM_DELAY = 4;
/** 铳: minimum attack speed (PRTS "最低20") and charge hit radius (PRTS 0.35 — also “碎铳之簧”'s 追逐模式 trample). */
const ASPD_FLOOR = 20, CHARGE_RADIUS = 0.35;
/** 盲信之誓 link half-width (PRTS 0.5). */
const LINK_WIDTH = 0.5;
/** 碎铳之簧 bullet bounce falloff / reach [ASSUMED], combo = 十连击; tag of the damage share passed between springs. */
const SPRING_BOUNCE_FALLOFF = 0.85, SPRING_BOUNCE_RANGE = 2, SPRING_COMBO_HITS = 10, SPRING_SHARE_TAG = 'springShare';
/**
* Leaders that are 自缚 and cannot be blocked — the 天赋 line of every 巨型单位 page (PRTS 假想敌:胄 (both copies) "自缚、
* 不可阻挡", 假想敌:管 (+ 隐秘核心) / 盐风主教昆图斯 / 阿利斯泰尔,帝国余晖 / “萨米的意志” "自缚 … 无法被阻挡"). 自缚 (PRTS
* 异常效果 UNMOVABLE_PRIVATE) = 无法移动, like 束缚 but never recognised as 束缚: flag noMove, not bind. They stand where
* they spawn whatever their route says (the official routes walk to a goal; only the talent holds them), so their
* hit area (body.js) stays on the pipe block. These are exactly the enemies with a data `hitArea`.
*/
export const SELF_BOUND = Object.freeze([
'enemy_9013_acstmk', 'enemy_9013_acstmk_2', 'enemy_9021_acduml', 'enemy_9021_acduml_2', 'enemy_1521_dslily',
'enemy_9032_aclionk', 'enemy_9033_acdeer',
]);
/** 阿利斯泰尔: enemy SP per second [ASSUMED] and 莫非王土 delay after each 王权号令 [ASSUMED]. */
const ENEMY_SP_PER_SEC = 1, ANIMATE_DELAY = 2;
const EQUIP_KEYS = Object.freeze(['enemy_10028_vtswd', 'enemy_10029_vtshld', 'enemy_10030_vtwand']);
const SHELL_KEY = 'enemy_9016_acstmr', ECHO_KEY = 'enemy_9023_acdums', PHANTOM_KEY = 'enemy_2017_csphts';
// ---------------------------------------------------------------------------------------------------------------
// per-battle state, template resolution
const STATE = new WeakMap();
function stOf(b) {
let st = STATE.get(b);
if (!st) { st = { tpl: undefined, mirrored: new Set() }; STATE.set(b, st); }
return st;
}
const pairEq = (a, b) => Array.isArray(a) && Array.isArray(b) && Math.abs(a[0] - b[0]) < 1e-6 && Math.abs(a[1] - b[1]) < 1e-6;
/** The wave template this battle was built from (or null). */
export function templateOf(b) {
const st = stOf(b);
if (st.tpl !== undefined) return st.tpl;
const o = b.opts || {};
for (const cand of [o.waveTemplate, o.template, o.templateId, o.waveId]) {
if (!cand) continue;
const t = typeof cand === 'string' ? b.data.getWave(cand) : cand;
if (t && typeof t === 'object') { st.tpl = t; return t; }
}
let best = null, bestScore = 0;
const ids = typeof b.data.waveIds === 'function' ? b.data.waveIds() : [];
for (const id of ids) {
const w = b.data.getWave(id);
if (!w || !Array.isArray(w.spawns) || !Array.isArray(w.routes)) continue;
if (!w.spawns.some((s) => s && s.tag === 'boss' && BOSS_KITS[s.key ?? s.enemyKey])) continue;
if (w.routes.length !== b.routes.length) continue;
let score = 1;
if (w.routes.every((r, i) => { const n = normalizeRoute(r); const m = b.routes[i]; return m && pairEq(n.start, m.start) && pairEq(n.end, m.end); })) score += 8;
if (/_s$/.test(id) === (b.players.length <= 1)) score += 1;
if (score > bestScore) { best = w; bestScore = score; }
}
st.tpl = bestScore >= 9 ? best : null;
return st.tpl;
}
const legSig = (legs) => legs.map((l) => `${l.t}:${l.r ?? ''},${l.c ?? ''},${l.time ?? ''}`).join('|');
/** Raw template route an enemy is walking (matched on compiled legs). */
function rawRouteOf(b, e, tpl) {
if (!tpl || !e.route || !Array.isArray(e.route.legs)) return null;
const sig = legSig(e.route.legs);
for (const r of [...(tpl.routes || []), ...(tpl.extraRoutes || [])]) {
if (!r) continue;
try { if (legSig(compileRoute(normalizeRoute(r), b.rect)) === sig) return r; } catch { /* skip */ }
}
return null;
}
/** Fallback route from a position to the nearest goal. */
function toGoal(b, x, y, motion = 'WALK') {
const ends = b.grid.specialTiles('end');
let best = ends[0] ?? [Math.round(y), Math.round(x)], bd = Infinity;
for (const p of ends) { const d = hypot(p[0] - y, p[1] - x); if (d < bd) { bd = d; best = p; } }
return { motion, start: [y, x], end: best, checkpoints: [] };
}
/** The extra route of a template branch's first spawn (or null). */
function branchRoute(tpl, name) {
const br = tpl && tpl.branches && tpl.branches[name];
const s = Array.isArray(br) && Array.isArray(br[0]) ? br[0][0] : null;
return (s && tpl.extraRoutes && tpl.extraRoutes[s.routeIndex]) || null;
}
/** Spawn one phase of a template branch. Returns the spawned enemies. */
function branchSpawn(b, tpl, name, phaseIdx, { fallback = null, mods = null, at = null } = {}) {
const br = tpl && tpl.branches && tpl.branches[name];
if (!Array.isArray(br) || !br.length) return fallback ? fallback() : [];
const phase = br[Math.max(0, Math.min(phaseIdx, br.length - 1))] || [];
const out = [];
for (const s of phase) {
if (!s || (s.action && String(s.action).toUpperCase() !== 'SPAWN')) continue;
// placeholder slots (NF/N…) of branch spawns: the match may pass the round's replacements as opts.slotKeys
const key = (s.slot && b.opts && b.opts.slotKeys && b.opts.slotKeys[s.slot]) || s.key || s.enemyKey;
if (!key || !String(key).startsWith('enemy_')) continue;
const route = tpl.extraRoutes && tpl.extraRoutes[s.routeIndex];
for (let i = 0; i < Math.max(1, s.count ?? 1); i++) {
const u = b.spawnEnemy(key, { route: route || (at ? toGoal(b, at.x, at.y) : undefined), pos: route ? undefined : at ? [at.y, at.x] : undefined, tag: s.tag ?? null, countInTotal: s.unharmful ? false : undefined, mods });
if (u) out.push(u);
}
}
return out;
}
/**
* Spawn mods of a leader's mid-fight summon (死亡集群's 妖怪, 刺胄之弹, “裂管之音”/“断弦之音”, 不祥幻影): the round's enemy
* effects (攻坚装备 / II / III, 补给线 / II, 急行军 — `enemy_attribute_mul` / `enemy_move_speed_mul` on 所有敌人, `enemy_exclude`
* only 炎佑 and, for 补给线, the 器物) reach a summon like any enemy; only the leader's own HP, the server pool, is exempt
* ("领袖单位于服务器的生命值加成不受上述加成影响", PRTS 下半). They come as the battle's `flags.enemyScale` (gd.enemyScale of the
* boss round, set by the match — a BattleSpec flag, so browsers run the same); `hpRatio` = the skill's own summon HP ratio
* (死亡集群 `summon.hp_ratio`), multiplied in. No flags (tests, tools): the ratio alone. Until 0.2.1 the summons took no
* round effect at all (PR #272). 王权号令's equipment is left out: invulnerable, untargetable, it never fights.
*/
function summonMods(b, hpRatio = null) {
const s = b.flags && b.flags.enemyScale;
if (!s || typeof s !== 'object') return hpRatio ? { hpMul: hpRatio } : null;
const m = { hpMul: (Number.isFinite(s.hpMul) && s.hpMul > 0 ? s.hpMul : 1) * (hpRatio || 1), atkMul: s.atkMul, speedMul: s.speedMul };
if (s.supplyHpMul != null) m.supplyHpMul = s.supplyHpMul;
return m;
}
// ---------------------------------------------------------------------------------------------------------------
// install
export function install(battle) {
ensureInstalled(battle);
battle.on('enemySpawn', ({ enemy }) => onSpawn(battle, enemy), { priority: 90 });
// "最终攻势中,敌方领袖不会受到侵蚀损伤" (PRTS 卫戍协议:盟约/PRTS盟约记录 规则; research 06 §10.1): leaders ignore erosion gauge damage
battle.on('elementHit', (c) => { if (c.target && c.target.isBoss && c.dmg.element === 'erosion') c.dmg.cancel = true; }, { priority: 100 });
}
export function registerMeta() {}
function onSpawn(b, e) {
const kit = BOSS_KITS[e.defId];
if (typeof kit !== 'function') return;
if (SELF_BOUND.includes(e.defId)) b.addBuff(e, { key: 'boss:selfBound', persist: true, flags: { noMove: true, selfBound: true, unblockable: true } });
const tpl = templateOf(b);
const ab = abOf(b, e);
let list = [];
try { list = kit(ab, e, b, tpl) || []; } catch (err) { b._handlerError('content:bosses', e, err); }
attach(b, e, list);
// patrol loop for leaders / parts
const raw = rawRouteOf(b, e, tpl);
if (raw && Array.isArray(raw.steps) && raw.steps.some((s) => s && s.t === 'patrol')) attach(b, e, [patrolLoop()]);
// mirrored leader for 2 players (boss_battle_multi_player)
if (e.isBoss || e.tag === 'boss') mirror(b, e, tpl);
}
function patrolLoop() {
return {
tick(b, e) {
const R = e.route;
if (!R || !Array.isArray(R.legs) || R.legs.length < 2) return;
if (R.legIdx >= R.legs.length - 1) { R.legIdx = 0; R.pts = null; R.waitLeft = null; }
},
};
}
function mirror(b, e, tpl) {
const st = stOf(b);
const br = tpl && tpl.branches && tpl.branches.boss_battle_multi_player;
if (!Array.isArray(br) || !br.length || st.mirrored.has(e.defId) || (b.opts && b.opts.bossMirror === false)) return;
if (b.players.length < 2 || e.mem.mirror) return;
st.mirrored.add(e.defId);
for (const s of br[0] || []) {
const key = s.key ?? s.enemyKey;
if (key !== e.defId) continue;
b.after(Math.max(0, (s.time ?? 0) - b.time), () => {
if (b.aliveEnemies().filter((o) => o.defId === key && o.isBoss).length >= 2) return; // the match already did it
const route = tpl.extraRoutes && tpl.extraRoutes[s.routeIndex];
const u = b.spawnEnemy(key, { route: route || undefined, tag: 'boss', mods: e.mods });
if (u) u.mem.mirror = true;
});
}
}
// ---------------------------------------------------------------------------------------------------------------
// shared bits
const opsOnly = (list) => list.filter((u) => u.kind === 'op');
const nearestOf = (b, e, pred) => {
let best = null, bd = Infinity;
for (const o of b.enemies) { if (!o.alive || o === e || !pred(o)) continue; const d = hypot(o.x - e.x, o.y - e.y); if (d < bd) { bd = d; best = o; } }
return best;
};
/** Apply a floor to an enemy whose data attack speed is 0 (skill-driven leaders: 铳/管/弦). */
function aspdFloor(b, e, extra = 0) {
const rawAspd = e.def.raw && e.def.raw.stats ? Number(e.def.raw.stats.aspd) : NaN;
if (!(rawAspd === 0)) return;
b.addBuff(e, { key: 'boss:aspd', persist: true, mods: { aspd: Math.max(ASPD_FLOOR, rawAspd + extra) - e.base.aspd } });
}
/**
* Engine target priority (blocker → taunt → latest deployed), made fair for two players on one boss field: within the top
* priority tier the picks alternate between the players' units, starting with the other player each call. Both
* players deploy at t = 0, so "latest deployed" alone would always single out the second player [ASSUMED fairness
* rule]. `P.turn` keeps the rotation.
*/
export function fairOrder(b, e, list, P) {
const l = byPriority(e, list);
if (b.players.length < 2 || l.length < 2) return l;
const key = (u) => `${e.blockedBy === u ? 0 : 1}|${u.s.taunt || 0}`;
const top = key(l[0]);
const tier = l.filter((u) => key(u) === top), rest = l.filter((u) => key(u) !== top);
const ids = b.players.map((p) => p.playerId);
P.turn = ((P.turn ?? -1) + 1) % ids.length;
const queues = ids.map((pid) => tier.filter((u) => u.ownerId === pid));
const out = [];
for (let k = 0; out.length < tier.length && k < tier.length * ids.length + ids.length; k++) {
const q = queues[(P.turn + k) % ids.length];
if (q.length) out.push(q.shift());
}
for (const u of tier) if (!out.includes(u)) out.push(u);
return out.concat(rest);
}
/**
* Stun + phys DoT on the 3×3 around (r, c), credited to `src` (胄). The blast is a flying unit's — `by`: 刺胄之弹 /
* 斩胄之剑 / 破胄之锤 (PRTS 行动方式 飞行) — and its damage 无来源, so 对地规避 does not stop it: `ignoreSelect` (an airborne
* 起飞 ally in the 3×3 is stunned and hurt like the others, although the credited 胄 walks). It is still that unit's area
* selection (PRTS: "令自身周围8格内的所有我方单位…" / "…无视迷彩,可对空", no 无视无法选择): areaAlliesInTiles of `by` — no
* 隐匿 ally, the one blocking it included (GitHub #97; 掷剑 / 掷锤's "无视无法选择" is their pick of the operator they fly at, not the blast).
*/
function stunBlast(b, src, by, r, c, stun, dot, dur, kind) {
b.fx('explode', { x: c, y: r, r: 1.5, kind, tiles: 'box' });
for (const u of areaAlliesInTiles(b, by, r, c, 'box', 1)) {
if (stun > 0) b.applyStatus(u, 'stun', { duration: stun, source: src, ignoreSelect: true });
if (dot > 0 && dur > 0) b.addBuff(u, { key: `boss:${kind}Dot`, duration: dur, refresh: 'replace', interval: 1, visible: true,
onTick: ({ battle, unit }) => battle.dealDamage(src, unit, { amount: dot, type: 'phys', canDodge: false, ignoreSelect: true, tags: ['enemyAbility', kind] }) });
}
}
/** Distance from point p to segment a–b. */
function segDist(px, py, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay, L = dx * dx + dy * dy;
const t = L > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / L)) : 0;
return hypot(px - (ax + t * dx), py - (ay + t * dy));
}
// ---------------------------------------------------------------------------------------------------------------
// 假想敌:胄 (boss_1 / boss_8) + 刺胄之弹 + 斩胄之剑 / 破胄之锤
function kitHelm(ab, e, b, tpl) {
const s1 = ab.sk['1'], s2 = ab.sk['2'];
const lowRatio = T(ab, '1.hp_ratio') ?? 0, lowScale = T(ab, '1.damage_scale') ?? 1;
const P = { low: false };
const range = () => e.base.rangeRadius || 8;
// 灭顶之灾 "触发索敌和普通攻击相同:选择攻击范围内攻击力最高的1名我方干员" (PRTS 技能, both copies)
const shellTargets = (b2) => opsOnly(targetsNear(b2, e, range()));
return [
{
spawn(b2, e2, a, ab2) { ab2.atkType = 'arts'; },
before(c, b2, e2) { const l = targetsNear(b2, e2, range()); if (l.length) c.targets = [b2.rng.pick(l)]; }, // 随机选择目标
attack(c, b2, e2) { const t = c.targets[0]; if (t) b2.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'helmRay' }); },
iv: 0.25,
tick(b2, e2) {
if (P.low || !(e2.hpRatio < lowRatio)) return;
P.low = true; // <20 %: damage taken ×0.5 (no extra shell: see 灭顶之灾)
b2.addBuff(e2, { key: 'boss:helmGuard', persist: true, visible: true, mods: { physTakenMul: lowScale, artsTakenMul: lowScale } }); // 物理与法术伤害降低
b2.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'helmLow' });
},
},
s1 && {
cd: s1.cd, icd: s1.icd, cond: (b2) => shellTargets(b2).length > 0,
// 【灭顶之灾】 one shell, also below 20 %: the handbook's "额外发射<刺胄之弹>" is wrong in game (PRTS 能力修正 原因 6
// "描述与游戏实际表现不符合": "【灭顶之灾】不会额外发射") and no blackboard holds a second shell (skill 1 has none)
fire(b2, e2) {
const t = shellTargets(b2).sort((p, q) => q.s.atk - p.s.atk || aggroCmp(p, q))[0];
if (t) fireShell(b2, e2, t);
},
},
s2 && {
cd: s2.cd, icd: s2.icd,
// 【死亡集群】: a drone on the branch route; when it dies (PRTS 能力修正 "该无人机单位死亡时", whoever kills it) the leader
// loses hp_ratio × max HP. Drone HP: `summon.hp_ratio` (隐秘核心 only) scales it [ASSUMED: the summon's max HP]; the
// `max_hp` 0.5 both copies carry is not read [ASSUMED] — no source says what it does (PRTS 技能 lists the 2 % only,
// the 妖怪 page has no summon rule, the level gives the 妖怪 no override; as an attribute key it would be +50 %).
fire(b2, e2) {
const hpMul = s2.bb['summon.hp_ratio'] > 0 ? s2.bb['summon.hp_ratio'] : null;
const key = s2.bs.enemy_key ?? 'enemy_1005_yokai';
const drones = branchSpawn(b2, tpl, s2.bs.branch_id ?? 'boss_summon_enemy', 0, {
mods: summonMods(b2, hpMul), at: { x: e2.x, y: e2.y },
fallback: () => [b2.spawnEnemy(key, { pos: [e2.y, e2.x], route: toGoal(b2, e2.x, e2.y, 'FLY'), mods: summonMods(b2, hpMul) })].filter(Boolean),
});
b2.fx('summon', { x: e2.x, y: e2.y, id: e2.id, key, n: drones.length });
const ratio = s2.bb.hp_ratio ?? 0;
for (const d of drones) attach(b2, d, [{
death(c, b3) {
if (c.reason !== 'killed' || !(ratio > 0)) return; // a leak is no death
const boss = e2.alive ? e2 : b3.aliveEnemies().find((o) => o.isBoss && o.defId === e2.defId);
const by = c.killer && c.killer.side === 'ally' ? c.killer : null; // credited to the killing operator
if (boss) { b3.loseHp(boss, droneLinkBase(boss) * ratio, { source: by, noHitLimit: true }); b3.fx('beam', { x: d.x, y: d.y, from: d.id, to: boss.id, kind: 'droneLink' }); }
},
}]);
},
},
];
}
/**
* Max HP the 死亡集群 drone link reads (DRONE_LINK_BASE): 'pool' = the leader's shown max HP (the shared pool), 'unit' =
* the unit's data max HP (template × spawn hpMul).
*/
export function droneLinkBase(boss, base = DRONE_LINK_BASE) {
if (base !== 'unit' || !boss.def || !(boss.def.maxHp > 0)) return boss.s.maxHp;
const m = boss.mods && Number(boss.mods.hpMul);
return boss.def.maxHp * (Number.isFinite(m) && m > 0 ? m : 1);
}
/** Launch a 刺胄之弹 from `boss` at `target`'s tile. */
function fireShell(b, boss, target) {
const tr = target.tileR, tc = target.tileC;
const sh = b.spawnEnemy(SHELL_KEY, { pos: [boss.y, boss.x], route: { motion: 'FLY', start: [boss.y, boss.x], end: [tr, tc], steps: [{ t: 'wait', s: 99999 }] }, tag: 'part', countInTotal: false, ownerPlayerId: boss.ownerId, mods: summonMods(b) });
if (!sh) return null;
sh.mem.ab.shell = { tr, tc, boss };
b.fx('shell', { x: boss.x, y: boss.y, id: sh.id, tx: tc, ty: tr, r: 1.5, kind: 'helmShell' });
return sh;
}
function kitShell(ab, e) {
const stun = T(ab, 'killed.duration') ?? 0, dot = T(ab, 'killed.value') ?? 0;
return [{
spawn(b, e2) {
setHits(e2, e2.base.maxHp); hitCount(b, e2, true); // 需要数次攻击击倒 (× the round's HP: summonMods)
b.addBuff(e2, { key: 'boss:shellGuard', persist: true, flags: { noDisplace: true } }); // 失衡免疫 (PRTS 天赋; also 静态刚体)
},
tick(b, e2, a, dt) {
const s = e2.mem.ab.shell;
if (!s || a.done) return;
if (!stepToward(e2, s.tc, s.tr, e2.s.moveSpeed * MOVE_SCALE * dt)) return;
a.done = true; // arrival: 3×3 long stun + phys DoT
stunBlast(b, s.boss && s.boss.alive ? s.boss : e2, e2, s.tr, s.tc, stun, dot, stun, 'helmShell');
b.kill(e2, null);
},
}];
}
/**
* “斩胄之剑” / “破胄之锤” (PRTS 天赋 + 技能 掷剑 / 掷锤; numbers from skill 1's blackboard).
* 初始模式 ('hover'): 无敌 + 自缚 [ASSUMED: also untargetable, so operators spend no attacks on it]; AoE physical attack
* on every ally in its range (锤 150 % ATK, BLADE_ATK_SCALE).
* 掷剑 (skill 1 cd / icd, an operator on the field) → 出击模式 ('dive'): no normal attack, vulnerable; blinks to the start
* of its `<hand>_blink` route and flies at the tile of the lowest-ATK operator (无视无法选择).
* Arriving (no 【瘫痪】): `stun` s stun + `dot_damage`/s for `dot_duration` s on the 3×3 around it, then it is replaced.
* Once, after `max_hit_cnt` damage instances: 【瘫痪】 ('down') — `special_stun_duration` s, but it ends with its own
* `stun` s stun ("该晕眩结束时也会结束【瘫痪】"): ground unit, damage taken ×`damage_scale`; when it ends, replaced.
* Replaced: a new 初始模式 copy with its current HP is summoned on its `<hand>_origin` route (at home without a template),
* then it is killed (强制击杀自身; a part: no kill count, no bounty). The copy is a new unit [ASSUMED: only its HP is
* inherited] — its 掷剑 starts from the initial cooldown again, so the data's cooldown (80 s) never comes into play.
* Every damage it takes is lost by 假想敌:胄 too (BLADE_TRANSFER = PART_TRANSFER 1, 无来源, credited to the attacker).
*/
function kitBlade(ab, e, b, tpl) {
const s = ab.sk['1'];
const bb = s ? s.bb : {};
const hand = BLADE_HAND[e.defId];
const P = { state: 'hover', home: { x: e.x, y: e.y }, hits: 0, target: null };
const leader = (b2) => nearestOf(b2, e, (o) => o.isBoss && /enemy_9013_acstmk/.test(o.defId));
const hover = (b2, on) => {
if (on) b2.addBuff(e, { key: 'boss:hover', persist: true, visible: true, flags: { invulnerable: true, untargetable: true } });
else b2.removeBuff(e, 'boss:hover');
};
const replace = (b2, e2) => { // 以关卡预设路径召唤一个继承自身生命值的初始模式的 copy,随后强制击杀自身
P.state = 'gone';
e2.motion = 'FLY';
const origin = hand ? branchRoute(tpl, `${hand}_origin`) : null;
const home = [P.home.y, P.home.x];
const n = b2.spawnEnemy(e2.defId, {
route: origin || { motion: 'FLY', start: home, end: home, steps: [{ t: 'wait', s: 99999 }] }, pos: origin ? undefined : home,
tag: e2.tag, countInTotal: false, mods: e2.mods, ownerPlayerId: e2.ownerId,
});
if (n) { n.hp = Math.min(n.s.maxHp, e2.hp); b2.fx('blink', { x: n.x, y: n.y, id: n.id, fx: e2.x, fy: e2.y }); }
b2.kill(e2, null);
};
const shotDown = (b2, e2) => { // 【瘫痪】
P.state = 'down';
e2.motion = 'WALK'; // 变为地面单位(不改变寻路方式)
const stun = bb.stun ?? 0;
b2.applyStatus(e2, 'stun', { duration: stun, source: null, force: true });
b2.addBuff(e2, { key: 'boss:downed', duration: Math.min(bb.special_stun_duration ?? stun, stun), visible: true,
mods: { dmgTakenMul: bb.damage_scale ?? 1 }, onExpire: () => { if (e2.alive && P.state === 'down') replace(b2, e2); } });
b2.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'shotDown' });
};
return [
{
spawn(b2, e2) {
hover(b2, true);
// 自缚 (moved by hand) · 不可阻挡 · 失衡免疫 (PRTS 天赋 "{{特殊机制|静态刚体}},不可阻挡、失衡免疫…" — data `staticBody` too)
b2.addBuff(e2, { key: 'boss:anchor', persist: true, flags: { noMove: true, selfBound: true, unblockable: true, noDisplace: true } });
if (BLADE_ATK_SCALE[e2.defId]) e2.profile.atkScale = BLADE_ATK_SCALE[e2.defId];
},
// 范围物理伤害 — "普通攻击对攻击范围内的所有我方单位造成…物理普通伤害": a normal attack on every operator it can target
// (canTargetAlly: no 隐匿 or 迷彩 one — it is never blocked —, PRTS 选择器: a normal attack does not ignore 迷彩; until
// 0.1.2 it took them all, `ranged: false`)
before(c, b2, e2) { const l = targetsNear(b2, e2, e2.base.rangeRadius || 1.6); if (l.length) c.targets = l; },
taken(c, b2, e2) {
if (c.amount > 0) { // 受到伤害时令假想敌:胄受到等量的无来源生命流失 (【瘫痪】; 出击模式 [ASSUMED], see PART_TRANSFER)
const L = leader(b2);
const src = c.source || c.credit; // a 无来源 burst still passes on (credited)
if (L) b2.loseHp(L, c.amount * BLADE_TRANSFER, { source: src && src.side === 'ally' ? src : null, from: c.dmg, sourceless: true });
}
if (P.state === 'dive' && e2.alive && ++P.hits >= (bb.max_hit_cnt ?? Infinity)) shotDown(b2, e2); // 仅1次
},
tick(b2, e2, a, dt) {
if (P.state !== 'dive' || !canCast(e2)) return; // a stunned blade does not fly on
const t = P.target;
if (!stepToward(e2, t.c, t.r, e2.s.moveSpeed * MOVE_SCALE * dt)) return;
// arrival: 3×3 stun + DoT (无来源: credited to 胄, as the shell's), then the 初始模式 copy takes over
stunBlast(b2, leader(b2) || e2, e2, t.r, t.c, bb.stun ?? 0, bb.dot_damage ?? 0, bb.dot_duration ?? 0, 'bladeDive');
replace(b2, e2);
},
},
s && {
cd: s.cd, icd: s.icd, cond: (b2) => P.state === 'hover' && opsOnly(allTargets(b2, e)).length > 0, // 存在至少1名可选我方干员
fire(b2, e2) { // 【掷剑】/【掷锤】 → 出击模式
const t = opsOnly(b2.allies()).sort((p, q) => p.s.atk - q.s.atk || aggroCmp(p, q))[0];
if (!t) return;
P.state = 'dive'; P.hits = 0; P.target = { r: t.tileR, c: t.tileC };
hover(b2, false);
b2.addBuff(e2, { key: 'boss:sortie', persist: true, flags: { disarm: true } }); // 出击模式:不进行普通攻击
const blink = hand ? branchRoute(tpl, `${hand}_blink`) : null;
if (blink && Array.isArray(blink.start)) {
const from = { x: e2.x, y: e2.y };
e2.x = blink.start[1]; e2.y = blink.start[0];
b2.fx('blink', { x: e2.x, y: e2.y, id: e2.id, fx: from.x, fy: from.y });
}
b2.fx('charge', { x: e2.x, y: e2.y, id: e2.id, tx: t.tileC, ty: t.tileR, kind: 'bladeDive' });
},
},
];
}
// ---------------------------------------------------------------------------------------------------------------
// 假想敌:铳 (boss_2 / boss_9) + 碎铳之簧
const isGun = (o) => /enemy_9017_achunt/.test(o.defId);
const isSpring = (o) => /enemy_90(18|19|20)_actrp/.test(o.defId);
function kitGun(ab, e, b) {
const hidden = /_2$/.test(e.defId);
const step = T(ab, '2.attack_speed') ?? 0, maxN = T(ab, '2.max_stack_cnt') ?? 0;
const P = { last: null, n: 0, charge: null };
const range = () => e.base.rangeRadius || 2;
const setAspd = (b2) => aspdFloor(b2, e, step * P.n);
const s1 = ab.sk['1'], s2 = ab.sk['2'], s3 = ab.sk['3'];
const groundTargets = (b2) => allTargets(b2, e).filter((u) => u.ground);
const list = [
{
spawn(b2, e2) { b2.addBuff(e2, { key: 'boss:unblockable', persist: true, flags: { unblockable: true } }); setAspd(b2); },
before(c, b2, e2) { const l = targetsNear(b2, e2, range()); if (l.length) c.targets = [l.sort((p, q) => q.s.def - p.s.def || aggroCmp(p, q))[0]]; }, // 优先攻击防御力最高的单位
attack(c, b2) {
const t = c.targets[0];
if (t === P.last) P.n = Math.min(maxN, P.n + 1); else { P.last = t; P.n = 0; }
setAspd(b2);
},
dealt(c, b2, e2) { elem(b2, e2, c.target, 'erosion', e2.s.atk * (T(ab, '1.ep_damage_ratio') ?? 0)); }, // 攻击时附带侵蚀损伤
tick(b2, e2, a, dt) {
const ch = P.charge;
if (!ch) return;
const t = ch.target;
const sp = (e2.s.moveSpeed + (T(ab, '3.move_speed') ?? 0)) * MOVE_SCALE * dt;
const nx = e2.x, ny = e2.y;
const arrived = stepToward(e2, t.x, t.y, sp);
if (!b2.grid.groundPassable(Math.round(e2.y), Math.round(e2.x))) { e2.x = nx; e2.y = ny; }
// "对进入自身0.35半径范围内的我方单位(包括飞行单位)造成一次…": an area selection (areaAllies)
for (const u of areaAllies(b2, e2, e2.x, e2.y, CHARGE_RADIUS)) if (!ch.hit.has(u)) { ch.hit.add(u); hurt(b2, e2, u, e2.s.atk * (T(ab, '3.atk_scale') ?? 1), 'phys'); }
if (arrived || b2.time >= ch.until || (e2.x === nx && e2.y === ny)) {
P.charge = null;
b2.removeBuff(e2, 'boss:charge');
if (e2.route) e2.route.pts = null;
}
},
},
s1 && {
cd: s1.cd, icd: s1.icd, cond: (b2) => groundTargets(b2).length > 0 && !P.charge,
fire(b2, e2) { // 【最终之罚】 charge at the highest-DEF ground unit
const t = groundTargets(b2).sort((p, q) => q.s.def - p.s.def || aggroCmp(p, q))[0];
if (!t) return;
const dur = T(ab, '3.duration') ?? 0;
P.charge = { target: { x: t.x, y: t.y }, until: b2.time + dur, hit: new Set() };
b2.addBuff(e2, { key: 'boss:charge', duration: dur, visible: true, flags: { noMove: true, disarm: true } });
b2.fx('charge', { x: e2.x, y: e2.y, id: e2.id, tx: t.x, ty: t.y, kind: 'finalPenance' });
},
},
];
if (hidden) {
list.push({
iv: 0.25,
tick(b2, e2) { // 【未尽的告解】 physical / arts damage taken ×4.damage_scale while the 碎铳之簧 it came with remain
const springs = b2.enemies.some((o) => o.alive && isSpring(o));
if (springs) { const m = T(ab, '4.damage_scale') ?? 1; b2.addBuff(e2, { key: 'boss:confession', duration: 0.35, refresh: 'replace', mods: { physTakenMul: m, artsTakenMul: m } }); }
},
});
if (s3) list.push({
iv: s3.bb.interval ?? 1,
tick(b2, e2) { // 【盲信之誓】 links: phys per second on operators standing on a line ("无视无法选择、迷彩": 起飞 too)
for (const sp of b2.enemies) {
if (!sp.alive || !isSpring(sp)) continue;
b2.fx('link', { x: e2.x, y: e2.y, from: e2.id, to: sp.id, kind: 'faithLink', dur: s3.bb.interval ?? 1 });
for (const u of b2.allies()) if (segDist(u.x, u.y, e2.x, e2.y, sp.x, sp.y) <= LINK_WIDTH) hurt(b2, e2, u, s3.bb.value ?? 0, 'phys', { ignoreSelect: true, tags: ['faithLink'] });
}
},
});
if (s2) list.push({
cd: s2.cd, icd: s2.icd, cond: (b2) => b2.enemies.some((o) => o.alive && isSpring(o)),
fire(b2, e2) { // 【末日布道】 springs dash to 铳, invulnerable, trampling operators
// PRTS “碎铳之簧” 追逐模式 "不进行普通攻击": `disarm` for the dash (until 0.1.3 it kept shooting while it ran)
for (const sp of b2.enemies) {
if (!sp.alive || !isSpring(sp) || !sp.mem.ab) continue;
sp.mem.ab.dash = { until: b2.time + (s2.bb.dog_duration ?? 0), mul: 1 + (s2.bb.move_speed ?? 0), gun: e2, hit: new Set() };
b2.addBuff(sp, { key: 'boss:dash', duration: s2.bb.dog_duration ?? 0, visible: true, flags: { invulnerable: true, noMove: true, unblockable: true, disarm: true } });
b2.fx('dash', { x: sp.x, y: sp.y, id: sp.id, tx: e2.x, ty: e2.y });
}
},
});
}
return list;
}
function kitSpring(ab, e) {
const kind = /9018/.test(e.defId) ? 'arts' : /9019/.test(e.defId) ? 'element' : 'hits';
const scale = T(ab, '1.damage_scale') ?? 1, regen = T(ab, '1.regenerate_duration') ?? 0;
const P = { up: false, barrier: 0, hits: 0, downAt: null };
const gun = (b) => nearestOf(b, e, (o) => isGun(o));
const raise = (b) => {
P.up = true;
if (kind === 'arts') P.barrier = e.s.maxHp * (T(ab, '1.shield_ratio') ?? 0);
if (kind === 'hits') P.hits = T(ab, '1.block_damage_max_times') ?? 0;
b.addBuff(e, { key: 'boss:springShield', persist: true, visible: true, flags: { unblockable: true } }); // 护盾生效时,自身无法被阻挡
b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: `spring_${kind}` });
};
const drop = (b) => { P.up = false; P.downAt = b.time; b.removeBuff(e, 'boss:springShield'); b.fx('shieldBreak', { x: e.x, y: e.y, id: e.id }); };
const sk = ab.sk['1'];
return [
{
spawn(b) { raise(b); },
hitIn(c, b, e2) {
if (!P.up) return;
const ty = c.dmg.type;
if (kind === 'arts') { // 法术屏障: absorbs arts (after RES, like a shield); phys ×0.1 + counter
if (ty === 'arts') {
P.barrier -= absorbArts(c, P.barrier);
if (P.barrier <= 1e-6) { P.barrier = 0; drop(b); }
} else if (ty === 'phys') {
c.dmg.mul *= scale;
// the counter "对来源造成…无来源物理附加伤害" picks its attacker directly — no selection, so an airborne 起飞
// attacker takes it too (PRTS 异常效果 无法选择: "'直接选中'的能力…不受这些仅在选择时生效的异常效果制约")
const s = c.source, o = { ignoreSelect: true, tags: ['springCounter'] };
if (s && s.side === 'ally' && s.alive) { hurt(b, e2, s, e2.s.atk * (T(ab, '1.atk_scale') ?? 0), 'phys', o); elem(b, e2, s, 'erosion', e2.s.atk * (T(ab, '1.ep_damage_ratio') ?? 0), o); }
}
} else if (kind === 'element') { // 元素护盾: phys/arts heavily reduced
if (ty === 'phys' || ty === 'arts') c.dmg.mul *= scale;
} else if (ty === 'phys' || ty === 'arts' || ty === 'true') { // 频次护盾: negates N hits
c.dmg.cancel = true;
if (--P.hits <= 0) drop(b);
}
},
burst(c, b) { if (kind === 'element' && P.up) drop(b); }, // 自身元素损伤爆发时,护盾消失
taken(c, b, e2) {
if (!(c.amount > 0)) return;
if (c.dmg.tags && c.dmg.tags.includes(SPRING_SHARE_TAG)) return; // a share never passes on again
const s0 = c.source || c.credit; // a 无来源 burst still passes on (credited)
const src = s0 && s0.side === 'ally' ? s0 : null;
// 【盲信之誓】 受到伤害时令假想敌:铳受到等量的无来源生命流失 (PRTS); the even split to the other springs follows the
// 隐秘核心 handbook ("传递给<假想敌:铳>和场上其他<“碎铳之簧”>") — not in the PRTS talent, [ASSUMED] amount
const g = gun(b);
if (g) b.loseHp(g, c.amount * PART_TRANSFER, { source: src, from: c.dmg, sourceless: true });
const others = b.enemies.filter((o) => o.alive && o !== e2 && isSpring(o));
for (const o of others) b.loseHp(o, (c.amount * PART_TRANSFER) / others.length, { source: src, from: c.dmg, tags: [SPRING_SHARE_TAG], sourceless: true });
},
tick(b, e2, a, dt) {
if (!P.up && P.downAt != null && b.time - P.downAt >= regen) raise(b);
const d = e2.mem.ab.dash;
if (!d) return;
const g = d.gun && d.gun.alive ? d.gun : gun(b);
const arrived = !g || stepToward(e2, g.x, g.y, e2.s.moveSpeed * d.mul * MOVE_SCALE * dt) || hypot(g.x - e2.x, g.y - e2.y) < 1;
// 追逐模式 "对进入自身0.35半径范围内的我方单位(包括飞行单位)造成一次攻击力100%的物理普通伤害" (until 0.1.3: radius 0.5)
for (const u of areaAllies(b, e2, e2.x, e2.y, CHARGE_RADIUS)) if (!d.hit.has(u)) { d.hit.add(u); hurt(b, e2, u, e2.s.atk, 'phys'); }
if (arrived || b.time >= d.until) { e2.mem.ab.dash = null; b.removeBuff(e2, 'boss:dash'); if (e2.route) e2.route.pts = null; }
},
},
// while shielded, every (spCost+1)-th attack (enemy SP +1 per attack, as 粉碎攻坚手's official text) adds the shield's
// special attack on that attack's target
(kind === 'element' || kind === 'hits') && sk && {
attack(c, b, e2, a) {
if (!P.up) return;
a.n = (a.n ?? 0) + 1;
if (a.n % nthOf(sk)) return;
const t0 = c.targets.find((u) => u && u.alive);
if (!t0) return;
if (kind === 'element') { // bouncing element bullet: `times` targets, weaker after each bounce
const times = sk.bb.times || 1, ratio = sk.bb.ep_damage_ratio || 0;
let t = t0;
const hit = new Set();
for (let k = 0; k < times && t; k++) {
hit.add(t);
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'springBullet' });
elem(b, e2, t, 'erosion', e2.s.atk * ratio * powi(SPRING_BOUNCE_FALLOFF, k));
const prev = t;
t = areaAllies(b, e2, prev.x, prev.y, SPRING_BOUNCE_RANGE).filter((u) => !hit.has(u)).sort((p, q) => hypot(p.x - prev.x, p.y - prev.y) - hypot(q.x - prev.x, q.y - prev.y) || aggroCmp(p, q))[0];
}
} else { // 十连击
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t0.id, kind: 'springCombo' });
for (let i = 0; i < SPRING_COMBO_HITS && t0.alive; i++) hurt(b, e2, t0, e2.s.atk * (sk.bb.atk_scale || 0), 'phys');
}
},
},
];
}
// ---------------------------------------------------------------------------------------------------------------
// 假想敌:管 (boss_3 / boss_10) + 假想敌:弦 + “余音”
const echoesOf = (b, form) => b.enemies.filter((o) => o.alive && o.defId === ECHO_KEY && o.mem.ab && o.mem.ab.form === form);
/** Switch an echo's form. */
export function setEchoForm(b, echo, form) {
const ab = echo.mem.ab;
if (!ab) return;
ab.form = form;
ab.strikes = 0;
const mods = form === 'gold' ? { aspd: T(ab, '1.attack_speed') ?? 0 } : { atkPct: T(ab, '2.atk') ?? 0, moveMul: 1 - (T(ab, '2.move_speed') ?? 0) };
b.addBuff(echo, { key: 'boss:echoForm', persist: true, visible: true, mods });
b.fx('phase', { x: echo.x, y: echo.y, id: echo.id, kind: `echo_${form}` });
}
/** An echo takes a strike/hit: pulse around it (PRTS “余音” "受到伤害时对半径1.6范围内的我方单位造成…": an area selection of the
* echo — no unblocking 隐匿 operator) and count towards the form switch. */
export function echoHit(b, echo) {
const ab = echo.mem.ab;
if (!ab || !echo.alive) return;
const atk = echo.s.atk;
b.fx('explode', { x: echo.x, y: echo.y, r: ECHO_PULSE_RADIUS, kind: 'echoPulse' });
for (const u of areaAllies(b, echo, echo.x, echo.y, ECHO_PULSE_RADIUS)) {
hurt(b, echo, u, atk * (T(ab, '3.atk_scale') ?? 0), 'arts');
elem(b, echo, u, 'apoptosis', atk * (T(ab, '3.ep_damage_ratio') ?? 0));
}
const need = ab.form === 'gold' ? T(ab, '1.hit_times_to_switch') : T(ab, '2.hit_times_to_switch');
ab.strikes = (ab.strikes ?? 0) + 1;
if (need > 0 && ab.strikes >= need) setEchoForm(b, echo, ab.form === 'gold' ? 'dark' : 'gold');
}
function kitEcho(ab, e) {
const s = ab.sk.Skill;
return [
{
spawn(b, e2) { setHits(e2, e2.base.maxHp); hitCount(b, e2, true); e2.blockWeight = ECHO_BLOCK_WEIGHT; setEchoForm(b, e2, 'dark'); }, // hits × the round's HP (summonMods)
taken(c, b, e2) { const s = c.source || c.credit; if (s && s.side === 'ally') echoHit(b, e2); }, // 受到伤害时以自身为中心造成一次范围伤害 (a 无来源 burst too)
},
s && {
cd: s.cd, icd: s.icd,
fire(b, e2) { // 合奏
const gold = e2.mem.ab.form === 'gold';
const r = gold ? ECHO_ENSEMBLE_RADIUS.gold : ECHO_ENSEMBLE_RADIUS.dark;
const ratio = gold ? T(ab, '4.ep_damage_ratio_passion') ?? 0 : T(ab, '4.ep_damage_ratio_depassion') ?? 0;
b.fx('explode', { x: e2.x, y: e2.y, r, kind: gold ? 'ensembleGold' : 'ensembleDark' });
for (const u of areaAllies(b, e2, e2.x, e2.y, r)) { hurt(b, e2, u, e2.s.atk, 'arts'); elem(b, e2, u, 'apoptosis', e2.s.atk * ratio); }
},
},
];
}
/** 管 / 弦 core: summon loop, strikes on its own form of 余音, skill. */
function pipeCore(ab, e, b, tpl, { form, prefix }) {
const hi = T(ab, `1.${prefix}_t_1[high_hp].interval`) ?? 40, lo = T(ab, `1.${prefix}_t_1[low_hp].interval`) ?? hi, thr = T(ab, '1.hp_ratio') ?? 0;
const key = ab.tS['1.enemy_key'] ?? ECHO_KEY;
const P = { acc: 0, atk: Infinity }; // first strike as soon as an echo of its form exists (engine attack rule)
const summon = (b2, e2) => {
const got = branchSpawn(b2, tpl, 'summon_enemy', 0, { mods: summonMods(b2), at: { x: e2.x, y: e2.y }, fallback: () => [b2.spawnEnemy(key, { pos: [e2.y, e2.x], route: toGoal(b2, e2.x, e2.y), mods: summonMods(b2) })].filter(Boolean) });
for (const u of got) if (u.defId === ECHO_KEY && form === 'gold') setEchoForm(b2, u, 'gold');
b2.fx('summon', { x: e2.x, y: e2.y, id: e2.id, key, n: got.length });
};
return {
spawn(b2, e2) { e2.profile.noAttack = true; aspdFloor(b2, e2); },
tick(b2, e2, a, dt) {
P.acc += dt;
if (P.acc >= (e2.hpRatio < thr ? lo : hi)) { P.acc = 0; summon(b2, e2); }
if (!canCast(e2, false)) return;
P.atk += dt;
if (P.atk < e2.s.interval) return;
const t = b2.rng.pick(echoesOf(b2, form)); // 优先攻击…形态余音
if (!t) return;
P.atk = 0;
e2.lastAttackAt = b2.time;
e2.stats.attacks++;
b2.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: `${prefix}Strike` });
echoHit(b2, t);
},
};
}
function kitPipe(ab, e, b, tpl) {
const s = ab.sk['1'];
return [
pipeCore(ab, e, b, tpl, { form: 'dark', prefix: 'acduml' }),
{
death(c, b2) { for (const o of b2.aliveEnemies()) if (o.defId === 'enemy_9022_acdumm') b2.kill(o, null); }, // 弦 leaves with 管
},
s && {
cd: s.cd, icd: s.icd, cond: (b2) => echoesOf(b2, 'dark').length > 0,
fire(b2, e2) { // 【裂管之奏】 three strikes on every dark echo
const r = s.bb.range_radius ?? 0, ep = s.bb.ep_damage_ratio ?? 0;
for (const echo of echoesOf(b2, 'dark')) for (let k = 0; k < 3; k++) b2.after(k * PIPE_STRIKE_GAP, () => {
if (!e2.alive || !echo.alive) return;
b2.fx('explode', { x: echo.x, y: echo.y, r, kind: 'pipeStrike' });
for (const u of areaAllies(b2, e2, echo.x, echo.y, r)) { hurt(b2, e2, u, e2.s.atk, 'arts'); elem(b2, e2, u, 'apoptosis', e2.s.atk * ep); }
echoHit(b2, echo);
}, { owner: e2 });
},
},
];
}
function kitString(ab, e, b, tpl) {
const s = ab.sk['1'];
return [
pipeCore(ab, e, b, tpl, { form: 'gold', prefix: 'acdumm' }),
{ spawn(b2, e2) { b2.addBuff(e2, { key: 'boss:eternal', persist: true, flags: { invulnerable: true, untargetable: true } }); } }, // 永久无敌
s && {
cd: s.cd, icd: s.icd, cond: (b2) => echoesOf(b2, 'gold').length > 0,
fire(b2, e2) { // 【断弦之奏】 AoE on every gold echo, which then turns dark
const r = s.bb.range_radius ?? 0, ep = s.bb.ep_damage_ratio ?? 0;
for (const echo of echoesOf(b2, 'gold')) {
b2.fx('explode', { x: echo.x, y: echo.y, r, kind: 'stringStrike' });
for (const u of areaAllies(b2, e2, echo.x, echo.y, r)) { hurt(b2, e2, u, e2.s.atk, 'arts'); elem(b2, e2, u, 'apoptosis', e2.s.atk * ep); }
setEchoForm(b2, echo, 'dark');
}
},
},
];
}
// ---------------------------------------------------------------------------------------------------------------
// 盐风主教昆图斯 (boss_4)
function kitQuintus(ab, e, b, tpl) {
const P = { stage: 0, t0: null, stageAt: null, tent: 0 };
const stageSkills = [['Tidewater', 'Rockfall', 'SummonTentac'], ['TidewaterG1', 'RockfallG1', 'SummonTentacG1'], ['TidewaterG2', 'RockfallG2', 'SummonTentacG2', 'Doom']];
const skills = [];
const atkRatio = T(ab, 'epdamage.attack@ep_damage_ratio') ?? 0;
const mk = (name, stage) => {
const s = ab.sk[name];
if (!s) return null;
const kind = name.replace(/G\d$/, '');
const a = { cd: s.cd, icd: s.icd, stage, cond: () => P.stage === stage, fire: (b2, e2) => QUINTUS[kind](b2, e2, s, P, tpl) };
skills.push(a);
return a;
};
const list = [{
spawn(b2, e2) { P.t0 = b2.time; P.stageAt = b2.time; e2.profile.maxTargets = 2; },
before(c, b2, e2) { // 同时攻击防御最高的两个单位
const l = targetsNear(b2, e2, e2.base.rangeRadius || 99).sort((p, q) => q.s.def - p.s.def || aggroCmp(p, q));
if (l.length) c.targets = l.slice(0, 2);
},
dealt(c, b2, e2) { elem(b2, e2, c.target, 'neural', e2.s.atk * atkRatio); },
iv: 0.25,
tick(b2, e2) {
const lost = 1 - e2.hpRatio;
if (P.stage === 0 && (lost >= (T(ab, 'growup1.hp_ratio') ?? 1) || b2.time - P.t0 >= (T(ab, 'growup1.interval') ?? Infinity))) grow(b2, e2, 1);
else if (P.stage === 1 && (lost >= 2 * (T(ab, 'growup2.hp_ratio') ?? 1) || b2.time - P.stageAt >= (T(ab, 'growup2.interval') ?? Infinity))) grow(b2, e2, 2);
},
}];
const grow = (b2, e2, n) => {
P.stage = n; P.stageAt = b2.time;
b2.addBuff(e2, { key: 'boss:grow', persist: true, visible: true, mods: { atkPct: T(ab, `atkup${n}.atk`) ?? 0 } });
for (const a of skills) if (a.stage === n) a.left = a.icd; // the new form's skills start from their initial cooldown
b2.fx('grow', { x: e2.x, y: e2.y, id: e2.id, stage: n });
};
stageSkills.forEach((names, i) => names.forEach((n) => list.push(mk(n, i))));
return list;
}
const QUINTUS = {
Tidewater(b, e, s) { // 【大潮】 "对场上所有我方单位…": every operator it can select (fieldAllies): arts + neural
b.fx('tide', { x: e.x, y: e.y, id: e.id });
for (const u of fieldAllies(b, e)) { hurt(b, e, u, e.s.atk * (s.bb.atk_scale ?? 0), 'arts'); elem(b, e, u, 'neural', e.s.atk * (s.bb.ep_damage_ratio ?? 0)); }
},
Rockfall(b, e, s, P) { // 【崩坍】 delayed strikes on the N highest-DEF units
const n = ROCKFALL_TARGETS[Math.min(P.stage, ROCKFALL_TARGETS.length - 1)];
const ts = allTargets(b, e).sort((p, q) => q.s.def - p.s.def || aggroCmp(p, q)).slice(0, n);
for (const t of ts) {
b.fx('rockfall', { x: t.x, y: t.y, id: t.id, dur: ROCKFALL_DELAY });
b.after(ROCKFALL_DELAY, () => { if (e.alive && t.alive) hurt(b, e, t, e.s.atk * (s.bb.atk_scale ?? 0), 'phys'); }, { owner: e });
}
},
SummonTentac(b, e, s, P, tpl) { // 【断裂生殖】 tentacles at the template devices (phase i on the i-th cast)
const br = tpl && tpl.branches && tpl.branches[s.bs.branch_id ?? 'dslily_dstnta'];
let spots = [];
if (Array.isArray(br) && br.length) {
const phase = br[Math.min(P.tent, br.length - 1)] || [];
const devs = (tpl.devices || []);
for (const a of phase) { const d = devs.find((x) => x.alias === a.key); if (d && d.pos) spots.push(d.pos); }
}
if (!spots.length) { const t = b.rng.pick(allTargets(b, e)); if (t) spots = [[t.tileR, t.tileC]]; }
P.tent++;
for (const [r, c] of spots) {
b.fx('tentacle', { x: c, y: r, r: TENTACLE_RADIUS, dur: TENTACLE_STUN });
for (const u of areaAllies(b, e, c, r, TENTACLE_RADIUS)) b.applyStatus(u, 'stun', { duration: TENTACLE_STUN, source: e });
}
},
Doom(b, e, s) { // 【物种爆发】
b.fx('telegraph', { x: e.x, y: e.y, r: 99, dur: DOOM_DELAY, kind: 'speciesBurst', id: e.id });
b.after(DOOM_DELAY, () => { if (e.alive) lpLoss(b, Math.abs(s.bb.value ?? 0), 'speciesBurst', e); }, { owner: e });
},
};
// ---------------------------------------------------------------------------------------------------------------
// 卢西恩,“猩红血钻” (boss_5) + 不祥幻影
function lucienCore(ab, e) {
const pen = T(ab, 'penetrate.def_penetrate') ?? 0, prob = T(ab, 'evade.prob') ?? 0, epr = T(ab, 'combat.attack@ep_damage_ratio') ?? 0;
const s = ab.sk.aoe;
return [
{
spawn(b, e2) { if (pen > 0) b.addBuff(e2, { key: 'boss:pierce', persist: true, mods: { defIgnorePct: pen } }); },
tick(b, e2, a) { // 未被阻挡时有几率闪避物理与法术攻击
const on = !e2.blockedBy && prob > 0;
if (on === a.on) return;
a.on = on;
if (on) b.addBuff(e2, { key: 'boss:evade', persist: true, mods: { dodgePhys: prob, dodgeArts: prob } });
else b.removeBuff(e2, 'boss:evade');
},
dealt(c, b, e2) { elem(b, e2, c.target, 'neural', e2.s.atk * epr); },
},
s && {
// cast with a target in the radius (PRTS 技能 "需要目标"; 不祥幻影 "仅在半径2范围内存在我方单位时触发"): the trigger
// selection (targetsNear — PRTS 选择器 "所有触发选择器通常不无视迷彩"): an airborne (起飞) operator evades a ground
// leader (对地规避 — counting her spent the skill on nobody, §21.22), nor does an unblocking 隐匿 or 迷彩 one count
cd: s.cd, icd: s.icd, cond: (b) => targetsNear(b, e, LUCIEN_AOE_RADIUS).length > 0,
fire(b, e2) {
// "该技能伤害无视迷彩": an area selection (areaAllies) — a 迷彩 operator is hit, an unblocking 隐匿 one not
b.fx('explode', { x: e2.x, y: e2.y, r: LUCIEN_AOE_RADIUS, kind: 'crimsonAoe' });
for (const u of areaAllies(b, e2, e2.x, e2.y, LUCIEN_AOE_RADIUS)) { hurt(b, e2, u, e2.s.atk * (s.bb.atk_scale ?? 0), 'phys'); elem(b, e2, u, 'neural', e2.s.atk * (s.bb.ep_damage_ratio ?? 0)); }
},
},
];
}
function kitLucien(ab, e) {
const s = ab.sk.blink;
return [...lucienCore(ab, e), s && {
cd: s.cd, icd: s.icd, cond: () => !!e.blockedBy,
fire(b, e2) { // blink past the blocker, leaving a 不祥幻影 on its path
const route = remainingRoute(e2);
const from = blinkForward(b, e2, s.bb.dist ?? 1.5);
if (!from) return;
const ph = b.spawnEnemy(PHANTOM_KEY, { pos: [from.y, from.x], route, ownerPlayerId: e2.ownerId, sourcePlayerId: e2.sourcePlayerId, mods: summonMods(b) });
if (ph) b.fx('summon', { x: from.x, y: from.y, id: e2.id, key: PHANTOM_KEY, n: 1 });
},
}];
}
// ---------------------------------------------------------------------------------------------------------------
// 阿利斯泰尔,帝国余晖 (boss_6)
function kitLion(ab, e, b) {
const eq = ab.sk.equip, eqA = ab.sk.equipA, st = ab.sk.store, stA = ab.sk.storeA, su = ab.sk.Suicide;
const P = { sp: 0, adv: false, nextAnim: null, wand: false };
const wandDef = b.data.getEnemy('enemy_10030_vtwand');
const disarm = wandDef && wandDef.talent ? wandDef.talent['1.disarmed_duration'] ?? 0 : 0;
const range = () => e.base.rangeRadius || 3.4;
const cost = eq ? (eq.sp > 0 ? eq.sp : 25) : Infinity;
/** Equipment lying on this field, oldest first. */
const equipment = (b2) => b2.enemies.filter((q) => q.alive && EQUIP_KEYS.includes(q.defId)).sort((p, q) => p.spawnSeq - q.spawnSeq);
const decree = (b2, e2) => { // 【王权号令】 plus-shaped strike + random equipment
const sk = P.adv && eqA ? eqA : eq;
const t = b2.rng.pick(targetsNear(b2, e2, range()));
if (!t) return false;
const r0 = t.tileR, c0 = t.tileC;
const tiles = [[r0, c0], [r0 + 1, c0], [r0 - 1, c0], [r0, c0 + 1], [r0, c0 - 1]];
b2.fx('telegraph', { x: c0, y: r0, r: 1, kind: 'royalDecree', tiles: 'plus', id: e2.id });
for (const u of areaAlliesInTiles(b2, e2, r0, c0, 'plus', 1)) hurt(b2, e2, u, e2.s.atk * ((sk && sk.bb.atk_scale) || 0), 'phys');
const taken = equipment(b2);
const free = tiles.filter(([r, c]) => b2.grid.inRect(r, c) && b2.grid.isLow(r, c) && b2.grid.groundPassable(r, c) && !b2.unitAt(r, c)
&& !taken.some((q) => Math.round(q.y) === r && Math.round(q.x) === c));
const spot = b2.rng.pick(free);
if (spot) {
const key = b2.rng.pick(EQUIP_KEYS);
const q = b2.spawnEnemy(key, { pos: spot, route: { motion: 'FLY', start: spot, end: spot, steps: [{ t: 'wait', s: 99999 }] }, tag: 'part', countInTotal: false, ownerPlayerId: e2.ownerId });
if (q) b2.fx('equip', { x: spot[1], y: spot[0], id: q.id, key });
}
return true;
};
const animate = (b2, e2) => { // 【莫非王土】 the latest equipment (2 at ≤50 %)
const n = P.adv && stA ? stA.bb.max_target ?? 2 : 1;
const sk = P.adv && stA ? stA : st;
for (const q of equipment(b2).slice(-n)) {
if (q.defId === 'enemy_10028_vtswd') {
const t = b2.rng.pick(targetsNear(b2, e2, range()));
if (t) { b2.fx('sword', { x: q.x, y: q.y, from: q.id, to: t.id }); hurt(b2, e2, t, e2.s.atk * ((sk && sk.bb.atk_scale) || 0), 'phys'); }
} else if (q.defId === 'enemy_10029_vtshld') {
const side = Math.sign(q.x - e2.x) || -1;
b2.addBuff(e2, { key: 'boss:vest', shield: (sk && sk.bb.dynamic) || 0, persist: true, visible: true, data: { side, cut: (sk && sk.bb.damage_resistance) || 0 } });
b2.fx('vest', { x: e2.x, y: e2.y, id: e2.id, side });
} else if (q.defId === 'enemy_10030_vtwand') {
P.wand = true; // [substitute for 黄金回响]: next attack becomes arts and disarms its target
b2.fx('equip', { x: q.x, y: q.y, id: q.id, key: q.defId, kind: 'wand' });
}
b2.kill(q, null);
}
};
return [
{
tick(b2, e2, a, dt) {
if (!P.adv && e2.hpRatio <= (T(ab, 'advance.hp_ratio') ?? 0)) { // ≤50 %: DEF +100 %, RES +45, animate two
P.adv = true;
b2.addBuff(e2, { key: 'boss:advance', persist: true, visible: true, mods: { defPct: T(ab, 'advance.def') ?? 0, resFlat: T(ab, 'advance.magic_resistance') ?? 0 } });
b2.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'lionAdvance' });
}
if (!canCast(e2, false)) return;
P.sp += dt * ENEMY_SP_PER_SEC;
if (P.sp >= cost && decree(b2, e2)) P.sp = 0;
// 【莫非王土】: ANIMATE_DELAY s after equipment first lies on the field, then once per 王权号令 cycle
const has = equipment(b2).length > 0;
if (!has) { P.nextAnim = null; return; }
if (P.nextAnim == null) P.nextAnim = b2.time + ANIMATE_DELAY;
if (b2.time >= P.nextAnim) { animate(b2, e2); P.nextAnim = b2.time + (Number.isFinite(cost) ? cost : ANIMATE_DELAY); }
},
hitIn(c, b2, e2) { // vest: damage from its side ×(1 − damage_resistance) while the barrier holds
const v = e2.findBuff('boss:vest');
if (v && v.shield > 0 && c.source && Math.sign(c.source.x - e2.x) === v.data.side) c.dmg.mul *= 1 - v.data.cut;
},
hitOut(c, b2, e2) { if (P.wand && c.dmg.isAttack) c.dmg.type = 'arts'; },
dealt(c, b2, e2) { if (!P.wand) return; P.wand = false; if (disarm > 0) b2.applyStatus(c.target, 'disarm', { duration: disarm, source: e2 }); },
},
su && {
cd: su.cd, icd: su.icd,
fire(b2, e2) { // 【斥退】 "对场上所有我方单位…": every operator it can select (fieldAllies)
b2.fx('telegraph', { x: e2.x, y: e2.y, r: 99, kind: 'repel', id: e2.id });
for (const u of fieldAllies(b2, e2)) hurt(b2, e2, u, e2.s.atk * (su.bb.atk_scale ?? 0), 'arts');
lpLoss(b2, Math.abs(su.bb.value ?? 0), 'repel', e2);
},
},
];
}
function kitEquip() {
return [{ spawn(b, e) { b.addBuff(e, { key: 'boss:equip', persist: true, flags: { invulnerable: true, untargetable: true, noMove: true } }); } }];
}
// ---------------------------------------------------------------------------------------------------------------
// “萨米的意志” (boss_7)
function kitDeer(ab, e) {
const dr = T(ab, 'Madness.damage_resistance') ?? 0;
const atkN = T(ab, 'Madness.enemy_smdeer_mad[attack].max_cnt') ?? 1, skN = T(ab, 'Madness.enemy_smdeer_mad[skill].max_target') ?? 1;
const lasso = ab.sk.Lasso, doom = ab.sk.Doom;
const P = { low: false, acc: Infinity }; // like engine attacks, the first 冰凌 comes as soon as it has a target
return [
{
spawn(b, e2) { e2.profile.noAttack = true; },
tick(b, e2, a, dt) {
if (!P.low && e2.hpRatio < 0.5) { // <50 %: damage taken reduced, extra targets
P.low = true;
b.addBuff(e2, { key: 'boss:madness', persist: true, visible: true, mods: { physTakenMul: 1 - dr, artsTakenMul: 1 - dr } }); // 物理和法术伤害降低
b.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'deerMadness' });
}
if (!canCast(e2, false)) return;
P.acc += dt;
if (P.acc < e2.s.interval) return;
const cands = fairOrder(b, e2, allTargets(b, e2), P);
if (!cands.length) return;
P.acc = 0;
const cols = [];
for (const t of cands) { if (!cols.includes(t.tileC)) cols.push(t.tileC); if (cols.length >= (P.low ? atkN : 1)) break; }
for (const c of cols) { // 【冰凌】 its normal attack: the whole column (an area selection: no unblocking 隐匿 operator)
const hit = b.allies().filter((u) => u.tileC === c && areaSelectable(e2, u));
b.fx('column', { x: c, y: e2.y, c, id: e2.id });
for (const u of hit) b.dealDamage(e2, u, { amount: e2.s.atk, type: 'phys', isAttack: true });
}
e2.lastAttackAt = b.time;
e2.stats.attacks++;
},
},
lasso && {
cd: lasso.cd, icd: lasso.icd, cond: (b) => allTargets(b, e).length > 0,
fire(b, e2) { // 【自然涌动】 stun + arts per second
const n = P.low ? skN : (lasso.bb.max_target ?? 1);
const dur = lasso.bb.projectile_life_time ?? 0;
for (const t of fairOrder(b, e2, allTargets(b, e2), P).slice(0, n)) {
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'naturalSurge', dur });
b.applyStatus(t, 'stun', { duration: dur, source: e2 });
b.addBuff(t, { key: 'boss:surge', duration: dur, interval: 1, visible: true,
onTick: ({ battle, unit }) => battle.dealDamage(e2, unit, { amount: e2.s.atk * (lasso.bb.atk_scale ?? 0), type: 'arts', canDodge: false, ignoreSelect: true, tags: ['enemyAbility'] }) });
}
},
},
doom && {
cd: doom.cd, icd: doom.icd,
fire(b, e2) {
b.fx('telegraph', { x: e2.x, y: e2.y, r: 99, kind: 'samiDoom', id: e2.id });
for (const u of fieldAllies(b, e2)) hurt(b, e2, u, e2.s.atk * (doom.bb.atk_scale ?? 0), 'arts');
lpLoss(b, Math.abs(doom.bb.value ?? 0), 'samiDoom', e2);
},
},
];
}
// ---------------------------------------------------------------------------------------------------------------
export const BOSS_KITS = Object.freeze({
enemy_9013_acstmk: kitHelm, // boss_1 假想敌:胄
enemy_9013_acstmk_2: kitHelm, // boss_8 假想敌:胄 (隐秘核心)
enemy_9014_acstma: kitBlade, // “斩胄之剑”
enemy_9015_acstmb: kitBlade, // “破胄之锤”
enemy_9016_acstmr: kitShell, // 刺胄之弹
enemy_9017_achunt: kitGun, // boss_2 假想敌:铳
enemy_9017_achunt_2: kitGun, // boss_9 假想敌:铳 (隐秘核心)
enemy_9018_actrpa: kitSpring, // “碎铳之簧” 法术护盾
enemy_9019_actrpb: kitSpring, // “碎铳之簧” 元素护盾
enemy_9020_actrpc: kitSpring, // “碎铳之簧” 频次护盾
enemy_9021_acduml: kitPipe, // boss_3 假想敌:管
enemy_9021_acduml_2: kitPipe, // boss_10 假想敌:管 (隐秘核心)
enemy_9022_acdumm: kitString, // 假想敌:弦
enemy_9023_acdums: kitEcho, // “余音”
enemy_1521_dslily: kitQuintus, // boss_4 盐风主教昆图斯
enemy_2016_csphtm: kitLucien, // boss_5 卢西恩,“猩红血钻”
enemy_2017_csphts: lucienCore, // 不祥幻影
enemy_9032_aclionk: kitLion, // boss_6 阿利斯泰尔,帝国余晖
enemy_10028_vtswd: kitEquip, // 未装配刀片 (inert until animated)
enemy_10029_vtshld: kitEquip, // 防护背心
enemy_10030_vtwand: kitEquip, // 冲击式施术单元
enemy_9033_acdeer: kitDeer, // boss_7 “萨米的意志”
});
export const BOSS_KEYS = Object.freeze(Object.keys(BOSS_KITS));