Files
ark-stronghold-protocol/server/sim/damage.js
T
sganggs 15291c2293 Playtest #5 fixes: hovering enemies, target order, blocking hand-over, 联防 knocked-out, elements, huge-boss hit area, maps, gear icon, phone HUD, 至简 price
- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them;
  PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air
- #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the
  redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included
- #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only
  raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed
- #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the
  blocker dies, withdraws or is stunned; melee blockers attack what they block
- #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右,
  operators before summons; special priorities break ties by taunt then deploy order
- #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views
- #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages
  audited against the official level files
- #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones
- #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable
- #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects
- DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and
  every browser suite green; dev recordings regenerated
2026-09-30 19:12:36 +08:00

459 lines
25 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/damage.js — damage & heal pipeline, shields, dodge, element gauges (DESIGN §5.5).
//
// dealDamage order: (element → gauge path) | invulnerable? → 'hit' hook (mutable DamageInfo, may set cancel)
// → dodge (phys/arts, canDodge) → mitigation (phys: DEF, arts: RES, true: none)
// → × source dmgDealtMul (× phys/artsDealtMul) × target dmgTakenMul (not for 元素伤害) × type-taken mul × dmg.mul
// → shields (hit-negating barriers first, then HP shields) → HP loss (boss pool routing) → 'damaged' hook
// → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill.
// Phys: max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %·A); Arts: max(A×(1 − R′/100), 5 %·A) with
// R′ = max(0, R×(1−resIgnorePct) − resIgnoreFlat); True: A; Elemental (元素伤害): max(A×(1 − 元素抗性/100), 5 %·A)
// (PRTS 游戏数据基础 DMG_e; 元素抗性 = the target's data `epDamageResistance`: 0 on every enemy in data/enemies.json).
// Element damage ('element' type + element, 元素损伤) fills a gauge instead of HP (1000; enemy leaders 2000):
// gain = amount × dmg.mul × target elemTakenMul (元素损伤倍率: "受到的元素损伤提高/降低…") × max(5 %, 1 − 损伤抵抗/100)
// with 损伤抵抗 = the target's data `epResistance` (EP_RESISTANCE, a percentage: PRTS 元素 "受到的元素损伤 = 损伤值 ×
// (1 − 损伤抵抗 × 0.01),后续可应用元素损伤倍率"; the 5 % floor of DMG_e — PRTS 游戏数据基础 "目标受到元素损伤时也可以使用
// 该公式计算,只需要将 D 值改为目标的损伤抵抗即可"; in data/enemies.json only 转译基底·α has 10, every other enemy — 海嗣
// included — 0; newer event enemies of the full enemy_database go up to 15, operators are all 0). No
// source-side multiplier touches it (a kit scales its own amount), no gauge ever decays (EP_RECOVERY_PER_SEC 0), and
// 元素脆弱 (`elementalTakenMul`, "受到的元素伤害提升") never scales it — only 元素伤害.
// A full gauge bursts with the official term-table effects (constants.js ELEMENT), which differ by the side hit:
// operators (enemy damage): burn 1200 arts + RES −20 10 s · neural stun 10 s, then 1000 true · apoptosis 15 s: 阻回
// (noSp) + 静默 (no skill activation), −1 SP/s, 100 arts/s · erosion permanent DEF −100, then 800 phys (10 s).
// enemies (operator damage, "·我方"): burn 7000 元素伤害 + RES −20 (10 s) · neural 3 麻痹, then 6000 元素伤害 (10 s) ·
// apoptosis 15 s: 50 % weaken recovering over the burst + 800 元素伤害/s · erosion 5000 元素伤害 + permanent DEF −120 (8 s).
// necrosis (legacy spare gauge): 12 s of 100 true dmg/s and ATK −20 %.
// Burst damage is 无来源 (PRTS 元素 "元素爆发通常造成无来源的伤害"; PRTS 伤害分类: 无来源 = "无法被追溯伤害来源", yet "无来源
// 伤害的击杀也能追溯击杀来源"): DamageInfo `sourceless` — the damage-dealt multipliers and penetration of the unit that
// filled the gauge do not apply (the target's own RES cuts do, "可享受法抗减少效果"), and the `hit` / `damaged` / `fatal`
// hooks get `source: null`, so no content keyed on the attacker (ATK ×1.5 vs 海怪, 精准狙击镜, bond / module 伤害提升,
// reflect, "受到来自…的伤害") touches it; the hook ctx's `credit` and the kill / stats keep the filler ("元素爆发本身来源于
// 造成“爆条”的元素损伤的来源").
// 爆发冷却 (PRTS 元素, "阻回" after a burst): for the burst's duration (the `<el>Burst` buff) NO element of the unit can
// fill or be recovered (reduceElement) — the bursting gauge shows full — and when it ends EVERY gauge of the unit
// resets to 0. A burst that is still resolving (its `elementBurst` hook runs before the `<el>Burst` lock exists) already
// counts as locked (`unit.burstPending[el]`), so a hook that spreads an element back (淤困 parasite hosts next to each
// other) cannot re-burst the unit recursively. The unit's shown gauge (the fullest, "当前损伤元素") and its cooldown
// travel in b.snap `elem` (elementView).
// 元素伤害 (HP damage of an element) is the DamageInfo type 'elemental' (+ optional `element` for display): no DEF/RES
// (元素抗性 instead), no dodge, × source dmgDealtMul × target elementalTakenMul (元素脆弱) only — the target's
// dmgTakenMul (脆弱 and the other "受到的伤害±" effects) does not scale it: ba.fragile is "受到的物理、法术、真实伤害提升"
// (gamedata_const termDescriptionDict), 元素伤害 has its own ba.elementfragile [ASSUMED for the damage_resistance-type
// cuts, which share the multiplier]. An element fill on a target with no HP left (hasHp: a lethal hit's `damaged` hook
// runs before battle.kill, while the target is still `alive` at 0 HP) is refused, so nothing bursts on a corpse.
// Element healing (reduceElement) lowers every element type by the amount, each on its own (PRTS 菲莱 备注: "清除元素损伤"
// = "一次等同于自身最大元素值的全类型元素损伤治疗"). Sleeping units (沉睡: 无敌) take no damage unless the attacker's profile
// has `hitSleep` or the damage carries `ignoreSleep`.
import { MIN_DAMAGE_RATIO, ELEMENT, ELEMENT_ORDER, PALSY_MAX } from './constants.js';
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
/**
* Alive AND still has HP (a boss: its pool's HP). A `damaged` hook sees a lethal hit before `battle.kill`, while the
* target is still `alive` at 0 HP — element riders attached to that damage (and applyElement itself) skip such a
* target, or a burst resolves on the corpse (a second fatal, 烛煌 熔点引爆's heal, 妮芙's stack …).
*/
export function hasHp(u) {
return !!u && u.alive && (u.bossPool ? u.bossPool.hp > 0 : u.hp > 0);
}
/** Normalise a DamageInfo descriptor. */
export function makeDamageInfo(d = {}) {
const type = d.type ?? 'phys';
return {
_norm: true,
amount: Number.isFinite(+d.amount) ? +d.amount : 0,
type,
element: d.element ?? null,
atkScale: d.atkScale ?? 1,
defIgnoreFlat: d.defIgnoreFlat ?? 0,
defIgnorePct: d.defIgnorePct ?? 0,
resIgnoreFlat: d.resIgnoreFlat ?? 0,
resIgnorePct: d.resIgnorePct ?? 0,
mul: d.mul ?? 1,
canDodge: d.canDodge ?? (type === 'phys' || type === 'arts'),
isSkill: !!d.isSkill,
isSplash: !!d.isSplash,
isAttack: !!d.isAttack,
isProjectile: !!d.isProjectile,
tags: d.tags ?? [],
cancel: false,
noSp: !!d.noSp,
ignoreSleep: !!d.ignoreSleep,
sourceless: !!d.sourceless,
attackId: d.attackId ?? 0,
};
}
/** 沉睡 (ba.sleep "无敌且无法行动"): only attackers whose profile has `hitSleep` (or `ignoreSleep` damage) reach a sleeper. */
function sleepBlocks(target, source, dmg) {
return !!target.s.flags.sleep && !dmg.ignoreSleep && !(source && source.profile && source.profile.hitSleep);
}
/**
* Pure mitigation formula (exported for tests). `target` = the target's stats; `ign.elementalRes` = its 元素抗性
* (epDamageResistance) for 'elemental' damage.
*/
export function mitigate(amount, type, target, ign = {}) {
if (type === 'phys') {
const D = target.def ?? 0;
const eff = Math.max(0, D * (1 - clamp01(ign.defIgnorePct ?? 0)) - (ign.defIgnoreFlat ?? 0));
return Math.max(amount - eff, MIN_DAMAGE_RATIO * amount);
}
if (type === 'arts') {
const R = target.res ?? 0;
const eff = Math.max(0, R * (1 - clamp01(ign.resIgnorePct ?? 0)) - (ign.resIgnoreFlat ?? 0));
return Math.max(amount * (1 - Math.min(100, eff) / 100), MIN_DAMAGE_RATIO * amount);
}
if (type === 'elemental') {
const D = Math.max(0, Math.min(100, Number.isFinite(ign.elementalRes) ? ign.elementalRes : 0));
return Math.max(amount * (1 - D / 100), MIN_DAMAGE_RATIO * amount);
}
return amount;
}
/** Absorb damage with shields on `target`. Returns the remaining amount. */
export function absorbShields(battle, target, amount) {
if (amount <= 0) return 0;
let changed = false;
let rest = amount;
for (let i = 0; i < target.buffs.length && rest > 0; i++) {
const b = target.buffs[i];
if (b.shieldHits > 0) {
b.shieldHits--;
rest = 0;
if (b.shieldHits <= 0 && !(b.shield > 0) && !b.mods && !b.flags) { battle._removeBuffAt(target, i); i--; }
changed = true;
break;
}
}
for (let i = 0; i < target.buffs.length && rest > 0; i++) {
const b = target.buffs[i];
if (b.shield > 0) {
const take = Math.min(b.shield, rest);
b.shield -= take;
rest -= take;
changed = true;
if (b.shield <= 1e-9) {
b.shield = 0;
if (!b.mods && !b.flags && !(b.shieldHits > 0)) { battle._removeBuffAt(target, i); i--; }
}
}
}
if (changed) target.markDirty();
return rest;
}
/**
* Full damage pipeline. Returns the HP actually removed (0 when dodged/cancelled/absorbed).
*/
export function dealDamage(battle, source, target, dmgIn) {
if (!target || !target.alive || target.removed || target.hidden || !target.deployed) return 0;
const dmg = dmgIn && dmgIn._norm ? dmgIn : makeDamageInfo(dmgIn);
if (dmg.type === 'element') return applyElement(battle, source, target, dmg);
// 无来源 (dmg.sourceless, element bursts): hooks see no source — nothing keyed on "damage dealt by X" (ATK-up vs a
// tag, 伤害提升 items / bonds / modules, reflect, "受到来自…的伤害") can recognise it; `credit` names the unit that still
// gets the stats and the kill (PRTS 伤害分类 无来源 ③)
const hs = dmg.sourceless ? null : source;
let ts = target.s;
if (ts.flags.invulnerable || sleepBlocks(target, hs, dmg)) return 0;
if (battle._hooks.hit) {
battle.emit('hit', { source: hs, target, dmg, credit: source });
if (dmg.cancel || !target.alive || !target.deployed) return 0;
if (dmg.type === 'element') return applyElement(battle, source, target, dmg);
ts = target.s; // handlers may have changed the target's buffs (fragile, invulnerable, dodge…): never use stale stats
if (ts.flags.invulnerable || sleepBlocks(target, hs, dmg)) return 0;
}
const type = dmg.type;
// dodge
if (dmg.canDodge && (type === 'phys' || type === 'arts')) {
const p = type === 'phys' ? ts.dodgePhys : ts.dodgeArts;
if (p > 0 && battle.rng() < p) {
battle.fx('dodge', { x: target.x, y: target.y, id: target.id });
if (battle._hooks.dodge) battle.emit('dodge', { source, target, dmg });
return 0;
}
}
// 频次 units (flags hitCount / hitCountArts): every damage instance removes exactly 1 HP (maxHp = hits needed);
// hitCountArts ignores physical instances. The instance keeps its own DamageInfo, so `damaged` handlers still
// recognise their own (tagged) damage — never re-create such a loss with a fresh loseHp.
if (ts.flags.hitCount || ts.flags.hitCountArts) {
const counts = !(ts.flags.hitCountArts && !ts.flags.hitCount && type === 'phys');
return applyHpLoss(battle, source, target, absorbShields(battle, target, counts ? 1 : 0), dmg);
}
// 无来源 damage (element bursts) takes nothing from its source's stats; the source still gets the credit below
const ss = source && source.s && !dmg.sourceless ? source.s : null;
let final = mitigate(dmg.amount, type, ts, {
defIgnorePct: dmg.defIgnorePct + (ss ? ss.defIgnorePct : 0),
defIgnoreFlat: dmg.defIgnoreFlat + (ss ? ss.defIgnoreFlat : 0),
resIgnorePct: dmg.resIgnorePct + (ss ? ss.resIgnorePct : 0),
resIgnoreFlat: dmg.resIgnoreFlat + (ss ? ss.resIgnoreFlat : 0),
elementalRes: type === 'elemental' ? (target.def?.epDamageResistance ?? 0) : 0,
});
// 脆弱 / "受到的伤害±" (dmgTakenMul) scale 物理、法术、真实 only; 元素伤害 takes 元素脆弱 alone (header)
let mul = dmg.mul * (type === 'elemental' ? 1 : ts.dmgTakenMul);
if (ss) mul *= ss.dmgDealtMul * (type === 'phys' ? ss.physDealtMul : type === 'arts' ? ss.artsDealtMul : 1);
mul *= type === 'phys' ? ts.physTakenMul : type === 'arts' ? ts.artsTakenMul : type === 'elemental' ? ts.elementalTakenMul : ts.trueTakenMul;
final *= mul;
if (!(final > 0) || !Number.isFinite(final)) final = 0;
final = absorbShields(battle, target, final);
return applyHpLoss(battle, source, target, final, dmg);
}
/**
* Remove HP (after mitigation) and run damaged/fatal/kill bookkeeping. Also used by `battle.loseHp` (流失).
*/
export function applyHpLoss(battle, source, target, amount, dmg) {
if (!target.alive) return 0;
let dealt = 0;
const hs = dmg && dmg.sourceless ? null : source; // the source hooks see (无来源: none; `credit` keeps it)
if (target.bossPool) {
const pool = target.bossPool;
const before = pool.hp;
const pid = source && source.side === 'ally' ? source.ownerId : null;
if (amount > 0) {
try { pool.damage(pid, amount); } catch (e) { battle._internalError('bossPool.damage', e); pool.hp = Math.max(0, pool.hp - amount); }
}
dealt = Math.max(0, before - pool.hp);
if (!Number.isFinite(dealt)) dealt = 0; // a misbehaving pool (NaN hp) must not poison damage stats
if (pid != null) { const pp = battle._pp(pid); if (pp) pp.bossDamage += dealt; }
battle._syncBossHp(target);
} else {
const before = target.hp;
target.hp -= amount;
if (target.hp <= 0) {
target.hp = 0;
if (battle._hooks.fatal) {
const fctx = { unit: target, source: hs, credit: source, dmg, amount, prevented: false };
battle.emit('fatal', fctx);
if (fctx.prevented && target.alive) { if (target.hp < 1) target.hp = Math.min(1, target.s.maxHp); }
}
}
dealt = Math.max(0, before - Math.max(0, target.hp));
}
if (source) {
source.stats.dmg += dealt;
if (source.side === 'ally' && source.ownerId != null) { const pp = battle._pp(source.ownerId); if (pp) pp.damageDealt += dealt; }
}
target.stats.taken += dealt;
target.lastHitAt = battle.time;
if (dmg && !dmg.silent && amount >= 0.5) {
const shown = dmg.type === 'element' ? dmg.element : dmg.type === 'elemental' ? (dmg.element || 'true') : dmg.type;
battle._ev(['dmg', target.id, Math.round(amount), shown]);
}
if (battle._hooks.damaged) battle.emit('damaged', { source: hs, target, amount, type: dmg ? dmg.type : 'true', dmg, credit: source });
if (target.side === 'ally' && target.skill && dmg && !dmg.noSp && dmg.type !== 'element') battle._skills.onDamaged(target);
const dead = target.bossPool ? target.bossPool.hp <= 0 : target.hp <= 0;
if (dead && target.alive) battle.kill(target, source);
return dealt;
}
/**
* Are `target`'s element gauges locked (爆发冷却, see header): a burst's `<el>Burst` lock is up (any element — the lock
* holds every gauge of the unit), or a burst of the unit is resolving right now. The element argument of older callers
* is ignored.
*/
export function burstLocked(target) {
const p = target.burstPending;
if (p) for (const k in p) if (p[k]) return true;
return !!target.s.flags.burstLock;
}
/** The burst lock buff (爆发冷却) running on `u`, or null. */
function burstLockOf(u) {
let best = null;
for (const b of u.buffs) if (b.flags && b.flags.burstLock && (!best || b.timeLeft > best.timeLeft)) best = b;
return best;
}
/**
* The element gauge a unit shows (b.snap `elem`, render/units.js): null when every gauge is empty and no burst
* cooldown runs. Official display (PRTS 元素): one icon, the "当前损伤元素" — the fullest gauge (ties: the lower
* official element id, constants.js ELEMENT_ORDER) — with its remaining EP; during a 爆发冷却 the bursting element,
* refilling over the cooldown. Returns `[el, fill 0..1 (2 decimals), cooldownEnd (game s), cooldown (s)]`; the two
* cooldown numbers are 0 outside a cooldown. `now` = the battle time.
*/
export function elementView(u, now) {
const e = u.elem;
if (!e) return null;
const lock = u.s.flags.burstLock ? burstLockOf(u) : null;
if (lock) {
const el = lock.key.endsWith('Burst') ? lock.key.slice(0, -5) : 'burn';
const dur = Number.isFinite(lock.duration) && lock.duration > 0 ? lock.duration : 0;
const left = Number.isFinite(lock.timeLeft) ? Math.max(0, lock.timeLeft) : 0;
return [el in e ? el : 'burn', 1, Math.round((now + left) * 100) / 100, Math.round(dur * 100) / 100];
}
let best = null, bv = 0;
for (const k of ELEMENT_ORDER) { const v = e[k]; if (v > bv) { bv = v; best = k; } }
if (!best) return null;
const fill = Math.min(1, Math.max(0.01, Math.round((bv / u.gaugeMax) * 100) / 100));
return [best, fill, 0, 0];
}
/**
* The share of an element hit that reaches `target`'s gauge (header): its 元素损伤倍率 (`elemTakenMul`) × max(5 %,
* 1 − 损伤抵抗 / 100) — the factor applyElement uses after the `elementHit` hook, exported for content that previews a
* gauge gain.
*/
export function elementIntake(target) {
const res = Number(target && target.def ? target.def.epResistance : 0) || 0;
return target.s.elemTakenMul * Math.max(MIN_DAMAGE_RATIO, 1 - clamp01(res / 100));
}
/** Element gauge accumulation + burst. Fires `elementHit` { source, target, dmg } first (mutable amount/mul, cancel). */
export function applyElement(battle, source, target, dmg) {
const el = dmg.element;
if (!el || !(el in target.elem)) return 0;
if (!hasHp(target)) return 0; // a killing blow's rider: the target is dead, nothing fills or bursts (header)
if (target.s.flags.invulnerable || sleepBlocks(target, source, dmg)) return 0;
if (burstLocked(target, el)) return 0;
if (battle._hooks.elementHit) {
battle.emit('elementHit', { source, target, dmg });
if (dmg.cancel || !target.alive || !target.deployed || burstLocked(target, el)) return 0;
if (dmg.type !== 'element') return dealDamage(battle, source, target, dmg); // a handler converted it
}
const max = target.gaugeMax;
// 元素损伤倍率 × max(5 %, 1 − 损伤抵抗 %) — official EP_RESISTANCE = data epResistance
let amt = dmg.amount * dmg.mul * elementIntake(target);
if (!(amt > 0) || !Number.isFinite(amt)) return 0;
target.elem[el] = Math.min(max, target.elem[el] + amt);
battle._ev(['dmg', target.id, Math.round(amt), el]);
if (source) source.stats.elem = (source.stats.elem ?? 0) + amt;
if (battle._hooks.damaged) battle.emit('damaged', { source, target, amount: amt, type: 'element', dmg });
if (target.elem[el] >= max && target.alive) elementBurst(battle, source, target, el);
return amt;
}
/**
* A burst (see header): side-aware official effects; the `<el>Burst` buff is the 爆发冷却 that locks every gauge of the
* unit for its duration. The unit is marked `burstPending[el]` while the burst resolves (hook, lock, hits), so nothing
* re-bursts it in the meantime.
*/
export function elementBurst(battle, source, target, el) {
if (!ELEMENT[el] || !target.alive || burstLocked(target)) return;
const pending = target.burstPending || (target.burstPending = {});
pending[el] = true;
try { resolveBurst(battle, source, target, el); } finally { pending[el] = false; }
}
function resolveBurst(battle, source, target, el) {
const cfg = ELEMENT[el];
target.elem[el] = target.gaugeMax;
// the cooldown's end restores every element of the unit ("随后单位所有种类的元素值恢复至最大值")
const reset = () => { for (const k of ELEMENT_ORDER) if (k in target.elem) target.elem[k] = 0; };
battle.fx('burst', { x: target.x, y: target.y, id: target.id, element: el });
if (battle._hooks.elementBurst) battle.emit('elementBurst', { source, target, element: el });
if (!target.alive) { reset(); return; }
const tags = ['burst', el];
// 无来源 damage credited to `source` (header)
const hit = (amount, type) => battle.dealDamage(source, target, { amount, type, element: el, canDodge: false, sourceless: true, tags });
const lock = (duration, extra = {}) => {
if (!(duration > 0)) { reset(); return null; }
return battle.addBuff(target, { key: `${el}Burst`, duration, visible: true, onExpire: reset, onRemove: reset, ...extra, flags: { burstLock: true, ...(extra.flags || {}) } });
};
if (el === 'necrosis') {
lock(cfg.duration, {
mods: { atkMul: 1 - cfg.atkDownPct }, interval: 1,
onTick: () => battle.dealDamage(source, target, { amount: cfg.dps, type: 'true', canDodge: false, sourceless: true, tags: ['burst', 'necrosis'] }),
});
return;
}
const enemy = target.side === 'enemy';
const c = enemy ? cfg.enemy : cfg.ally;
if (el === 'burn') {
lock(c.duration, { mods: { resFlat: -c.resDown } });
if (enemy) hit(c.elemDamage, 'elemental'); else hit(c.damage, c.type);
} else if (el === 'neural') {
// PRTS 元素: the status first ("立刻获得等时长的眩晕" / "获得3层麻痹"), then the hit
lock(c.duration);
if (enemy) {
battle.applyStatus(target, 'palsy', { value: c.palsy, source });
if (target.alive) hit(c.elemDamage, 'elemental');
} else {
battle.applyStatus(target, 'stun', { duration: c.stun, source, force: true });
if (target.alive) hit(c.damage, c.type);
}
} else if (el === 'apoptosis') {
if (enemy) {
// 50 % 虚弱, 800 元素伤害 per second; each second the 虚弱 drops to 50 % × 剩余时间 ÷ 总持续时间 (PRTS 元素)
lock(c.duration, {
mods: { atkMul: 1 - c.weaken }, interval: 1,
onTick: ({ buff }) => {
buff.mods = { atkMul: 1 - c.weaken * Math.max(0, buff.timeLeft) / c.duration };
target.markDirty();
hit(c.elemDps, 'elemental');
},
});
} else {
// 15 s of 阻回 ("停止并阻止任意形式的技力回复": noSp) + 静默 (no skill activation), −1 SP and 100 arts damage per second
lock(c.duration, {
flags: { silence: true, noSp: true }, interval: 1,
onTick: () => {
const sk = target.skill;
if (sk && !sk.noSkill && sk.kind !== 'passive' && !(sk.active && sk.isTimed) && sk.sp > 0) sk.sp = Math.max(0, sk.sp - c.spLossPerSec);
hit(c.dps, c.dpsType);
},
});
}
} else if (el === 'erosion') {
// the permanent DEF cut lands first, then the hit; both sides then have their cooldown (operators 10 s, enemies 8 s)
lock(c.duration);
battle.addBuff(target, { key: 'erosionDown', refresh: 'stack', stacks: 1, maxStacks: 1e6, mods: { defFlat: -c.defDown }, visible: true });
if (enemy) hit(c.elemDamage, 'elemental'); else hit(c.damage, c.type);
}
}
/** Add `n` 麻痹 stacks (at most PALSY_MAX). */
export function palsyBuff(n) {
const stacks = Math.max(1, Math.min(PALSY_MAX, Math.floor(Number.isFinite(n) ? n : 1)));
return { key: 'palsy', refresh: 'stack', stacks, maxStacks: PALSY_MAX, visible: true, status: 'palsy' };
}
/**
* Element healing (e.g. wandermedic "回复元素损伤"): lowers element `el`, or — without `el` — EVERY element type, each
* by `amount` on its own (PRTS 菲莱 备注: clearing = "一次等同于自身最大元素值(通常为1000)的全类型元素损伤治疗").
* Returns the total removed. Nothing recovers during a burst cooldown ("爆发冷却状态下,单位所有类型的元素值均无法损失、
* 无法被其他手段回复").
*/
export function reduceElement(target, amount, el = null) {
let removed = 0;
if (!target || !target.elem || burstLocked(target) || !(amount > 0)) return 0;
const els = el ? [el] : Object.keys(target.elem);
for (const k of els) {
const take = Math.min(target.elem[k] || 0, amount);
if (take > 0) { target.elem[k] -= take; removed += take; }
}
return removed;
}
/** Heal pipeline. Returns the HP actually restored. */
export function heal(battle, source, target, amount, opts = {}) {
if (!target || !target.alive || target.removed || !target.deployed || target.bossPool) return 0;
const self = source === target || !!opts.self;
if (!self && (target.s.flags.noHeal || (target.profile && target.profile.noHeal))) return 0;
let amt = amount * (source && source.s ? source.s.healingDealtMul : 1) * target.s.healingTakenMul;
if (!(amt > 0) || !Number.isFinite(amt)) return 0;
if (battle._hooks.heal) {
const ctx = { source, target, amount: amt, opts };
battle.emit('heal', ctx);
amt = Number.isFinite(ctx.amount) ? Math.max(0, ctx.amount) : 0;
// a handler may have killed / retreated the target: healing a dead unit would leave it "dead with hp > 0"
if (!target.alive || !target.deployed) return 0;
}
const max = target.s.maxHp;
const actual = Math.max(0, Math.min(amt, max - target.hp));
target.hp = Math.min(max, target.hp + actual);
if (opts.overheal && amt > actual) {
// overheal becomes a shield (capped at max HP) so hp stays within [0, maxHp]
const cur = target.findBuff('overheal');
const val = Math.min(max, (cur ? cur.shield : 0) + (amt - actual));
battle.addBuff(target, { key: 'overheal', shield: val, duration: opts.overhealDuration ?? Infinity });
}
if (source) {
source.stats.heal += actual;
if (source.side === 'ally' && source.ownerId != null) { const pp = battle._pp(source.ownerId); if (pp) pp.healingDone += actual; }
}
if (actual >= 0.5 && !opts.silent) battle._ev(['heal', target.id, Math.round(actual)]);
return actual;
}