Files
ark-stronghold-protocol/server/sim/content/enemies/helpers.js
T
sganggs 1db84d799d feedback7 R22-UNBAL: 失衡 review fixes — a pulled 静态刚体 is held for the pull's window, 弧光锋卫 bleeds 400 every 0.066 s while the state lasts, no enemy skill / blink / scripted move during it, a route APPEAR or a 逐火 重生 ends it, every push row holds its exact frames; golden: 32 move, 8 in outcomes (below)
Branch review of fb7-unbalance (MERGE AFTER FIXES):
1. A 静态刚体 pulled got the 0.1 s floor: _staticForce now knows push from pull — a push's force is instant (推与拉), so
   0.1 s; a pull's lasts its window (pullUnbalance: 1 s, 0.5 s below −1) — PRTS 特殊机制 静态刚体 「在失去施力后立刻」 out,
   「0.1 秒保底」. pull() checks the puller's own block before the static case.
2. 弧光锋卫 (PRTS 天赋 「处于失衡状态时,每0.066s受到400点无来源真实持续伤害」, data unbalanced_bleed): a tick bleed of 400 every
   0.066 s while unbalancedNow holds; the per-tile one-shot from the position jump ([ASSUMED], UNBALANCE_SPEED) is gone.
   A 受力等级 0 push (0.8 s) → 12 hits = 4800 (was 2037), a 急停 pull with no movement (1 s) → 15 = 6000 (was 0), a
   wall-shortened slide keeps the 0.8 s row → 4800.
3. 失衡免疫 「失衡期间无法自主移动、发动攻击、使用技能」: enemies/helpers.js unbalancedNow; canCast(e, sil, battle) refuses while it
   runs (content skills; the boss parts' calls pass the battle), blinkForward returns null, 枯朽之种's dive / blast and the
   铳 / 碎铳之簧 charges wait, 乌顶巨角卢鲁's clash waits for its end. A route APPEAR relocation clears it (a forced state
   switch, 失衡位移机制). Left running on purpose (docs/SIM.md): 水遁忍者's damage check (异常效果 「仍然能正常造成伤害」), passive
   auras and terrain / periodic effects, death / damage-taken triggers, skill cooldowns, 弧光锋卫's bleed itself.
4. husk() (逐火 余烬 重生) clears the state like reborn().
5. The 35/30 row held 36 frames (t0 + 35/30 overshoots in floats): every check compares with 1e-9; the frames are asserted.

Tests: test/sim/unbalance.test.js +6 (exact frames 6 / 12 / 24 / 27 / 32 / 35 with the walk on the next frame; static pull
1 s / 0.5 s, push 0.1 s; 弧光锋卫 4800 / 6000 / 4800; canCast / blinkForward refused, 枯朽之种 held then diving; the elk's clash
after the state; APPEAR and a 逐火 重生 clear it — all six fail on the parent); enemies_bosses (失衡 kit test) and
feedback1d-solvent (BUFF damage) updated to the state bleed. test/sim + test/content + test/render pass. Docs: SIM.md
(push / pull row), PLAYING.md, design/engine.md (helpers, enemy AI), constants / displacement comments.

Golden against the previous commit — outcome moves: roster-010 (歌蕾蒂娅 pulls 妖怪 drones, now held 1 s: killed 44→45,
leaks 58→57), roster-040 (static pulls of 妖怪: killed 28→25, leaks 37→40, deaths 29→30), roster-043 / 044 (山: deaths 14→16 /
14→13), hidden-boss_9-pair (锏: boss HP 3636991→3586929), coop4-ABYSS-7 (ai_2 LP +1 from round 5), diy-115 (黑键: leaks
70→75), diy-116 (黑键: killed 2→3, leaks 121→120) — the static pulls (probe) and the 1e-9 end (a frame shorter where the float
sum overshot: the 35/30 row, some pull windows); other moves (damage / snapshots only): roster-008 / 017 / 039, 9 bonds,
boss-boss_4-pair / boss_6-pair / hidden-boss_10-pair, coop2-NORMAL-14-standins / coop2-NORMAL-3 / solo-ABYSS-1 /
solo-HARD-1 / solo-HARD-9-boosted / solo-NORMAL-1, diy-053 / 085 / 102.
2026-10-08 22:42:26 +08:00

411 lines
21 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; }
/**
* Is enemy `e` in its 失衡 (UNBALANCE) state now (battle/displacement.js _unbalance; the 1e-9 keeps a float-summed
* end — 35/30 s — on its frame)? PRTS 异常效果图鉴/失衡免疫: 「失衡期间无法自主移动、发动攻击、使用技能」 — content skills
* (canCast), blinks and scripted moves wait for its end.
*/
export function unbalancedNow(b, e) { return !!(b && e && b.time < e.unbalanceUntil - 1e-9); }
/** Can enemy `e` use a skill now: alive, on the field, not stunned, not silenced (`sil`), not in its 失衡 state (`b` given). */
export function canCast(e, sil, b = null) { return e.alive && !e.hidden && !e.s.flags.stun && !(sil && e.s.flags.silence) && !unbalancedNow(b, e); }
// ---------------------------------------------------------------------------------------------------------------
// 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,
};