Files
ark-stronghold-protocol/server/sim/damage.js
T
sganggs 6bfc08270c 0.2.4-dev: round-29 community fixes and the official shared card pool
- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
2026-10-11 01:58:52 +08:00

613 lines
36 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? / 对地规避 (a ground enemy's damage to an airborne 起飞 ally —
// targeting.js evadesGround; not `ignoreSelect` damage: no selection, e.g. a debuff's tick) → '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
// → 限伤 (leaders in boss / hidden battles: a hit of ceil(final) ≥ BOSS_HIT_LIMIT is cancelled, see leaderHitCancelled)
// → shields (hit-negating barriers first, then HP shields; a typed one — buff `shieldType` — only its damage type)
// → 'hpDamage' hook (what passed the shields; handlers may only lower `amount`: the 伤判效果 that act after a
// barrier — 煌's 紧急除颤 HP floor, 左乐's 庇护 re-applied after his 行险 barrier) → HP loss (boss pool routing)
// → 'damaged' hook → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill.
// Damage-dealt stats (the source's `stats.dmg`, the player's `damageDealt`) count only HP removed from the other side:
// self and friendly damage (a 源石溶剂 drain, an operator's own 流失) is the target's `taken` and keeps the kill credit.
// 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';
import { BOSS_HIT_LIMIT } from '../../shared/constants.js';
import { evadesGround } from './targeting.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,
// no selection 无法选择 effects stop (an ability that "无视无法选择" such as PRTS 【污染秽蚀】, a direct pick, a flying
// unit's blast credited to a ground leader, the tick of a debuff already on the unit): reaches an airborne 起飞 ally
// whatever the source's 行动方式
ignoreSelect: !!d.ignoreSelect,
sourceless: !!d.sourceless,
attackId: d.attackId ?? 0,
// 咒愈师 skills whose damage triggers their trait for ONE named ally ("并仅对该角色/干员触发…特性"): the instance
// carries that ally, and the trait hook (professions.js installIncantation) heals it instead of the lowest-HP ally
// in range. null = the ordinary target of the trait.
traitAlly: d.traitAlly ?? null,
};
}
/**
* DamageInfo of the official `periodic_damage` template — 源石溶剂 (PRTS 盟约记录: "受到60真实伤害", 修正 "并非流失", 备注
* "造成无来源真实持续环境伤害"), 狂暴宿主组长 ("自身每秒受到500无来源真实伤害"): 无来源 true 持续 damage (PRTS 伤害分类: attack
* type BUFF, "自残类型"). A damage instance, not a 流失 (Battle.loseHp skips every damage event — PRTS 作战机制 "生命流失不会
* 触发反伤、受击回复"): shields, damage-taken multipliers and target-side `hit` effects apply, and it is a "受到伤害" for
* 受击回复 SP and the 重装 TAKE_DAMAGE trigger. Tagged 'dot' (a fixed-value DoT: ATK multipliers skip it; 锡人's 持续伤害
* boost takes it) and 'periodic'. The caller passes the credit (the carrier / null) as the dealDamage source.
*/
export function periodicDamage(amount) {
return makeDamageInfo({ amount, type: 'true', canDodge: false, sourceless: true, tags: ['dot', 'periodic'] });
}
/**
* Is this the DamageInfo of a 流失 (Battle.loseHp, tag 'hpLoss')? Its `damaged` hook still runs (stats, leader parts,
* HP thresholds), but a 流失 is not "受到伤害": "受到伤害时" content (heals, counters, 未受伤害 timers, 反伤 chances)
* skips it — PRTS 作战机制 "生命流失…跳过所有结算与伤判效果…不会触发反伤、受击回复等受到攻击触发的时点".
*/
export function isHpLoss(dmg) {
return !!dmg && Array.isArray(dmg.tags) && dmg.tags.includes('hpLoss');
}
/**
* Record one redirect ratio of the pre-redirect hit and leave the victim the rest.
* Each recorded ratio is a share of that hit; the victim keeps `1 − sum`. Two redirects therefore
* add up with the part the victim still takes. [ASSUMED] 浊心斯卡蒂 S1 and 坚守 do not say how they
* combine; multiplying the two remainders drops the product of the ratios. A sum of 1 or more leaves
* the victim with none of this hit (the same loss a single 100 % redirect already has, because the
* HP that arrives is 0 and no share is dealt).
* @param {object} dmg normalised DamageInfo
* @param {number} ratio
*/
export function applyRedirectCut(dmg, ratio) {
const r = ratio < 0 ? 0 : ratio > 1 ? 1 : Number(ratio) || 0;
if (!(r > 0) || !dmg) return 0;
const prev = Array.isArray(dmg.redirectCuts) ? dmg.redirectCuts : (dmg.redirectCuts = []);
let prevSum = 0;
for (const x of prev) prevSum += x;
const prevKeep = prevSum >= 1 ? 0 : 1 - prevSum;
prev.push(r);
const nextSum = prevSum + r;
const nextKeep = nextSum >= 1 ? 0 : 1 - nextSum;
if (prevKeep > 1e-12) dmg.mul *= nextKeep / prevKeep;
else dmg.mul *= 0;
return r;
}
/**
* This redirect's share of the HP that reached the victim, taken from the pre-redirect amount.
* @param {number} amount HP removed from the victim after every redirect cut
* @param {number} own this redirect's ratio
* @param {number[]|undefined} cuts every ratio recorded on the hit
*/
export function redirectShare(amount, own, cuts) {
const list = Array.isArray(cuts) ? cuts : [];
let sum = 0;
for (const x of list) sum += x;
const o = own > 0 ? own : 0;
if (!(amount > 0) || !(o > 0) || !(sum > 0)) return 0;
const keep = 1 - sum;
if (keep > 1e-12) return amount / keep * o;
return amount * (o / sum);
}
/** 沉睡 (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);
}
/**
* 起飞 (flag `liftoff`): a ground enemy's damage never reaches the airborne ally — it cannot select it (对地规避, PRTS 作战机制
* "AOE的判定是对攻击范围内的每个可以被选中的敌人进行判定"), so its splash, area abilities and element fills skip it, and a shot
* already in flight when it took off lands on nothing [ASSUMED: PRTS 伤害流程 7 "取消掉隐匿/无敌状态下的攻击" read for
* 对地规避]. Checked before the `hit` hook only: 蒂比 S2 takes off inside the hook of the hit that set it off, which then
* resolves as usual (dodged if physical / arts — PRTS 备注). Sourceless damage and `ignoreSelect` damage still land: no
* selection (targeting.js evadesGround) — 无视无法选择 abilities, direct picks, flying units' blasts, and the ticks of a
* debuff already on it (PRTS 异常效果: 无法选择 effects "仅在选择时生效"; the debuff's mods stay too).
*/
function liftoffEvades(target, source, dmg) {
return !!target.s.flags.liftoff && !dmg.ignoreSelect && evadesGround(source, target);
}
/**
* 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;
}
/**
* 限伤 — the official boss-hit limit (shared/constants.js BOSS_HIT_LIMIT, docs/research/11-limits-official.md §2;
* client `AutoChessStepModeManager._OnBossEnemyTakeDamage`: `d = ceil(value); if (d >= 300000) modifier.Cancel()`).
* True when the hit of `amount` HP about to land on `target` is cancelled: the target is a leader (`isBoss`: the
* tag-'boss' units — data/bosses.json enemyKey, the official IsBossEnemy list — and their mirrored copies; never a part,
* escort, drone or other minion), the battle is a boss / hidden one (the client's step mode: every boss battle outside
* training) and ceil(amount) ≥ BOSS_HIT_LIMIT. The cancel is whole (no clamp): no HP / pool loss, no credit, no damage
* number. A 'hitCap' fx event `{ id, n: ceil(amount) }` marks it for the client, which draws nothing — the official
* shows no number [ASSUMED]. Every HP-damage kind is checked as the official `modifier.isDamage` (phys, arts, true,
* 元素伤害 incl. element bursts, DoT ticks — they all come through dealDamage — and losses passed on to a leader through
* Battle.loseHp, except the 胄 drone link's pool share: `noHitLimit`, DESIGN §25.13.4); element 损伤 (gauge fill,
* 'element') removes no HP and is never checked. Deterministic (Math.ceil of the same double on every engine).
*/
export function leaderHitCancelled(battle, target, amount) {
if (!(BOSS_HIT_LIMIT > 0) || !target || !target.isBoss || (battle.kind !== 'boss' && battle.kind !== 'hidden')) return false;
if (!(Math.ceil(amount) >= BOSS_HIT_LIMIT)) return false;
battle.fx('hitCap', { x: target.x, y: target.y, id: target.id, n: Math.ceil(amount) });
return true;
}
/**
* Absorb damage with shields on `target`. Returns the remaining amount. `type` = the damage type: a shield buff with a
* `shieldType` absorbs only that type (夜莺 S2 "屏障能吸收…法术伤害") — or, a list of types, only those (机械师's 屏障:
* BlockDamage PHYSICAL_AND_MAGICAL, buff_template_data mcnist_t_2 / mcnist_s_2_shield: ['phys', 'arts']); one without
* absorbs every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害"). Older shields first (buff order:
* "优先消耗先生成的屏障").
*/
export function absorbShields(battle, target, amount, type = null) {
if (amount <= 0) return 0;
let changed = false;
let rest = amount;
const absorbs = (b) => !b.shieldType || (Array.isArray(b.shieldType) ? b.shieldType.includes(type) : b.shieldType === type);
for (let i = 0; i < target.buffs.length && rest > 0; i++) {
const b = target.buffs[i];
if (b.shieldHits > 0 && absorbs(b)) {
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 && absorbs(b)) {
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) || liftoffEvades(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, type), 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;
// 限伤: a leader's hit of ≥ BOSS_HIT_LIMIT in a boss / hidden battle is cancelled before it reaches shields / HP — what
// ran before it (the attack, its SP, `hit` hook effects, separate element 损伤) stays; nothing after it happens
if (final > 0 && leaderHitCancelled(battle, target, final)) return 0;
final = absorbShields(battle, target, final, type);
// 伤判效果 after the barriers (header): a handler may lower what reaches the HP — never raise it (a 流失 skips this)
if (final > 0 && battle._hooks.hpDamage) {
const hctx = { source: hs, target, amount: final, dmg, credit: source };
battle.emit('hpDamage', hctx);
if (Number.isFinite(hctx.amount)) final = Math.max(0, Math.min(final, hctx.amount));
}
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));
}
// damage dealt (unit stats, the results screen's 造成伤害) never counts a unit's own side — its own drain or 流失 (源石溶剂,
// 史尔特尔 S3 …), friendly damage; `taken` and the kill credit do
if (source && source.side !== target.side) {
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, i.e. the least 元素值
* left (ties: the lower official element id, constants.js ELEMENT_ORDER) — with a white bar of its remaining 元素值;
* during a 爆发冷却 the bursting element, refilling over the cooldown. Returns `[el, fill, cooldownEnd (game s),
* cooldown (s)]`; the two cooldown numbers are 0 outside a cooldown. `fill` has 2 decimals and is rounded DOWN
* (0.01–0.99 while no burst runs, 1 during one), so the remainder shown (1 − fill) is never less than what is left —
* except that a first chip of less than 1 % (under 10 元素值 of 1000) already shows as 1 % taken — and the bar only
* runs out when the gauge bursts (user playtest #6). `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;
// rounded down (1e-9: 290 / 1000 × 100 is 28.999…), kept inside 1–99 %: a sliver shows, an empty bar = a burst
const fill = Math.min(0.99, Math.max(0.01, Math.floor((bv / u.gaugeMax) * 100 + 1e-9) / 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 (liftoffEvades(target, dmg.sourceless ? null : 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.
// The loss is of the official 技力 (Skill.spTotal: the stored charges × cost + the SP towards the next) — PRTS 技能
// 可充能 "当持有者的技力流失时,充能次数也会实时降低": taking it from the partial bar alone left a full skill every
// charge it had (a one-charge skill stayed ready through the burst; PR #262). setSpTotal rebuilds the charges
// silently (no spGain) and leaves a running timed skill alone, whose SP was spent at its cast.
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.spTotal > 0) sk.setSpTotal(Math.max(0, sk.spTotal - 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. `noHeal` stops heals from others (`self` heals pass); `healFree` (禁疗,
* PRTS 异常效果 HEAL_FREE "受到的治疗量变为0") stops the unit's own too — except an HP-regeneration attribute (`regen`:
* "增减生命回复速度或生命回复速度(百分比)属性的效果不会被识别为治疗类能力") and a heal that ignores it (`ignoreHealFree`:
* 史尔特尔 S3's start heal, PRTS "无视禁疗").
* A `regen` tick (Battle status: the unit's own 生命回复速度, `s.hpRegen` — 吟游者 / 调香师 / 瑕光 S2 / 铃兰 S3 / 锡人 add to it
* with an hpRegen buff, PRTS 备注 "不受治疗加成和禁疗影响") is no 治疗: no 治疗加成 scales it — neither the healing
* multipliers nor a `heal` handler (handlers still see it, with `opts.regen`; a change they make to its amount is
* ignored).
* A healer whose profile names the target in `healThrough(healer, target)` heals it through its 禁疗 — the `noHeal` flag
* a summon's 禁疗 sets and the flag `healFree` (not a profile's `noHeal`, 无法被友方治疗): 凯尔希 on her Mon3tr (PRTS
* Mon3tr(凯尔希的召唤物) "持有禁疗(可被凯尔希…无视)"; her heal selection takes it too, Battle.injuredAlliesInKeys).
*/
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;
const through = !!(source && source.profile && typeof source.profile.healThrough === 'function' && source.profile.healThrough(source, target));
if (!self && ((target.s.flags.noHeal && !through) || (target.profile && target.profile.noHeal))) return 0;
const regen = !!opts.regen;
if (target.s.flags.healFree && !regen && !opts.ignoreHealFree && !through) return 0;
let amt = regen ? amount : 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);
if (!regen) 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;
}