Files
ark-stronghold-protocol/server/sim/content/kits/ops/op-leizi2.js
T
sganggs 221e6b8dd2 0.2.5-dev: community round 30 and follow-ups
- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently).
- Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision).
- 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified).
- Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage.
- Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue.
- Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia).
- golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
2026-10-12 14:07:08 +08:00

353 lines
23 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-leizi2.js — 司霆惊蛰 (char_1043_leizi2) 自选 operator kit: 6★ 解放者 (近卫), an owned-6★
// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her module (LIB-X) at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_1043_leizi2, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, LIB-X 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); the client's battle
// logic — buff_template_data (leizi2_tr, leizi2_e_002_tr, leizi2_s_cast, leizi2_t_1[atk_up] / [on_calcul] / [fly] /
// [trigger] / [buff_start], leizi2_s_2[try_add_atk], leizi2_s_3), her charpack (Trait buff: triggerInterval 1; the
// "thunder" ability: `_maxCount` 15, `_resetCountList` 3…7, `_rangeCastDelay` 0.25; S1 abilities s1_m / s1_l / s1_r:
// `_preDelay` 0.3, ground selectors; S3 attack: projectile_char_leizi2_s3 every `_triggerDelta` 0.28 s), the skill prefabs
// (skchr_leizi2_1: leizi2_s_1 lifeTime 0.5; skchr_leizi2_2: the flood-fill selector `_depth` 3, `checkHeightType`) and
// the projectile prefabs (projectile_char_leizi2_t_1 / [start]: lands `_delayTime` 0.15 s after its cast, ground + air;
// projectile_char_leizi2_s3: speed 1, `_lifeTime` 3.1, `_keepAlreadyHitTime` 0.6, ground only, magical, modifier key
// "leizi2", rebounds off its source), her skeleton's clips (Skill_1_Begin 0.5 s with OnAttack at 0.3; Skill_2_Begin /
// Skill_3_Begin 0.5 s; Skill_1_End / Skill_2_End 0.933 s, Skill_3_End 0.833 s) — and PRTS 司霆惊蛰 (talent / skill 备注).
// The official map of a battle is its field (`rect`): stage tiles beyond it (another player's half) are no tiles here —
// no strike falls there, S2's range and S3's currents stop at its edge [ASSUMED].
// - Trait (解放者) "通常不攻击且阻挡数为0,技能未开启时40秒内攻击力逐渐提升至最高+200%且技能结束时重置攻击力": the librator
// profile (professions.js: no attack and block 0 while the skill is off, ATK +atk / max_stack_cnt every second up to
// +atk while it is off, reset when it ends). Module LIB-X “一念” "部署后获得+100%加成": its hidden talent init_atk
// (leizi2_e_002_tr: init_atk / (atk / max_stack_cnt) stacks at each deployment) — the profile's rampInit (her DISPLAY
// trait part carries no blackboard, unlike 玛恩纳's, so the kit hands it over).
// - T1 明断 "攻击范围内每个地块每秒有10%的概率落雷对所有敌人造成相当于攻击力100%的法术伤害。未开启技能时起飞;技能期间攻击时攻击
// 力提升至107%" (full potential: 111 %):
// · the strikes (PRTS 备注's algorithm, the thunder ability's numbers): every 1.0333 s from each deployment, each tile of
// her current attack range — first seen: a counter at a random 0…14; else +1, and at 15 the tile is struck and its
// counter restarts at a random 3…7; counters outlive a tile leaving the range and die with the deployment. A strike
// lands 0.15 s after its cast: atk_scale_t × ATK arts on every enemy she can select on that tile, air units too
// (PRTS 可对空). Counting pauses while she cannot act [ASSUMED: the ability does not cast under 晕眩 / 冻结].
// · 起飞 while no skill runs (leizi2_t_1[fly] in her default mode only; gamedata_const ba.liftoff): flag `liftoff` —
// no ground enemy selects her or is blocked by her (her block count is 0 then anyway). PRTS "开启技能时的降落动作与技能
// 结束时的起飞动作…期间自身不处于起飞状态,其中降落动作计入技能总时长": she lands at the cast and makes no attack for the
// landing clip (S2 / S3 0.5 s, inside the skill's time; S1's attacks are its own), and after the skill she is up
// again only when the take-off clip is over (S1 / S2 0.933 s, S3 0.833 s) — ground enemies may select her meanwhile;
// her block count is the trait's 0 from the skill's end on.
// · leizi2_t_1[on_calcul] (AtkScaleUp on ON_CALCULATE_DAMAGE while a skill runs; PRTS "对自身技能期间造成的所有伤害生效"):
// an atkScaleMul ×atk_scale[skill_up] buff for the skill's time — her attacks (ai.js resolveHit) and every damage this
// kit computes (strikes, S1, currents) multiply it. LIB-X stage 2+: the module talent's value (stage 3: 1.17).
// - T2 追责 "开启技能时,使攻击范围内所有地面地块落雷,对所有敌人造成相当于攻击力100%的法术伤害和2秒战栗" (full potential: 3 s): at every cast (each S1
// charge too) every low tile of her attack range — the skill's, S2's flood fill included — is struck, ring by ring by
// Manhattan distance from her tile (PRTS "自内向外"), 0.25 s per ring (the thunder `_rangeCastDelay`; 0.07 s under S1 —
// leizi2_t_1[trigger] `_specialRangeCastDelay`), each landing 0.15 s after its cast: atk_scale_t2 × ATK arts and 战栗
// (`tremble`) not_combat s on every enemy she can select there, air units too. The strikes start at the cast
// [ASSUMED: leizi2_t_1[trigger] rides her landing clip's start event (≤ 0.17 s in), not modelled].
// - S1 浩气长存 (MANUAL, 可充能 3 次, data SEARCH): a 0.5 s skill (PRTS "技能持续0.5s"; skill prefab lifeTime 0.5) whose
// range is the 3-19; no normal attack in it; 0.3 s after the cast (the abilities' `_preDelay`) three attacks — a 3-2 to
// her left, her front and her right (PRTS: the 3-19 only explains the coverage) — each hit every ground enemy she can
// select on it for attack@atk_scale_s1 × ATK physical (one on her own tile takes all three). "充能耗尽前特性不重置"
// (leizi2_s_cast: the trait resets only when no charge is left): while a charge remains the ramp is kept.
// - S2 正霆摄威 (MANUAL, data SEARCH): at the cast her range becomes the low tiles reachable from hers in up to three 4-way
// steps over the field (PRTS 备注: "任意高度类型为低地的地块以及干员自己所在的地块", "不存在地块数据的地块始终视为不合法";
// a real attack range — rangeExtend applies), fixed for the skill; attacks hit attack@max_target_s2 targets for
// attack@atk_scale_s2 × ATK physical, air units too (PRTS 攻击可对空); every lightning cast of hers while it runs
// (leizi2_s_2[try_add_atk], the thunder ability's buffsToOwnerOnCast — 追责's strikes too) gives ATK +thunder_atk, up
// to thunder_max_stack_cnt stacks, gone when it ends.
// - S3 天地通明 (MANUAL, data CUSTOM_RANGE on its 2-1): range 2-1, attack interval +base_attack_time s (flat), each attack
// hits its target and every enemy within attack@range_radius of it for attack@atk_scale_s3 × ATK physical (air units
// too: PRTS 普通攻击可对空); after each attack four currents leave the target's spot — up, right, down, left (screen
// directions), 0.28 s apart (the first at the hit [ASSUMED]) — at 1 tile/s for 3.1 s, turning back before they would
// enter a high tile, her own tile or the field's edge (PRTS "只在即将进入地块时进行反弹判定"), gone when she leaves (they
// outlive the skill [ASSUMED]); each hits every ground enemy she can select on its
// tile for attack@atk_scale_current × ATK arts, each enemy at most every 0.6 s per current, and — while S3 runs
// (leizi2_s_3 ON_OUTPUT_MODIFIER, modifier key "leizi2") — with `prob` to 战栗 it not_combat s.
import { num, talentBb, moduleBb, skillRec, batMod, up } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { toLocal } from '../../../dir.js';
import { COLS } from '../../../constants.js';
const S1 = 'skchr_leizi2_1';
const S2 = 'skchr_leizi2_2';
const S3 = 'skchr_leizi2_3';
/** The skill ranges when a record carries none (data: 3-19, 2-1). */
const R3_19 = Object.freeze([[3, 0], [2, 0], [1, 0], [0, 0], [0, 1], [0, 2], [0, 3], [-1, 0], [-2, 0], [-3, 0]]);
const R2_1 = Object.freeze([[2, 0], [1, 0], [1, 1], [0, 0], [0, 1], [0, 2], [-1, 0], [-1, 1], [-2, 0]]);
/** S1's three attacks (PRTS 备注): a 3-2 to her front, her left and her right (facing-RIGHT offsets). */
const S1_ARMS = Object.freeze([
Object.freeze([[0, 0], [0, 1], [0, 2], [0, 3]]),
Object.freeze([[0, 0], [1, 0], [2, 0], [3, 0]]),
Object.freeze([[0, 0], [-1, 0], [-2, 0], [-3, 0]]),
]);
/** skchr_leizi2_1 leizi2_s_1 lifeTime (PRTS "技能持续0.5s") and the s1_* abilities' `_preDelay`. */
const S1_TIME = 0.5;
const S1_STRIKE_AT = 0.3;
/** PRTS 明断 备注: "部署后每1.0333秒对自身当前攻击范围内所有地块执行计数判定" (31 ticks). */
const COUNT_IV = 31 / 30;
/** The thunder ability: a strike at `_maxCount` 15, then a restart at one of `_resetCountList` 3…7; a new tile 0…14. */
const COUNT_MAX = 15;
const RESET_MIN = 3;
const RESET_SPAN = 5;
/** projectile_char_leizi2_t_1 / [start] `_delayTime`: a strike lands this long after its cast. */
const STRIKE_DELAY = 0.15;
/** 追责: the thunder `_rangeCastDelay` per Manhattan ring; leizi2_t_1[trigger] `_specialRangeCastDelay` under S1. */
const RING_DELAY = 0.25;
const RING_DELAY_S1 = 0.07;
/** projectile_char_leizi2_s3: speed, lifetime, one hit per enemy per this long; the S3 attack's `_triggerDelta`. */
const CURRENT_SPEED = 1;
const CURRENT_LIFE = 3.1;
const CURRENT_REHIT = 0.6;
const CURRENT_GAP = 0.28;
/** PRTS: "依次生成向上/向右/向下/向左移动的电流" — screen directions as [dRow, dCol] (row 0 is the bottom). */
const CURRENT_DIRS = Object.freeze([[1, 0], [0, 1], [-1, 0], [0, -1]]);
const FOUR = Object.freeze([[1, 0], [0, 1], [-1, 0], [0, -1]]);
const AIR = Object.freeze({ canHitFly: true });
const GROUND = Object.freeze({ canHitFly: false });
/** Her skeleton's landing clips (Skill_2_Begin / Skill_3_Begin; S1's is its own 0.5 s) and take-off clips (Skill_N_End). */
const LANDING = Object.freeze({ [S2]: 0.5, [S3]: 0.5 });
const TAKEOFF = Object.freeze({ [S1]: 0.933, [S2]: 0.933, [S3]: 0.833 });
const LIFTOFF_KEY = 'talent:leizi2:liftoff';
const SKILL_UP_KEY = 'talent:leizi2:skillUp';
const S2_ATK_KEY = 'skill:leizi2:thunderAtk';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** Absolute tile keys of a grid at the unit's tile and facing. */
const keysOf = (unit, grid) => absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0);
/** A tile of the battle's field — the official map of a battle: no lightning falls on a stage tile beyond it. */
const onField = (battle, k) => battle.grid.inRect(Math.floor(k / COLS), k % COLS);
/**
* S2's range: the tiles reachable from hers in up to three 4-way steps over low tiles (her own tile always; a tile
* without data never — off the battle's field: the official map of a battle is its field, `rect`), as facing-RIGHT
* offsets (PRTS 备注, the selector's `_depth` 3).
*/
function floodGrid(battle, unit) {
const r0 = unit.tileR, c0 = unit.tileC;
const seen = new Set([r0 * COLS + c0]);
const cells = [[r0, c0]];
let frontier = [[r0, c0]];
for (let step = 0; step < 3 && frontier.length; step++) {
const next = [];
for (const [r, c] of frontier) {
for (const [dr, dc] of FOUR) {
const rr = r + dr, cc = c + dc, k = rr * COLS + cc;
if (seen.has(k) || !battle.grid.inRect(rr, cc) || battle.grid.tile(rr, cc).height !== 'LOW') continue;
seen.add(k);
next.push([rr, cc]);
cells.push([rr, cc]);
}
}
frontier = next;
}
return cells.map(([r, c]) => toLocal(r - r0, c - c0, unit.dir));
}
export default {
char_1043_leizi2: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const hidden = moduleBb(chess); // LIB-X: init_atk
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s1 = skillRec(chess, S1), s3 = skillRec(chess, S3);
const skillUp = num(t0['atk_scale[skill_up]'], 1);
const s2Grid = [[0, 0]]; // this unit's S2 range, rebuilt at every cast
/** A lightning cast on tile `key` (T1's strikes, `accuse` = T2's): S2's stack at once, the strike 0.15 s later. */
const cast = (battle, unit, key, accuse) => {
const sk = unit.skill;
if (sk && sk.active && sk.id === S2 && num(b2.thunder_atk) > 0) {
battle.addBuff(unit, { key: S2_ATK_KEY, refresh: 'stack', stacks: 1, maxStacks: Math.max(1, Math.floor(num(b2.thunder_max_stack_cnt, 25))), mods: { atkPct: num(b2.thunder_atk) }, tags: ['skill'] });
}
const seq = unit.deploySeq;
battle.after(STRIKE_DELAY, () => {
if (!up(unit) || unit.deploySeq !== seq) return; // _stopWhenSourceInvalid
const r = Math.floor(key / COLS), c = key % COLS;
battle.fx('lightning', { x: c, y: r, id: unit.id, skill: accuse ? 'leizi2:accuse' : 'leizi2:thunder' });
const scale = num(accuse ? t1.atk_scale_t2 : t0.atk_scale_t, 1);
for (const e of battle.enemiesInKeys([key], unit, AIR)) {
if (!e.alive) continue;
battle.dealDamage(unit, e, { amount: unit.s.atk * scale * unit.s.atkScaleMul, type: 'arts', tags: ['talent', accuse ? 'leizi2:accuse' : 'leizi2:thunder'] });
if (accuse && e.alive && num(t1.not_combat) > 0) battle.applyStatus(e, 'tremble', { duration: num(t1.not_combat), source: unit });
}
}, { owner: unit });
};
return {
trait: {
// LIB-X “一念” 部署后获得+100%加成: the trait's start (the module's hidden talent; her trait part has no blackboard)
...(num(hidden.init_atk) > 0 ? { rampInit: num(hidden.init_atk) } : {}),
// S2 / S3: no attack while she lands (PRTS "降落动作计入技能总时长")
canAttack: (b, u) => !(num(u.mem.leiziLandUntil) > b.time + 1e-9),
},
skills: {
[S1]: {
kind: 'duration',
duration: S1_TIME,
targeting: { rangeGrid: s1?.rangeGrid ?? R3_19 },
attack: { noAttack: true }, // the three attacks below are the skill's
onStart({ battle, unit, skill }) {
const seq = unit.deploySeq, act = skill.activations;
battle.after(S1_STRIKE_AT, () => {
if (!up(unit) || unit.deploySeq !== seq || skill.activations !== act) return;
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 3, id: unit.id, skill: 'leizi2:s1' });
for (const arm of S1_ARMS) {
for (const e of battle.enemiesInKeys(keysOf(unit, arm), unit, GROUND)) {
if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(b1['attack@atk_scale_s1'], 1) * unit.s.atkScaleMul, type: 'phys', isSkill: true, tags: ['skill', 'leizi2:s1'] });
}
}
}, { owner: unit });
},
},
[S2]: {
kind: 'duration',
targeting: { rangeGrid: s2Grid, canHitFly: true, maxTargets: Math.max(1, Math.floor(num(b2['attack@max_target_s2'], 3))) },
attack: { atkScale: num(b2['attack@atk_scale_s2'], 1) },
onStart({ battle, unit }) {
const g = floodGrid(battle, unit);
s2Grid.length = 0;
for (const p of g) s2Grid.push(p);
battle.refreshRange(unit);
},
onEnd({ battle, unit }) { battle.removeBuff(unit, S2_ATK_KEY); },
},
[S3]: {
kind: 'duration',
mods: { batPct: batMod(b3.base_attack_time, chess) },
targeting: { rangeGrid: s3?.rangeGrid ?? R2_1, canHitFly: true },
attack: {
atkScale: num(b3['attack@atk_scale_s3'], 1),
splashRadius: num(b3['attack@range_radius'], 1.65),
splashScale: 1,
onHit({ battle, unit, x, y }) { // the currents leave the target's spot
const seq = unit.deploySeq;
CURRENT_DIRS.forEach(([dr, dc], i) => {
const spawn = () => {
if (!up(unit) || unit.deploySeq !== seq) return;
(unit.mem.leiziCurrents ??= []).push({ x, y, dr, dc, life: CURRENT_LIFE, seq, hitAt: new Map() });
};
if (i === 0) spawn();
else battle.after(CURRENT_GAP * i, spawn, { owner: unit });
});
battle.fx('pulse', { x, y, id: unit.id, skill: 'leizi2:current' });
},
},
},
},
talents: [
{ install(battle, unit) { // 明断: the strikes, 起飞 while no skill runs, ×atk_scale[skill_up] during a skill
const lift = () => { if (up(unit)) battle.addBuff(unit, { key: LIFTOFF_KEY, flags: { liftoff: true }, tags: ['talent'] }); };
battle.on('deploy', (c) => {
if (c.unit !== unit) return;
if (!unit.skill?.active) lift();
if (c.move) return;
// the strike counters: a new list with every deployment
const seq = unit.deploySeq;
const counters = new Map();
battle.every(COUNT_IV, (b, sc) => {
if (!up(unit) || unit.deploySeq !== seq) { sc.cancel(); return; }
if (!unit.canAct) return;
for (const k of unit.rangeKeys) {
if (!onField(b, k)) continue;
if (!counters.has(k)) { counters.set(k, b.rng.int(COUNT_MAX)); continue; }
const n = counters.get(k) + 1;
if (n >= COUNT_MAX) { counters.set(k, RESET_MIN + b.rng.int(RESET_SPAN)); cast(b, unit, k, false); } else counters.set(k, n);
}
}, { owner: unit });
}, { owner: unit });
battle.on('skillStart', (c) => {
if (c.unit !== unit) return;
battle.removeBuff(unit, LIFTOFF_KEY);
unit.mem.leiziLandUntil = battle.time + (LANDING[unit.skill.id] ?? 0);
if (skillUp !== 1) battle.addBuff(unit, { key: SKILL_UP_KEY, mods: { atkScaleMul: skillUp }, tags: ['talent'] });
}, { owner: unit, priority: 50 });
battle.on('skillEnd', (c) => {
if (c.unit !== unit) return;
battle.removeBuff(unit, SKILL_UP_KEY);
unit.mem.leiziLandUntil = 0;
if (c.reason === 'death') return;
// (the trait's own handler releases what she blocked) — 起飞 again once the take-off clip is over
const seq = unit.deploySeq;
battle.after(TAKEOFF[unit.skill.id] ?? 0, () => { if (unit.deploySeq === seq && !unit.skill?.active) lift(); }, { owner: unit });
}, { owner: unit });
} },
{ install(battle, unit) { // 追责: every cast strikes the low tiles of her range, ring by ring from her tile
if (!(num(t1.atk_scale_t2) > 0)) return;
battle.on('skillStart', (c) => {
if (c.unit !== unit) return;
const gap = unit.skill?.id === S1 ? RING_DELAY_S1 : RING_DELAY;
const rings = new Map();
for (const k of unit.rangeKeys) {
const r = Math.floor(k / COLS), cc = k % COLS;
if (!onField(battle, k) || !battle.grid.isLow(r, cc)) continue;
const d = Math.abs(r - unit.tileR) + Math.abs(cc - unit.tileC);
if (!rings.has(d)) rings.set(d, []);
rings.get(d).push(k);
}
const seq = unit.deploySeq;
for (const d of [...rings.keys()].sort((a, b) => a - b)) {
const fire = () => { if (up(unit) && unit.deploySeq === seq) for (const k of rings.get(d)) cast(battle, unit, k, true); };
if (d === 0) fire();
else battle.after(d * gap, fire, { owner: unit });
}
}, { owner: unit });
} },
],
install(battle, unit) {
const id = unit.skill?.id;
if (id === S2) {
// The owner's larger-range auto-cast rule also covers this terrain-dependent range (no static rangeId).
// Keep SEARCH when the actual running area does not strictly contain her initial range.
unit.skill.addTriggerRange(() => {
const extend = unit.s.baseRangeExtend || 0;
const keys = absoluteRangeKeys(floodGrid(battle, unit), unit.tileR, unit.tileC, unit.dir, extend);
const initial = absoluteRangeKeys(unit.def.rangeGrid, unit.tileR, unit.tileC, unit.dir, extend);
const set = new Set(keys);
return set.size > initial.length && initial.every(k => set.has(k)) ? [{ keys, profile: AIR }] : [];
});
}
if (id === S1) {
// 充能耗尽前特性不重置: the trait resets the ramp on skillEnd (priority 0) — keep it while a charge remains
battle.on('skillEnd', (c) => {
if (c.unit === unit) unit.mem.leiziRamp = c.reason !== 'death' && unit.skill.charges > 0 ? num(unit.trait.ramp) : null;
}, { owner: unit, priority: 100 });
battle.on('skillEnd', (c) => {
if (c.unit !== unit || unit.mem.leiziRamp == null) return;
unit.trait.ramp = unit.mem.leiziRamp;
unit.mem.leiziRamp = null;
if (unit.trait.ramp > 0 && unit.alive) battle.addBuff(unit, { key: 'trait:libratorRamp', mods: { atkPct: unit.trait.ramp } });
}, { owner: unit, priority: -100 });
}
if (id !== S3) return;
// S3: the currents
const scale = num(b3['attack@atk_scale_current']), prob = num(b3.prob), stop = num(b3.not_combat);
battle.on('tick', ({ dt }) => {
const list = unit.mem.leiziCurrents;
if (!list || !list.length) return;
const keep = [];
for (const cur of list) {
cur.life -= dt;
if (cur.life <= 1e-9 || !up(unit) || unit.deploySeq !== cur.seq) continue;
const nx = cur.x + cur.dc * CURRENT_SPEED * dt, ny = cur.y + cur.dr * CURRENT_SPEED * dt;
const r = Math.round(ny), c = Math.round(nx);
if (r !== Math.round(cur.y) || c !== Math.round(cur.x)) {
// about to enter (r, c): back off a high tile (高台), her own tile or the field's edge [ASSUMED]
if (!battle.grid.inRect(r, c) || battle.grid.tile(r, c).height === 'HIGH' || (r === unit.tileR && c === unit.tileC)) { cur.dr = 0 - cur.dr; cur.dc = 0 - cur.dc; } else { cur.x = nx; cur.y = ny; }
} else { cur.x = nx; cur.y = ny; }
keep.push(cur);
const s3On = !!(unit.skill?.active && unit.skill.id === S3);
for (const e of battle.enemiesInKeys([Math.round(cur.y) * COLS + Math.round(cur.x)], unit, GROUND)) {
const at = cur.hitAt.get(e);
if (!e.alive || (at != null && battle.time - at < CURRENT_REHIT - 1e-9)) continue;
cur.hitAt.set(e, battle.time);
const dealt = battle.dealDamage(unit, e, { amount: unit.s.atk * scale * unit.s.atkScaleMul, type: 'arts', isSkill: true, tags: ['skill', 'leizi2:current'] });
if (s3On && dealt > 0 && e.alive && stop > 0 && battle.rng.chance(prob)) battle.applyStatus(e, 'tremble', { duration: stop, source: unit });
}
if (!unit.alive) break;
}
unit.mem.leiziCurrents = keep;
}, { owner: unit });
},
};
},
};