feedback5: merge O5 — 自选 kits for 左乐, 重岳 and 煌; engine hook hpDamage after the shields (typed shields from O8 kept: absorbShields(…, type)); SIM.md / damage.js headers take both; golden: only diy moved

This commit is contained in:
sganggs committed 2026-10-06 00:18:48 +08:00
commit aebca2f6d8
12 files changed
+2157 -131

No files matched your search

+3
View File
@@ -95,6 +95,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([
'op-logos.js',
'op-mantra.js',
'op-veen.js',
'op-zuole.js',
'op-chyue.js',
'op-huang.js',
]);
/**
+210
View File
@@ -0,0 +1,210 @@
// server/sim/content/kits/ops/op-chyue.js — 重岳 (char_2024_chyue) 自选 operator kit: 6★ 斗士 (近卫), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_2024_chyue, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 重岳 (talent and
// skill 备注); the client's buff templates (buff_template_data `chyue_t_1` / `_passive`, `chyue_t_2` / `_sp`,
// `chyue_e_002_t[unkill]`, `chyue_s_1_enhance_judge`, `chyue_s_2_levitate` / `_finish_levitate` / `_atk_scale`,
// `chyue_s_3_enhance` / `_trigger` / `_end_check`); Arknights Terra Wiki "Chongyue".
// - Trait (斗士) "能够阻挡一个敌人": the profession default (block 1, melee, ground only). Module FGT-X "拥有15%的物理闪避"
// (trait bb prob): a permanent physical dodge. Module FGT-Y "生命值高于50%时攻击速度+10" (trait bb attack_speed /
// hp_ratio): ASPD while HP > hp_ratio.
// - T1 止戈 "对目标普通攻击时,有23%的概率使重岳2.5秒内对其造成的伤害提升65%" (bb prob / damage_scale / up_duration):
// chyue_t_1 rolls on every damage instance a normal attack outputs (PRTS 备注 "于普通攻击每次造成伤害时独立触发,重复施加
// 时仅刷新持续时间" — after it lands, so not for that instance) and marks the target for up_duration s (his own mark:
// independentCharacterSource); chyue_t_1_passive: every damage of his to a marked enemy × damage_scale (DamageScale, a
// final multiplier — skills included). FGT-Y stage 3: damage_scale 1.75. S2's second segment marks at 100 %.
// - T2 万象为宾 "若重岳释放一次技能击倒不少于一个敌人,则回复3点技力" (bb sp): PRTS 备注 — only a kill by skill damage counts,
// judged when the skill (or an ability of it) finishes; the SP ignores 阻回 (ModifySp forceFlag). FGT-X stage 3:
// 4 SP, and "未击倒敌人时变为回复1点技力" (the hidden part's sp: chyue_e_002_t[unkill]). S2's second segment is judged on
// its own (PRTS: "第二段攻击打出时恢复一次SP…只与这第二段攻击是否击杀了敌人有关").
// - S1 冲盈 (MANUAL, attack SP, 可充能 3 次; data DEFAULT): the next attack strikes its target for atk_scale × ATK; cast
// from a full stack (蓄力, chyue_s_1_enhance_judge: ≥ 3 charges) it spends every charge (ClearCharacterSp) and strikes
// `times` times.
// - S2 拂尘 (MANUAL, 可充能 2 次; data SKILL_RANGE on its x-4): at once up to max_target enemies around him (PRTS 备注
// "可对空,优先选择自身第一天赋生效中的>自身阻挡的>仇恨值最高的敌人") take atk_scale × ATK physical, and those he marked are
// 浮空 for 2 s (chyue_s_2_levitate: ground enemies only — the engine refuses data flyers); then the second segment
// (SecAttack, outside the skill — 0.5 s later: his skeleton's Skill_2_Begin hits at 0.367 s of its 0.833 s, Skill_2_End
// at 0.033 s): every 浮空 enemy around him loses every 浮空 buff (chyue_s_2_finish_levitate), takes atk_scale_down × ATK
// physical and gets his mark (100 %, after that damage). The first segment lands at the cast [ASSUMED: the kits'
// instant convention — the clip's 0.367 s wind-up is not modelled].
// - S3 我无 (MANUAL, attack SP; data DEFAULT): the next attack strikes its target and the ground enemies within 0.8 of it
// (PRTS 备注 "溅射半径0.8"; the skill cannot hit air) for atk_scale × ATK. Its cast_cnt-th cast of a deployment turns him
// (【变身】, chyue_s_3_end_check — "第五次开启本技能时【变身】(每次部署单独计算)"; that cast keeps its single strike, the
// text's "累计使用五次技能后"): his range becomes x-6, every normal attack hits twice and gives 1 more SP ignoring 阻回
// (chyue_s_3_enhance), the skill fires by itself the moment its SP is full and a normal-attack target exists
// (chyue_s_3_trigger TriggerSkill, checked every frame: not the 3 s automatic-operation cooldown) and its strike lands
// twice. The fixed animation (不受攻击速度影响) is modelled as one attack interval [ASSUMED].
import { num, talentBb, moduleBb, traitBb, up, onHitBy, statBuff, toggleBuff, skillRec } from '../shared/tier1.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
import { hasHp, isHpLoss } from '../../../damage.js';
const S1 = 'skchr_chyue_1';
const S2 = 'skchr_chyue_2';
const S3 = 'skchr_chyue_3';
/** S2's 技能范围 when the data carries none (x-4: the 8 tiles around him and his own). */
const X4 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
/** His range after the S3 【变身】 (PRTS 备注 "攻击范围扩大至 x-6"; range_table x-6). */
const X6 = Object.freeze([[2, 0], [1, 0], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, 0], [-2, 0]]);
/** S3 "溅射半径0.8" (PRTS 备注). */
const S3_SPLASH = 0.8;
/** S2 浮空 "持续2s" (PRTS 备注; chyue_s_2_levitate lifeTime 2). */
const S2_LEVITATE = 2;
/** S2's second segment after the first: the skeleton's OnAttack events (Skill_2_Begin 0.367 / 0.833 s, Skill_2_End 0.033 s). */
const S2_SECOND_DELAY = 0.833 + 0.033 - 0.367;
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const markKey = (unit) => `talent:chyue:mark#${unit.id}`;
const marked = (unit, e) => !!e.findBuff(markKey(unit));
export default {
char_2024_chyue: (bb, chess, def) => {
const tb = traitBb(chess);
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const hb = moduleBb(chess); // FGT-X stage 3: sp when the skill killed nobody
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2);
const picked = def?.skill?.id ?? chess?.skill?.skillId ?? null;
const castCnt = Math.max(1, Math.floor(num(b3.cast_cnt, 5)));
const mark = (battle, unit, e) => {
if (hasHp(e)) battle.addBuff(e, { key: markKey(unit), duration: num(t0.up_duration, 2.5), refresh: 'replace', source: unit, tags: ['talent'] });
};
/** 万象为宾: the SP of a finished skill / segment (a kill by his skill damage: sp, else the FGT-X stage 3 part). */
const settle = (unit) => {
const killed = !!unit.mem.chyueKilled;
unit.mem.chyueKilled = false;
const n = killed ? num(t1.sp) : num(hb.sp);
if (n > 0 && up(unit) && unit.skill) unit.skill.gainSp(n, 'init'); // ModifySp forceFlag: 阻回 does not stop it
};
const s2Keys = (unit) => absoluteRangeKeys(s2?.rangeGrid?.length ? s2.rangeGrid : X4, unit.tileR, unit.tileC, unit.dir, 0);
/** S2's second segment (SecAttack). */
const secondSegment = (battle, unit, seq) => {
if (!up(unit) || unit.deploySeq !== seq) return;
unit.mem.chyueKilled = false;
const atk = unit.s.atk;
const lifted = battle.enemiesInKeys(s2Keys(unit), unit, { canHitFly: true }).filter((e) => e.s.flags.levitate);
if (lifted.length) battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'chyue:down' });
for (const e of lifted) {
for (const b of e.buffs.slice()) if (b.flags && b.flags.levitate) battle.removeBuff(e, b);
if (!e.alive) continue;
battle.dealDamage(unit, e, { amount: atk * num(b2.atk_scale_down, 1), type: 'phys', isSkill: true, tags: ['skill'] });
mark(battle, unit, e);
}
settle(unit);
};
/** After the S3 【变身】: x-6, double hits, +1 SP per attack, the self-cast. */
const awaken = (battle, unit) => {
unit.mem.chyueAwake = true;
unit.rangeGrid = X6;
battle.refreshRange(unit);
battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'atk' });
};
return {
skills: {
[S1]: {
kind: 'charges',
attack: { atkScale: num(b1.atk_scale, 1), hitsFn: (b, u) => u.mem.chyueS1Hits || 1 },
onStart({ unit, skill }) {
// 蓄力: the cast took one charge of a full stack ⇒ the rest goes too and the strike lands `times` times
const full = skill.maxCharges > 1 && skill.charges === skill.maxCharges - 1;
unit.mem.chyueS1Hits = full ? Math.max(1, Math.floor(num(b1.times, skill.maxCharges))) : 1;
if (full) { skill.charges = 0; skill.sp = 0; }
},
onEnd({ unit }) { unit.mem.chyueS1Hits = 1; settle(unit); },
},
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
onStart({ battle, unit }) {
unit.mem.chyueKilled = false;
const foes = battle.enemiesInKeys(s2Keys(unit), unit, { canHitFly: true });
sortEnemyTargets(battle, unit, foes, null); // the ones he blocks first, then aggro
foes.sort((a, b) => (marked(unit, b) ? 1 : 0) - (marked(unit, a) ? 1 : 0)); // his marked ones before them (stable)
const atk = unit.s.atk;
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'chyue:whisk' });
for (const e of foes.slice(0, Math.max(1, Math.floor(num(b2.max_target, 4))))) {
if (!e.alive) continue;
const lift = marked(unit, e);
battle.dealDamage(unit, e, { amount: atk * num(b2.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
if (lift && e.alive) battle.applyStatus(e, 'levitate', { duration: S2_LEVITATE, source: unit });
}
const seq = unit.deploySeq;
battle.after(S2_SECOND_DELAY, () => secondSegment(battle, unit, seq), { owner: unit });
},
onEnd({ unit }) { settle(unit); },
},
[S3]: {
kind: 'instant',
attack: {
atkScale: num(b3.atk_scale, 1), splashRadius: S3_SPLASH, hitsFn: () => 1,
onHit({ battle, unit, target, x, y }) {
if (!unit.mem.chyueAwake) return;
// after the 【变身】 the strike lands once more: its target and the ground enemies within 0.8 of it
const amount = unit.s.atk * num(b3.atk_scale, 1);
const cx = target ? target.x : x, cy = target ? target.y : y;
if (target && target.alive) battle.dealDamage(unit, target, { amount, type: 'phys', isSkill: true, tags: ['skill'] });
for (const e of battle.foesInRadius(cx, cy, S3_SPLASH, true)) {
if (e === target || !e.alive || e.isFlying || e.s.flags.untargetable) continue;
battle.dealDamage(unit, e, { amount, type: 'phys', isSkill: true, isSplash: true, tags: ['skill'] });
}
},
},
onStart({ unit }) { unit.mem.chyueCasts = (unit.mem.chyueCasts || 0) + 1; },
onEnd({ battle, unit }) {
settle(unit);
if (!unit.mem.chyueAwake && unit.mem.chyueCasts >= castCnt && up(unit)) awaken(battle, unit);
},
},
},
talents: [
{ install(battle, unit) { // 止戈
const p = num(t0.prob), scale = num(t0.damage_scale, 1);
if (scale !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (marked(unit, target)) dmg.mul *= scale; });
if (!(p > 0)) return;
battle.on('damaged', (c) => {
const e = c.target, d = c.dmg;
if (c.source !== unit || !e || e.side !== 'enemy' || !d || !d.isAttack || d.isSkill || isHpLoss(d) || c.type === 'element' || !hasHp(e)) return;
if (battle.rng.chance(p)) mark(battle, unit, e);
}, { owner: unit });
} },
{ install(battle, unit) { // 万象为宾: a kill by his skill damage, for the running skill / segment (settle)
battle.on('damaged', (c) => {
if (c.source === unit && c.dmg && c.dmg.isSkill && c.target && c.target.side === 'enemy' && !hasHp(c.target)) unit.mem.chyueKilled = true;
}, { owner: unit });
} },
],
// the S3 【变身】's double hit (normal attacks only: S1 / S3 strikes set their own hits)
trait: picked === S3 ? { hitsFn: (b, u) => (u.mem.chyueAwake ? 2 : 1) } : undefined,
install(battle, unit) {
// FGT-X 物理闪避 / FGT-Y 生命值高于50%时攻击速度+10
if (num(tb.prob) > 0) statBuff(battle, unit, 'trait:chyue:dodge', { dodgePhys: num(tb.prob) });
if (num(tb.attack_speed)) toggleBuff(battle, unit, 'trait:chyue:aspd', () => unit.hpRatio > num(tb.hp_ratio, 0.5), { aspd: num(tb.attack_speed) });
// every deployment starts untransformed with no cast counted (每次部署单独计算)
battle.on('deploy', ({ unit: u }) => {
if (u !== unit) return;
unit.mem.chyueCasts = 0;
unit.mem.chyueAwake = false;
unit.mem.chyueS1Hits = 1;
unit.mem.chyueKilled = false;
const own = unit.def?.rangeGrid;
if (own && unit.rangeGrid !== own) { unit.rangeGrid = own; battle.refreshRange(unit); }
}, { owner: unit });
if (picked !== S3) return;
// 【变身】: +1 SP per normal attack (阻回 ignored) and the self-cast the moment SP and a target are there
battle.on('attack', (c) => {
if (c.attacker === unit && !c.isSkill && unit.mem.chyueAwake && unit.skill) unit.skill.gainSp(1, 'init');
}, { owner: unit });
battle.on('tick', () => {
const sk = unit.skill;
if (!unit.mem.chyueAwake || !sk || !up(unit) || !unit.canAct || !sk.ready || sk.pending || unit.s.flags.silence) return;
const prof = battle.effectiveProfile(unit);
if (!battle.enemiesInKeys(unit.rangeKeys, unit, prof).length && !battle.blockedTargets(unit, prof).length) return;
if (!sk.activate('awake')) return;
if (battle.forceAttack(unit)) unit.atkCd = Math.max(unit.atkCd, unit.s.interval);
}, { owner: unit });
},
};
},
};
+209
View File
@@ -0,0 +1,209 @@
// server/sim/content/kits/ops/op-huang.js — 煌 (char_017_huang) 自选 operator kit: 6★ 强攻手 (近卫), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_017_huang, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 煌 (talent and
// skill 备注, the CEN-X note); the client's buff templates (buff_template_data `huang_s_3`, `huang_t_1[heal]` / `[lock]`,
// `huang_e_003[lock]` / `[heal]`, `huang_t_2…`); Arknights Terra Wiki "Blaze" (the S3 notes).
// - Trait (强攻手) "同时攻击阻挡的所有敌人": the profession default (centurion `hitAllBlocked`, block 3). Module CEN-X
// "攻击被阻挡的敌人攻击力提升至110%" (trait bb atk_scale): PRTS "于每次计算伤害时检查目标是否处于阻挡/被阻挡状态…提升效果
// 为攻击力倍率提升" — every damage instance of hers whose target is blocked (by anyone) takes ×atk_scale on its ATK-based
// amount (before DEF): normal attacks, S1, the S3 cuts and burst. Module CEN-Y "生命值高于50%时受到的物理伤害降低20%"
// (trait bb hp_ratio / damage_resistance): physical damage taken × (1 − damage_resistance) while HP > hp_ratio.
// - T1 紧急除颤 "在生命值低于25%时,仅一次回复50%的生命值并在6秒内使生命值不低于50%" (bb hp_ratio,
// huang_t_1[heal].hp_ratio, huang_t_1[lock].min_hp_ratio / .duration). PRTS 备注: every deployment she holds 不死 until
// the first time her HP ratio is ≤ hp_ratio; then the heal (max HP × [heal].hp_ratio, a heal — huang_t_1[heal]
// HealViaMaxHpRatio) and for [lock].duration s every damage that would take her below [lock].min_hp_ratio × max HP is
// cut to what is left above it ("该伤害减少(伤害值-煌当前生命值+煌最大生命值×50%)点": the `hpDamage` hook after the
// shields — a 伤判效果, so a 流失 such as her S3's is not held). CEN-Y stage 3 (talent hp_ratio 0.5 + the hidden
// huang_e_003[lock] part): "在生命值首次低于25%、50%时…8秒内…": the talent fires at 50 %, and a second trigger at
// check_hp_ratio (25 %) with its own 不死 (huang_e_003[undead]) fires only once the first is spent ("生命值首次低于50%
// 始终先于…25%生效"), re-checking the HP after the first one's heal [ASSUMED: both read the current HP]; its heal is
// skipped while the first lock runs (huang_e_003[heal] "CheckContainsBuff huang_t_1[lock] → IfNot → heal"). The hidden
// part's hp_ratio / def_penetrate_fixed (CEN-Y stage 2+): "生命值高于50%时攻击无视目标150点防御力" — a defIgnoreFlat
// buff while HP > hp_ratio (every damage of hers).
// - T2 严酷训练 "在战场停留15秒后获得抵抗" (bb one_minus_status_resistance, interval): `interval` s after each deployment
// the engine 抵抗 (huang_t_2 creates status_resistance[inf]: control statuses last half as long) until she leaves.
// CEN-X stage 3 adds "30秒后攻击力+6%,45秒后攻速+12" (huang_t_2[e_002_atk].atk / .interval, [e_002_atk_speed]
// .attack_speed / .interval: an ATK MULTIPLIER and an ATTACK_SPEED ADDITION, INFINITY lifetime).
// - S1 强力击·γ型 (AUTO, attack SP): the next attack at atk_scale × ATK — every blocked enemy it hits (her trait); the
// data's DEFAULT trigger (an AUTO "next attack" waits for her attack).
// - S2 链锯延伸模块 (AUTO, 持续时间无限): ATK +atk, DEF +def and the skill range 2-2 for the rest of the deployment (PRTS 备注
// "攻击距离加长…使用技能提供的攻击范围(非攻击距离+1)"); an AUTO skill acting on herself only fires as soon as its SP is
// full (the owner's AUTO rule, kits/README.md checklist 5).
// - S3 沸腾爆裂 (MANUAL, data DEFAULT): a 维持技能状态 (PRTS 游戏数据基础: no normal attack while it runs; its cadence
// follows the animation — no attack speed change touches it — and a hard control (晕眩 / 冻结 …) ends it early).
// PRTS 备注 + huang_s_3: every second for the first 8 s the ATK / DEF bonus is updated, then one physical cut hits every
// ground enemy of her attack range ("对前方一格内的敌方单位进行切割" — her own 1-1 and the enemies she blocks; 不可对空;
// 100 % ATK [ASSUMED: no blackboard key — her attack's own damage]); the bonus is atk / def × min(1, t ÷ (duration −
// 1.05)) at the t-th update (huang_s_3 RemainingRatioToAttributeModifier `_endTime` 1.05: full at 8.95 s; the Terra
// Wiki's "reaches maximum after 8.95 seconds"); at 9 s it reaches the maximum and the finish follows (PRTS "9s时…";
// Terra "triggered at the 9 seconds mark"; her skeleton, whose Skill_2_* clips are S3's: the 3 s Skill_2_Loop_End's
// last OnAttack falls at 9 s of the 10 s run — Begin 1.4 s + the 0.4 s loops to 7 s): the huang_s_3 ON_SKILL_FINISH part, she loses hp_ratio ×
// MAX HP (DamageViaMaxHpRatio, undeadable — never below 1 HP; PRTS calls it 流失) and then every enemy of the 3 × 3
// area ahead (PRTS "前方3×3区域 3-6…碰撞判定,可对空": air units too) takes damage_by_atk_scale × her ATK at that
// moment, physical; the state runs on to its 10 s. A run cut short by a hard control finishes at once
// [ASSUMED: ON_SKILL_FINISH fires on any end but her knock-out]. The range the game shows while it runs is the
// burst's 3-6 (PRTS); the data has no skill range and the cuts use her own, so the card keeps her 1-1 and the trigger
// stays DEFAULT.
import { num, talentBb, moduleBb, traitBb, up, onHitBy, onHitOn, toggleBuff, skillRec } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js';
const S1 = 'skchr_huang_1';
const S2 = 'skchr_huang_2';
const S3 = 'skchr_huang_3';
/** S3's final area "前方3×3区域" (PRTS 备注 range 3-6; range_table): her tile, the two ahead and the rows beside them. */
const GRID_3_6 = Object.freeze([[1, 0], [1, 1], [1, 2], [0, 0], [0, 1], [0, 2], [-1, 0], [-1, 1], [-1, 2]]);
/** huang_s_3 RemainingRatioToAttributeModifier `_endTime`: the ramp is full this long before the run's nominal end. */
const S3_RAMP_END = 1.05;
/** S3 updates: eight cuts (1 s apart), then the finish at the ninth (PRTS "9s时"). */
const S3_CYCLES = 9;
const S3_BUFF = 'skill:huang:ramp';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
export default {
char_017_huang: (bb, chess, def) => {
const tb = traitBb(chess);
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const hb = moduleBb(chess); // CEN-Y stage 2+: hp_ratio / def_penetrate_fixed; stage 3: huang_e_003[lock].*
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2);
const ownGrid = chess?.rangeGrid ?? def?.rangeGrid ?? [[0, 0], [0, 1]];
/** The ground enemies her own range holds and the ones she blocks (the S3 cuts). */
const cutTargets = (battle, unit) => {
const keys = absoluteRangeKeys(unit.rangeGrid || ownGrid, unit.tileR, unit.tileC, unit.dir, unit.s.rangeExtend || 0);
const list = battle.enemiesInKeys(keys, unit, { canHitFly: false });
for (const e of battle.blockedTargets(unit, { canHitFly: false })) if (!list.includes(e)) list.push(e);
return list;
};
/** S3's finish (huang_s_3 ON_SKILL_FINISH): the 流失 first (undeadable), then the burst at her ATK of that moment. */
const finish = (battle, unit, s) => {
s.done = true;
const loss = Math.min(unit.s.maxHp * num(b3.hp_ratio), unit.hp - 1);
if (loss > 0) battle.loseHp(unit, loss, { source: unit });
if (!up(unit)) return;
const atk = unit.s.atk;
const keys = absoluteRangeKeys(GRID_3_6, unit.tileR, unit.tileC, unit.dir, 0);
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'huang:burst' });
for (const e of battle.enemiesInKeys(keys, unit, { canHitFly: true })) {
if (e.alive) battle.dealDamage(unit, e, { amount: atk * num(b3.damage_by_atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
}
};
const setRamp = (battle, unit, r) => {
const b = unit.findBuff(S3_BUFF);
if (!b) return;
b.mods = { atkPct: num(b3.atk) * r, defPct: num(b3.def) * r };
unit.markDirty();
};
return {
skills: {
[S1]: { kind: 'instant', attack: { atkScale: num(b1.atk_scale, 1) } },
[S2]: {
kind: 'toggle', trigger: 'SP_FULL',
mods: { atkPct: num(b2.atk), defPct: num(b2.def) },
...(s2?.rangeGrid?.length ? { targeting: { rangeGrid: s2.rangeGrid } } : {}),
onStart({ battle, unit }) { battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'atk' }); },
},
[S3]: {
kind: 'duration',
attack: { noAttack: true },
onStart({ battle, unit }) {
unit.mem.huangS3 = { t: 0, k: 0 };
battle.addBuff(unit, { key: S3_BUFF, mods: { atkPct: 0, defPct: 0 }, tags: ['skill'], visible: true });
},
onTick({ battle, unit, skill, dt }) {
const s = unit.mem.huangS3;
if (!s) return;
// 维持技能状态: a hard control ends it early (PRTS 游戏数据基础 "眩晕、冻结等硬性控制会提前结束")
if (!unit.canAct || unit.s.flags.sleep) { skill.end('interrupted'); return; }
s.t += dt;
while (s.k < S3_CYCLES && s.t >= s.k + 1 - 1e-9) {
s.k++;
setRamp(battle, unit, Math.min(1, s.k / Math.max(1e-6, skill.duration - S3_RAMP_END)));
if (s.k >= S3_CYCLES) { finish(battle, unit, s); return; }
const foes = cutTargets(battle, unit);
if (foes.length) battle.fx('aoe', { x: unit.x, y: unit.y, radius: 0.6, id: unit.id, skill: 'huang:cut' });
for (const e of foes) if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk, type: 'phys', isSkill: true, tags: ['skill'] });
}
},
onEnd({ battle, unit, reason }) {
const s = unit.mem.huangS3;
unit.mem.huangS3 = null;
if (s && !s.done && reason !== 'death' && up(unit)) finish(battle, unit, s); // cut short: it finishes at once
battle.removeBuff(unit, S3_BUFF);
},
},
},
talents: [
{ install(battle, unit) { // 紧急除颤 (+ CEN-Y stage 3's second trigger and stage 2+'s 防御穿透)
const trig = num(t0.hp_ratio, 0.25);
const healA = num(t0['huang_t_1[heal].hp_ratio'], 0.5);
const floorA = num(t0['huang_t_1[lock].min_hp_ratio'], 0.5), durA = num(t0['huang_t_1[lock].duration'], 6);
const second = hb['huang_e_003[lock].check_hp_ratio'] != null;
const trigB = num(hb['huang_e_003[lock].check_hp_ratio'], 0.25);
const healB = num(hb['huang_e_003[lock].hp_ratio'], 0.5);
const floorB = num(hb['huang_e_003[lock].min_hp_ratio'], 0.5), durB = num(hb['huang_e_003[lock].duration'], 8);
const st = { undeadA: false, undeadB: false, lockA: -Infinity, lockB: -Infinity };
unit.mem.huangDefib = st;
battle.on('deploy', ({ unit: u }) => {
if (u !== unit) return;
st.undeadA = true;
st.undeadB = second;
st.lockA = st.lockB = -Infinity;
}, { owner: unit });
// 不死 while a trigger is unspent: a lethal hit leaves her at 1 HP (the trigger check below follows)
battle.on('fatal', (c) => { if (c.unit === unit && !c.prevented && (st.undeadA || st.undeadB)) c.prevented = true; }, { owner: unit });
battle.on('hpDamage', (c) => {
if (c.target !== unit) return;
const t = battle.time;
const floor = Math.max(t < st.lockA ? floorA : 0, t < st.lockB ? floorB : 0);
if (floor > 0) c.amount = Math.min(c.amount, Math.max(0, unit.hp - floor * unit.s.maxHp));
}, { owner: unit });
battle.on('damaged', (c) => {
if (c.target !== unit || !up(unit)) return;
if (st.undeadA && unit.hpRatio <= trig + 1e-9) {
st.undeadA = false;
st.lockA = battle.time + durA;
battle.heal(unit, unit, unit.s.maxHp * healA, { self: true, tags: ['talent'] });
battle.fx('heal', { x: unit.x, y: unit.y, id: unit.id });
}
if (st.undeadB && !st.undeadA && unit.hpRatio <= trigB + 1e-9) {
st.undeadB = false;
st.lockB = battle.time + durB;
if (!(battle.time < st.lockA)) battle.heal(unit, unit, unit.s.maxHp * healB, { self: true, tags: ['talent'] });
battle.fx('heal', { x: unit.x, y: unit.y, id: unit.id });
}
}, { owner: unit });
const pen = num(hb.def_penetrate_fixed);
if (pen > 0) toggleBuff(battle, unit, 'talent:huang:defPen', () => unit.hpRatio > num(hb.hp_ratio, 0.5), { defIgnoreFlat: pen });
} },
{ install(battle, unit) { // 严酷训练 (+ CEN-X stage 3: ATK after 30 s, ASPD after 45 s)
const resAt = num(t1['huang_t_2.interval'], num(t1.interval, 15));
const res = Math.min(0.95, Math.max(0, -num(t1['huang_t_2.one_minus_status_resistance'], num(t1.one_minus_status_resistance))));
const atk = num(t1['huang_t_2[e_002_atk].atk']), atkAt = num(t1['huang_t_2[e_002_atk].interval']);
const as = num(t1['huang_t_2[e_002_atk_speed].attack_speed']), asAt = num(t1['huang_t_2[e_002_atk_speed].interval']);
battle.on('deploy', ({ unit: u }) => {
if (u !== unit) return;
const seq = unit.deploySeq;
const at = (s, fn) => battle.after(s, () => { if (up(unit) && unit.deploySeq === seq) fn(); }, { owner: unit });
if (res > 0) at(resAt, () => battle.applyStatus(unit, 'resist', { value: res, source: unit }));
if (atk) at(atkAt, () => battle.addBuff(unit, { key: 'talent:huang:atk', mods: { atkPct: atk }, tags: ['talent'] }));
if (as) at(asAt, () => battle.addBuff(unit, { key: 'talent:huang:aspd', mods: { aspd: as }, tags: ['talent'] }));
}, { owner: unit });
} },
],
install(battle, unit) {
// CEN-X: ×atk_scale on her ATK-based damage to a blocked enemy (every damage calculation)
const blockedMul = num(tb.atk_scale, 1);
if (blockedMul !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (target.blockedBy) dmg.amount *= blockedMul; });
// CEN-Y: 生命值高于50%时受到的物理伤害降低20%
const dr = num(tb.damage_resistance);
if (dr > 0) onHitOn(battle, unit, ({ dmg }) => { if (dmg.type === 'phys' && unit.hpRatio > num(tb.hp_ratio, 0.5)) dmg.mul *= 1 - dr; });
},
};
},
};
+234
View File
@@ -0,0 +1,234 @@
// server/sim/content/kits/ops/op-zuole.js — 左乐 (char_4121_zuole) 自选 operator kit: 6★ 武者 (近卫), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4121_zuole, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 左乐 (S2 / S3
// 备注); the client's buff templates (buff_template_data `zuole_trait`, `zuole_t_2`, `zuole_s_2[shield]` /
// `[shield_core]`, `zuole_s_3` / `[shield]`, `zuole_e_002[resistance]`, `zuole_e_003_talent`, `zuole_e_003_trait`);
// Arknights Terra Wiki "Zuo Le" (the barrier caps count each skill's own barrier only).
// - Trait (武者) "不成为其他角色的治疗目标,每次攻击到敌人后回复自身70生命": the profession default (musha: no heal from
// others, `value` per enemy hit; zuole_trait heals on every damage he outputs — the profession heals a normal attack
// per target and any other damage per instance, so S1's extra strikes are separate damage instances).
// - T1 秉烛照影 "在场时,自身获得最高+50攻击速度和技力自然回复速度+2/秒的坚忍(损失70%生命值时达到最大加成)" (bb
// min_attack_speed / min_sp_recovery_per_sec / min_hp_ratio): 坚忍 (ba.berserk "根据已损失的生命值获得相应比例的属性
// 加成") — ASPD and SP recovery × min(1, lost HP ÷ (1 − min_hp_ratio)) while he is deployed. SBL-X stage 3: +70 /
// +2.3 / at 50 % lost (the module talent's bb).
// - T2 守正自明 "攻击时有20%的概率获得1点技力,生命低于50%时概率变为70%" (bb prob_1 / prob_2 / hp_ratio / sp): zuole_t_2
// rolls on every damage he outputs (ON_OUTPUT_DAMAGE — skill damage too), prob_2 below hp_ratio. SBL-Y stage 3
// (zuole_e_003_talent, ON_CALCULATE_DAMAGE): "…且当次攻击的攻击力提升至120%…80%": a success also scales that damage
// instance by atk_scale.
// - Module SBL-X “岂苦夜长” (trait moduleDesc "生命值低于50%时,获得25%的庇护"; the hidden part merged into the first talent's
// bb: hp_ratio / damage_resistance): 庇护 (ba.protect "受到的物理和法术伤害降低相应比例") while HP < hp_ratio
// (zuole_e_002[resistance]: the 庇护 buff is created when HP drops below it and finished when it is back). PRTS S2 备注:
// 行险 resets it, it comes back 0.1 s later "并因此导致庇护的BUFF顺序落后于本技能的屏障" — a 庇护 created after his
// barrier cuts only what passes the barrier (the `hpDamage` hook), one created before it cuts the hit first.
// - Module SBL-Y “秉烛人的记录” (trait bb hp_ratio): "被击倒时不撤退且回复30%生命(单次部署只触发1次)" — the first lethal
// hit of a deployment is prevented and he heals hp_ratio × max HP (zuole_e_003_trait[heal] HealViaMaxHpRatio, 无视禁疗);
// not while a 不死 already holds him ("_dontConsumeWhenUndeadable": 坚固维式重锤's window, items/battle.js holdsUndying).
// - S1 破虏 (AUTO, 可充能 2 次): the next attack at atk_scale × ATK; "自身生命低于80%时额外攻击1次,低于50%时额外攻击2次"
// (hp_ratio_double / hp_ratio_tripple): one / two more strikes on that target at the same scale, separate damage
// instances [ASSUMED: the same target — nothing when it fell]. The data's DEFAULT trigger (an AUTO "next attack").
// - S2 行险 (MANUAL, 12 s): "立即流失50%当前生命" (zuole_s_2[shield] DamageViaCurHpRatio, undeadable: never below 1 HP;
// a 流失), then a barrier of scale × max HP (ba.barrier "可以吸收一定数值的伤害") added to the one he still has, capped at
// max_scale × max HP at each gain (PRTS "上限于每次获取屏障时实时发生变化"; Terra: the cap counts this skill's barrier
// only); ATK +atk, block +block_cnt, every blocked enemy struck at once. Once the skill is over the barrier loses
// −shield_decrease every second (PRTS "技能结束时屏障以每秒-200点的速度流失"; zuole_s_2[shield_core] ON_BUFF_TRIGGER).
// - S3 佑序有炎 (MANUAL, data SKILL_RANGE on its 3-2): 7 slashes ("立刻…": all in the cast instant [ASSUMED: the kits'
// instant convention — his skeleton's 2 s Skill_3 clip spreads its 7 OnAttack events over 0.2 … 1.2 s, and the engine
// models no animation lock]), each on at most 3 enemies of the skill range (the 3 is the text's: no
// blackboard key; air units too [ASSUMED: no 对空 note — docs/SIM.md §8]) for atk_scale × ATK physical, the last
// ×last_atk_bonus and 晕眩 `stun` s (zuole_s_3: AtkScaleUp once spell_times reaches times). Meanwhile the trait heals
// nothing (zuole_trait "CheckContainsBuff zuole_s_3 → IfNot → heal") and every damage instance he outputs adds
// value × shield_scale to a barrier (zuole_s_3 ON_OUTPUT_DAMAGE; PRTS "屏障获取不经过治疗流程,不受治疗倍率等影响"), capped
// at max_scale × max HP at each gain, lasting shield_duration s from the latest gain (PRTS "每次获取屏障时重置剩余持续
// 时间为15s").
import { num, talentBb, traitBb, up, onHitBy, onHitOn, giveSp, skillRec } from '../shared/tier1.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
import { isHpLoss } from '../../../damage.js';
import { holdsUndying } from '../../items/battle.js';
const S1 = 'skchr_zuole_1';
const S2 = 'skchr_zuole_2';
const S3 = 'skchr_zuole_3';
/** S3 "每次对最多3名敌人" — in the text only (no blackboard key). */
const S3_TARGETS = 3;
/** PRTS S2 备注 "庇护BUFF于技能开启0.1s后重新判定并生效". */
const PROTECT_RESET = 0.1;
/** zuole_s_2[shield_core] decays once per trigger: "每秒-200点" (PRTS S2 备注). */
const S2_DECAY_EVERY = 1;
const S2_BARRIER = 'skill:zuole:barrier2';
const S3_BARRIER = 'skill:zuole:barrier3';
const BERSERK = 'talent:zuole:berserk';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** The barrier of his skill he holds (one skill per pick), or null. */
const barrierOf = (u) => u.findBuff(S2_BARRIER) || u.findBuff(S3_BARRIER);
export default {
char_4121_zuole: (bb, chess) => {
const tb = traitBb(chess);
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s1 = skillRec(chess, S1), s3 = skillRec(chess, S3);
const healValue = num(tb.value, 70);
// SBL-X 庇护 (merged into the first talent's bb) — its state is shared with S2's reset
const protectRatio = num(t0.hp_ratio), protectCut = t0.hp_ratio != null ? num(t0.damage_resistance) : 0;
const P = { since: null, blockedUntil: -Infinity };
const s2Decay = ({ battle, unit, buff }) => {
const sk = unit.skill;
if (sk && sk.active && sk.id === S2) return;
buff.shield = Math.max(0, buff.shield + num(b2.shield_decrease, -200));
unit.markDirty();
if (!(buff.shield > 0)) battle.removeBuff(unit, buff);
};
const gainS3Barrier = (battle, unit) => {
const cap = unit.s.maxHp * num(b3.max_scale, 2);
const cur = unit.findBuff(S3_BARRIER);
const val = Math.min(cap, (cur ? cur.shield : 0) + healValue * num(b3.shield_scale));
if (!(val > 0)) return;
battle.addBuff(unit, { key: S3_BARRIER, shield: val, duration: num(b3.shield_duration, 15), refresh: 'replace', visible: true,
tags: ['skill'], data: { createdAt: cur?.data?.createdAt ?? battle.time } });
};
return {
skills: {
[S1]: {
kind: num(s1?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
attack: {
atkScale: num(b1.atk_scale, 1),
onHit({ battle, unit, target }) {
const r = unit.hpRatio;
const extra = (r < num(b1.hp_ratio_double, 0.8) ? 1 : 0) + (r < num(b1.hp_ratio_tripple, 0.5) ? 1 : 0);
for (let i = 0; i < extra && target && target.alive; i++) {
battle.dealDamage(unit, target, { amount: unit.s.atk * num(b1.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
}
},
},
},
[S2]: {
kind: 'duration',
mods: { atkPct: num(b2.atk), blockCnt: num(b2.block_cnt) },
attack: { hitAllBlocked: true },
onStart({ battle, unit }) {
const loss = Math.min(unit.hp * num(b2.hp_ratio), unit.hp - 1);
if (loss > 0) battle.loseHp(unit, loss, { source: unit });
const cap = unit.s.maxHp * num(b2.max_scale, 2);
const add = Math.min(cap, unit.s.maxHp * num(b2.scale));
const cur = unit.findBuff(S2_BARRIER);
if (cur) { cur.shield = Math.min(cap, cur.shield + add); unit.markDirty(); }
else if (add > 0) {
battle.addBuff(unit, { key: S2_BARRIER, shield: add, interval: S2_DECAY_EVERY, onTick: s2Decay, visible: true, tags: ['skill'],
data: { createdAt: battle.time } });
}
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id });
// SBL-X: 行险 resets the 庇护, which is judged again PROTECT_RESET s later (after the barrier)
P.since = null;
P.blockedUntil = battle.time + PROTECT_RESET;
},
},
[S3]: {
kind: 'instant',
onStart({ battle, unit }) {
const grid = s3?.rangeGrid?.length ? s3.rangeGrid : [[0, 0], [0, 1], [0, 2], [0, 3]];
const keys = absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0);
const times = Math.max(1, Math.floor(num(b3.times, 7)));
battle.fx('aoe', { x: unit.x + 1.5 * unit.fwd.x, y: unit.y + 1.5 * unit.fwd.y, radius: 1, id: unit.id, skill: 'zuole:slash' });
unit.mem.zuoleS3 = true;
try {
for (let i = 1; i <= times && up(unit); i++) {
const last = i === times;
const foes = battle.enemiesInKeys(keys, unit, { canHitFly: true });
sortEnemyTargets(battle, unit, foes, null);
const amount = unit.s.atk * num(b3.atk_scale, 1) * (last ? num(b3.last_atk_bonus, 1) : 1);
for (const e of foes.slice(0, S3_TARGETS)) {
if (!e.alive) continue;
battle.dealDamage(unit, e, { amount, type: 'phys', isSkill: true, tags: ['skill'] });
if (last && e.alive) battle.applyStatus(e, 'stun', { duration: num(b3.stun), source: unit });
}
}
} finally {
unit.mem.zuoleS3 = false;
}
},
},
},
talents: [
{ install(battle, unit) { // 秉烛照影 (坚忍)
const maxAs = num(t0.min_attack_speed), maxSp = num(t0.min_sp_recovery_per_sec), full = num(t0.min_hp_ratio, 0.3);
if (!(maxAs > 0 || maxSp > 0) || !(full < 1)) return;
let last = -1;
const update = () => {
if (!up(unit)) { last = -1; return; }
const f = Math.max(0, Math.min(1, (1 - unit.hpRatio) / (1 - full)));
if (Math.abs(f - last) < 1e-9) return;
last = f;
const cur = unit.findBuff(BERSERK);
if (!(f > 0)) { if (cur) battle.removeBuff(unit, cur); return; }
const mods = { aspd: maxAs * f, spRecoveryFlat: maxSp * f };
if (cur) { cur.mods = mods; unit.markDirty(); } else battle.addBuff(unit, { key: BERSERK, mods, tags: ['talent'] });
};
battle.on('deploy', ({ unit: u }) => { if (u === unit) { last = -1; update(); } }, { owner: unit });
battle.on('damaged', (c) => { if (c.target === unit) update(); }, { owner: unit });
battle.on('tick', update, { owner: unit });
} },
{ install(battle, unit) { // 守正自明 (+ SBL-Y stage 3: ×atk_scale on a success)
const p1 = num(t1.prob_1), p2 = num(t1.prob_2), thr = num(t1.hp_ratio, 0.5), sp = num(t1.sp), scale = num(t1.atk_scale, 1);
if (!(sp > 0)) return;
const roll = () => battle.rng.chance(unit.hpRatio < thr ? p2 : p1);
if (scale !== 1) {
onHitBy(battle, unit, ({ dmg }) => { if (dmg.type !== 'element' && roll()) { giveSp(unit, sp); dmg.amount *= scale; } });
} else {
battle.on('damaged', (c) => {
if (c.source !== unit || !c.target || c.target.side !== 'enemy' || isHpLoss(c.dmg) || c.type === 'element') return;
if (roll()) giveSp(unit, sp);
}, { owner: unit });
}
} },
],
install(battle, unit) {
unit.mem.zuoleProtect = P;
// S3: the trait's heal turns into barrier while the slashes run (every damage instance he outputs)
battle.on('heal', (c) => {
if (unit.mem.zuoleS3 && c.source === unit && c.target === unit && c.opts && c.opts.self && !c.opts.tags) c.amount = 0;
}, { owner: unit });
battle.on('damaged', (c) => {
if (!unit.mem.zuoleS3 || c.source !== unit || !c.target || c.target.side !== 'enemy' || isHpLoss(c.dmg) || c.type === 'element') return;
gainS3Barrier(battle, unit);
}, { owner: unit });
// SBL-X 庇护: active while HP < protectRatio, judged again PROTECT_RESET s after 行险
if (protectCut > 0) {
const active = () => {
const now = battle.time;
if (!up(unit) || now < P.blockedUntil - 1e-9) { P.since = null; return false; }
if (unit.hpRatio < protectRatio) { if (P.since == null) P.since = now; return true; }
P.since = null;
return false;
};
const afterBarrier = new WeakSet();
battle.on('tick', active, { owner: unit });
onHitOn(battle, unit, ({ dmg }) => {
if ((dmg.type !== 'phys' && dmg.type !== 'arts') || !active()) return;
const bar = barrierOf(unit);
if (bar && bar.shield > 0 && (bar.data?.createdAt ?? Infinity) < P.since) afterBarrier.add(dmg);
else dmg.mul *= 1 - protectCut;
});
battle.on('hpDamage', (c) => { if (c.target === unit && afterBarrier.has(c.dmg)) c.amount *= 1 - protectCut; }, { owner: unit });
}
// SBL-Y: 被击倒时不撤退且回复30%生命(单次部署只触发1次)
const revive = num(tb.hp_ratio);
if (revive > 0) {
let usedSeq = -1;
battle.on('fatal', (c) => {
if (c.unit !== unit || c.prevented || usedSeq === unit.deploySeq || holdsUndying(battle, unit)) return;
c.prevented = true;
usedSeq = unit.deploySeq;
battle.heal(unit, unit, unit.s.maxHp * revive, { self: true, ignoreHealFree: true, tags: ['trait'] });
battle.fx('revive', { x: unit.x, y: unit.y, id: unit.id });
}, { owner: unit, priority: -60 });
}
},
};
},
};
+9 -2
View File
@@ -6,8 +6,9 @@
// → × 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)
// → HP loss (boss pool routing) → 'damaged' hook
// → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill.
// → '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
@@ -274,6 +275,12 @@ export function dealDamage(battle, source, target, dmgIn) {
// 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);
}