Files
ark-stronghold-protocol/server/sim/content/kits/ops/op-heyak.js
T
sganggs f4b826fe28 feedback5 O2: 自选 operator kits for 艾雅法拉, 刻俄柏 and 霍尔海雅 — every skill, talent, trait and module (stages 1 and 3) at every form, with tests
- kits/ops/op-amgoat.js (艾雅法拉): S1 二重咏唱 (ATK from the second cast of a deployment), S2 点燃 (main target's RES cut
  first, ½ explosion r1.5 on all, ½ again on the main target), S3 火山 (x-3, −1.1 s, lava on random 4 / 5 enemies), 炎息
  aura (CCR-X Lv3 携带时 22 %), 乱火 deploy SP (CCR-Y Lv3 ASPD, maxima with an elite / leader in range), CCR-X RES ignore,
  CCR-Y SP per hit on an elite / leader.
- kits/ops/op-cerber.js (刻俄柏): S1 unblocked first + 束缚, S2 interval ×0.6 / ×0.4 highest DEF, S3 3-3 physical lowest DEF
  + silence, 剥壳 DEF × ratio arts (CCR-X Lv3 ramp 50 → 75 %), 独行长路 x-5 incl. PRTS's enemy-like-ally rule (CCR-Y Lv3:
  decided per deployment).
- kits/ops/op-heyak.js (霍尔海雅): S1 two targets / single-target 浮空, S2 nine random shots with 浮空 rolls, S3 three
  whirlwinds (PRTS speed 1, 1x1 box, 5 tiles, 1.77 s ramp, away rules), 传承终焉 (lifts resolve first), 曾有羽翼 失重 / CCR-Y
  Lv3 法术脆弱; ISW-A: attributes and 传承终焉 numbers only (its trait and hidden talents are validInGameTag roguelike).
- kits/index.js: the three files appended to OPERATOR_KIT_FILES (one line per file).
- Golden: only the diy family moved — diy-003 … diy-006 re-packed and diy-007 … diy-013 new, because the diy family fields
  every kitted owned 6★ in every form × module × skill (the three new operators, then the prototypes); diy-001 / 002 and
  every other family (roster, bonds, fields, matches, standins) byte-identical. test/golden/README.md counts updated
  (13 diy battles, 61 of 157 fast).
- Reported, not changed: 艾雅法拉 S3's data trigger is SKILL_RANGE (攻击范围增大 escapes build-data's ATTACK_RANGE_CHANGE).
2026-10-06 00:00:14 +08:00

