Files
ark-stronghold-protocol/server/sim/content/kits/ops/op-zuole.js
T
sganggs 806a047717 feedback6 R21P: every operator fights at full potential (潜能 6) — the owner's decision of 2026-10-07 (GitHub #252, PR #255; no host slider): tools/build-data.mjs OPERATOR_POTENTIAL adds every character_table potentialRanks attribute step (部署费用 / 再部署时间 / ATK / HP / DEF / RES / ASPD, all ADDITION, before rounding) to every chess (normal and elite), 补位 and 自选 unit form and picks talent / trait / module-talent candidates up to requiredPotentialRank 5 (「天赋效果增强」); a summon picks its talent candidates at its owner's potential (their requiredPotentialRank mirrors the owner's — PRTS 凯尔希: Mon3tr 3.5 s / 1400) and takes none of the owner's attribute steps; the 原型干员 stand-ins have no potential ranks (unchanged). 刺玫 413 / 17 → 435 / 15 as in the tournament video, 砾 18 → 16 s, 至简 80 → 70 s. data/chess.json (256 records), backups.json (the 71 owned picks, 8 summons), tokens.json (5 summons) and the data translations en / ja / ko / zh-TW rebuilt — only potential-driven fields (JSON-compared); the 干员调配 局内数值 and detail cards read the records. op-wang.js reads her stock cap 「最多拥有8(+1)枚」 through the potential step (the pattern fell back to 7: she sat at the cap from the start). Docs: DATA §2 / §18 / tokens, PLAYING (detail card), research 03, kits README, shared/diy.js / standIn.js, the 71 自选 kit headers and the kit comments that quoted potential-0 numbers. Tests: test/full-potential.test.js (new, fails on the parent); data.test.js re-derivation adds the potential steps (+ respawnTime); every test that hard-coded a potential-0 number carries the full-potential one (from the data and the test's own formula; no check loosened); golden: moves almost everywhere — roster 49/49, bonds 46/46, fields 22/22, matches 18/18, diy 133/138 (each battle parts on a stat: roster-001 / roster-020 at tick 1 on 角峰's HP (生命上限+250), bond-arcaneShip-high at 4.73 s and diy-001 at 3.70 s on an enemy's HP from a higher ATK; diy-110 / 111 also by 望's cap; the matches through those battles); unchanged: standins 10/10 and diy-135…138 (no potential ranks), diy-012 (twelve 夜莺 never hit, never redeployed)
2026-10-07 18:35:39 +08:00

249 lines
16 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). Full potential (the owner's decision of 2026-10-07).
// 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%" (full potential: 23 % / 75 %; 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%" (full potential: 85 %): 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 "受到的物理和法术伤害降低相应比例(同名效果取最高)": the shared effect of
// every source, tier1.js holdProtect) 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, holdProtect, PROTECT, PROTECT_TICK_HOLD } 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) — his own held 庇护
// goes now (another source's comes back with its next refresh)
P.since = null;
P.blockedUntil = battle.time + PROTECT_RESET;
const held = unit.findBuff(PROTECT);
if (held && held.source === unit) battle.removeBuff(unit, held);
},
},
[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 行险 — the shared 庇护 (holdProtect: the
// strongest of every source holds), refreshed every tick and at each hit on him. When the 庇护 held is his own and his
// barrier is older than it, his cut comes after the barrier (the hpDamage hook) instead of before it
if (protectCut > 0 && protectCut < 1) {
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 keep = () => { if (active()) holdProtect(battle, unit, protectCut, PROTECT_TICK_HOLD, unit); };
const own = () => { const b = unit.findBuff(PROTECT); return !!b && b.source === unit && b.data?.value === protectCut; };
const afterBarrier = new WeakSet();
battle.on('tick', keep, { owner: unit });
onHitOn(battle, unit, ({ dmg }) => {
keep();
if ((dmg.type !== 'phys' && dmg.type !== 'arts') || P.since == null || !own()) return;
const bar = barrierOf(unit);
// (the held buff's mods cut this hit before the barrier: undo that and cut what passes the barrier instead)
if (bar && bar.shield > 0 && (bar.data?.createdAt ?? Infinity) < P.since) { dmg.mul /= 1 - protectCut; afterBarrier.add(dmg); }
});
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 });
}
},
};
},
};