- server/sim/detmath.js: hypot (sqrt of the squares, rescaled outside 2^±500), powi (integer powers in double-double, one rounding), sin / cos (fdlibm 5.3) and atan2 (fdlibm / FreeBSD msun) built only from correctly rounded IEEE operations; the sim's Math.hypot / sin / cos / atan2 / pow calls go through it (65 files); eslint.config.js forbids the approximated Math functions under server/sim and test/sim/detmath.test.js scans for them. - sin / cos / atan2 are the algorithms V8 runs in Node, so the server's results stay; only hypot differs from V8's. Golden: 2 moved — diy-027 (a hypot distance, damage +80) and coop3-ABYSS-5 (a later card pick from round 9, same end). - Applied unchanged after review (Grok static review: MERGE; the diff read in full: no network, no new dependency). - Tests: broad node --test 5155 / 5155 with the new file tracked (detmath 7 / 7, package 10 / 10); lint 0 errors, typecheck, check:imports.
218 lines
15 KiB
JavaScript
218 lines
15 KiB
JavaScript
// server/sim/content/kits/ops/op-ifrit.js — 伊芙利特 (char_134_ifrit) 自选 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_134_ifrit, 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 伊芙利特 (炎爆 备注
|
||
// "灼伤:目标每秒受到伊芙利特当前攻击力33%的法术持续伤害,伊芙利特离场后也会生效" and "<模组相关>于伊芙利特在场时受模组基础效果影响";
|
||
// 灼地 备注 "属于维持技能状态,时间模式为跟随动画", "降低法术抗性的效果持续1秒"; 精神融解 备注 "<Δ模组>造成的元素伤害为持续伤害";
|
||
// “同熔” 特性 "目标距离自身0格或以上时应用加成,按距离线性提升,目标距离自身4格或以上时达到最大值"); PRTS 游戏数据基础 §维持技能状态;
|
||
// PRTS 分支特性信息 轰击术师 ("可对空", "普通攻击同时攻击范围内的所有敌人"); the client's battle data (battle/prefabs [uc]skills
|
||
// skchr_ifrit_1/2/3, [uc]equips ifrit_equip_*, charpack char_134_ifrit, buff_template_data: burn, atk_scale_by_distance,
|
||
// periodic_damage_by_hp_ratio[skip_modifier_fix], ifrit_e_002[sp] / [dice_sp], ifrit_e_003_tr, ifrit_e_003_ele) and her
|
||
// battle skeleton (Skill_2_Begin's OnAttack event).
|
||
// - Trait (轰击术师) "攻击造成超远距离的群体法术伤害": the profession's `rangeAoe` — every selectable enemy of her 5-1 line at
|
||
// once, air units too, the same damage near and far (professions.js blastcaster).
|
||
// - Module BLA-X “同熔” (trait min_dist 0 / max_dist 4 / damage_scale 0.1): "距离越远伤害越高,最高达到110%" — ×(1 + 0.1 ×
|
||
// clamp((d − 0) / (4 − 0), 0, 1)) by the Euclidean distance d between her and the target, on EVERY damage she outputs
|
||
// while on the field (the module's buff atk_scale_by_distance: ON_OUTPUT_DAMAGE, no damage mask) — her attacks, the 灼地
|
||
// ticks and the 炎爆 灼伤 (PRTS: "于伊芙利特在场时受模组基础效果影响"). Stage 3 also changes T2 (below).
|
||
// - Module BLA-D “热成形记忆” (trait ep_damage_ratio 0.08): "造成法术伤害时附带相当于8%伤害的灼燃损伤" (ifrit_e_003_tr: ON_AFTER_
|
||
// OUTPUT_DAMAGE, every MAGICAL damage of hers) — 8 % of the HP damage dealt as 灼燃损伤 (`type: 'element'`, docs/SIM.md §7.2
|
||
// element conventions), the 灼伤 too while she stands. Stage 3 also changes T1 (below).
|
||
// - T1 精神融解 "攻击范围内的敌军法术抗性-40%" (full potential: −44 %; magic_resistance −0.44: a ratio; charpack ifrit_t_1, removed
|
||
// when the enemy leaves): RES ×0.56 on every enemy on her range, refreshed every AURA_IV s; several 伊芙利特 keep one
|
||
// (applyStrongest — the mode's "同名效果取最高" convention; [ASSUMED] the client's independentCharacterSource flag would let
|
||
// two stack, which a lone base-game 伊芙利特 never shows). BLA-D stage 2+ "若攻击目标处于灼燃损伤爆发期间,攻击对其额外造成
|
||
// 相当于攻击力50%的元素伤害" (the module's hidden talent element_atk_scale; ifrit_e_003_ele: IsTargetInEPBreakRecovery FIRE
|
||
// ⇒ ELEMENT damage, attack type BUFF, 持续伤害): every hit of her attack abilities — normal attacks and the 灼地 ticks
|
||
// (the equip adds it to the abilities of `_targetFamilyMask` 31, [ASSUMED] every family: 1 / 3 mark the narrower equips)
|
||
// — on an enemy in its 灼燃 burst (its `burnBurst` lock) adds 50 % ATK 元素伤害 (`type: 'elemental'`), tagged 'dot'
|
||
// (PRTS: "<Δ模组>造成的元素伤害为持续伤害"); never the 灼伤 (a buff's damage).
|
||
// - T2 莱茵回路 "每6秒额外回复2点技力" (full potential: every 5.5 s; periodic_sp; BLA-X stage 2+ ifrit_e_002[sp]): +sp SP every
|
||
// `interval` s of a deployment (the first one interval in: waitFirstTriggerInterval; a gift during a running 狂热 / 灼地
|
||
// is lost — no SP during a skill). BLA-X stage 2+ "有30%概率额外回复5点技力" (ifrit_e_002[dice_sp] prob / interval / sp):
|
||
// one roll per its own interval.
|
||
// - S1 狂热 (MANUAL, data DEFAULT, 20 s): ATK +atk, ASPD +attack_speed.
|
||
// - S2 炎爆 (AUTO, 可充能 2 / 3 次, data DEFAULT — the "next attack" waits for her attack): the next attack hits her whole line
|
||
// (ground and air: its selector's targetMotion ALL) for atk_scale × ATK; every enemy it hits gets the buff ifrit_s_2
|
||
// (template burn): DEF +def (−100 / −200, "同名效果取最高") for `duration` (3.01) s and the 灼伤 — burn.atk_scale (33 %) × her
|
||
// CURRENT ATK arts every second of it (triggerInterval 1, the first a second in: 3 ticks), a 持续伤害 (no dodge) that keeps
|
||
// ticking after she leaves the field. A new 炎爆 on the same enemy restarts both.
|
||
// - S3 灼地 (MANUAL, data DEFAULT, 20 s): a 维持技能状态 — no normal attack. The ability (skchr_ifrit_3: `_triggerDelta` 1,
|
||
// ground-only selector) strikes first at the attack event of its Begin clip (SCORCH_FIRST: 0.4 s; facing UP / DOWN the
|
||
// back / down clips, 0.333 s) and then every S3_CADENCE (1) s — a fixed cadence no ASPD change touches: every ground enemy
|
||
// on her range takes atk_scale × ATK arts and the active buff ifrit_s_3, RES +magic_resistance (−7 / −10, flat) for 1 s
|
||
// (PRTS 备注) — the cut before the damage [ASSUMED order], so the tick it comes with counts it. Her own periodic_damage_by_
|
||
// hp_ratio buff takes hp_ratio (2 %) of her max HP every second of the skill (the first a second in; a 流失 — it ignores
|
||
// for SP and skips the damage events —, it can knock her out; PRTS "实际的生命流失量略低于标注值" gives no number [ASSUMED:
|
||
// the stated 2 %]). 晕眩 / 冻结 (any state that stops her acting) ends it at once (PRTS 维持技能状态).
|
||
|
||
import { num, talentBb, moduleBb, traitBb, skillRec, up, giveSp } from '../shared/tier1.js';
|
||
import { bodyInKeys } from '../../../body.js';
|
||
import { hasHp } from '../../../damage.js';
|
||
import { hypot } from '../../../detmath.js';
|
||
|
||
const S1 = 'skchr_ifrit_1';
|
||
const S2 = 'skchr_ifrit_2';
|
||
const S3 = 'skchr_ifrit_3';
|
||
/** T1 aura refresh period and buff length (the non-stacking aura convention of the kits). */
|
||
const AURA_IV = 0.25;
|
||
const AURA_DUR = 0.4;
|
||
/** S3 灼地: the ability's `_triggerDelta` (s) — one damage tick per second, "每秒". */
|
||
const S3_CADENCE = 1;
|
||
/** S3 灼地: the first tick at the OnAttack event of Skill_2_Begin (front 0.4 s; the back clip and Skill_Down_2_Begin 0.333 s). */
|
||
const SCORCH_FIRST = Object.freeze({ side: 0.4, vertical: 0.333 });
|
||
/** S3 灼地 RES cut length (ifrit_s_3 lifeTime 1; PRTS 备注 "降低法术抗性的效果持续1秒"). */
|
||
const S3_RES_TIME = 1;
|
||
/** S3 灼地 self 流失 period (periodic_damage_by_hp_ratio triggerInterval 1). */
|
||
const S3_LOSS_IV = 1;
|
||
const MELT_KEY = 'ifrit:meltdown';
|
||
const S2_DEF_KEY = 'ifrit:pyroDef';
|
||
const S2_BURN_KEY = 'ifrit:pyroBurn';
|
||
const S3_RES_KEY = 'ifrit:scorchRes';
|
||
const SCORCH_TAG = 'ifrit:scorch';
|
||
|
||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||
/** RES cut mods for a blackboard magic_resistance value (applyStrongest): |v| < 1 = ×(1 + v), else flat v. */
|
||
const resCut = (v) => (Math.abs(v) < 1 ? { resMul: Math.max(0, 1 + v) } : { resFlat: v });
|
||
const defCut = (v) => ({ defFlat: v });
|
||
|
||
/** S2 炎爆 on one enemy its attack hit: DEF cut and the 灼伤 for `duration` s. */
|
||
function pyroclasm(battle, unit, e, b2) {
|
||
if (!e || !e.alive || e.side !== 'enemy') return;
|
||
const dur = num(b2.duration, 3);
|
||
const d = num(b2.def);
|
||
if (d) battle.applyStrongest(e, S2_DEF_KEY, { duration: dur, value: d, mods: defCut, source: unit });
|
||
const sc = num(b2['burn.atk_scale']);
|
||
if (!(sc > 0)) return;
|
||
battle.addBuff(e, {
|
||
key: S2_BURN_KEY, duration: dur, interval: 1, source: unit, tags: ['dot'],
|
||
// "伊芙利特当前攻击力": her ATK at each tick; it ticks on after she leaves the field (PRTS 备注)
|
||
onTick: ({ battle: b, unit: tgt }) => b.dealDamage(unit, tgt, { amount: unit.s.atk * sc, type: 'arts', isSkill: true, canDodge: false, tags: ['dot', 'skill', 'ifrit:burn'] }),
|
||
});
|
||
}
|
||
|
||
/** S3 灼地 tick: every ground enemy on her range — the RES cut, then the damage. */
|
||
function scorch(battle, unit, b3) {
|
||
const scale = num(b3.atk_scale), mr = num(b3.magic_resistance);
|
||
const victims = battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: false });
|
||
for (const e of victims) {
|
||
if (mr) battle.applyStrongest(e, S3_RES_KEY, { duration: S3_RES_TIME, value: mr, mods: resCut, source: unit });
|
||
if (e.alive && scale > 0) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', SCORCH_TAG] });
|
||
}
|
||
battle.fx('flame', { x: unit.x, y: unit.y, id: unit.id, n: victims.length, skill: SCORCH_TAG });
|
||
}
|
||
|
||
export default {
|
||
char_134_ifrit: (bb, chess) => {
|
||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||
const hidden = moduleBb(chess); // BLA-D stage 2+: element_atk_scale
|
||
const tb = traitBb(chess); // BLA-X: min_dist / max_dist / damage_scale; BLA-D: ep_damage_ratio
|
||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||
const s2 = skillRec(chess, S2);
|
||
return {
|
||
skills: {
|
||
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) } },
|
||
[S2]: {
|
||
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||
attack: {
|
||
atkScale: num(b2.atk_scale, 1),
|
||
onEachHit({ battle, unit, target }) { pyroclasm(battle, unit, target, b2); },
|
||
},
|
||
onStart({ battle, unit }) { battle.fx('aoe', { x: unit.x, y: unit.y, radius: 0.5, id: unit.id, skill: 'ifrit:pyroclasm' }); },
|
||
},
|
||
[S3]: {
|
||
kind: 'duration',
|
||
attack: { noAttack: true },
|
||
onStart({ unit }) {
|
||
unit.mem.scorchAt = 0;
|
||
unit.mem.scorchNext = unit.dir === 'UP' || unit.dir === 'DOWN' ? SCORCH_FIRST.vertical : SCORCH_FIRST.side;
|
||
unit.mem.scorchLoss = 0;
|
||
},
|
||
onTick({ battle, unit, skill, dt }) {
|
||
// 维持技能状态: a hard control (晕眩 / 冻结 / 浮空 / 睡眠 — the unit cannot act) ends it at once
|
||
if (!unit.canAct) { skill.end('interrupted'); return; }
|
||
const t = (unit.mem.scorchAt = (unit.mem.scorchAt ?? 0) + dt);
|
||
while (t >= unit.mem.scorchNext - 1e-6 && unit.alive && skill.active) {
|
||
unit.mem.scorchNext += S3_CADENCE;
|
||
scorch(battle, unit, b3);
|
||
}
|
||
const ratio = num(b3.hp_ratio);
|
||
while (ratio > 0 && t >= (unit.mem.scorchLoss + 1) * S3_LOSS_IV - 1e-6 && unit.alive) {
|
||
unit.mem.scorchLoss++;
|
||
battle.loseHp(unit, unit.s.maxHp * ratio, { source: unit, tags: ['skill', SCORCH_TAG] });
|
||
}
|
||
},
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 精神融解: RES −44 % on her range; BLA-D stage 2+: +50 % ATK 元素伤害 on a bursting target
|
||
const v = num(t0.magic_resistance);
|
||
if (v) {
|
||
battle.every(AURA_IV, () => {
|
||
if (!up(unit) || !unit.rangeKeySet) return;
|
||
for (const e of battle.enemies) {
|
||
if (!e.alive || e.hidden || !bodyInKeys(e, unit.rangeKeySet)) continue;
|
||
battle.applyStrongest(e, MELT_KEY, { duration: AURA_DUR, value: v, mods: resCut, source: unit });
|
||
}
|
||
}, { owner: unit, immediate: true });
|
||
}
|
||
const ex = num(hidden.element_atk_scale);
|
||
if (ex > 0) {
|
||
battle.on('damaged', (c) => {
|
||
const e = c.target, d = c.dmg;
|
||
if (c.source !== unit || !d || !(d.isAttack || d.tags?.includes(SCORCH_TAG)) || !e || e.side !== 'enemy' || !hasHp(e) || !e.findBuff('burnBurst')) return;
|
||
battle.dealDamage(unit, e, { amount: unit.s.atk * ex, type: 'elemental', element: 'burn', canDodge: false, tags: ['dot', 'module', 'ifrit:meltdown'] });
|
||
}, { owner: unit });
|
||
}
|
||
} },
|
||
{ install(battle, unit) { // 莱茵回路: +sp SP every interval s; BLA-X stage 2+: prob to +5 SP more
|
||
const sp = num(t1.sp), iv = num(t1.interval, 6);
|
||
const dProb = num(t1['ifrit_e_002[dice_sp].prob']), dSp = num(t1['ifrit_e_002[dice_sp].sp']);
|
||
const dIv = num(t1['ifrit_e_002[dice_sp].interval'], iv);
|
||
if (!(sp > 0 && iv > 0) && !(dProb > 0 && dSp > 0 && dIv > 0)) return;
|
||
battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.rhineAcc = 0; unit.mem.rhineDice = 0; } }, { owner: unit });
|
||
battle.on('tick', ({ dt }) => {
|
||
if (!up(unit)) return;
|
||
if (sp > 0 && iv > 0) {
|
||
unit.mem.rhineAcc = (unit.mem.rhineAcc ?? 0) + dt;
|
||
while (unit.mem.rhineAcc >= iv - 1e-6) { unit.mem.rhineAcc -= iv; giveSp(unit, sp); }
|
||
}
|
||
if (dProb > 0 && dSp > 0 && dIv > 0) {
|
||
unit.mem.rhineDice = (unit.mem.rhineDice ?? 0) + dt;
|
||
while (unit.mem.rhineDice >= dIv - 1e-6) {
|
||
unit.mem.rhineDice -= dIv;
|
||
if (battle.rng.chance(dProb)) giveSp(unit, dSp);
|
||
}
|
||
}
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
// BLA-X “同熔”: farther targets take more of every damage she outputs (up to ×1 + damage_scale at max_dist tiles)
|
||
const ds = num(tb.damage_scale);
|
||
if (ds > 0 && tb.max_dist != null) {
|
||
const lo = num(tb.min_dist), hi = num(tb.max_dist, 4);
|
||
battle.on('hit', (c) => {
|
||
const e = c.target;
|
||
if (c.source !== unit || !e || e.side !== 'enemy' || c.dmg.type === 'element' || !up(unit)) return;
|
||
const d = hypot(e.x - unit.x, e.y - unit.y);
|
||
c.dmg.mul *= 1 + ds * (hi > lo ? Math.max(0, Math.min(1, (d - lo) / (hi - lo))) : 1);
|
||
}, { owner: unit });
|
||
}
|
||
// BLA-D “热成形记忆”: 8 % of each arts damage she deals as 灼燃损伤
|
||
const ep = num(tb.ep_damage_ratio);
|
||
if (ep > 0) {
|
||
battle.on('damaged', (c) => {
|
||
const e = c.target;
|
||
if (c.source !== unit || c.type !== 'arts' || !(c.amount > 0) || !e || e.side !== 'enemy' || !hasHp(e) || !up(unit)) return;
|
||
battle.dealDamage(unit, e, { type: 'element', element: 'burn', amount: c.amount * ep, tags: ['module', 'ifrit:thermal'] });
|
||
}, { owner: unit });
|
||
}
|
||
},
|
||
};
|
||
},
|
||
};
|