Files
ark-stronghold-protocol/server/sim/content/kits/ops/op-ray.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

324 lines
21 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-ray.js — 莱伊 (char_4117_ray) 自选 operator kit: 6★ 猎手 (狙击), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait, both modules (HUN-X 《跳舞的月光》, HUN-Y 证明什么?) at
// every form, and the kit of her summon 沙地兽 (token_10034_ray_sndbst). Kit contract and the 自选 rules: ../README.md
// ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4117_ray): 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 / the token's character_table row (zh_CN, as
// built into backups.json) and PRTS 莱伊 (巡哨伙伴 / 入神 / S1 / S2 / S3 备注), PRTS 沙地兽 (备注 "持有禁疗、无敌", "退场时返还1
// 个可部署的沙地兽", "莱伊退场时强制撤退场上的沙地兽"; its skill "仅在持有者携带技能2时才会携带"), PRTS 分支特性信息 猎手, BWIKI
// 莱伊 ("装填间隔即为攻击间隔,初始子弹数即为最大子弹数"), PRTS 卫戍协议/帮助 (a placed summon that leaves is deployed again on
// its tile "在满足条件后"); the client's battle data read from the local install — buff_template_data ray_tr / ray_tr_add /
// ray_tr_sub (the magazine), ray_s_1[konckback] (Knockback _useSourceDirection, _decreaseForceLevelWhenNotInDirection 1),
// ray_s_1[kill_add_bullet], ray_t_1[damage_scale] (PHYSICAL DamageScale on targets holding ray_sndbst_aura), ray_s_2[deck],
// ray_s_3[reload] / [check_full] / [sp], ray_sndbst_tr / ray_sndbst_collect, and charpack char_4117_ray.
// - Trait (猎手) "攻击时需要消耗子弹且攻击力提升至120%,不攻击时会缓慢地装填子弹(最多8发)" (trait bb value / atk_scale; the
// hunter profile's ×atk_scale on every bullet attack, skill attacks included). PRTS 分支特性信息 猎手: "入场时持有自身子弹上限
// 数的子弹" — full at every deployment; "存在子弹且攻击范围内存在目标时进行攻击,不存在子弹/子弹未满且攻击范围内无目标时进行
// 装弹,通常情况下每次装填1颗" and "装弹行为属于普通攻击行为,持续时间使用装填间隔,装填间隔默认为自身基础攻击间隔,不受攻速/
// 攻击间隔变化影响": a reload is an action of her attack loop lasting her BASE attack time (1.6 s; ASPD never shortens it),
// loading one bullet when it ends — taken when the magazine is empty (targets or not) or not full with nobody to shoot.
// This kit's own reload (kit trait install / canAttack / afterAttack); the engine's hunter profile (1 bullet per second
// after 1 s without attacking) stays for 雪猎. Can hit air units (PRTS "可对空"), blocks 1, ground enemies target her.
// HUN-X adds "子弹数量为空时下次装填额外加装1发子弹" (trait bb extra_add): a reload begun on an empty magazine loads 1 + that.
// HUN-Y raises the trait to atk_scale 1.33 (and ASPD +5 / +7 in the stats).
// - T1 巡哨伙伴 "可以在攻击范围内部署沙地兽于25秒内侦察一片区域延伸攻击范围,自身优先攻击该区域内的目标且对其造成的物理伤害提
// 高15%" (bb damage_scale; HUN-X stage 3: 20 % and "沙地兽的再部署时间-10秒" — the token's module stats, respawn 20 s).
// PRTS 备注: "攻击范围内" of her attacks, talents and skills means "自己或自己的召唤物的攻击范围内" — while a 沙地兽 scouts,
// the enemies of its x-4 (3×3) are in her range (Battle.setExtraRange) and in her skills' trigger range
// (skill.addTriggerRange); she picks a target of that area before any other (after the ones she blocks); her physical
// damage on an enemy there ×(1 + damage_scale) ("伤害倍率提升": DamageInfo mul).
// The 沙地兽 is a hand piece the player places in her range (data ownerRange): it deploys with the board, scouts for the
// token talent's `duration` (25 s) and then leaves [ASSUMED: the scouting ends with its stay — PRTS "退场时返还1个可部署
// 的沙地兽"]; it is untargetable (its trait text), 无敌 and 禁疗 (PRTS 备注), makes no attack (实体类型 装置) [ASSUMED], and
// is withdrawn when 莱伊 leaves the field (PRTS 备注). A 沙地兽 that left comes back on its tile once its redeploy time
// (×(1 + S2 respawn_time) while 广域警觉 runs) has passed, paying its cost (3 DP), while 莱伊 is on the field.
// - T2 入神 "攻击相同目标时每次攻击提高自身攻击力8%,最多3层" (full potential: 9 %; bb atk / max_stack_cnt; HUN-Y stage 3: 10 %, 4 layers). PRTS 备注:
// "每次攻击前:若目标与上次攻击目标不同,失去之前获得的增益;随后获得一层增益,持续时间无限" — before each attack (the special
// bullet of S1 too; never a reload), lost on a new target, gone when she leaves the field.
// - S1 脱身矢 (MANUAL, 2 charges, data DEFAULT): "立即用额外特殊子弹攻击目标,造成相当于攻击力N%的物理伤害并将其中等力度地推开,
// 若将其击倒则使下次装填额外加装1发子弹". PRTS 备注: "'额外特殊子弹'实际为无需消耗子弹的一次攻击,可受特性加成" — an attack
// at once (Battle.forceAttack; the cast's normal attack follows as usual) at atk_scale × ATK × the trait scale that takes no
// bullet; a 中力 (bb force) push along her facing, radial at 力度 −1 when the target is off it (PRTS "推动方向默认为自身朝
// 向,因角度过大变为径向推力时,力度仅-1": the engine's directional rule with a −1 instead of −2); a kill adds bb cnt bullets
// to the next reload ("额外装填效果可叠加并于下1次装填时一同生效"). With an empty magazine (no attack to wait for) the cast
// comes as soon as an enemy is in range [ASSUMED, as 雪猎's special bullets]. The 地穴 clause has nothing to act on here.
// - S2 广域警觉 (AUTO, attack SP; 持续时间无限 — a toggle): range 4-10, ATK +atk, the 沙地兽's redeploy time ×(1 +
// respawn_time). Self / summon effects only: it fires at full SP (`trigger: 'SP_FULL'`, the owner's AUTO rule).
// Passive "沙地兽撤退时回收命中该区域的子弹": while she carries S2, the bullets of her attacks that hit an enemy inside a
// scouting 沙地兽's area are counted (its own counter: ≤ its trait value 8 — ray_sndbst_tr) and come back to her magazine
// when it leaves, while she is on the field (ray_sndbst_collect → ray_tr_add: HUN-X's extra_add first on an empty
// magazine, capped at her maximum); S1's special bullet is no bullet. PRTS "装弹行为不触发攻击回复": no reload gives
// attack SP.
// - S3 “得见光芒” (MANUAL, 16 s, data ACTIVE_RANGE on its 3-8): "立即停止攻击直至子弹装满,装填间隔大幅缩短(-1.2)": at the cast
// she stops attacking and reloads (a reload under way is cut to the new interval) until her magazine is full, every reload
// while it runs lasting base attack time + reload_interval (0.4 s); range 3-8; every attack attack@atk_scale × ATK
// (× the trait scale) and 束缚 (bind) attack@unmove_duration s; a kill of hers during it ⇒ +sp SP when it ends, through
// 阻回 (PRTS 备注 "获得技力效果无视阻回").
import { num, talentBb, traitBb, skillRec, up } from '../shared/tier1.js';
import { acquireTargets, effectiveProfile } from '../../../ai.js';
import { sortEnemyTargets } from '../../../targeting.js';
import { bodyInKeys } from '../../../body.js';
import { PUSH_DIRECTIONAL_MIN_DIST } from '../../../constants.js';
import { startCountdown } from '../../tokens.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_ray_1';
const S2 = 'skchr_ray_2';
const S3 = 'skchr_ray_3';
export const SANDBEAST = 'token_10034_ray_sndbst';
/** 巡哨伙伴 (E2): the 沙地兽's scouting when its def carries no duration (PRTS: "持续25秒"). */
const LIFE_FALLBACK = 25;
/** Seconds between two tries of a 沙地兽 that left to come back (tile busy, DP short, 莱伊 away). */
const RETRY = 0.25;
/** Tag of S1's special bullet (no bullet of the magazine). */
export const SPECIAL_TAG = 'ray:special';
const TRANCE_KEY = 'talent:ray:trance';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** The magazine of the trait ("最多8发"). */
const ammoMax = (unit) => Math.max(1, Math.floor(num(unit.profile?.ammoMax, 8)));
/** First talent of a normalised token def holding `key` (token talents keep no index). */
const tokTalent = (def, key) => (def?.talents ?? []).find((t) => t && t.bb && t.bb[key] != null) ?? null;
/** The 沙地兽's kit: no attack, 无敌, 禁疗 (its untargetability comes from its trait text, simdata normalizeToken). */
function sandbeastKit() {
return {
skill: null,
trait: { noAttack: true },
talents: [],
install(battle, t) {
battle.addBuff(t, { key: 'ray:sandbeast', flags: { invulnerable: true, noHeal: true }, persist: true, allowDead: true });
},
};
}
export default {
char_4117_ray: (bb, chess) => {
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const extraAdd = Math.max(0, Math.floor(num(traitBb(chess).extra_add)));
const picked = chess?.skill?.skillId ?? null;
const tokenId = (chess?.talents ?? []).find((t) => t && t.tokenKey)?.tokenKey ?? SANDBEAST;
/** One reload: her base attack time, S3's reload_interval while it runs (PRTS: ASPD never changes it). */
const reloadInterval = (unit) => {
const s3on = !!(unit.skill && unit.skill.active && unit.skill.id === S3);
return Math.max(0.05, num(unit.base?.bat, 1.6) + (s3on ? num(b3.reload_interval) : 0));
};
const startReload = (unit) => {
const m = unit.mem;
m.rayReloading = true;
if ((unit.trait.ammo ?? 0) <= 0 && extraAdd > 0) m.rayLoadExtra = (m.rayLoadExtra ?? 0) + extraAdd; // HUN-X
unit.atkCd = reloadInterval(unit);
};
const finishReload = (unit) => {
const m = unit.mem;
m.rayReloading = false;
const add = 1 + Math.max(0, Math.floor(num(m.rayLoadExtra)));
m.rayLoadExtra = 0;
unit.trait.ammo = Math.min(ammoMax(unit), (unit.trait.ammo ?? 0) + add);
};
const resetTrait = (unit) => {
unit.trait.ammo = ammoMax(unit);
Object.assign(unit.mem, { rayReloading: false, rayLoadExtra: 0, rayFill: false });
};
return {
trait: {
// 猎手 (PRTS 分支特性信息): her own reload, an action of the attack loop (canAttack runs once the cooldown is over)
install(battle, unit) {
resetTrait(unit);
battle.on('deploy', (ctx) => { if (ctx.unit === unit) resetTrait(unit); }, { owner: unit });
},
canAttack(battle, unit) {
const m = unit.mem;
if (m.rayReloading) finishReload(unit);
const max = ammoMax(unit), ammo = unit.trait.ammo ?? 0;
if (m.rayFill) { // S3: no attack until the magazine is full
if (ammo < max) { startReload(unit); return false; }
m.rayFill = false;
}
if (ammo <= 0 || (ammo < max && !acquireTargets(battle, unit, effectiveProfile(unit)).length)) { startReload(unit); return false; }
return true;
},
afterAttack(battle, unit) { unit.trait.ammo = Math.max(0, (unit.trait.ammo ?? 0) - 1); },
},
skills: {
[S1]: {
kind: num(skillRec(chess, S1)?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
// the special bullet: an attack that spends no bullet (its own afterAttack) and takes the trait scale
attack: {
atkScale: num(b1.atk_scale, 1),
tags: ['skill', SPECIAL_TAG],
afterAttack() {},
onHit({ battle, unit, target }) {
if (!target) return;
if (!target.alive) { unit.mem.rayLoadExtra = num(unit.mem.rayLoadExtra) + Math.max(0, Math.floor(num(b1.cnt, 1))); return; }
const force = num(b1.force, 1);
const [fr, fc] = unit.fwd;
const vx = target.x - unit.x, vy = target.y - unit.y, d = hypot(vx, vy);
if (d >= PUSH_DIRECTIONAL_MIN_DIST && vx * fc + vy * fr >= d * Math.SQRT1_2) battle.push(target, force, { from: unit, dir: { x: fc, y: fr }, fixed: true });
else battle.push(target, force - 1, { from: unit });
},
},
onStart({ battle, skill, unit }) {
if (battle.forceAttack(unit)) return;
// nobody to shoot after all: no cast
skill.pending = false;
skill.addCharge(1);
},
},
[S2]: {
kind: 'toggle',
trigger: 'SP_FULL',
mods: { atkPct: num(b2.atk) },
targeting: s2?.rangeGrid ? { rangeGrid: s2.rangeGrid.map((p) => [p[0], p[1]]) } : undefined,
},
[S3]: {
kind: 'duration',
targeting: s3?.rangeGrid ? { rangeGrid: s3.rangeGrid.map((p) => [p[0], p[1]]) } : undefined,
attack: {
atkScale: num(b3['attack@atk_scale'], 1),
onHit({ battle, unit, target }) {
if (target && target.alive) battle.applyStatus(target, 'bind', { duration: num(b3['attack@unmove_duration'], 2), source: unit });
},
},
onStart({ unit }) {
const m = unit.mem;
m.rayFill = true;
m.rayKilled = false;
// 立即停止攻击: the reload starts now (one under way is cut to the new interval)
unit.atkCd = m.rayReloading ? Math.min(unit.atkCd, reloadInterval(unit)) : 0;
},
onEnd({ unit, reason }) {
const m = unit.mem;
m.rayFill = false;
if (m.rayKilled && reason !== 'death' && up(unit) && unit.skill) unit.skill.gainSp(num(b3.sp), 'init'); // 无视阻回
m.rayKilled = false;
},
},
},
talents: [
{ install(battle, unit) { // 巡哨伙伴 + the 沙地兽 (and S2's bullet recovery)
const m = unit.mem;
const mine = (t) => !!t && t.kind === 'token' && t.defId === tokenId && t.ownerUnit === unit;
// her placed 沙地兽 pieces run the 沙地兽's kit (set up here, before the battle starts — as 鸿雪's 打字机)
for (const t of battle.allyUnits) {
if (!mine(t) || t.alive || t.deployed) continue;
if (t.kit) battle.offOwner(t);
battle._setupUnit(t, sandbeastKit());
}
const ds = num(t0.damage_scale);
m.rayArea = null;
m.rayBullets = 0;
if (unit.skill) unit.skill.addTriggerRange(() => (m.rayAreaKeys ? [m.rayAreaKeys] : []));
const endArea = () => { m.rayArea = null; m.rayAreaKeys = null; m.rayBeast = null; battle.setExtraRange(unit, null); };
battle.on('deploy', (ctx) => {
const t = ctx.unit;
if (!mine(t)) return;
const life = num(tokTalent(t.def, 'duration')?.bb?.duration, LIFE_FALLBACK);
m.rayBeast = t;
m.rayAreaKeys = [...t.rangeKeys];
m.rayArea = new Set(m.rayAreaKeys);
m.rayBullets = 0;
m.rayCounted = 0;
battle.setExtraRange(unit, m.rayAreaKeys);
startCountdown(battle, t, life); // a countdown summon (content/tokens.js): its bar = the life left
const seq = t.deploySeq;
battle.after(life, () => { if (t.alive && t.deploySeq === seq) battle.retreat(t, { reason: 'expired', permanent: true }); }, { owner: unit });
}, { owner: unit });
battle.on('death', (ctx) => {
const t = ctx.unit;
if (t === unit) { // "莱伊退场时强制撤退场上的沙地兽"
for (const x of battle.allyUnits) if (mine(x) && x.alive) battle.retreat(x, { reason: 'retreat', permanent: true });
return;
}
if (!mine(t) || battle.finished) return;
t.removed = false; // the piece stays (Battle.redeploy needs it; its hooks are kept)
if (m.rayBeast === t) endArea();
// S2 passive: the bullets that hit in its area come back (ray_sndbst_collect → ray_tr_add: its count — ≤ its own
// trait value — onto her magazine, HUN-X's extra_add first when she is empty, capped at her maximum)
if (picked === S2 && m.rayBullets > 0 && up(unit)) {
const back = Math.min(m.rayBullets, Math.max(1, Math.floor(num(t.def?.traitBb?.value, ammoMax(unit)))));
const empty = (unit.trait.ammo ?? 0) <= 0 ? extraAdd : 0;
unit.trait.ammo = Math.min(ammoMax(unit), (unit.trait.ammo ?? 0) + empty + back);
}
m.rayBullets = 0;
const left = battle.time;
battle.every(RETRY, (b, sched) => {
if (t.alive || t.removed || b.finished) { sched.cancel(); return; }
const mul = unit.skill && unit.skill.active && unit.skill.id === S2 ? Math.max(0, 1 + num(b2.respawn_time)) : 1;
if (b.time + 1e-9 < left + Math.max(0, num(t.base.respawnTime)) * mul || !up(unit)) return;
if (b.redeploy(t, { free: false })) sched.cancel();
}, { owner: unit });
}, { owner: unit, priority: -10 });
// 自身优先攻击该区域内的目标 (after the enemies she blocks)
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !m.rayArea || !ctx.targets.length) return;
const area = m.rayArea, prof = ctx.profile ?? unit.profile;
const all = battle.enemiesInKeys(unit.rangeKeys, unit, prof);
for (const e of battle.blockedTargets(unit, prof)) if (!all.includes(e)) all.push(e);
if (!all.some((e) => bodyInKeys(e, area))) return;
sortEnemyTargets(battle, unit, all, prof.priority ?? null);
const rank = (e) => (e.blockedBy === unit ? 0 : bodyInKeys(e, area) ? 1 : 2);
const order = all.map((e, i) => ({ e, i, r: rank(e) })).sort((a, b) => a.r - b.r || a.i - b.i).map((x) => x.e);
ctx.targets = order.slice(0, ctx.targets.length);
}, { owner: unit, priority: 10 });
// ×(1 + damage_scale) on her physical damage to an enemy of the area
if (ds > 0) {
battle.on('hit', (ctx) => {
if (ctx.source !== unit || !m.rayArea || !ctx.target || ctx.target.side !== 'enemy' || ctx.dmg.type !== 'phys') return;
if (bodyInKeys(ctx.target, m.rayArea)) ctx.dmg.mul *= 1 + ds;
}, { owner: unit });
}
// S2 passive: count the bullets of her attacks that hit an enemy of the area (one per attack)
if (picked === S2) {
battle.on('damaged', (ctx) => {
const d = ctx.dmg, e = ctx.target;
if (ctx.source !== unit || !m.rayArea || !d || !d.isAttack || !e || e.side !== 'enemy' || (d.tags && d.tags.includes(SPECIAL_TAG))) return;
if ((d.attackId && d.attackId === m.rayCounted) || !bodyInKeys(e, m.rayArea)) return;
m.rayCounted = d.attackId;
m.rayBullets++;
}, { owner: unit });
}
} },
{ install(battle, unit) { // 入神: +atk per attack on the same target (≤ max_stack_cnt), lost on a new target
const atk = num(t1.atk), max = Math.max(1, Math.floor(num(t1.max_stack_cnt, 3)));
if (!(atk > 0)) return;
const m = unit.mem;
m.rayTrance = null;
m.rayStacks = 0;
battle.on('deploy', (ctx) => { if (ctx.unit === unit) { m.rayTrance = null; m.rayStacks = 0; } }, { owner: unit });
battle.on('beforeAttack', (ctx) => {
const t = ctx.attacker === unit ? ctx.targets[0] : null;
if (!t) return;
if (m.rayTrance !== t) { m.rayTrance = t; m.rayStacks = 0; }
m.rayStacks = Math.min(max, m.rayStacks + 1);
battle.addBuff(unit, { key: TRANCE_KEY, mods: { atkPct: atk * m.rayStacks }, tags: ['talent'] });
}, { owner: unit, priority: -10 });
} },
],
install(battle, unit) {
// S3: 技能期间若击倒敌人 — a kill of hers while it runs
if (picked === S3) {
battle.on('kill', (ctx) => {
if (ctx.killer === unit && unit.skill && unit.skill.active && unit.skill.id === S3) unit.mem.rayKilled = true;
}, { owner: unit });
}
// S1 with an empty magazine (the attack loop only reloads then): cast once an enemy is in range [ASSUMED]
if (picked !== S1) return;
battle.on('tick', () => {
const sk = unit.skill;
if (!up(unit) || !unit.canAct || !sk || !sk.ready || sk.pending || sk.opCooling || unit.s.flags.silence || unit.s.flags.disarm) return;
if ((unit.trait.ammo ?? 1) > 0) return;
if (acquireTargets(battle, unit, effectiveProfile(unit)).length) sk.activate('DEFAULT');
}, { owner: unit });
},
};
},
};