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

403 lines
20 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/helpers.js — shared helpers of the enemy kits (split from content/enemies.js; the module
// map is in its header): the constants several families use, the per-battle state, the ability bookkeeping (abOf /
// attach / dispatch, T, canCast …) and the selection and damage helpers (elem, hurt, areaAllies & friends, zone,
// spawnChildren, setForm, lpLoss, expose …).
import { ELEMENT } from '../../constants.js';
import { canTargetAlly, sortAllyTargets, areaSelectable, auraSelectable } from '../../targeting.js';
import { mitigate } from '../../damage.js';
import { hypot } from '../../detmath.js';
// ---------------------------------------------------------------------------------------------------------------
// constants (numbers that exist nowhere in the data)
/** 侵蚀 → engine gauge. */
export const EROSION = 'erosion';
/** Official erosion burst on operators (gamedata_const termDescriptionDict ba.dt.erosion; engine constants ELEMENT.erosion.ally). */
export const EROSION_BURST = Object.freeze({ defDown: ELEMENT.erosion.ally.defDown, physDamage: ELEMENT.erosion.ally.damage });
/** 重生 of a Revive[Trigger] knock-out before its husk (PRTS 深池逐火战士 and 假想敌:再生 天赋: "被击倒后重生,持续1s,随后变为
* 怨恨的余烬 / 再生状态,1s内不移动且持有无敌+无法阻挡+失衡免疫"; both models' knock-out clips last 1 s). */
export const HUSK_REBIRTH = 1;
/** Death-explosion radius when the enemy has no official radius [ASSUMED]. */
const BOOM_RADIUS = 1.25;
/** 【污染秽蚀】 ticks once per second; "同名效果不叠加": a unit inside several zones takes one tick per second. */
const POLLUTION_INTERVAL = 1;
/**
* 隐匿 that comes back sooner after a block than the general 3 s (PRTS 作战机制 §隐匿 "对于绝大部分可隐匿的敌人而言…不被阻挡的
* 3秒后重新进入隐匿"; engine STEALTH_RESTORE): the enemy pages' 天赋 "隐匿(解除阻挡N秒后恢复)" — PRTS text search
* "解除阻挡0秒后恢复" (21 enemies; these 6 are in this mode) and "解除阻挡1秒后恢复" (the two 家族灭迹人), checked
* 2026-10-03. Every other stealthy enemy here has a plain "隐匿" (3 s), the 深池逐火 embers included. 清明 / 堂皇's veil
* ("获得隐匿(解除阻挡0秒后恢复)") is kitInvisShield's.
*/
const STEALTH_RESTORE_BY_KEY = Object.freeze({
enemy_10031_cnvsld: 0, // 业余竞演者
enemy_10034_cnvsax: 0, // 节日爵士乐手
enemy_9008_acbunn: 0, // 假想敌:骨刺
enemy_2034_sythef: 0, // 流泪小子
enemy_2034_sythef_2: 0, // 流泪小子 (its stronger copy)
enemy_1389_winbab_2: 0, // 访问团强攻冠军
enemy_1283_sgkill: 1, // 家族灭迹人
enemy_1283_sgkill_2: 1, // 家族暗影灭迹人
});
/**
* 隐匿 of the 鸭爵 strategy's swapped-in enemies (spawn tag 'duck': content/bands/meta.js duckReplace) comes back no sooner
* than this many seconds after a block ends — in practice 流泪小子 (enemy_2034_sythef_2, the only one of the four with 隐匿;
* officially 0 s, STEALTH_RESTORE_BY_KEY). A deliberate deviation from the official game: the owner's decision of
* 2026-10-07 「鸭爵策略产生的泪眼汪汪…要不等待个1秒这样再重新隐匿」 (community report of 2026-10-07, GitHub #214). The same
* enemy anywhere else — the 流泪小子 of a bounty card (enemy_2034_sythef), a test spawn — keeps the official time.
*/
const DUCK_STEALTH_RESTORE = 1;
/** Splash radius (中点判定 around the target) of 烹泉 / 沏虹's attack (PRTS 天赋 "普通攻击对目标对及目标周围半径1.0范围内的所有
* 我方单位造成法术普通伤害(无视迷彩,不可对空)") and of 集团军重型火炮's shell (PRTS "对目标半径1.0范围内的所有我方单位造成攻击力
* 100%的物理伤害(此弹道会强制击中主目标,碰撞无视迷彩,不可对空)"). */
const TEA_SPLASH_RADIUS = 1, SHELL_SPLASH_RADIUS = 1;
/** Every Nth attack for "数次攻击后" abilities without an SP cost in the data [ASSUMED]; with a cost N = spCost + 1
* (official 粉碎攻坚手 text for spCost 2: "攻击2次后,下一次攻击" ⇒ 3rd; 码头水手 / 澪 / 清扫小队 spCost 3 ⇒ 4th). */
const NTH_ATTACK = 3;
export const nthOf = (s) => (s && s.sp > 0 ? s.sp + 1 : NTH_ATTACK);
// ---------------------------------------------------------------------------------------------------------------
// per-battle state + installation
const STATE = new WeakMap();
function stOf(b) {
let st = STATE.get(b);
if (!st) { st = { lpLoss: 0, freedAll: false, deathWatch: new Set() }; STATE.set(b, st); }
return st;
}
// ---------------------------------------------------------------------------------------------------------------
// ability bookkeeping
/** The enemy's ability record (created on demand). */
export function abOf(b, e) {
if (e.mem.ab) return e.mem.ab;
const key = e.defId;
const ov = (b.enemyOverrides && (b.enemyOverrides[key] ?? b.enemyOverrides[e.def && e.def.key])) || null;
const raw = (e.def && e.def.raw) || {};
const t = { ...((e.def && e.def.talent) || {}), ...((ov && ov.talents && ov.talents.bb) || {}) };
const tS = { ...((raw.talents && raw.talents.bbStr) || {}), ...((ov && ov.talents && ov.talents.bbStr) || {}) };
const sk = {};
for (const s of (ov && Array.isArray(ov.skills) ? ov.skills : (e.def && e.def.skills) || [])) {
if (!s || s.prefabKey == null) continue;
sk[s.prefabKey] = { cd: num(s.cooldown, 0), icd: num(s.initCooldown, 0), sp: num(s.spCost, 0), bb: s.bb || {}, bs: s.bbStr || {} };
}
e.mem.ab = { key, t, tS, sk, list: [], times: null, hitShield: 0, atkType: null, immune: null, origMaxHp: e.base.maxHp };
return e.mem.ab;
}
/** Attach abilities to an enemy (calls their spawn()). */
export function attach(b, e, list) {
const ab = abOf(b, e);
for (const a of list) {
if (!a) continue;
if (a.cd != null) a.left = num(a.left, num(a.icd, 0));
ab.list.push(a);
if (a.spawn) safe(b, e, () => a.spawn(b, e, a, ab));
}
return ab;
}
function safe(b, e, fn) {
try { return fn(); } catch (err) { b._handlerError('content:enemies', e, err); return undefined; }
}
function dispatch(b, e, name, c) {
const ab = e && e.mem && e.mem.ab;
if (!ab) return false;
let any = false;
for (const a of ab.list) {
const f = a[name];
if (!f) continue;
if (a.sil && e.s.flags.silence) continue;
try { if (f.call(a, c, b, e, a, ab)) any = true; } catch (err) { b._handlerError(`enemies:${name}`, e, err); }
}
return any;
}
const num = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
/** First finite talent value among keys. */
export function T(ab, ...keys) { for (const k of keys) { const v = ab.t[k]; if (typeof v === 'number' && Number.isFinite(v)) return v; } return undefined; }
export function silenced(e) { return !!e.s.flags.silence; }
export function canCast(e, sil) { return e.alive && !e.hidden && !e.s.flags.stun && !(sil && e.s.flags.silence); }
// ---------------------------------------------------------------------------------------------------------------
// helpers (exported for bosses.js)
/** Element damage (erosion mapped onto the engine gauge); `ignoreSelect` / `tags` as hurt(). */
export function elem(b, src, tgt, el, amount, { ignoreSelect = false, tags = [] } = {}) {
if (!tgt || !tgt.alive || !(amount > 0)) return 0;
const element = el === 'erosion' ? EROSION : el;
return b.dealDamage(src, tgt, { type: 'element', element, amount, ignoreSelect, tags: ['enemyAbility', ...tags] });
}
/**
* Skill / ability damage (no dodge unless asked). `ignoreSelect` = no selection the target's 无法选择 effects stop
* (abilities that "无视无法选择", direct picks, a flying unit's blast credited to a ground leader): it also reaches an
* airborne 起飞 ally, which a ground enemy's damage otherwise skips (damage.js, targeting.js evadesGround).
*/
export function hurt(b, src, tgt, amount, type = 'phys', { canDodge = false, tags = [], isSkill = true, ignoreSelect = false } = {}) {
if (!tgt || !tgt.alive || !(amount > 0)) return 0;
return b.dealDamage(src, tgt, { amount, type, canDodge, isSkill, ignoreSelect, tags: ['enemyAbility', ...tags] });
}
/** Allies whose tile is within `n` of (r, c): 'plus' = Manhattan (周围四格), 'box' = Chebyshev (周围8格). */
export function alliesInTiles(b, r, c, shape = 'plus', n = 1) {
const out = [];
for (const a of b.allies()) {
if (a.hidden) continue;
const dr = Math.abs(a.tileR - r), dc = Math.abs(a.tileC - c);
if (shape === 'plus' ? dr + dc <= n : Math.max(dr, dc) <= n) out.push(a);
}
return out;
}
/** Allies within radius that enemy `e` could target (ranged rules: stealth / untargetable skipped). */
export function targetsNear(b, e, r, { ranged = true, x = e.x, y = e.y } = {}) {
return b.alliesInRadius(x, y, r).filter((a) => canTargetAlly(e, a, ranged));
}
/** Every targetable ally on the field. */
export function allTargets(b, e) {
return b.allies().filter((a) => canTargetAlly(e, a, true));
}
/**
* The allies an AREA effect of enemy `src` selects within `r` of (x, y) — splash, blast, area skill / status, pulse, zone
* tick, chain or bounce jump (targeting.js areaSelectable: no 隐匿 ally, the blocker included, no untargetable or sleeping ally, no 起飞 one
* for a ground `src`; 迷彩 is not checked). `src` = the enemy whose effect it is (null: none — 隐匿 still applies). A
* locked target goes first through targetAndArea. Abilities PRTS marks "无视无法选择 / 无视可选性" use b.alliesInRadius with
* `ignoreSelect` instead.
*/
export function areaAllies(b, src, x, y, r) {
return b.alliesInRadius(x, y, r).filter((a) => areaSelectable(src, a));
}
/** alliesInTiles for an area effect of `src` (周围四格 / 周围八格 / cross / 3×3 areas — 格子判定): areaSelectable. */
export function areaAlliesInTiles(b, src, r, c, shape = 'plus', n = 1) {
return alliesInTiles(b, r, c, shape, n).filter((a) => areaSelectable(src, a));
}
/** Every ally on the field a whole-field ability of `src` selects ("对场上所有我方单位…": 【大潮】, 【斥退】 …). */
export function fieldAllies(b, src) {
return b.allies().filter((a) => areaSelectable(src, a));
}
/**
* The allies within `r` of (x, y) a buff aura of enemy `src` takes (targeting.js auraSelectable: no 隐匿 ally, the blocker included,
* untargetable or sleeping ally — PRTS "隐匿状态下的单位一般无法被敌方的…Buff选择器选中"; an airborne 起飞 ally is still
* taken [ASSUMED, §21.22]). 寒霜's aura, which PRTS says ignores 隐匿, keeps b.alliesInRadius (allyAura).
*/
export function auraAllies(b, src, x, y, r) {
return b.alliesInRadius(x, y, r).filter((a) => auraSelectable(src, a));
}
/**
* A locked target `t` and the allies an area takes around it: the target was picked directly when the ability started
* (an attack's target, a channel's or a C4's lock), so a 隐匿 it gains meanwhile does not save it — PRTS 异常效果 "'直接选中'
* 的能力不会进行具体的目标选择,故同样不受这些仅在选择时生效的异常效果制约"; only the others are an area selection (`area`).
*/
export function targetAndArea(t, area) {
return t && t.alive && t.deployed ? [t, ...area.filter((u) => u !== t)] : area;
}
/** Engine priority (blocker → taunt → latest deployed) on a copy. */
export function byPriority(e, list) { return sortAllyTargets(e, list.slice()); }
/**
* An HP-loss / damage zone. `tick(units)` runs every `iv` s for `life` s on the allies inside: `pick(x, y, r)` chooses
* them — by default every ally in the circle (【污染秽蚀】 "无视无法选择"); the other zones (dmgZone, 烹泉's steam) pass
* their area selection (areaAllies).
*/
export function zone(b, { x, y, r, life, iv = 1, kind = 'zone', tick, pick = null }) {
b.fx('zone', { x, y, r, dur: life, kind });
let left = life;
const h = b.every(iv, () => {
tick(pick ? pick(x, y, r) : b.alliesInRadius(x, y, r));
left -= iv;
if (left <= 1e-6) h.cancel();
});
return h;
}
/** Remaining route of an enemy from its current position (for summons / phantoms that continue its path). */
export function remainingRoute(e) {
const R = e.route;
const here = [e.y, e.x];
if (!R || !Array.isArray(R.legs) || !R.legs.length) return { motion: e.motion === 'FLY' ? 'FLY' : 'WALK', start: here, end: here, checkpoints: [] };
const steps = [];
let end = null;
for (let i = Math.max(0, R.legIdx); i < R.legs.length; i++) {
const L = R.legs[i];
if (L.final) { end = [L.r, L.c]; break; }
if (L.t === 'move') steps.push({ t: 'move', p: [L.r, L.c] });
else if (L.t === 'wait') steps.push({ t: 'wait', s: i === R.legIdx && R.waitLeft != null ? R.waitLeft : L.time });
else if (L.t === 'disappear') steps.push({ t: 'disappear' });
else if (L.t === 'appear') steps.push({ t: 'appear', p: [L.r, L.c] });
}
if (!end) { const last = R.legs[R.legs.length - 1]; end = last && last.r != null ? [last.r, last.c] : [Math.round(e.y), Math.round(e.x)]; }
const route = { motion: e.motion === 'FLY' ? 'FLY' : 'WALK', start: here, end, checkpoints: [] };
if (steps.length) route.steps = steps;
return route;
}
/** A route that keeps an enemy in place (content moves it by hand). */
export function stayRoute(e, secs = 99999) {
const p = [Math.round(e.y), Math.round(e.x)];
return { motion: e.motion === 'FLY' ? 'FLY' : 'WALK', start: p, end: p, steps: [{ t: 'wait', s: secs }] };
}
/**
* Mods copied onto a split or summoned child. The parent's stat multipliers travel; a kill bounty does not
* (owner 2026-10-04: the main body alone carries it — GitHub #67, #89-2). `bountyId` is the 悬赏 card and
* `bountyCoins` is the coin copy the match writes for 联防. The parent object is left as it is.
*/
function modsWithoutBounty(mods) {
if (!mods || typeof mods !== 'object') return mods ?? null;
if (mods.bountyId == null && mods.bountyCoins == null) return mods;
const copy = { ...mods };
delete copy.bountyId;
delete copy.bountyCoins;
return copy;
}
/** Spawn `n` enemies at the parent's position that continue its route. Returns the spawned units. */
export function spawnChildren(b, parent, key, n, opts = {}) {
const out = [];
const route = opts.route ?? remainingRoute(parent);
const cnt = Math.max(0, Math.min(20, Math.floor(n)));
const mods = modsWithoutBounty(opts.mods ?? parent.mods ?? null);
for (let i = 0; i < cnt; i++) {
const off = cnt > 1 ? (i - (cnt - 1) / 2) * 0.2 : 0;
const pos = opts.pos ?? [parent.y, parent.x + off];
const c = b.spawnEnemy(key, {
pos, route, mods, tag: opts.tag ?? null, countInTotal: opts.countInTotal,
ownerPlayerId: parent.ownerId ?? null, sourcePlayerId: parent.sourcePlayerId ?? null,
});
if (c) out.push(c);
}
if (out.length) b.fx('summon', { x: parent.x, y: parent.y, id: parent.id, key, n: out.length });
return out;
}
/** Move a unit straight toward (tx, ty) by `dist` tiles. Returns true on arrival. */
export function stepToward(u, tx, ty, dist) {
const dx = tx - u.x, dy = ty - u.y;
const d = hypot(dx, dy);
if (d <= dist + 1e-9) { u.x = tx; u.y = ty; return true; }
u.x += (dx / d) * dist;
u.y += (dy / d) * dist;
return false;
}
/** Set a 频次 unit's hits (maxHp := hits, full): `n` rounded to a whole count, at least 1. */
export function setHits(e, n) {
const h = Math.max(1, Math.round(n));
e.base.maxHp = h;
e.markDirty();
void e.s;
e.hp = h;
}
const HIT_COUNT_KEY = 'ab:hitCount';
/** 频次 on/off: every damage instance removes exactly 1 HP (engine flag; `artsOnly` = 茶器, physical instances remove 0). */
export function hitCount(b, e, on, artsOnly = false) {
if (!on) { b.removeBuff(e, HIT_COUNT_KEY); return; }
b.addBuff(e, { key: HIT_COUNT_KEY, persist: true, flags: artsOnly ? { hitCountArts: true } : { hitCount: true } });
}
export const isHitCount = (e) => !!e.findBuff(HIT_COUNT_KEY);
/**
* The enemy's model takes another form (render/units.js FORMS: a clip set of its skeleton): kept on the unit
* (`e.form`, published by snapshot.js unitInfo — a view built mid-battle from fieldMeta(): a teammate's field watched
* later, 联防 observers, a reconnect — starts in it) and announced by the fx `fxKind` (+ id, x, y, `params`) carrying it
* as `form` (a 'phase' fx also as its `kind`). The `form` key marks the fx as state (shared/protocol.js fxForm): the
* client never drops it, not in a catch-up frame nor while its tab is hidden. Barrier / charge 'phase' kinds are no
* forms: they go through b.fx directly, without `form`.
*/
export function setForm(b, e, form, fxKind = 'phase', params = null) {
e.form = form;
b.fx(fxKind, fxKind === 'phase' ? { ...params, x: e.x, y: e.y, id: e.id, kind: form, form } : { ...params, x: e.x, y: e.y, id: e.id, form });
}
/** Leader "扣除目标生命" effects: recorded for the match (hook 'lpLoss' + result.lpLoss). */
export function lpLoss(b, amount, reason, source = null) {
if (!(amount > 0)) return;
const st = stOf(b);
st.lpLoss += amount;
b.fx('lpLoss', { x: source ? source.x : 0, y: source ? source.y : 0, value: amount, reason });
b.emit('lpLoss', { amount, reason, source });
}
/** 暴露: damage taken ×scale for `dur` s. */
export function expose(b, u, dur, scale) {
if (!u || !u.alive || !(dur > 0)) return;
b.addBuff(u, { key: 'ab:exposed', duration: dur, refresh: 'extend', mods: { dmgTakenMul: scale }, visible: true });
b.fx('expose', { x: u.x, y: u.y, id: u.id });
}
/**
* Final HP damage the pipeline would deal for a phys/arts/true/elemental `dmg` before shields (mitigation is linear in
* the amount) — the same terms as damage.js dealDamage (元素伤害: 元素抗性 + elementalTakenMul, no dmgTakenMul).
*/
export function expectedFinal(src, tgt, dmg) {
const ts = tgt.s, ss = src && src.s && !dmg.sourceless ? src.s : null, ty = dmg.type; // 无来源 (bursts): no source stats
const v = mitigate(dmg.amount, ty, ts, {
defIgnorePct: (dmg.defIgnorePct || 0) + (ss ? ss.defIgnorePct : 0), defIgnoreFlat: (dmg.defIgnoreFlat || 0) + (ss ? ss.defIgnoreFlat : 0),
resIgnorePct: (dmg.resIgnorePct || 0) + (ss ? ss.resIgnorePct : 0), resIgnoreFlat: (dmg.resIgnoreFlat || 0) + (ss ? ss.resIgnoreFlat : 0),
elementalRes: ty === 'elemental' ? (tgt.def?.epDamageResistance ?? 0) : 0,
});
let mul = (dmg.mul ?? 1) * (ty === 'elemental' ? 1 : ts.dmgTakenMul); // 脆弱 skips 元素伤害 (damage.js)
if (ss) mul *= ss.dmgDealtMul * (ty === 'phys' ? ss.physDealtMul : ty === 'arts' ? ss.artsDealtMul : 1);
mul *= ty === 'phys' ? ts.physTakenMul : ty === 'arts' ? ts.artsTakenMul : ty === 'elemental' ? ts.elementalTakenMul : ts.trueTakenMul;
const f = v * mul;
return Number.isFinite(f) && f > 0 ? f : 0;
}
/**
* "吸收法术伤害的屏障": absorb up to `left` of an arts instance in a `hit` handler (after RES, like a shield) by scaling
* the instance down (cancelled when fully absorbed). Returns the amount absorbed.
*/
export function absorbArts(c, left) {
if (!c || c.dmg.type !== 'arts' || !(left > 0) || c.dmg.cancel) return 0;
const f = expectedFinal(c.source, c.target, c.dmg);
if (!(f > 0)) return 0;
const take = Math.min(left, f);
if (take >= f - 1e-9) c.dmg.cancel = true;
else c.dmg.amount *= (f - take) / f;
return take;
}
/** Timed buff on each unit (same key never stacks: aura semantics). */
function auraBuff(b, u, key, iv, mods, flags = null, visible = false) {
b.addBuff(u, { key, duration: iv + 0.1, refresh: 'replace', mods, flags, visible });
}
/** Mark an ally/enemy ability as watching every death on the field. */
function watchDeaths(b, e) { stOf(b).deathWatch.add(e); }
const tileOf = (u) => [Math.round(u.y), Math.round(u.x)];
const onTerrain = (b, u, terrain) => { const [r, c] = tileOf(u); return b.grid.tile(r, c).terrain === terrain; };
// used by the other enemy modules (content/enemies.js and content/enemies/*.js)
export {
BOOM_RADIUS, POLLUTION_INTERVAL, STEALTH_RESTORE_BY_KEY, DUCK_STEALTH_RESTORE, TEA_SPLASH_RADIUS, SHELL_SPLASH_RADIUS, stOf, safe, dispatch,
num, auraBuff, watchDeaths, onTerrain,
};