Files
ark-stronghold-protocol/server/sim/content/kits/ops/op-zuole.js
T
sganggs 29fa988604 feedback5 O5: 自选 operator kits for 左乐, 重岳 and 煌 — every skill at ranks 4 / 7, both talents, the trait and both modules at stages 1 / 3, from the data, PRTS 备注, the client's buff templates and the battle skeletons' clip events
- 左乐 (op-zuole.js): S1 next attack + 1 / 2 extra strikes below 80 / 50 % HP (each a damage instance and a trait heal); S2 流失 50 % current HP (never below 1), a barrier of scale × max HP stacking to 2×, ATK / block +1 / every blocked enemy, −200 per second once over; S3 7 slashes on ≤ 3 enemies of 3-2, the last ×2 + stun, the trait's heal turned into a barrier (70 × 2.5 / 3 per damage instance, ≤ 2× max HP, 15 s); 坚忍 ASPD / SP recovery; 守正自明 per damage instance (SBL-Y Lv3 ×1.2 on a success); SBL-X 庇护 (behind the barrier once 行险 resets it — PRTS S2 备注 — through the hpDamage hook); SBL-Y revive at 30 % once per deployment.
- 重岳 (op-chyue.js): 止戈 marks (normal-attack instances, ×1.65 / ×1.75 on all his damage); 万象为宾 SP at each skill / segment end, 阻回 ignored (FGT-X Lv3 4, else 1); S1 charges with the full-stack burst of 3; S2 x-4 ≤ 4 enemies (air too) + 浮空 2 s on the marked, the second segment 0.5 s later (skeleton events) on every 浮空 enemy; S3 strike + 0.8 splash, the 5th cast transforms him (x-6, double hits, +1 SP per attack, self-cast, the strike twice); FGT-X dodge, FGT-Y ASPD.
- 煌 (op-huang.js): CEN-X ×1.1 vs blocked enemies on every damage; CEN-Y −20 % physical above 50 %; 紧急除颤 不死 + heal + the 50 % HP floor through the hpDamage hook (CEN-Y Lv3: 50 % then 25 %, 8 s, DEF −150 above 50 %); 严酷训练 抵抗 after 15 s (CEN-X Lv3 ATK +6 % / ASPD +12 after 30 / 45 s); S1 next attack; S2 SP_FULL toggle with 2-2; S3 a 10 s 维持技能状态: cuts each second for 8 s, ramp to full at 8.95 s, at 9 s 流失 25 % max HP and the 3-6 burst (air too).
- kits/index.js OPERATOR_KIT_FILES lists the three files; test/content/diy.test.js's "owned 6★ without a kit" case takes the first kitless operator of diy.ownedPool (it named 煌).
- Golden: only test/golden/diy.json moved — the diy family packs the new kits: diy-003 / 004 field 左乐, diy-005 / 006 重岳, diy-007 / 008 煌 (tiers 5 / 6, every form × module × skill, no battle error); the prototypes and reserves moved from diy-003 … 006 to diy-009 … 012 (new ids, same packing); diy-001 / 002 (推进之王) and every other family unchanged.
- Tests: test/content/op_zuole.test.js (10), op_chyue.test.js (8), op_huang.test.js (10).
2026-10-06 00:13:05 +08:00

235 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, `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 });
}
},
};
},
};