Files
ark-stronghold-protocol/server/sim/content/kits/ops/op-poca.js
T
Cloudnyco ae22335cfe feedback7: the battle sim uses engine-independent math — Chrome, Firefox, Safari and the server agree bit for bit (PR #373 by @Cloudnyco)
- 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.
2026-10-08 18:35:55 +08:00

173 lines
11 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-poca.js — 早露 (char_197_poca) 自选 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_197_poca, 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 早露 (备注 of
// 深入骨髓, 学生楷模 and 雪崩击); PRTS 分支特性信息 攻城手 ("可对空。攻击范围不包含自身所在地块").
// - Trait (攻城手) "优先攻击重量最重的敌人": target priority 'heaviest' (targeting.js: the highest current 重量等级 first, then
// the usual order); ranged physical arrows that hit air units (data canHitFly, PRTS "可对空"), range 4-3.
// - Module SIE-X 攻城器械训练装置: trait "攻击重量较重(重量等级大于等于3)的敌人时,攻击力提升至115%" — the hidden module talent
// (atk_scale / value, merged into 深入骨髓's blackboard): ×atk_scale on her attacks (normal attacks and the S3 strikes, the
// profile dmgMul — the kits' "攻击…时攻击力提升至X%" convention, 斯卡蒂 DRE-X) on an enemy of 重量等级 ≥ value. Stage 2+ 深入骨髓
// "…并额外造成60%攻击力的物理伤害" (extra_atk_scale): every attack hit that lands on such an enemy is followed by a physical hit
// of extra_atk_scale × ATK (which 深入骨髓's 物理穿透 also gets).
// - Module SIE-Y “心”: trait "攻击越远的敌人造成的伤害越高(最高提升12%)" (trait bb min_dist / max_dist / damage_scale): her attack
// damage × (1 + damage_scale × clamp((d − min_dist) / (max_dist − min_dist), 0, 1)), d = her distance to the target in tiles —
// the kits' reading of these keys (远牙, 协律, 芳汀 modules) [ASSUMED linear: PRTS states no formula]. Stage 2+: 学生楷模 below.
// - T1 深入骨髓 "攻击重量较重(重量等级大于等于3)的敌人时,无视其防御力的60%": every damage she deals to an enemy of 重量等级 ≥ value
// ignores def_penetrate of its DEF (DamageInfo defIgnorePct, added — PRTS 备注 "临时获取永久的物理穿透(百分比)Buff(不可叠加,
// 直接加算)").
// - T2 学生楷模 "编入队伍时,所有【乌萨斯学生自治团】干员攻击力+8%" (full potential: +10 %): every 【乌萨斯学生自治团】 operator of
// her player's team ATK +atk for the whole battle, deployed or not (the kits' 编入队伍时 convention, 斯卡蒂 深海掠食者). SIE-Y
// stage 3: +14 % and "每有一名【乌萨斯学生自治团】干员处于技能期间时,【乌萨斯学生自治团】干员的攻击力额外+15%(最多+45%)"
// (hidden init_atk / max_atk): the number of those operators on the field whose skill runs, re-read every STUDENT_TICK s
// and at every skill start (PRTS 备注 "每0.3秒及每次开启技能时更新一次自身的额外加成") [ASSUMED: her team's operators only,
// as the base talent].
// - S1 攻击力强化·γ型 (30 s): ATK +atk. S2 分裂射击 (60 s): ATK +atk, attack@max_target targets.
// - S3 雪崩击 (6 / 7 s): ATK +atk; at the cast, harpoons link the max_target heaviest enemies of her range (her own target
// order); each linked enemy is 束缚 (bind) while linked and takes one attack every hit_interval s, hit_duration /
// hit_interval attacks in all, the first as the harpoons land (the tick after the cast) [ASSUMED] — her own attacks (Battle.forceAttack:
// every attack rider of hers applies), instant along the links; she makes no other attack meanwhile [ASSUMED: the
// strikes are what "每秒受到一次攻击" names]. PRTS 备注: a link breaks when its enemy is knocked out, 离地 (OUT_OF_GROUND —
// no enemy of the mode holds it) or 消失 (hidden), every link at once when she is stunned / frozen / silenced, and the
// skill ends as soon as no link is left. The binds end with their links.
import { num, talentBb, moduleBb, moduleOn, traitBb, skillRec, statBuff, onHitBy, enemiesInGrid, up } from '../shared/tier1.js';
import { hasHp } from '../../../damage.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skcom_atk_up[3]';
const S2 = 'skchr_poca_2';
const S3 = 'skchr_poca_3';
/** 【乌萨斯学生自治团】: character_table teamId `student` (古米, 凛冬, 烈夏, 苦艾, 真理, 早露, 怒潮凛冬); data/chess.json has no team id. */
const STUDENTS = new Set(['char_196_sunbr', 'char_115_headbr', 'char_194_leto', 'char_405_absin', 'char_195_glassb',
'char_197_poca', 'char_1051_headb2']);
/** PRTS 学生楷模 备注: the SIE-Y extra is re-read every 0.3 s (and at every skill start). */
const STUDENT_TICK = 0.3;
const STUDENT_KEY = 'talent:poca:students';
const DRIVE_KEY = 'talent:poca:studentSkills';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const count = (v, d) => Math.max(1, Math.floor(num(v, d)));
const isStudent = (a) => !!a && a.kind === 'op' && STUDENTS.has(a.def?.charId);
/** A running skill of `a` (a passive is no 技能期间). */
const inSkill = (a) => !!(a.skill && a.skill.active && a.skill.kind !== 'passive');
export default {
char_197_poca: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const heavyAt = num(t0.value, 3);
const heavy = (e) => !!e && e.side === 'enemy' && e.weight >= heavyAt;
// SIE-X (hidden part on 深入骨髓's index: atk_scale / value; stage 2+ extra_atk_scale), SIE-Y (trait bb; hidden
// init_atk / max_atk at stage 3) — chess is the loadout-resolved record, so no module ⇒ none of these keys
const xScale = moduleOn(chess) ? num(t0.atk_scale, 1) : 1;
const extra = num(t0.extra_atk_scale);
const tb = traitBb(chess);
const hidden = moduleBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
/** S3: the links of the running cast (unit.mem.pocaS3 = { links: Set, left, timer }). */
const unbind = (battle, unit, e) => {
const b = e.findBuff('bind');
if (b && b.source === unit) battle.removeBuff(e, b);
};
const linkBroken = (e) => !e.alive || !e.deployed || e.hidden || !!e.s.flags.outOfGround;
const strike = (battle, unit, st) => {
const targets = [...st.links].filter((e) => !linkBroken(e));
if (!targets.length || !unit.canAct || st.left <= 0) return;
st.left--;
battle.forceAttack(unit, targets);
};
return {
skills: {
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk) } },
[S2]: { kind: 'duration', mods: { atkPct: num(b2.atk) }, targeting: { maxTargets: count(b2['attack@max_target'], 2) } },
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk) },
// no attack of her own while the links hold; the strikes are instant along them ('beam')
attack: { noAttack: true, projectile: 'beam' },
onStart({ battle, unit, skill }) {
const links = enemiesInGrid(battle, unit, null, { n: count(b3.max_target, 3), priority: 'heaviest', canHitFly: true });
const st = { links: new Set(links), left: count(num(b3.hit_duration, skill.duration) / Math.max(0.1, num(b3.hit_interval, 1)), 6), timer: null };
unit.mem.pocaS3 = st;
for (const e of links) {
battle.applyStatus(e, 'bind', { duration: skill.timeLeft, source: unit });
battle.fx('harpoon', { x: e.x, y: e.y, id: unit.id, target: e.id });
}
// the harpoons land on the next tick (after the cast's skillStart handlers — 学生楷模's re-read), then every
// hit_interval s
st.timer = battle.after(0, () => {
if (unit.mem.pocaS3 !== st) return;
strike(battle, unit, st);
st.timer = battle.every(Math.max(0.1, num(b3.hit_interval, 1)), (b, sc) => {
if (unit.mem.pocaS3 !== st || st.left <= 0) { sc.cancel(); return; }
strike(battle, unit, st);
}, { owner: unit });
}, { owner: unit });
},
onTick({ battle, unit, skill }) {
const st = unit.mem.pocaS3;
if (!st) return;
const f = unit.s.flags;
if (f.stun || f.freeze || f.silence) { skill.end('interrupted'); return; }
for (const e of [...st.links]) if (linkBroken(e)) { st.links.delete(e); unbind(battle, unit, e); }
if (!st.links.size) skill.end('unlinked');
},
onEnd({ battle, unit }) {
const st = unit.mem.pocaS3;
unit.mem.pocaS3 = null;
if (!st) return;
if (st.timer) st.timer.cancel();
for (const e of st.links) unbind(battle, unit, e);
},
},
},
talents: [
{ install(battle, unit) { // 深入骨髓: +def_penetrate 物理穿透 on heavy enemies; SIE-X stage 2+: the extra physical hit
const pen = num(t0.def_penetrate);
if (pen > 0) onHitBy(battle, unit, ({ target, dmg }) => { if (heavy(target)) dmg.defIgnorePct += pen; });
if (extra > 0) {
battle.on('damaged', (c) => {
if (c.source !== unit || !c.dmg?.isAttack || !heavy(c.target) || !hasHp(c.target)) return;
battle.dealDamage(unit, c.target, { amount: unit.s.atk * extra, type: 'phys', tags: ['talent', 'poca:extra'] });
}, { owner: unit });
}
} },
{ install(battle, unit) { // 学生楷模: every student of her team ATK +atk; SIE-Y stage 3: +init_atk per student in skill
const team = battle.allyUnits.filter((a) => a.ownerId === unit.ownerId && isStudent(a));
for (const a of team) statBuff(battle, a, STUDENT_KEY, { atkPct: num(t1.atk) });
const per = num(hidden.init_atk), cap = num(hidden.max_atk, Infinity);
if (!(per > 0)) return;
const update = () => {
const v = Math.min(cap, per * team.filter((a) => up(a) && inSkill(a)).length);
for (const a of team) {
const cur = a.findBuff(DRIVE_KEY);
if (!(v > 0)) { if (cur) battle.removeBuff(a, cur); continue; }
if (!cur || cur.mods?.atkPct !== v) battle.addBuff(a, { key: DRIVE_KEY, mods: { atkPct: v }, persist: true, allowDead: true, tags: ['talent'], source: unit });
}
};
battle.every(STUDENT_TICK, update, { owner: unit, immediate: true });
battle.on('skillStart', (c) => { if (team.includes(c.unit)) update(); }, { owner: unit });
} },
],
// 攻城手: heaviest first; SIE-X ×atk_scale on her attacks against heavy enemies
trait: { priority: 'heaviest', ...(xScale !== 1 ? { dmgMul: (b, u, target) => (heavy(target) ? xScale : 1) } : null) },
install(battle, unit) {
// SIE-Y: the farther the target, the more damage (attacks only)
const ds = num(tb.damage_scale), lo = num(tb.min_dist, 1), hi = num(tb.max_dist, 4.5);
if (ds > 0) {
onHitBy(battle, unit, ({ target, dmg }) => {
if (!dmg.isAttack) return;
const d = hypot(target.x - unit.x, target.y - unit.y);
dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo)));
});
}
},
};
},
};