Files
ark-stronghold-protocol/server/sim/content/kits/ops/op-zuole.js
T
sganggs a7d62b5fc1 feedback5 WE1: 武者 trait heal on every damage instance, through 禁疗 (follow-up 1)
PRTS 分支特性信息 武者: "特性治疗于干员每次输出伤害时触发(不局限于攻击)", "通过自身特性/天赋/技能产生的作用于自身的治疗效果会无视
自身的禁疗" (utage_trait / helage_trait / zuole_trait: ON_OUTPUT_DAMAGE); PRTS 异常效果: an abnormal flag is one switch.

- professions.js installMushaHeal: one heal of the trait value per damage instance dealt to an enemy, at that instance
  (every hit of an attack, skill / item / bond damage); a dodged hit, a 流失, an element 损伤 or a talent / DoT damage heals
  nothing; ignoreHealFree. 收割者 keeps its rule (installReaperHeal) and ignores 禁疗 too (the same PRTS sentence).
- 赫拉格's kit: its second-hit heal patch is gone (redundant). 左乐's S1 reads his HP when it fires, before its strike's
  own heal [ASSUMED].

Golden (diy only): diy-007, diy-008, diy-009 (赫拉格: a multi-target attack heals 70 per target in separate heals instead
of one 70 × n heal — HP identical, heal hook / event counts move); diy-033, diy-034, diy-035 (左乐: heals land between
his S1 / S2 hits, so his HP-dependent T2 roll, 坚忍 and 庇护 read the healed HP from the second instance on). roster,
bonds, fields, matches, standins unchanged (宴 hits once per attack).

Tests: test/sim/feedback5-musha-heal.test.js (new, 4); op_helage, op_zuole, kits_t1t2, kits_t5, kits_alt_t5 pass.
Docs: SIM.md §Professions.
2026-10-06 15:32:30 +08:00

240 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/sim/content/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; zuole_trait heals on every damage he outputs — the profession heals `value` per damage instance, PRTS 分支特性信息
// 武者, so S1's extra strikes heal too).
// - 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 HP read when the skill fires, before the strike's
// own trait heal]. 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',
// the HP that decides the extra strikes: when the skill fires, before its strike lands (the trait heals at each
// damage instance — professions.js musha — so after the first strike he would read healed) [ASSUMED]
onStart({ unit }) { unit.mem.zuoleS1Hp = unit.hpRatio; },
attack: {
atkScale: num(b1.atk_scale, 1),
onHit({ battle, unit, target }) {
const r = unit.mem.zuoleS1Hp ?? unit.hpRatio;
unit.mem.zuoleS1Hp = null;
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 });
}
},
};
},
};