- 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.
188 lines
13 KiB
JavaScript
188 lines
13 KiB
JavaScript
// server/sim/content/kits/ops/op-haak.js — 阿 (char_225_haak) 自选 operator kit: 6★ 怪杰 (特种), an owned-6★ pick of the
|
||
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules (GEE-X 什锦果味医用箱, GEE-Y 毒医残章) at
|
||
// every form. Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||
//
|
||
// Forms (data/backups.json units.char_225_haak, 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) — the owner's decision of 2026-10-05.
|
||
// 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 阿 (混合药物射击 备注 "每次攻击必定触发随机效果,4种效果的概率平均分布" / "<X模组>先判断是否“同时触发”所有
|
||
// 效果,未触发情况下再判断随机效果"; 爆发剂 备注 "进行完整的15次攻击后阿与目标获得持续时间相当于技能持续时间的增益;15次攻击均为无途径
|
||
// 攻击,但可满足近战/远程攻击相关的触发条件"); PRTS 分支特性信息 怪杰 ("可对空", "每秒流失最大生命值1%的生命值,不会致命且最低保留1
|
||
// 生命值"); the client's battle data — charpack char_225_haak (Talents/1: the additive active buff haak_t_1; Talents/2:
|
||
// heal_scale; Trait: periodic_damage_by_hp_ratio[skip_modifier_fix][undeadable] every 1 s), buff_template_data
|
||
// (haak_t_1: RandomCreateBuff of haak_t_atk (AtkScaleUp `atk_scale` on ON_CALCULATE_DAMAGE) / sluggish / stun / haak_t_heal
|
||
// (HealViaMaxHpRatio), weight 1 each; haak_e_002[dice]: Dice `prob` ⇒ all four, else the random one), the skill prefabs
|
||
// skchr_haak_2 / _3 (Attack + Fake_Attack: 15 shots — `_additionalTimes` 14, `_triggerDelta` 0.03 s — of
|
||
// instant_damage_physical[add_sp] = FixedValueDamage PHYSICAL `damage`; the target's buff fed with the last shot, his own
|
||
// by the Buff ability after the volley; ally selector `_targetSide` 1, `_excludeOwner`, `_postFilter` 21, his attack
|
||
// range) and the equips haak_equip_1_* (the hidden sp_recovery talent while his HP ratio is in [0.8, 1]) / haak_equip_2_2,
|
||
// _2_3 (heal_scale + the ability healinrange: one ally of his range, the same selector, healed healinrange.heal_scale ×
|
||
// ATK at once); Terra Wiki Aak (爆发剂: "an ally within range (prioritizes allies directly in front)", "the one in the
|
||
// frontal row/column from the closest to the furthest").
|
||
// - Trait (怪杰) "自身生命会不断流失": the profession default (professions.js geek, installHpDrain: hp_ratio × max HP every
|
||
// second, never below 1 HP), ranged physical arrows that hit air units (data canHitFly; PRTS "可对空"), blocks 1, 3-3;
|
||
// ground enemies target him (no flag). Module GEE-X adds "生命值高于80%时,技力自然回复速度+0.25/秒" (the hidden module
|
||
// talent sp_recovery_per_sec: SP recovery +0.25/s while his HP ratio is at least 80 % — the equip checker's [0.8, 1]);
|
||
// GEE-Y "再部署时间减少" is its attribute respawn_time −15 (in the stats, as its ATK / ASPD).
|
||
// - T1 混合药物射击 "每次攻击时会随机触发下列效果之一": every attack of his on an enemy rolls once, at its hit (the talent's
|
||
// additive active buff rides the attack onto the target — not damage-missable: a dodged hit still rolls), one of four
|
||
// with equal weight: heal himself hp_ratio × max HP (a 治疗: T2 scales it), that attack's damage ×atk_scale (an ATK scale,
|
||
// before mitigation — "当次攻击力提升至150%"), 停顿 `sluggish` s or 晕眩 `stun` s on the target. GEE-X stage 2+: first
|
||
// `prob` to trigger all four at once (haak_e_002[dice]), else the random one; stage 3's numbers are the module's.
|
||
// [ASSUMED] his 爆发剂 shots at an ally never roll (PRTS names no such effect); the client's haak_t_atk lingers until his
|
||
// next roll and could scale damage he deals meanwhile — here it scales the attack that rolled it only.
|
||
// - T2 药剂扩散 "自身受到的治疗量+20%" (full potential: +25%): healing received ×heal_scale. GEE-Y stage 2+ (1.3 at stage 3) adds "部署后立即恢复攻击
|
||
// 范围内一名友方干员(优先选取正前方)的生命值,恢复量为阿攻击力的150%": at each deployment, after the deploy-time buffs, a heal
|
||
// of healinrange.heal_scale × ATK on the ally frontAlly picks among the healable ones [ASSUMED: injured or not — the
|
||
// selector has no HP filter; summons count, as for 爆发剂's identical selector].
|
||
// - S1 快速射击 (MANUAL, data DEFAULT): ASPD +attack_speed for its duration.
|
||
// - S2 爆发剂·γ型 / S3 爆发剂·榴莲味 (MANUAL, data DEFAULT; timed: 30 / 20 s): at the cast, `stimulant`: the ally frontAlly
|
||
// picks takes 15 physical hits of `damage` (500) fixed attack power each (FixedValueDamage: DEF applies, 5 % minimum;
|
||
// 普通伤害, skill damage — 受击回复 SP and the 重装 TAKE_DAMAGE trigger answer them like any damage taken: the
|
||
// template's "[add_sp]"); then he and — still standing — the target get max HP +max_hp / DEF +def (S2) or ATK +atk / ASPD
|
||
// +attack_speed (S3) for the skill's duration (per 阿 on the target: independentCharacterSource). [ASSUMED] the volley
|
||
// lands at once (its 0.42 s of shots and flight are not modelled); with no ally in his range the cast does nothing (the
|
||
// client's sequence stops when its attack finds no target) — his skill still runs and spends its SP: the data's DEFAULT
|
||
// trigger needs an enemy, not an ally — settled by the sources in 0.2.0 WV: no skillTriggerDataList row names 阿 or 怪杰
|
||
// (the basic strategy), the skill prefabs cast with no target and keep the SP spent (skchr_haak_2 / _3 `_allowNoTarget`
|
||
// 1, `_checkHasTargetBeforeDoCast` 0, `_recoverSpIfNoTarget` 0) and PRTS 阿 has no note on it.
|
||
|
||
import { num, talentBb, moduleBb, moduleOn, skillRec, statBuff, toggleBuff, up } from '../shared/tier1.js';
|
||
import { toLocal } from '../../../dir.js';
|
||
import { COLS } from '../../../constants.js';
|
||
import { hypot } from '../../../detmath.js';
|
||
|
||
const S1 = 'skchr_haak_1';
|
||
const S2 = 'skchr_haak_2';
|
||
const S3 = 'skchr_haak_3';
|
||
/** 爆发剂's shots when the text names none ("攻击15次"; skill prefab `_additionalTimes` 14 + the first). */
|
||
const SHOTS_FALLBACK = 15;
|
||
/** GEE-X "生命值高于80%时" when the trait text names none (the equip checker's `_minHpRatio`). */
|
||
const GEEX_HP_FALLBACK = 0.8;
|
||
/** 混合药物射击's four effects, in the client's RandomCreateBuff order (weight 1 each). */
|
||
const DICE = Object.freeze(['atk', 'sluggish', 'stun', 'heal']);
|
||
const TAG_STIM = 'haak:stim';
|
||
|
||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||
|
||
/**
|
||
* 爆发剂 / 药剂扩散's target (the client selector `_postFilter` 21; Terra Wiki Aak): an ally — an operator or a summon,
|
||
* never a device, never a 孤立 one (Battle.allySelectable) — standing in his current attack range, himself excluded; the
|
||
* ones on his straight line ahead (local row 0) first, each group nearest first; ties: the higher taunt level, then the
|
||
* latest deployed [ASSUMED: the Terra Wiki's "highest aggression level"], then the unit id. `heal` = a heal selector: no
|
||
* 禁疗 / 无法被友方治疗 ally (PRTS 选择器 "若掩码中禁疗为1,且选择器属于治疗行为,则不可选中").
|
||
*/
|
||
export function frontAlly(battle, unit, heal = false) {
|
||
const keys = unit.rangeKeySet || new Set(unit.rangeKeys || []);
|
||
let best = null, bk = null;
|
||
for (const a of battle.allyUnits) {
|
||
if (a === unit || !a.alive || !a.deployed || a.hidden || a.kind === 'device') continue;
|
||
if (!keys.has(a.tileR * COLS + a.tileC) || !battle.allySelectable(a, unit)) continue;
|
||
if (heal && (a.s.flags.noHeal || a.s.flags.healFree || a.profile?.noHeal)) continue;
|
||
const dr = a.tileR - unit.tileR, dc = a.tileC - unit.tileC;
|
||
const [lr, lc] = toLocal(dr, dc, unit.dir);
|
||
const k = [lr === 0 && lc > 0 ? 0 : 1, hypot(dr, dc), -(a.s.taunt || 0), -(a.aggroSeq || a.deploySeq || 0), a.id];
|
||
let less = !bk;
|
||
for (let i = 0; !less && i < k.length; i++) {
|
||
if (k[i] === bk[i]) continue;
|
||
less = k[i] < bk[i];
|
||
break;
|
||
}
|
||
if (less) { best = a; bk = k; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/**
|
||
* 爆发剂 at the cast: `shots` hits of `damage` fixed attack power on frontAlly's pick, then `mods` on him and on the
|
||
* target (if it still stands) for `dur` s. No ally in range: nothing [ASSUMED, header].
|
||
*/
|
||
function stimulant(battle, unit, { damage, shots, dur, mods, key }) {
|
||
const t = frontAlly(battle, unit);
|
||
if (!t) return;
|
||
for (let i = 0; i < shots && t.alive && unit.alive; i++) {
|
||
battle.dealDamage(unit, t, { amount: damage, type: 'phys', isSkill: true, tags: ['skill', TAG_STIM] });
|
||
}
|
||
battle.fx('volley', { x: t.x, y: t.y, id: unit.id, target: t.id, n: shots, skill: key });
|
||
if (!(dur > 0)) return;
|
||
if (unit.alive) battle.addBuff(unit, { key: `${key}:self`, duration: dur, mods, source: unit, tags: ['skill'] });
|
||
if (t.alive && t !== unit) {
|
||
battle.addBuff(t, { key: `${key}:${unit.id}`, duration: dur, mods, source: unit, tags: ['skill'] });
|
||
battle.fx('buff', { x: t.x, y: t.y, id: t.id, kind: key });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 混合药物射击: one roll per attack on an enemy (its `attackId`), at its first hit — `all` (GEE-X stage 2+) for the four
|
||
* at once, else one of DICE uniformly; the heal and the statuses then, the ATK scale on every damage instance of it.
|
||
*/
|
||
function installDice(battle, unit, t0) {
|
||
const hr = num(t0.hp_ratio), scale = num(t0.atk_scale, 1), slug = num(t0.sluggish), stun = num(t0.stun), all = num(t0.prob);
|
||
battle.on('hit', (ctx) => {
|
||
const d = ctx.dmg, t = ctx.target;
|
||
if (ctx.source !== unit || !d || !d.isAttack || d.isSplash || d.type === 'element' || !t || t.side !== 'enemy') return;
|
||
const m = unit.mem;
|
||
if (!d.attackId || m.haakDiceId !== d.attackId) {
|
||
m.haakDiceId = d.attackId;
|
||
m.haakDice = all > 0 && battle.rng.chance(all) ? DICE : [DICE[battle.rng.int(DICE.length)]];
|
||
for (const e of m.haakDice) {
|
||
if (e === 'heal' && hr > 0) battle.heal(unit, unit, unit.s.maxHp * hr, { self: true });
|
||
else if (e === 'sluggish' && slug > 0) battle.applyStatus(t, 'sluggish', { duration: slug, source: unit });
|
||
else if (e === 'stun' && stun > 0) battle.applyStatus(t, 'stun', { duration: stun, source: unit });
|
||
}
|
||
if (m.haakDice.includes('atk') && scale !== 1) battle.fx('crit', { x: t.x, y: t.y, id: unit.id });
|
||
}
|
||
if (m.haakDice.includes('atk')) d.amount *= scale;
|
||
}, { owner: unit });
|
||
}
|
||
|
||
export default {
|
||
char_225_haak: (bb, chess) => {
|
||
const t0 = talentBb(chess, 0); // 混合药物射击 (GEE-X stage 2+: + prob, its own numbers)
|
||
const t1 = talentBb(chess, 1); // 药剂扩散 (GEE-Y stage 2+: heal_scale 1.3, healinrange.heal_scale)
|
||
const hidden = moduleBb(chess); // GEE-X: sp_recovery_per_sec
|
||
const b1 = bbOf(chess, S1);
|
||
const stim = (id, key, mods) => {
|
||
const rec = skillRec(chess, id);
|
||
const b = rec?.bb ?? {};
|
||
const shots = Math.max(1, Math.floor(num(+(/攻击(\d+)次/.exec(String(rec?.desc ?? ''))?.[1]), SHOTS_FALLBACK)));
|
||
const dur = num(rec?.duration, 0);
|
||
return {
|
||
kind: 'duration',
|
||
onStart({ battle, unit }) { stimulant(battle, unit, { damage: num(b.damage, 500), shots, dur, mods: mods(b), key }); },
|
||
};
|
||
};
|
||
return {
|
||
skills: {
|
||
[S1]: { kind: 'duration', mods: { aspd: num(b1.attack_speed) } },
|
||
[S2]: stim(S2, 'haak:s2', (b) => ({ hpPct: num(b.max_hp), defPct: num(b.def) })),
|
||
[S3]: stim(S3, 'haak:s3', (b) => ({ atkPct: num(b.atk), aspd: num(b.attack_speed) })),
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { installDice(battle, unit, t0); } }, // 混合药物射击
|
||
{ install(battle, unit) { // 药剂扩散: healing received ×heal_scale; GEE-Y stage 2+: the deploy heal
|
||
statBuff(battle, unit, 'talent:haak:heal', { healingTakenMul: num(t1.heal_scale, 1) });
|
||
const hs = num(t1['healinrange.heal_scale']);
|
||
if (!(hs > 0)) return;
|
||
battle.on('deploy', (c) => {
|
||
if (c.unit !== unit || c.move || !up(unit)) return;
|
||
const t = frontAlly(battle, unit, true);
|
||
if (!t) return;
|
||
battle.heal(unit, t, unit.s.atk * hs);
|
||
battle.fx('heal', { x: t.x, y: t.y, id: t.id, src: unit.id });
|
||
}, { owner: unit, priority: -10 });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
// GEE-X "生命值高于80%时,技力自然回复速度+0.25/秒"
|
||
const sp = num(hidden.sp_recovery_per_sec);
|
||
if (sp > 0 && moduleOn(chess)) {
|
||
const pct = +(/生命值高于(\d+)%/.exec(String(chess?.trait?.moduleDesc ?? ''))?.[1]);
|
||
const thr = Number.isFinite(pct) ? pct / 100 : GEEX_HP_FALLBACK;
|
||
toggleBuff(battle, unit, 'module:haak:sp', () => unit.hp >= unit.s.maxHp * thr - 1e-9, { spRecoveryFlat: sp });
|
||
}
|
||
},
|
||
};
|
||
},
|
||
};
|