Files
ark-stronghold-protocol/server/sim/content/enemies/invisible.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

151 lines
10 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/enemies/invisible.js — INVISIBLE 隐匿 kits (stealthy enemies) and their part of KITS (split from
// content/enemies.js).
import { canTargetAlly, enemyStealthed } from '../../targeting.js';
import { T, elem, hurt, targetsNear, byPriority, auraBuff } from './helpers.js';
import { stealth, onHitStatus, skill, kitStealth } from './archetypes.js';
import { hypot } from '../../detmath.js';
// ---------------------------------------------------------------------------------------------------------------
// constants (numbers that exist nowhere in the data)
/** "数个目标" of 假想敌:骨刺 while stealthed [ASSUMED]. */
const ACBUNN_TARGETS = 3;
/** 重弩突袭者 直击 reach along a row/column [ASSUMED]; the skill's length and its charge before the bolt (PRTS 直击 "蓄力1.4s后
* 向目标方向发射1支弩箭…※技能持续2.5s"). */
const CROSS_REACH = 6, CROSS_SKILL = 2.5, CROSS_CHARGE = 1.4;
// ---------------------------------------------------------------------------------------------------------------
// kits
function kitCrossbow(ab) {
const s = ab.sk.CrossAttack;
const aligned = (b, e) => b.allies().filter((u) => canTargetAlly(e, u, true) && (Math.abs(u.y - e.y) < 0.5 || Math.abs(u.x - e.x) < 0.5) && hypot(u.x - e.x, u.y - e.y) <= CROSS_REACH);
const nearest = (b, e, l) => l.sort((p, q) => hypot(p.x - e.x, p.y - e.y) - hypot(q.x - e.x, q.y - e.y))[0];
// PRTS 重弩突袭者: 天赋 "隐匿(被阻挡,主动攻击期间均可解除)"; 直击 "蓄力1.4s后向目标方向发射1支弩箭,对击中的首个目标造成攻击力100%
// 的法术伤害与5s晕眩 ※技能持续2.5s": revealed for the whole skill, the bolt leaves CROSS_CHARGE s in — at the nearest unit still
// in line in the aimed direction; it stands meanwhile [ASSUMED], and a stun / silence or its death before the release
// cancels the shot [ASSUMED]. The blackboard's duration (2) is not the skill's length PRTS gives. Until 0.1.3: revealed
// 2 s, the bolt at once.
return [stealth(), skill(s, (b, e) => {
const t = nearest(b, e, aligned(b, e));
if (!t) return;
const bb = s.bb;
const dx = Math.sign(Math.round(t.x - e.x)), dy = Math.sign(Math.round(t.y - e.y));
b.addBuff(e, { key: 'ab:revealed', duration: CROSS_SKILL, flags: { reveal: true } });
b.addBuff(e, { key: 'ab:aim', duration: CROSS_SKILL, flags: { noMove: true, disarm: true } });
b.after(CROSS_CHARGE, () => {
if (!e.alive || e.hidden || e.s.flags.stun || e.s.flags.silence) return;
const tt = nearest(b, e, aligned(b, e).filter((u) => Math.sign(Math.round(u.x - e.x)) === dx && Math.sign(Math.round(u.y - e.y)) === dy));
if (!tt) return;
b.addProjectile({ from: e, target: tt, speed: 12, visual: 'enemy', source: e, onHit: (c) => {
if (!c.target || !c.target.alive) return;
hurt(b, e, c.target, e.s.atk * (bb.atk_scale ?? 1), 'arts');
if (bb.stun > 0 && c.target.alive) b.applyStatus(c.target, 'stun', { duration: bb.stun, source: e });
} });
}, { owner: e });
}, { sil: true, cond: (b, e) => aligned(b, e).length > 0 })];
}
/**
* 山海众头目 / 山海众秘使 (PRTS 天赋 "隐匿,该隐匿每次生效后自身获得强击标记(不可叠加)"; 技能 破隐一击 "仅持有强击标记且被阻挡时可
* 触发:对阻挡目标造成攻击力200%的物理普通伤害,技能开始时消耗强击标记"): the mark comes with its 隐匿 — at the spawn and every
* time it hides again (3 s after a block, Battle._stealthSwitch; or once a 反隐 ends) — and its next attack (a melee
* one: blocked) spends it at InvisibleCombat.atk_scale. A new block inside the 3 s gives no new mark (until 0.1.2 every
* block did).
*/
function kitShadowKiller(ab) {
const scale = (ab.sk.InvisibleCombat && ab.sk.InvisibleCombat.bb.atk_scale) || 1;
return [stealth(), {
spawn(b, e, a) { a.power = true; a.on = true; },
tick(b, e, a) { const on = enemyStealthed(e); if (on && !a.on) a.power = true; a.on = on; },
hitOut(c, b, e, a) { if (a.power && c.dmg.isAttack) c.dmg.amount *= scale; },
attack(c, b, e, a) { a.power = false; },
}];
}
function kitJazz(ab, e) {
const s = ab.sk.fire;
const r = (e.def.raw && e.def.raw.stats && e.def.raw.stats.rawRangeRadius) || 2.5;
const revealed = (u) => !enemyStealthed(u); // blocked, 反隐 (its 隐匿 returns 0 s after a block)
return [stealth(), {
// PRTS 天赋 "隐匿期间不进行普通攻击;未受隐匿影响时进入反击模式:仅进行阻挡攻击,造成100%物理伤害": its normal attack only ever
// hits its blocker (`melee`), so a revealed (反隐) unblocked one neither shoots nor stands for its attack clip (until
// 0.1.3 it shot everyone within its 2.5 range and stood still while it had a target — ai.js attackStand)
spawn(b, e2) { e2.profile.noAttack = true; e2.profile.melee = true; },
tick(b, e2) { e2.profile.noAttack = !revealed(e2); }, // 平时不攻击,失去隐匿时反击
}, s ? {
// 狂欢式演奏 (PRTS 节日爵士乐手, 反击模式): "仅攻击范围内存在我方单位时可触发:锁定目标持续施法,最多持续10.6s,每0.5s对目标
// 造成攻击力20%的法术伤害和攻击力10%的灼燃损伤 ※施法期间受到沉默影响后,立即结束技能" — ONE locked target (its own
// priority: the blocker first), floor(10.6 / 0.5) = 21 ticks at 0.5 … 10.5 s. The channel also ends when that target
// is gone, makes no normal attacks meanwhile and is never re-cast over itself — the 10 s cooldown (enemy_database
// skill 'fire') runs out mid-channel, the next cast waits for its end [ASSUMED "持续施法", as 死亡之眼].
sil: true, cd: s.cd, icd: s.icd, cond: (b, e2) => revealed(e2) && !(e2.mem.jazzChannel && !e2.mem.jazzChannel.cancelled) && targetsNear(b, e2, r).length > 0,
fire(b, e2) {
const dur = s.bb['enemy_cnvsax[cd].duration'] ?? 0, iv = s.bb.hit_interval ?? 0.5;
const t = byPriority(e2, targetsNear(b, e2, r))[0];
if (!t || !(dur > 0) || !(iv > 0)) return;
const n = Math.max(1, Math.floor(dur / iv + 1e-9));
const buff = b.addBuff(e2, { key: 'ab:channel', duration: dur, flags: { disarm: true } });
let k = 0;
const end = () => { h.cancel(); e2.mem.jazzChannel = null; if (buff) b.removeBuff(e2, buff); };
const h = b.every(iv, () => {
if (!e2.alive || e2.s.flags.silence || !t.alive || !t.deployed) { end(); return; }
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'jazzFire' });
hurt(b, e2, t, e2.s.atk * (s.bb.atk_scale ?? 0), 'arts');
elem(b, e2, t, 'burn', e2.s.atk * (s.bb.ep_damage_ratio ?? 0));
if (++k >= n) end();
}, { owner: e2 });
e2.mem.jazzChannel = h;
},
} : null];
}
function kitShadowBlade(ab) {
const bat = ab.t['traitAbility.base_attack_time'] ?? 0;
return [stealth(), {
iv: 0.5,
tick(b, e) {
const partner = b.enemiesInRadius(e.x, e.y, 3).find((o) => /enemy_1174_duholy/.test(o.defId));
const r = partner && partner.mem.ab ? (T(partner.mem.ab, 'traitAbility.range_radius') ?? 1.1) : 1.1;
if (partner && hypot(partner.x - e.x, partner.y - e.y) <= r + 1e-9 && e.base.bat > 0) auraBuff(b, e, 'ab:shadowSync', 0.5, { batPct: bat / e.base.bat });
},
}];
}
function kitBoneSpike() {
return [stealth(), {
before(c, b, e) {
if (!enemyStealthed(e)) return; // 隐匿状态下同时攻击数个目标 (back 0 s after a block)
const l = byPriority(e, targetsNear(b, e, e.base.rangeRadius));
if (l.length) c.targets = l.slice(0, ACBUNN_TARGETS);
},
}];
}
// ---------------------------------------------------------------------------------------------------------------
// this family's part of KITS (content/enemies.js spreads the parts in this order)
export const INVISIBLE_KITS = Object.freeze({
// --- INVISIBLE 隐匿
enemy_1009_lurker: kitStealth, // 潜伏者 · stealth
enemy_1019_jshoot: kitStealth, // 隐形弩手 · stealth
enemy_1019_jshoot_2: kitStealth, // 隐形弩手组长 · stealth
enemy_1023_jmage: kitStealth, // 隐形术师 · stealth
enemy_1283_sgkill: kitStealth, // 家族灭迹人 · stealth (清算时刻 n/a)
enemy_1283_sgkill_2: kitStealth, // 家族暗影灭迹人 · stealth
enemy_1299_ymkilr: kitShadowKiller, // 山海众头目 · stealth; its 隐匿 turning on brings the mark: next attack ×InvisibleCombat.atk_scale (§22.8)
enemy_1299_ymkilr_2: kitShadowKiller, // 山海众秘使 · same
enemy_1389_winbab_2: kitStealth, // 访问团强攻冠军 · stealth (供暖器 priority n/a)
enemy_1404_msnip: kitCrossbow, // 重弩突袭者 · stealth + 直击 (row/column bolt: arts + stun, reveals itself)
enemy_10031_cnvsld: kitStealth, // 业余竞演者 · stealth
enemy_10034_cnvsax: kitJazz, // 节日爵士乐手 · stealth; revealed: attacks its blocker only; channel on one target (arts + burn)
enemy_10042_prtrop: kitStealth, // 架桥船工 · stealth (bridges n/a)
enemy_10042_prtrop_2: kitStealth, // 扶桥老手 · stealth
enemy_9008_acbunn: kitBoneSpike, // 假想敌:骨刺 · stealth; hits 3 targets while stealthed
enemy_1175_dushdo_2: kitShadowBlade, // 深池伙友影刃精英 · stealth; BAT −1.3 s next to 卫队精英
enemy_2034_sythef: (ab) => [stealth(), onHitStatus('stun', T(ab, 'Combat.attack@stun'))], // 流泪小子 · stealth, attacks stun
enemy_2034_sythef_2: (ab) => [stealth(), onHitStatus('stun', T(ab, 'Combat.attack@stun'))], // 流泪小子 · same
});