276 lines
17 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-heyak.js — 霍尔海雅 (char_4027_heyak) 自选 operator kit: 6★ 中坚术师 (术师), an owned-6★
// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and the three modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4027_heyak, 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) and PRTS 霍尔海雅
// (传承终焉 备注 "技能触发的浮空效果于本天赋结算前进行结算"; S1 备注 "“仅攻击到1名目标”仅判定实际发射的弹道数"; S3 备注 "攻击以自身格
// 中心处为准,于此处、左1.0、右1.0格在0~0.3s内随机延迟后生成三股旋风", "移动速度1.0,碰撞范围为1×1的正方形,旋风移动至5格远处中心点
// 自然消失,造成伤害时实时应用霍尔海雅的当前攻击力", "旋风在生成后1.77s内,攻击倍率由最低值线性递增至最大值", "霍尔海雅离场时已存在的旋风
// 不会因此立刻消失,但其命中敌人时不享受模组加成,不触发第一天赋").
// - Trait (中坚术师) "攻击造成法术伤害": the profession default (ranged arts, hits air, block 1). Module CCR-X “图书馆” adds
// "无视目标10点法术抗性" (trait bb magic_resist_penetrate_fixed: a permanent resIgnoreFlat), CCR-Y “羽蛇的时间博物馆”
// "普通攻击命中精英或领袖敌人时获得1点技力" (trait bb sp: per hit of one of her attacks on an elite / leader; a running timed
// skill takes no SP). ISW-A “霍尔海雅特限证章”: its trait "在集成战略中,无视目标10点法术抗性,每个敌人首次进入目标点所在地块时,恢复
// 自身30%的技力" and its hidden talents (首次部署 1 点临时目标生命值, the goal-tile 浮空 15 s, stage 3's radius-1.1 skill
// splash "自身技能会对敌人及其周围的目标同时造成浮空效果和伤害效果") are battle_equip_table parts with `validInGameTag:
// roguelike` — N/A here; what applies is its stats and, at stage 2+, 传承终焉's new numbers (a TALENT_DATA_ONLY part with
// no game tag: 145 % / 5 s at stage 3).
// - T1 传承终焉 "攻击空中目标时攻击力提升至120%,并使其特殊能力失效3秒": every hit of her attacks on an air unit (flying, 近地悬浮
// or 浮空) at the hit is × atk_scale and silences it for `silence` s. Her skills' 浮空 of that very hit resolve first (PRTS
// 备注), so a target her S1 / S2 / S3 lifts takes the ×atk_scale of that hit: the S1 / S2 lifts land in the hit's `hit`
// step, ahead of this talent (a dodged hit has lifted already [ASSUMED]). CCR-X stage 3: 135 % / 5 s, ISW-A stage 3:
// 145 % / 5 s (the talent's merged bb).
// - T2 曾有羽翼 "攻击范围内所有生命值高于80%的敌人失重": every enemy of her current attack range (a running S3's included) above
// hp_ratio of its HP is 失重 (weight −1 level, 同名效果不可叠加: none of hers while another source's 失重 is on it), off
// again at once below it or outside the range. CCR-Y stage 3 lowers it to 50 % and adds "生命值低于50%的敌人获得20%的
// 【法术脆弱】" (its hidden talent: damage_scale 1.2, hp_ratio 0.5): the 法术脆弱 status, renewed every 0.2 s for 0.3 s
// (as 史尔特尔 AFT-Y's).
// - S1 但为求索 (AUTO, 可充能 1 / 2, data DEFAULT): the next attack hits one more target (2), atk_scale × ATK arts each; when
// it fires at one target only it lifts it (浮空) `levitate` s — counted at the shot (PRTS 备注).
// - S2 群星逶迤 (MANUAL, 16 s, data DEFAULT): each attack becomes SHOTS (9, the text's "9连发"; no blackboard key) shots of
// attack@atk_scale × ATK, each at a random enemy of her range (independent draws), each with attack@prob to lift it
// attack@levitate s [ASSUMED: the nine leave with the attack; the burst's spacing is not modelled].
// - S3 博览者的狂语 (MANUAL, 45 s, data ACTIVE_RANGE on 3-10): attack range 3-10 while it runs, attack interval +1.4 s
// (base_attack_time, flat); each attack (an enemy in that range, her attack timer) releases three whirlwinds from the
// centre of her tile and the tiles left and right of it, each after its own random 0–0.3 s delay. A whirlwind flies
// straight forward at 1 tile/s over 5 tiles (PRTS; a 1×1 square), hits the first enemy it touches — an enemy her
// attacks can select, air units too [ASSUMED] — lifting it attack@levitate s and then dealing (min + (max − min) ×
// min(1, age / 1.77 s)) × her current ATK arts (attack@min_atk_scale → attack@max_atk_scale), and is gone; the nearest
// of several touched at once is hit first [ASSUMED]. A whirlwind out when she leaves the field flies on, without 传承终焉
// and without her module's RES ignore [ASSUMED: "模组加成" = the module's trait; its attributes stay in her ATK].
// (wiki.gg reads the end of the flight as the far end of her range, 4 tiles; PRTS's 5 is kept.)
// - No summon (the forms list no token).
import { num, talentBb, traitBb, skillRec, statBuff, batMod, giveSp, onHitBy, up } from '../shared/tier1.js';
import { canTargetEnemy } from '../../../targeting.js';
import { dirVec, rotateOffset } from '../../../dir.js';
import { bodyDist, hitRect } from '../../../body.js';
const S1 = 'skchr_heyak_1';
const S2 = 'skchr_heyak_2';
const S3 = 'skchr_heyak_3';
/** S1 "额外攻击一个目标": the next attack's targets (no blackboard key; `cnt` is the charge count). */
const S1_TARGETS = 2;
/** S2 "9连发": shots per attack when the text gives no number (no blackboard key). */
const SHOTS = 9;
/** S3 whirlwinds (PRTS 备注): lanes (facing-RIGHT row offsets: her row, "左1.0", "右1.0"), spawn delay, speed, flight, box. */
const WIND_LANES = Object.freeze([0, 1, -1]);
const WIND_DELAY = 0.3;
const WIND_SPEED = 1;
const WIND_RANGE = 5;
const WIND_HALF = 0.5;
/** "旋风在生成后1.77s内,攻击倍率由最低值线性递增至最大值". */
const WIND_RAMP = 1.77;
/** How often a flying whirlwind is drawn again (fx 'tornado'). */
const WIND_FX_EVERY = 0.5;
/** S3's 3-10 when the data carries no grid. */
const R310 = Object.freeze([[1, 0], [1, 1], [1, 2], [1, 3], [1, 4], [0, 0], [0, 1], [0, 2], [0, 3], [0, 4], [-1, 0], [-1, 1], [-1, 2], [-1, 3], [-1, 4]]);
/** A whirlwind hit after she left the field: no 传承终焉, no module trait (PRTS 备注). */
const AWAY_TAG = 'heyak:away';
const WEIGHTLESS_KEY = 'heyak:weightless';
const FRAGILE_EVERY = 0.2;
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** An elite or leader enemy (精英或领袖敌人). */
const isEliteEnemy = (e) => !!e && e.side === 'enemy' && (e.isBoss || e.def?.rank === 'ELITE' || e.def?.rank === 'BOSS');
const tagged = (d, tag) => !!(d && d.tags && d.tags.includes(tag));
/** A module trait that works 在集成战略中 only (ISW-A): nothing of it applies in this mode. */
const roguelikeOnly = (chess) => /集成战略/.test(String(chess?.trait?.moduleDesc ?? ''));
/** The record's active module, or null. */
const activeModule = (chess) => (chess?.module?.active ? (chess.modules ?? []).find((m) => m && m.uniEquipId === chess.module.id) ?? null : null);
/** Does the enemy's body touch the 1×1 square centred on (x, y)? */
function inBox(e, x, y) {
const R = hitRect(e);
if (!R) return Math.abs(e.x - x) <= WIND_HALF + 1e-9 && Math.abs(e.y - y) <= WIND_HALF + 1e-9;
return R.x0 <= x + WIND_HALF && R.x1 >= x - WIND_HALF && R.y0 <= y + WIND_HALF && R.y1 >= y - WIND_HALF;
}
export default {
char_4027_heyak: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const isw = roguelikeOnly(chess);
const mod = activeModule(chess);
// CCR-Y's hidden part (the 【法术脆弱】 rider); an ISW module's hidden parts are 集成战略-only
const rider = isw || !mod ? {} : Object.assign({}, ...(mod.talentChanges ?? []).filter((t) => t && t.talentIndex === -1).map((t) => t.bb ?? {}));
const tb = isw ? {} : traitBb(chess);
const pen = num(tb.magic_resist_penetrate_fixed);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s1 = skillRec(chess, S1), s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
const shots = Math.max(1, Math.floor(num(+(/(\d+)连发/.exec(String(s2?.desc ?? ''))?.[1]), SHOTS)));
/** S3: release one attack's three whirlwinds. */
function releaseWinds(battle, unit, winds) {
const seq = unit.deploySeq;
const [fr, fc] = dirVec(unit.dir);
const x0 = unit.tileC, y0 = unit.tileR;
for (const lane of WIND_LANES) {
const [dr, dc] = rotateOffset(lane, 0, unit.dir);
const delay = battle.rng.range(0, WIND_DELAY);
battle.after(delay, () => {
// a whirlwind not yet out when she left is never released [ASSUMED: "已存在的旋风" only fly on]
if (!(unit.alive && unit.deployed && unit.deploySeq === seq)) return;
const w = { x: x0 + dc, y: y0 + dr, dx: fc, dy: fr, left: WIND_RANGE, age: 0, fxAge: 0, seq };
battle.fx('tornado', { x: w.x, y: w.y, id: unit.id, r: WIND_HALF, dur: WIND_FX_EVERY + 0.1 });
if (!windHit(battle, unit, w)) winds.push(w);
}, { owner: unit });
}
}
/** The first enemy the whirlwind touches, struck: 浮空 first (PRTS: before 传承终焉), then the damage. True when it hit. */
function windHit(battle, unit, w) {
let best = null, bd = Infinity;
for (const e of battle.enemies) {
if (!e.alive || !inBox(e, w.x, w.y) || !canTargetEnemy(unit, e, unit.profile)) continue;
const d = bodyDist(e, w.x, w.y);
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; }
}
if (!best) return false;
const away = !(unit.alive && unit.deployed && unit.deploySeq === w.seq);
const lev = num(b3['attack@levitate']);
if (lev > 0) battle.applyStatus(best, 'levitate', { duration: lev, source: unit });
const lo = num(b3['attack@min_atk_scale']), hi = num(b3['attack@max_atk_scale'], lo);
const scale = lo + (hi - lo) * Math.min(1, w.age / WIND_RAMP);
if (best.alive) {
battle.dealDamage(unit, best, {
amount: unit.s.atk * scale, type: 'arts', isAttack: true, isSkill: true,
...(away ? { resIgnoreFlat: -pen, tags: [AWAY_TAG] } : null),
});
}
battle.fx('tornadoPulse', { x: w.x, y: w.y, id: unit.id, r: WIND_HALF });
return true;
}
return {
skills: {
[S1]: {
kind: num(s1?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
targeting: { maxTargets: S1_TARGETS },
attack: { atkScale: num(b1.atk_scale, 1) },
},
[S2]: {
kind: 'duration',
attack: { atkScale: num(b2['attack@atk_scale'], 1) },
},
[S3]: {
kind: 'duration',
mods: { batPct: batMod(b3.base_attack_time, chess) },
targeting: { rangeGrid: s3?.rangeGrid ?? R310 },
// the attack becomes the whirlwinds (onTick): her attack timer, an enemy of the skill's range, no projectile
attack: { noAttack: true },
onTick({ battle, unit }) {
if (unit.atkCd > 0 || !unit.canAct || unit.s.flags.disarm) return;
const foes = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile);
if (!foes.length) return;
unit.atkCd = unit.s.interval;
unit.lastAttackAt = battle.time;
unit.stats.attacks++;
releaseWinds(battle, unit, unit.mem.heyakWinds);
// an attack was performed ("攻击时" content), the whirlwinds still on their way
if (battle.hasHook('attack')) battle.emit('attack', { attacker: unit, targets: foes.slice(0, 1), isSkill: true });
},
},
},
talents: [
{ install(battle, unit) { // 传承终焉: × atk_scale and 沉默 on an air unit hit by her attack
const sc = num(t0.atk_scale, 1), sil = num(t0.silence);
onHitBy(battle, unit, (ctx) => {
const d = ctx.dmg, t = ctx.target;
if (!d.isAttack || tagged(d, AWAY_TAG) || !t.isFlying) return;
if (sc !== 1) d.amount *= sc;
if (sil > 0 && t.alive) battle.applyStatus(t, 'silence', { duration: sil, source: unit });
});
} },
{ install(battle, unit) { // 曾有羽翼: 失重 above hp_ratio in her range; CCR-Y stage 3: 【法术脆弱】 below its hp_ratio
const hiRatio = num(t1.hp_ratio, 1);
battle.on('tick', () => {
const on = up(unit);
const inRange = on ? new Set(battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) : null;
for (const e of battle.enemies) {
if (!e.alive) continue;
// (another 霍尔海雅's 失重 carries the same key: it is hers to keep or drop)
const mine = e.findBuff(WEIGHTLESS_KEY);
const want = on && inRange.has(e) && e.hpRatio > hiRatio + 1e-9
&& !e.buffs.some((b) => b.status === 'weightless' && b.key !== WEIGHTLESS_KEY);
if (want && !mine) battle.addBuff(e, { key: WEIGHTLESS_KEY, status: 'weightless', visible: true, mods: { massFlat: -1 }, source: unit });
else if (!want && mine && mine.source === unit) battle.removeBuff(e, mine);
}
}, { owner: unit });
const fragile = Math.round((num(rider.damage_scale, 1) - 1) * 1e6) / 1e6, loRatio = num(rider.hp_ratio); // 1.2 → 0.2
if (fragile > 0) {
battle.every(FRAGILE_EVERY, () => {
if (!up(unit)) return;
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) {
if (e.hpRatio < loRatio - 1e-9) battle.applyStatus(e, 'artsFragile', { duration: FRAGILE_EVERY + 0.1, value: fragile, source: unit });
}
}, { owner: unit, immediate: true });
}
} },
],
install(battle, unit) {
// module traits (none on the normal form, nothing of ISW-A's): CCR-X RES ignore, CCR-Y SP per hit on an elite / leader
if (pen > 0) statBuff(battle, unit, 'trait:heyak:resIgnore', { resIgnoreFlat: pen });
const sp = num(tb.sp);
if (sp > 0) {
battle.on('damaged', (ctx) => {
const d = ctx.dmg;
if (ctx.source !== unit || !d || !d.isAttack || d.isSplash || tagged(d, AWAY_TAG) || !isEliteEnemy(ctx.target)) return;
giveSp(unit, sp, 'trait');
}, { owner: unit });
}
const sid = unit.skill?.id;
if (sid === S1) {
// 但为求索: the shot count decides the 浮空 (PRTS: the shots fired, whatever happens in flight)
const lev = num(b1.levitate);
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !ctx.isSkill) return;
unit.mem.heyakLift = ctx.targets.length === 1 ? ctx.targets[0] : null;
}, { owner: unit, priority: -10 });
onHitBy(battle, unit, (ctx) => { // before 传承终焉 (priority 0): the lift is settled first
const d = ctx.dmg;
if (!d.isAttack || !d.isSkill || unit.mem.heyakLift !== ctx.target) return;
unit.mem.heyakLift = null;
if (lev > 0) battle.applyStatus(ctx.target, 'levitate', { duration: lev, source: unit });
}, 10);
} else if (sid === S2) {
// 群星逶迤: the nine shots, each at a random enemy of her range, each rolling its 浮空 before 传承终焉
const prob = num(b2['attack@prob']), lev = num(b2['attack@levitate']);
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !unit.skill?.active || !ctx.targets.length) return;
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile);
for (const e of battle.blockedTargets(unit, ctx.profile)) if (!cands.includes(e)) cands.push(e);
if (!cands.length) return;
const out = [];
for (let i = 0; i < shots; i++) out.push(battle.rng.pick(cands));
ctx.targets = out;
}, { owner: unit });
onHitBy(battle, unit, (ctx) => {
const d = ctx.dmg;
if (!d.isAttack || !d.isSkill || !(prob > 0) || !(lev > 0)) return;
if (battle.rng.chance(prob)) battle.applyStatus(ctx.target, 'levitate', { duration: lev, source: unit });
}, 10);
} else if (sid === S3) {
// 博览者的狂语: the whirlwinds in flight (also after she left the field)
const winds = (unit.mem.heyakWinds = []);
battle.on('tick', ({ dt }) => {
for (let i = winds.length - 1; i >= 0; i--) {
const w = winds[i];
const step = Math.min(WIND_SPEED * dt, w.left);
w.x += w.dx * step;
w.y += w.dy * step;
w.left -= step;
w.age += dt;
if (windHit(battle, unit, w) || w.left <= 1e-9) { winds.splice(i, 1); continue; }
w.fxAge += dt;
if (w.fxAge + 1e-9 >= WIND_FX_EVERY) {
w.fxAge = 0;
battle.fx('tornado', { x: w.x, y: w.y, id: unit.id, r: WIND_HALF, dur: WIND_FX_EVERY + 0.1 });
}
}
}, { owner: unit });
}
},
};
},
};