diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ba9f4b6b..4ffb3469 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -34,8 +34,12 @@ effects), [DATA.md](DATA.md) (generated data), [ASSETS.md](ASSETS.md) (art and a against the spec (`server/match/fields.js` `validateClientResult`), runs the battles no connected human owns (bots, a dropped player, an authority that missed its deadline) and can re-simulate results (`SP_VERIFY`). `SP_COMBAT=server` is the older mode in which the server runs every battle and streams snapshots. DESIGN §14. -- **Determinism** is what makes this work: fixed 1/30 s ticks, a seeded PRNG (`server/sim/rng.js`), no wall clock and - no `Math.random` in the sim, so the same spec and data give the same battle on every machine, bit for bit. The golden +- **Determinism** is what makes this work: fixed 1/30 s ticks, a seeded PRNG (`server/sim/rng.js`), no wall clock, + no `Math.random` and only correctly rounded arithmetic in the sim, so the same spec and data give the same battle on + every machine, bit for bit. ECMA-262 leaves `Math.hypot`, `sin`, `cos`, `atan2`, `pow` (and `**`) implementation- + approximated and the engines differ in their last bits (V8, SpiderMonkey and JavaScriptCore each compute `hypot` + differently; Chrome's and Node's `sin` differ), so the sim takes them from `server/sim/detmath.js`, built only from + + − × ÷ and `Math.sqrt`; ESLint and `test/sim/detmath.test.js` refuse the Math ones in `server/sim/`. The golden results (§5) rely on it. ## 2. The WebSocket protocol diff --git a/docs/SIM.md b/docs/SIM.md index 45d84c7b..08a53a3f 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -3,7 +3,9 @@ Audience: **content authors** (kits, bonds, garrisons, items, bands, enemies, bosses, devices, choices) and the **match owner** who drives `Battle`. The normative contract is DESIGN.md §5 ([design/engine.md](design/engine.md)); this file documents the concrete implementation, every hook and helper, the SkillSpec schema with worked examples, -the profession defaults and the test harness. Everything here is deterministic: the only randomness is `battle.rng()`. +the profession defaults and the test harness. Everything here is deterministic: the only randomness is `battle.rng()`, +and the only floating-point functions are correctly rounded ones (`detmath.js` below), so a battle gives the same bits in +every browser and on the server. ``` server/sim/ @@ -13,6 +15,8 @@ server/sim/ tiles (relocation, bodies, tactical points), displacement, economy (DP, layers, coins), events constants.js TICK, MOVE_SCALE, ATTACK_PAUSE, element numbers, tuning knobs rng.js mulberry32 PRNG (+ int/range/chance/pick/shuffle/weighted) + detmath.js hypot / powi / sin / cos / atan2 with the same bits on every engine — the sim never calls the + implementation-approximated Math functions (hypot, sin, cos, atan2, pow, ** …; lint + test enforce it) grid.js stage grid, tile semantics, 8-dir A* (no corner cutting), obstacles units.js Unit model + stat aggregation buffs.js Buff model, mod keys, status catalogue @@ -851,7 +855,7 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l | `push(enemy, force, {from, dir, fixed, fixedAngle, inward, effect})`, `pull(enemy, force, {to, center, stop})`, `pullToFront(enemy, unit, force)`, `forceLevel(enemy, force)`, `pushDistance(enemy, force, {effect})` | the official 位移 (PRTS 游戏数据基础 §重量公式 / 推与拉; user playtest #6 item 14): 受力等级 = 力度 (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3 …) − current 重量等级 (massLevel, 失重 counts). Push distance per level (`constants.js PUSH_TILES`, PRTS 推与拉's 弹道 column): ≤ −3 → 0, −2 → 0.12, −1 → 0.44, 0 → 1.7, 1 → 2.14, 2 → 2.96, ≥ 3 → 3.53 tiles; `effect` = a 特效 push (`PUSH_TILES_EFFECT`: −2 → 0.085, −1 → 0.374, 0 → 1.562, 1 → 1.987, 2 → 2.773, ≥ 3 → 3.331 — 见行者 S1 / S2, `PUSH_EFFECT_SKILLS`; every other pusher uses the 弹道 column [ASSUMED]); radial (away from `from`; the client's buff template `knockback[relative]`: 莫斯提马 S3, 山 S3, 琳琅诗怀雅 S3 — also PRTS 备注 "推开效果为径向推动") unless `dir` (directional; template `knockback[dir]` = Knockback {`_useSourceDirection` true, `_decreaseForceLevelWhenNotInDirection` 2}: 推击手, 野鬃 S2 "往攻击方向" (charpack char_496_wildmn: buff `wildmn_s_2[force]`) — > 45° off or < 0.25 tile ⇒ radial and level −2; `fixed` waives both (圣聆初雪 S1 朝部署方向, `KnockBackWithCharacterDirection`), `fixedAngle` only the angle (见行者 S2)); `inward` = a push towards `from` (薄绿 S2), stopping at the 急停 radius. Pull: level ≥ 0 → to `to` / the 急停 radius around `center`, −1 → 35 % of the way, −2 → 0.03, ≤ −3 → 0; `pullToFront` aims at the 拉力起点 0.5 tile ahead of the unit with the 急停 radius 0.6708 around it (never moves an enemy the unit itself blocks — nor does a pull towards an ally's centre, e.g. the 流形 S3 pulse) A 静态刚体 (data `staticBody`: every air unit of the mode except “炎佑”, plus 昆图斯 — PRTS 特殊机制 "可以进入失衡状态…但物理层面上无法产生任何速度或移动", "与单位的行动方式无关") moves 0 from every source while the skills that reach it still hit it (薄绿 S2, 锏 S3, the 钩索师 …; player report after 0.1.0) | | `displace(enemy, {x, y}, tiles)` | the raw mover behind push / pull: along passable tiles (by `motion`: FLY in the rect, else ground-passable), no weight rule; nothing for 失衡免疫 (`noDisplace`: 近地悬浮 incl. 喷气人's 飞行模式, 浮空, the 胄 parts, 守墓石像's statue and flight), 静态刚体 (`def.staticBody`) and leaders (`_displaceable`); unblocks + re-paths | | `after(seconds, fn, {owner})`, `every(seconds, fn, {owner, immediate})` | return `{cancel()}`; fn(battle, sched) | -| `fx(kind, params)`, `rng()` (+ `rng.int/range/chance/pick/shuffle/weighted`) | never use Math.random | +| `fx(kind, params)`, `rng()` (+ `rng.int/range/chance/pick/shuffle/weighted`) | never use Math.random; for distances, angles and powers use `hypot`, `sin`, `cos`, `atan2`, `powi` from `server/sim/detmath.js`, never `Math.hypot` / `sin` / `cos` / `atan2` / `pow` / `**` (their last bits differ between browsers) | | `forceAttack(unit, targets?, { noAmmo })` | an immediate attack with the current profile (hooks, attack SP, a running ammo skill's bullet); `noAmmo: true` = spends no bullet and emits no `ammoUsed` (圣约送葬人 extra attack). Returns true when it attacked | | `findTacticalPoint(unit)`, `groundPathTiles()` | tactical point (战术点, tactician 援军 / talent tokens): a free (`isReservedTile`) walkable, standable (`canStand`: never the 深水区) tile of the initial range **on an enemy ground path first** (`groundPathTiles`: grid paths of every non-FLY route, cached per grid version), then nearest (Chebyshev, rows break ties), then the lowest tile key | | `effectiveProfile(unit)`, `reduceElement(unit, amount, el?)` | | diff --git a/eslint.config.js b/eslint.config.js index 43a85fe6..3073ce80 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -39,6 +39,10 @@ const rules = { 'preserve-caught-error': 'warn', }; +// ECMA-262 §21.3.2: the Math functions whose results are "implementation-approximated". +const APPROXIMATED_MATH = ['acos', 'acosh', 'asin', 'asinh', 'atan', 'atanh', 'atan2', 'cbrt', 'cos', 'cosh', 'exp', + 'expm1', 'hypot', 'log', 'log1p', 'log10', 'log2', 'pow', 'sin', 'sinh', 'tan', 'tanh']; + const nodeFiles = [ 'server/**/*.js', 'shared/**/*.js', @@ -79,6 +83,20 @@ export default [ }, rules, }, + { + // The sim runs in every player's browser and on the server, and must give the same bits everywhere. ECMA-262 + // leaves these Math functions (and **) implementation-approximated: engines differ in the last bits. + files: ['server/sim/**/*.js'], + rules: { + 'no-restricted-properties': ['error', ...APPROXIMATED_MATH.map((property) => ({ + object: 'Math', property, message: 'implementation-approximated (engines differ in the last bits): use server/sim/detmath.js', + }))], + 'no-restricted-syntax': ['error', + { selector: "BinaryExpression[operator='**']", message: 'implementation-approximated: use powi from server/sim/detmath.js' }, + { selector: "AssignmentExpression[operator='**=']", message: 'implementation-approximated: use powi from server/sim/detmath.js' }, + ], + }, + }, { // Node test runner, plus browser tests whose page.evaluate callbacks use DOM globals. // PIXI is the page global those render tests read inside evaluate(). diff --git a/jsconfig.json b/jsconfig.json index a8f02f2c..aa30a313 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -18,6 +18,7 @@ "include": [ "shared/**/*.js", "server/sim/rng.js", + "server/sim/detmath.js", "server/sim/dir.js", "server/sim/constants.js", "types/**/*.js" diff --git a/server/sim/ai.js b/server/sim/ai.js index c0fb8750..c9a9f0eb 100644 --- a/server/sim/ai.js +++ b/server/sim/ai.js @@ -38,6 +38,7 @@ import { sortEnemyTargets, sortAllyTargets, canTargetEnemy, canTargetAlly, tileK import { reduceElement } from './damage.js'; import { straightClear } from './grid.js'; import { moveFeared, endFear } from './fear.js'; +import { hypot, powi } from './detmath.js'; // --------------------------------------------------------------------------------------------------------------- // profiles @@ -325,13 +326,13 @@ export function resolveHit(b, u, prof, target, info, x, y) { let best = null, bd = Infinity; for (const e of b.enemiesInRadius(prev.x, prev.y, prof.chain.radius || CHAIN_RADIUS)) { if (hit.has(e.id) || !canTargetEnemy(u, e, prof)) continue; - const d = Math.hypot(e.x - prev.x, e.y - prev.y); + const d = hypot(e.x - prev.x, e.y - prev.y); if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; } } if (!best) break; hit.add(best.id); b._ev(['atk', prev.id, best.id, 'chain']); - const d = b.dealDamage(u, best, { amount: atk * scale * skillMul * Math.pow(1 - (prof.chain.falloff ?? 0.15), k), type: baseType, isAttack: true, isSkill: info.isSkill, tags: ['chain'], attackId }); + const d = b.dealDamage(u, best, { amount: atk * scale * skillMul * powi(1 - (prof.chain.falloff ?? 0.15), k), type: baseType, isAttack: true, isSkill: info.isSkill, tags: ['chain'], attackId }); dealtTotal += d; if (prof.chain.sluggish && best.alive) b.applyStatus(best, 'sluggish', { duration: prof.chain.sluggish, source: u }); if (each) each(best, d, 'chain'); @@ -389,7 +390,7 @@ function doHeal(b, u, prof, t) { if (!best) break; seen.add(best.id); b._ev(['atk', prev.id, best.id, 'chainHeal']); - b.heal(u, best, amount * Math.pow(1 - (h.falloff ?? 0.25), k)); + b.heal(u, best, amount * powi(1 - (h.falloff ?? 0.25), k)); prev = best; } } @@ -475,7 +476,7 @@ function planLeg(b, e, leg) { R.version = b.grid.version; // suffix lengths for remaining-distance queries const suf = new Float64Array(pts.length); - for (let i = pts.length - 2; i >= 0; i--) suf[i] = suf[i + 1] + Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y); + for (let i = pts.length - 2; i >= 0; i--) suf[i] = suf[i + 1] + hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y); R.suffix = suf; } @@ -497,7 +498,7 @@ function tailLength(b, e, idx) { let len = 0; if (L.t === 'move') { const [sr, sc] = starts[i]; - len = Math.hypot(L.r - sr, L.c - sc); + len = hypot(L.r - sr, L.c - sc); if (e.motion !== 'FLY' && b.grid.inBounds(sr, sc)) { const f = legField(b, L.r, L.c, sr * COLS + sc); const fl = f ? b.grid.fieldLength(f, sr * COLS + sc) : Infinity; @@ -519,9 +520,9 @@ export function remainingDistance(b, e) { const leg = R.legs[R.legIdx]; if (leg && leg.t === 'move' && R.pts && R.ptIdx < R.pts.length) { const p = R.pts[R.ptIdx]; - d += Math.hypot(p.x - e.x, p.y - e.y) + R.suffix[R.ptIdx]; + d += hypot(p.x - e.x, p.y - e.y) + R.suffix[R.ptIdx]; } else if (leg && leg.t === 'move') { - d += Math.hypot(leg.c - e.x, leg.r - e.y); + d += hypot(leg.c - e.x, leg.r - e.y); } return d; } @@ -614,7 +615,7 @@ function moveAttracted(b, e, dt) { let moved = false; while (dist > 1e-9 && A.i < A.pts.length) { const p = A.pts[A.i]; - const dx = p.x - e.x, dy = p.y - e.y, d = Math.hypot(dx, dy); + const dx = p.x - e.x, dy = p.y - e.y, d = hypot(dx, dy); if (d <= dist) { e.x = p.x; e.y = p.y; dist -= d; A.i++; } else { e.x += (dx / d) * dist; e.y += (dy / d) * dist; dist = 0; } moved = true; } @@ -667,7 +668,7 @@ function advanceRoute(b, e, dt, R, standing = false) { if (R.ptIdx >= R.pts.length) break; const p = R.pts[R.ptIdx]; const dx = p.x - e.x, dy = p.y - e.y; - const d = Math.hypot(dx, dy); + const d = hypot(dx, dy); if (d <= dist) { e.x = p.x; e.y = p.y; dist -= d; @@ -848,7 +849,7 @@ function enemyAttack(b, e, prevCd) { if (!tt || !tt.alive || !e.alive && !rangedShot) return; b.dealDamage(e, tt, { amount: e.s.atk * (e.profile?.atkScale ?? 1), type, isAttack: true, attackId, isProjectile }); }; - if (rangedShot && Math.hypot(t.x - e.x, t.y - e.y) > 0.75) { + if (rangedShot && hypot(t.x - e.x, t.y - e.y) > 0.75) { b.addProjectile({ from: e, target: t, speed: PROJECTILE_SPEEDS.enemy, visual: 'enemy', source: e, onHit: (c) => hit(c.target, true) }); } else hit(t); } diff --git a/server/sim/battle/displacement.js b/server/sim/battle/displacement.js index 88d6d790..12a2cfc7 100644 --- a/server/sim/battle/displacement.js +++ b/server/sim/battle/displacement.js @@ -4,6 +4,7 @@ import { COLS, PUSH_TILES, PUSH_TILES_EFFECT, PULL_WEAK_SHARE, PULL_CRAWL, PULL_ORIGIN, PULL_STOP_RADIUS, PUSH_DIRECTIONAL_MIN_DIST } from '../constants.js'; import { fin } from './util.js'; +import { hypot } from '../detmath.js'; /** * Official push distance (tiles) of a 受力等级 (constants.js PUSH_TILES: ≤ −3 → 0, ≥ 3 → the 3 value); `effect` = a 特效 @@ -42,10 +43,10 @@ export class BattleDisplacement { if (!this._displaceable(e)) return 0; let level = this.forceLevel(e, force); const fx0 = fin(from?.x, e.x), fy0 = fin(from?.y, e.y); - const vx = e.x - fx0, vy = e.y - fy0, d = Math.hypot(vx, vy); + const vx = e.x - fx0, vy = e.y - fy0, d = hypot(vx, vy); let ux = 0, uy = 0; const dirX = dir ? fin(dir.x, 0) : 0, dirY = dir ? fin(dir.y, 0) : 0; - const dl = Math.hypot(dirX, dirY); + const dl = hypot(dirX, dirY); if (dl > 0) { ux = dirX / dl; uy = dirY / dl; if (from && !fixed && (d < PUSH_DIRECTIONAL_MIN_DIST || (!fixedAngle && vx * ux + vy * uy < d * Math.SQRT1_2))) { @@ -71,7 +72,7 @@ export class BattleDisplacement { // an enemy the puller itself blocks already stands in front of it (at contact) [ASSUMED: no pull, no unblocking] if (center && center.side === 'ally' && e.blockedBy === center) return 0; const tx = fin(to.x, e.x), ty = fin(to.y, e.y); - const dx = tx - e.x, dy = ty - e.y, d0 = Math.hypot(dx, dy); + const dx = tx - e.x, dy = ty - e.y, d0 = hypot(dx, dy); if (!(d0 > 1e-6)) return 0; const ux = dx / d0, uy = dy / d0; // travel until inside the stop circle around `center` (smaller root of |e + t·u − c| = stop), else up to `to` @@ -122,7 +123,7 @@ export class BattleDisplacement { displace(e, dir, distance) { if (!this._displaceable(e) || !dir) return 0; const dxv = fin(dir.x, 0), dyv = fin(dir.y, 0); - const len = Math.hypot(dxv, dyv); + const len = hypot(dxv, dyv); if (!(len > 0)) return 0; const eff = Math.min(fin(distance, 0), 2 * COLS); if (!(eff > 0)) return 0; diff --git a/server/sim/body.js b/server/sim/body.js index 52191434..6e65f3d0 100644 --- a/server/sim/body.js +++ b/server/sim/body.js @@ -24,6 +24,7 @@ // `hitRect`). import { ROWS, COLS } from './constants.js'; +import { hypot } from './detmath.js'; /** * Validated hit area of a data record (`{ w, h, dx, dy }`, tiles) or null for a regular (point) unit. @@ -83,10 +84,10 @@ export function bodyInKeys(u, keys) { /** Distance (tiles) from point (x, y) to the unit's body: its position, or its rectangle (0 inside). */ export function bodyDist(u, x, y) { const R = hitRect(u); - if (!R) return Math.hypot(u.x - x, u.y - y); + if (!R) return hypot(u.x - x, u.y - y); const dx = x < R.x0 ? R.x0 - x : x > R.x1 ? x - R.x1 : 0; const dy = y < R.y0 ? R.y0 - y : y > R.y1 ? y - R.y1 : 0; - return Math.hypot(dx, dy); + return hypot(dx, dy); } /** Is the unit's body within `r` tiles of point (x, y)? (the same ≤ r + 1e-9 test as Battle.enemiesInRadius) */ diff --git a/server/sim/buffs.js b/server/sim/buffs.js index 86bca4b7..08744909 100644 --- a/server/sim/buffs.js +++ b/server/sim/buffs.js @@ -8,6 +8,7 @@ // Additive mod keys scale with stacks (value × stacks); *Mul keys multiply (value ^ stacks). import { COLD_ASPD, COLD_FREEZE_DURATION, FREEZE_RES_DOWN, RESIST_DEFAULT, RESIST_PALSY_DECAY } from './constants.js'; +import { powi } from './detmath.js'; /** 抵抗: "麻痹等状态每5秒流失1层" — tick of the resist buff. */ function resistPalsyDecay({ battle, unit }) { @@ -200,12 +201,12 @@ export function aggregateMods(buffs) { const v = m[k]; if (typeof v !== 'number' || !Number.isFinite(v)) continue; if (k === 'shield') continue; - if (k.endsWith('Mul')) mul[k] = (mul[k] ?? 1) * (st === 1 ? v : Math.pow(v, st)); + if (k.endsWith('Mul')) mul[k] = (mul[k] ?? 1) * (st === 1 ? v : powi(v, st)); else if ((k === 'dodgePhys' || k === 'dodgeArts') && v > 0) { const p = Math.min(1, v); const d = dodge[k]; - if (!d) dodge[k] = { n: st, p, miss: Math.pow(1 - p, st) }; - else { d.n += st; d.miss *= Math.pow(1 - p, st); } + if (!d) dodge[k] = { n: st, p, miss: powi(1 - p, st) }; + else { d.n += st; d.miss *= powi(1 - p, st); } } else { add[k] = (add[k] ?? 0) + v * st; if (k === 'rangeExtend' && b.persist && b.duration === Infinity) permRangeExtend += v * st; diff --git a/server/sim/content/bosses.js b/server/sim/content/bosses.js index 56eecc4d..eb3cd4be 100644 --- a/server/sim/content/bosses.js +++ b/server/sim/content/bosses.js @@ -75,6 +75,7 @@ import { ensureInstalled, abOf, attach, T, elem, hurt, targetsNear, allTargets, byPriority, areaAllies, areaAlliesInTiles, fieldAllies, remainingRoute, stayRoute, stepToward, setHits, hitCount, lpLoss, blinkForward, canCast, absorbArts, nthOf, } from './enemies.js'; +import { hypot, powi } from '../detmath.js'; // --------------------------------------------------------------------------------------------------------------- // constants @@ -209,7 +210,7 @@ function rawRouteOf(b, e, tpl) { function toGoal(b, x, y, motion = 'WALK') { const ends = b.grid.specialTiles('end'); let best = ends[0] ?? [Math.round(y), Math.round(x)], bd = Infinity; - for (const p of ends) { const d = Math.hypot(p[0] - y, p[1] - x); if (d < bd) { bd = d; best = p; } } + for (const p of ends) { const d = hypot(p[0] - y, p[1] - x); if (d < bd) { bd = d; best = p; } } return { motion, start: [y, x], end: best, checkpoints: [] }; } @@ -318,7 +319,7 @@ function mirror(b, e, tpl) { const opsOnly = (list) => list.filter((u) => u.kind === 'op'); const nearestOf = (b, e, pred) => { let best = null, bd = Infinity; - for (const o of b.enemies) { if (!o.alive || o === e || !pred(o)) continue; const d = Math.hypot(o.x - e.x, o.y - e.y); if (d < bd) { bd = d; best = o; } } + for (const o of b.enemies) { if (!o.alive || o === e || !pred(o)) continue; const d = hypot(o.x - e.x, o.y - e.y); if (d < bd) { bd = d; best = o; } } return best; }; /** Apply a floor to an enemy whose data attack speed is 0 (skill-driven leaders: 铳/管/弦). */ @@ -370,7 +371,7 @@ function stunBlast(b, src, by, r, c, stun, dot, dur, kind) { function segDist(px, py, ax, ay, bx, by) { const dx = bx - ax, dy = by - ay, L = dx * dx + dy * dy; const t = L > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / L)) : 0; - return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); + return hypot(px - (ax + t * dx), py - (ay + t * dy)); } // --------------------------------------------------------------------------------------------------------------- @@ -708,7 +709,7 @@ function kitSpring(ab, e) { const d = e2.mem.ab.dash; if (!d) return; const g = d.gun && d.gun.alive ? d.gun : gun(b); - const arrived = !g || stepToward(e2, g.x, g.y, e2.s.moveSpeed * d.mul * MOVE_SCALE * dt) || Math.hypot(g.x - e2.x, g.y - e2.y) < 1; + const arrived = !g || stepToward(e2, g.x, g.y, e2.s.moveSpeed * d.mul * MOVE_SCALE * dt) || hypot(g.x - e2.x, g.y - e2.y) < 1; // 追逐模式 "对进入自身0.35半径范围内的我方单位(包括飞行单位)造成一次攻击力100%的物理普通伤害" (until 0.1.3: radius 0.5) for (const u of areaAllies(b, e2, e2.x, e2.y, CHARGE_RADIUS)) if (!d.hit.has(u)) { d.hit.add(u); hurt(b, e2, u, e2.s.atk, 'phys'); } if (arrived || b.time >= d.until) { e2.mem.ab.dash = null; b.removeBuff(e2, 'boss:dash'); if (e2.route) e2.route.pts = null; } @@ -730,9 +731,9 @@ function kitSpring(ab, e) { for (let k = 0; k < times && t; k++) { hit.add(t); b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'springBullet' }); - elem(b, e2, t, 'erosion', e2.s.atk * ratio * Math.pow(SPRING_BOUNCE_FALLOFF, k)); + elem(b, e2, t, 'erosion', e2.s.atk * ratio * powi(SPRING_BOUNCE_FALLOFF, k)); const prev = t; - t = areaAllies(b, e2, prev.x, prev.y, SPRING_BOUNCE_RANGE).filter((u) => !hit.has(u)).sort((p, q) => Math.hypot(p.x - prev.x, p.y - prev.y) - Math.hypot(q.x - prev.x, q.y - prev.y) || aggroCmp(p, q))[0]; + t = areaAllies(b, e2, prev.x, prev.y, SPRING_BOUNCE_RANGE).filter((u) => !hit.has(u)).sort((p, q) => hypot(p.x - prev.x, p.y - prev.y) - hypot(q.x - prev.x, q.y - prev.y) || aggroCmp(p, q))[0]; } } else { // 十连击 b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t0.id, kind: 'springCombo' }); diff --git a/server/sim/content/devices.js b/server/sim/content/devices.js index ebd0c344..84b57608 100644 --- a/server/sim/content/devices.js +++ b/server/sim/content/devices.js @@ -37,6 +37,7 @@ import { COLS, ROWS } from '../constants.js'; import { performAttack } from '../ai.js'; import { sortEnemyTargets } from '../targeting.js'; import { DIR_VEC, normDir, oppositeDir } from '../dir.js'; +import { hypot } from '../detmath.js'; const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d)); /** ASPD blackboard values: a fraction (|v| < 1, e.g. −0.6 / −0.05) is ×100 ASPD, otherwise flat (+20). */ @@ -192,7 +193,7 @@ function deviceTile(battle, r0, c0) { const exact = r === r0 && c === c0; if (!exact && battle.grid.tile(r, c).build !== 'NONE') continue; const walk = !exact && battle.grid.groundPassable(r, c, true) ? 1 : 0; - const s = [exact ? 0 : 1, walk, Math.hypot(r - r0, c - c0), r * COLS + c]; + const s = [exact ? 0 : 1, walk, hypot(r - r0, c - c0), r * COLS + c]; let less = !bs; if (!less) for (let i = 0; i < s.length; i++) { if (s[i] < bs[i] - 1e-9) { less = true; break; } if (s[i] > bs[i] + 1e-9) break; } if (less) { best = [r, c]; bs = s; } @@ -455,7 +456,7 @@ function tickAirflowEnemy(battle, st, e) { const r = Math.round(e.y), c = Math.round(e.x); const f = r >= 0 && r < ROWS && c >= 0 && c < COLS ? st.flow.get(r * COLS + c) : null; const vx = e.x - px, vy = e.y - py; - const v = Math.hypot(vx, vy); + const v = hypot(vx, vy); if (f && v > 1e-6) { const rel = airflowRelation((vx * f.fx + vy * f.fy) / v); if (rel !== 'vertical') mul = 1 + num(f.bb[`blower_s_enemy[${rel}].move_speed`], 0); diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index ed568ca6..1ce33f4d 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -116,6 +116,7 @@ import { REFLECTION_KITS } from './enemies/reflection.js'; import { FLY_KITS } from './enemies/fly.js'; import { SPECIAL_KITS } from './enemies/special.js'; import { LEADER_KITS } from './enemies/leaders.js'; +import { hypot } from '../detmath.js'; export { EROSION, EROSION_BURST, HUSK_REBIRTH, nthOf, abOf, attach, T, silenced, canCast, elem, hurt, alliesInTiles, @@ -312,7 +313,7 @@ function chaliceShare(b, c) { for (const o of b.enemies) { if (!o.alive || o === t || o.defId !== 'enemy_1430_lrrook' || !o.mem.ab) continue; const a = o.mem.ab.list[0]; - if (!a || !(a.share > 0) || Math.hypot(o.x - t.x, o.y - t.y) > a.r) continue; + if (!a || !(a.share > 0) || hypot(o.x - t.x, o.y - t.y) > a.r) continue; const part = c.dmg.amount * a.share; c.dmg.amount -= part; // a 无来源 burst's share stays 无来源, credited like the burst (damage.js) diff --git a/server/sim/content/enemies/archetypes.js b/server/sim/content/enemies/archetypes.js index 028487d0..19185942 100644 --- a/server/sim/content/enemies/archetypes.js +++ b/server/sim/content/enemies/archetypes.js @@ -8,6 +8,7 @@ import { HUSK_REBIRTH, BOOM_RADIUS, POLLUTION_INTERVAL, STEALTH_RESTORE_BY_KEY, DUCK_STEALTH_RESTORE, nthOf, stOf, safe, num, T, elem, hurt, areaAllies, areaAlliesInTiles, zone, spawnChildren, stepToward, setHits, hitCount, setForm, absorbArts, auraBuff, } from './helpers.js'; +import { hypot } from '../../detmath.js'; // --------------------------------------------------------------------------------------------------------------- // archetypes @@ -105,7 +106,7 @@ function pathKeysAhead(e) { if (!R || !Array.isArray(R.pts)) return keys; let x = e.x, y = e.y; for (let i = R.ptIdx ?? 0; i < R.pts.length; i++) { - const p = R.pts[i], n = Math.max(1, Math.ceil(Math.hypot(p.x - x, p.y - y) * 4)); + const p = R.pts[i], n = Math.max(1, Math.ceil(hypot(p.x - x, p.y - y) * 4)); for (let k = 1; k <= n; k++) keys.add(Math.round(y + ((p.y - y) * k) / n) * COLS + Math.round(x + ((p.x - x) * k) / n)); x = p.x; y = p.y; } @@ -405,7 +406,7 @@ function unbalanced(onMove) { a.px = e.x; a.py = e.y; a.hid = e.hidden; if (px == null || hid || e.hidden) return; const own = e.s.moveSpeed * MOVE_SCALE * dt * 1.5 + 1e-3; - const extra = Math.hypot(e.x - px, e.y - py) - own; + const extra = hypot(e.x - px, e.y - py) - own; a.lx = px; a.ly = py; // where the move started (direction for onMove) if (extra > 0.05) onMove(b, e, a, extra); }, @@ -605,7 +606,7 @@ export function blinkForward(b, e, dist) { let i = R.ptIdx; while (left > 1e-9 && i < R.pts.length) { const p = R.pts[i]; - const d = Math.hypot(p.x - e.x, p.y - e.y); + const d = hypot(p.x - e.x, p.y - e.y); if (d <= left) { e.x = p.x; e.y = p.y; left -= d; i++; } else { stepToward(e, p.x, p.y, left); left = 0; } } } else if (R && R.legs && R.legs[R.legIdx] && R.legs[R.legIdx].r != null) { diff --git a/server/sim/content/enemies/fly.js b/server/sim/content/enemies/fly.js index f90e917c..03d9ab6b 100644 --- a/server/sim/content/enemies/fly.js +++ b/server/sim/content/enemies/fly.js @@ -7,6 +7,7 @@ import { stepToward, setForm, expose, } from './helpers.js'; import { enemyAura, allyAura, selfFear, skill, kitSelfFear } from './archetypes.js'; +import { hypot } from '../../detmath.js'; // --------------------------------------------------------------------------------------------------------------- // constants (numbers that exist nowhere in the data) @@ -62,7 +63,7 @@ function kitBlackCloud(ab, e) { const prey = (b, e2) => b.enemiesInRadius(e2.x, e2.y, grabR).filter((o) => isPrey(b, e2, o)); return [ skill(k, (b, e2) => { - const list = prey(b, e2).sort((p, q) => Math.hypot(p.x - e2.x, p.y - e2.y) - Math.hypot(q.x - e2.x, q.y - e2.y)).slice(0, GRAB_MAX_PREY); + const list = prey(b, e2).sort((p, q) => hypot(p.x - e2.x, p.y - e2.y) - hypot(q.x - e2.x, q.y - e2.y)).slice(0, GRAB_MAX_PREY); if (!list.length) return; b.addBuff(e2, { key: 'ab:grabbing', duration: dur, flags: { bind: true, noMove: true } }); // 技能持续4秒,期间持有束缚 b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: list[0].id, kind: 'devour' }); @@ -250,12 +251,12 @@ export const FLY_KITS = Object.freeze({ tick(b, e, a, dt) { if (a.t && !a.t.alive) { a.t = null; b.removeBuff(e, 'ab:dive'); } if (!a.t) { - const l = targetsNear(b, e, e.base.rangeRadius || 1).sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y)); + const l = targetsNear(b, e, e.base.rangeRadius || 1).sort((p, q) => hypot(p.x - e.x, p.y - e.y) - hypot(q.x - e.x, q.y - e.y)); if (!l.length) return; a.t = l[0]; b.addBuff(e, { key: 'ab:dive', flags: { noMove: true } }); } - if (!stepToward(e, a.t.x, a.t.y, Math.max(e.s.moveSpeed, 0.5) * MOVE_SCALE * dt) && Math.hypot(a.t.x - e.x, a.t.y - e.y) > 0.3) return; + if (!stepToward(e, a.t.x, a.t.y, Math.max(e.s.moveSpeed, 0.5) * MOVE_SCALE * dt) && hypot(a.t.x - e.x, a.t.y - e.y) > 0.3) return; hurt(b, e, a.t, e.s.atk, 'phys'); b.fx('explode', { x: e.x, y: e.y, r: 0.5, kind: 'seed' }); b.kill(e, null); diff --git a/server/sim/content/enemies/helpers.js b/server/sim/content/enemies/helpers.js index e23f3ebd..700f0e6d 100644 --- a/server/sim/content/enemies/helpers.js +++ b/server/sim/content/enemies/helpers.js @@ -6,6 +6,7 @@ import { ELEMENT } from '../../constants.js'; import { canTargetAlly, sortAllyTargets, areaSelectable, auraSelectable } from '../../targeting.js'; import { mitigate } from '../../damage.js'; +import { hypot } from '../../detmath.js'; // --------------------------------------------------------------------------------------------------------------- // constants (numbers that exist nowhere in the data) @@ -295,7 +296,7 @@ export function spawnChildren(b, parent, key, n, opts = {}) { /** Move a unit straight toward (tx, ty) by `dist` tiles. Returns true on arrival. */ export function stepToward(u, tx, ty, dist) { const dx = tx - u.x, dy = ty - u.y; - const d = Math.hypot(dx, dy); + const d = hypot(dx, dy); if (d <= dist + 1e-9) { u.x = tx; u.y = ty; return true; } u.x += (dx / d) * dist; u.y += (dy / d) * dist; diff --git a/server/sim/content/enemies/invisible.js b/server/sim/content/enemies/invisible.js index 7a9f242e..9e5fc7b5 100644 --- a/server/sim/content/enemies/invisible.js +++ b/server/sim/content/enemies/invisible.js @@ -4,6 +4,7 @@ import { canTargetAlly, enemyStealthed } from '../../targeting.js'; import { T, elem, hurt, targetsNear, byPriority, auraBuff } from './helpers.js'; import { stealth, onHitStatus, skill, kitStealth } from './archetypes.js'; +import { hypot } from '../../detmath.js'; // --------------------------------------------------------------------------------------------------------------- // constants (numbers that exist nowhere in the data) @@ -20,8 +21,8 @@ const CROSS_REACH = 6, CROSS_SKILL = 2.5, CROSS_CHARGE = 1.4; function kitCrossbow(ab) { const s = ab.sk.CrossAttack; - const aligned = (b, e) => b.allies().filter((u) => canTargetAlly(e, u, true) && (Math.abs(u.y - e.y) < 0.5 || Math.abs(u.x - e.x) < 0.5) && Math.hypot(u.x - e.x, u.y - e.y) <= CROSS_REACH); - const nearest = (b, e, l) => l.sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y))[0]; + const aligned = (b, e) => b.allies().filter((u) => canTargetAlly(e, u, true) && (Math.abs(u.y - e.y) < 0.5 || Math.abs(u.x - e.x) < 0.5) && hypot(u.x - e.x, u.y - e.y) <= CROSS_REACH); + const nearest = (b, e, l) => l.sort((p, q) => hypot(p.x - e.x, p.y - e.y) - hypot(q.x - e.x, q.y - e.y))[0]; // PRTS 重弩突袭者: 天赋 "隐匿(被阻挡,主动攻击期间均可解除)"; 直击 "蓄力1.4s后向目标方向发射1支弩箭,对击中的首个目标造成攻击力100% // 的法术伤害与5s晕眩 ※技能持续2.5s": revealed for the whole skill, the bolt leaves CROSS_CHARGE s in — at the nearest unit still // in line in the aimed direction; it stands meanwhile [ASSUMED], and a stun / silence or its death before the release @@ -108,7 +109,7 @@ function kitShadowBlade(ab) { tick(b, e) { const partner = b.enemiesInRadius(e.x, e.y, 3).find((o) => /enemy_1174_duholy/.test(o.defId)); const r = partner && partner.mem.ab ? (T(partner.mem.ab, 'traitAbility.range_radius') ?? 1.1) : 1.1; - if (partner && Math.hypot(partner.x - e.x, partner.y - e.y) <= r + 1e-9 && e.base.bat > 0) auraBuff(b, e, 'ab:shadowSync', 0.5, { batPct: bat / e.base.bat }); + if (partner && hypot(partner.x - e.x, partner.y - e.y) <= r + 1e-9 && e.base.bat > 0) auraBuff(b, e, 'ab:shadowSync', 0.5, { batPct: bat / e.base.bat }); }, }]; } diff --git a/server/sim/content/enemies/leaders.js b/server/sim/content/enemies/leaders.js index 441794de..3a4fddb8 100644 --- a/server/sim/content/enemies/leaders.js +++ b/server/sim/content/enemies/leaders.js @@ -12,6 +12,7 @@ import { unblockable, maxTargets, onHitStatus, splashAttack, pathKeysAhead, noAirTargets, resist, nthAttackStatus, lowHpBuff, freeAllPrisoners, reborn, frontGuard, faceCrowd, unbalanced, artsBarrier, skill, blinkForward, } from './archetypes.js'; +import { hypot, powi } from '../../detmath.js'; // --------------------------------------------------------------------------------------------------------------- // constants (numbers that exist nowhere in the data) @@ -312,7 +313,7 @@ function kitLeaderMisc(key, ab, e) { const nx = areaAllies(b, e2, prev.x, prev.y, jr).find((u) => !hit.has(u)); if (!nx) break; hit.add(nx); - hurt(b, e2, nx, e2.s.atk * Math.pow(fall, k), 'arts'); + hurt(b, e2, nx, e2.s.atk * powi(fall, k), 'arts'); if (nx.alive && cold > 0) b.applyStatus(nx, 'cold', { duration: cold, source: e2 }); prev = nx; } @@ -323,7 +324,7 @@ function kitLeaderMisc(key, ab, e) { skill(cb, (b, e2) => { const bb = cb.bb, jump = bb.projectile_range ?? jr, max = bb['chain.max_target'] ?? 3; const near = (x, y, r, seen) => b.enemiesInRadius(x, y, r).filter((o) => o !== e2 && !seen.has(o)) - .sort((p, q) => Math.hypot(p.x - x, p.y - y) - Math.hypot(q.x - x, q.y - y) || p.spawnSeq - q.spawnSeq)[0]; + .sort((p, q) => hypot(p.x - x, p.y - y) - hypot(q.x - x, q.y - y) || p.spawnSeq - q.spawnSeq)[0]; const seen = new Set(); let prev = e2, cur = near(e2.x, e2.y, e2.base.rangeRadius || 3.5, seen); while (cur && seen.size < max) { @@ -357,7 +358,7 @@ function kitLeaderMisc(key, ab, e) { function segDist(px, py, ax, ay, bx, by) { const dx = bx - ax, dy = by - ay, L = dx * dx + dy * dy; const t = L > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / L)) : 0; - return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); + return hypot(px - (ax + t * dx), py - (ay + t * dy)); } /** A second normal hit on the same target ("二连击"): same damage type, no further on-hit procs. */ @@ -454,7 +455,7 @@ function kitWarden(ab) { function kitXi(ab) { const cross = ab.sk.CrossAttack, sb = ab.sk.ShieldBurst, sb2 = ab.sk.ShieldBurstReborn; const P = { form2: false }; - const byDist = (b, e) => allTargets(b, e).sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y) || aggroCmp(p, q)); + const byDist = (b, e) => allTargets(b, e).sort((p, q) => hypot(p.x - e.x, p.y - e.y) - hypot(q.x - e.x, q.y - e.y) || aggroCmp(p, q)); const crossAt = (b, e, t) => { const r0 = t.tileR, c0 = t.tileC; b.fx('telegraph', { x: c0, y: r0, r: XI_CROSS_REACH, kind: 'xiCross', tiles: 'plus', id: e.id }); @@ -783,7 +784,7 @@ export const LEADER_KITS = Object.freeze({ if (st > 0) b.applyStatus(e, 'stun', { duration: st, source: null }); })], enemy_10138_xdsnow: (ab) => [unbalanced((b, e, a) => { // 雪孩子 · pushed / pulled into high ground ⇒ hitWall.value damage - const dx = e.x - a.lx, dy = e.y - a.ly, d = Math.hypot(dx, dy); + const dx = e.x - a.lx, dy = e.y - a.ly, d = hypot(dx, dy); if (!(d > 0)) return; const r = Math.round(e.y + (dy / d) * 0.6), c = Math.round(e.x + (dx / d) * 0.6); if (b.grid.isLow(r, c) && b.grid.groundPassable(r, c)) return; // stopped by nothing: no collision diff --git a/server/sim/content/enemies/special.js b/server/sim/content/enemies/special.js index e14efd6c..0a64f969 100644 --- a/server/sim/content/enemies/special.js +++ b/server/sim/content/enemies/special.js @@ -12,6 +12,7 @@ import { unblockable, runWhenHit, blockWeight, taunt, maxTargets, ep, nthAttackPower, lowHpBuff, deathBoom, setFloat, float, reborn, frontGuard, faceMove, unbalanced, skill, kitEp, kitPrisoner, kitStun3, } from './archetypes.js'; +import { hypot } from '../../detmath.js'; // --------------------------------------------------------------------------------------------------------------- // constants (numbers that exist nowhere in the data) @@ -245,7 +246,7 @@ export const SPECIAL_KITS = Object.freeze({ enemy_1183_mlasrt: (ab) => [ep('erosion', T(ab, 'EpDamage.attack@ep_damage_ratio') ?? 0), nthAttackPower(nthOf(ab.sk.PowerAttack), (ab.sk.PowerAttack && ab.sk.PowerAttack.bb.atk_scale) || 1)], // 无胄盟清扫小队 · erosion; every 4th attack ×1.5 enemy_1273_stmgun_2: (ab) => { // 高准度伦蒂尼姆城防自行炮 · locks the highest max-HP unit in range, bombards the highest HP% on its 9 tiles // the allies in its range (the engine's ranged reach: radius + the ally collider) - const inRange = (b, e) => allTargets(b, e).filter((a) => Math.hypot(a.x - e.x, a.y - e.y) <= (e.base.rangeRadius || 0) + ALLY_COLLIDER_RADIUS + 1e-9); + const inRange = (b, e) => allTargets(b, e).filter((a) => hypot(a.x - e.x, a.y - e.y) <= (e.base.rangeRadius || 0) + ALLY_COLLIDER_RADIUS + 1e-9); return [skill(ab.sk.Cannon, (b, e) => { const lock = inRange(b, e).sort((p, q) => q.s.maxHp - p.s.maxHp)[0]; if (!lock) return; @@ -279,7 +280,7 @@ export const SPECIAL_KITS = Object.freeze({ spawn(b, e) { watchDeaths(b, e); }, otherDeath(c, b, e) { const u = c.unit; - if (!u || u === e || c.reason !== 'killed' || Math.hypot(u.x - e.x, u.y - e.y) > (T(ab, 'Attack.range_radius') ?? TORTURER_RADIUS) + 1e-9) return; + if (!u || u === e || c.reason !== 'killed' || hypot(u.x - e.x, u.y - e.y) > (T(ab, 'Attack.range_radius') ?? TORTURER_RADIUS) + 1e-9) return; b.heal(e, e, e.s.maxHp * (T(ab, 'Attack.hp_ratio') ?? 0), { self: true }); b.addBuff(e, { key: 'ab:torture', refresh: 'stack', stacks: 1, maxStacks: T(ab, 'Attack.max_stack_cnt') ?? 1, persist: true, mods: { atkPct: T(ab, 'Attack.atk') ?? 0 } }); }, diff --git a/server/sim/content/items/battle.js b/server/sim/content/items/battle.js index e88da4e8..9cda94b7 100644 --- a/server/sim/content/items/battle.js +++ b/server/sim/content/items/battle.js @@ -47,6 +47,7 @@ import { alliesAround, passiveBuff, fxOn, battleStore, contentInfo, itemsOf, directMods, } from '../support/index.js'; import { mitigate, hasHp, periodicDamage, isHpLoss } from '../../damage.js'; +import { hypot } from '../../detmath.js'; // ===================================================================================================================== // data helpers @@ -465,7 +466,7 @@ const BY_ITEM = { const r = num(p.radius, 3), sc = num(p.damage_scale, 1); S.on('hit', (c) => { if (c.source !== u || !c.target || c.target.side !== 'enemy' || c.dmg.type === 'element') return; - if (Math.hypot(c.target.x - u.x, c.target.y - u.y) >= r - 1e-6) c.dmg.mul *= sc; + if (hypot(c.target.x - u.x, c.target.y - u.y) >= r - 1e-6) c.dmg.mul *= sc; }); }, // 炎国短刀: each skill activation +atk (≤ atk_buff_cnt stacks) — one 直接乘算 bonus of atk × stacks diff --git a/server/sim/content/kits/ops/chess_char_2_15-akkord.js b/server/sim/content/kits/ops/chess_char_2_15-akkord.js index c78f3997..174c143b 100644 --- a/server/sim/content/kits/ops/chess_char_2_15-akkord.js +++ b/server/sim/content/kits/ops/chess_char_2_15-akkord.js @@ -2,6 +2,7 @@ // Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. import { num, talentBb, traitBb, onHitBy, alliesInGridOf, toggleBuff } from '../shared/tier1.js'; +import { hypot } from '../../../detmath.js'; export default { // --------------------------------------------------------------------------------------------------------------- @@ -33,7 +34,7 @@ export default { const mn = num(tb.min_dist), mx = num(tb.max_dist, 4), ds = num(tb.damage_scale); onHitBy(battle, unit, ({ target, dmg }) => { if (!dmg.isAttack) return; - const d = Math.hypot(target.x - unit.x, target.y - unit.y); + const d = hypot(target.x - unit.x, target.y - unit.y); dmg.amount *= 1 + ds * (mx > mn ? Math.max(0, Math.min(1, (d - mn) / (mx - mn))) : 1); }); } diff --git a/server/sim/content/kits/ops/chess_char_3_04-swire2.js b/server/sim/content/kits/ops/chess_char_3_04-swire2.js index 5004c3b5..0b137b44 100644 --- a/server/sim/content/kits/ops/chess_char_3_04-swire2.js +++ b/server/sim/content/kits/ops/chess_char_3_04-swire2.js @@ -7,6 +7,7 @@ import { num, defOf, talentBb, moduleTalentBb, selectedId, altSkills, alive, gridKeys, fx, copyGrid, textNum, freeTile, groundTile, enemiesOn, } from '../shared/tier3.js'; +import { powi } from '../../../detmath.js'; /** 琳琅诗怀雅 S3's coin range (PRTS 备注 "前方范围2-4"; range_table "2-4", facing right). */ const SWIRE2_COIN_GRID = Object.freeze([[1, 1], [0, 0], [0, 1], [0, 2], [-1, 1]]); @@ -267,7 +268,7 @@ export default { battle.on('fatal', (ctx) => { if (ctx.unit !== unit || ctx.prevented) return; const n = unit.mem.saveCount ?? 0; - const cost = Math.abs(num(t1.cost, -5)) * Math.pow(num(t1.cost_multi, 2), n); + const cost = Math.abs(num(t1.cost, -5)) * powi(num(t1.cost_multi, 2), n); const pl = battle.getPlayer(unit.ownerId); if (!pl || pl.dp + 1e-9 < cost) return; battle.addDp(unit.ownerId, -cost); diff --git a/server/sim/content/kits/ops/chess_char_3_07-forcer.js b/server/sim/content/kits/ops/chess_char_3_07-forcer.js index a3183016..4b84b175 100644 --- a/server/sim/content/kits/ops/chess_char_3_07-forcer.js +++ b/server/sim/content/kits/ops/chess_char_3_07-forcer.js @@ -3,6 +3,7 @@ import { PUSH_DIRECTIONAL_MIN_DIST } from '../../../constants.js'; import { num, defOf, talentBb, traitBb, onTiles, gridKeys, fx, copyGrid } from '../shared/tier3.js'; +import { hypot } from '../../../detmath.js'; /** Two enemy bodies touch within this distance (tiles) — 见行者 collision stun. */ const COLLIDE = 0.6; @@ -33,7 +34,7 @@ export default { // (only the angle: a target nearer than 0.25 tile still turns radial at 受力等级 −2) — by the official // 力度 − 重量 distance of a 特效 push (PRTS 推与拉 names 见行者's skills 特效类; Battle.push / pushDistance); // stopped short of it ⇒ it hit a wall - const near = Math.hypot(e.x - unit.x, e.y - unit.y) < PUSH_DIRECTIONAL_MIN_DIST; + const near = hypot(e.x - unit.x, e.y - unit.y) < PUSH_DIRECTIONAL_MIN_DIST; const expect = battle.pushDistance(e, near ? force - 2 : force, { effect: true }); const moved = battle.push(e, force, { from: unit, dir: { x: unit.fwd[1], y: unit.fwd[0] }, fixedAngle: true, effect: true }); const wall = expect > 0 && moved + 0.05 < expect; diff --git a/server/sim/content/kits/ops/chess_char_4_01-rmixer.js b/server/sim/content/kits/ops/chess_char_4_01-rmixer.js index 21564fc2..03e2d796 100644 --- a/server/sim/content/kits/ops/chess_char_4_01-rmixer.js +++ b/server/sim/content/kits/ops/chess_char_4_01-rmixer.js @@ -7,6 +7,7 @@ import { AURA, num, tbb, grid, nationOf, enemiesOnRange, targetsInRange, whileDeployed, reveal, skillActive, isSel, alt, instantKind, withDefaults, } from '../shared/tier4.js'; +import { hypot } from '../../../detmath.js'; /** Is module `id` the active one? (elites; `'none'` / normal chess ⇒ false) */ const moduleIs = (def, id) => !!(def?.raw?.module?.active && def.raw.module.id === id); @@ -39,7 +40,7 @@ export default withDefaults({ const n = num(bb.charge, 1); const cand = battle.alliesInRadius(unit.x, unit.y, 1.5, unit.ownerId) .filter((a) => a !== unit && a.kind === 'op' && nationOf(a) === 'laterano' && a.skill && a.skill.active && a.skill.kind === 'ammo') - .sort((a, b) => Math.hypot(a.x - unit.x, a.y - unit.y) - Math.hypot(b.x - unit.x, b.y - unit.y) || a.id - b.id); + .sort((a, b) => hypot(a.x - unit.x, a.y - unit.y) - hypot(b.x - unit.x, b.y - unit.y) || a.id - b.id); if (!cand[0] || !(n > 0)) return; cand[0].skill.addAmmo(n); battle.fx('reload', { x: cand[0].x, y: cand[0].y, id: cand[0].id, n }); diff --git a/server/sim/content/kits/ops/chess_char_4_04-ines.js b/server/sim/content/kits/ops/chess_char_4_04-ines.js index 1570baa0..f2055310 100644 --- a/server/sim/content/kits/ops/chess_char_4_04-ines.js +++ b/server/sim/content/kits/ops/chess_char_4_04-ines.js @@ -49,7 +49,7 @@ export default withDefaults({ unit.mem.sentry = null; unit.mem.inesSentryAt = null; const ax = from.x, ay = from.y, bx = unit.x, by = unit.y; - const L2 = (bx - ax) ** 2 + (by - ay) ** 2; + const L2 = (bx - ax) * (bx - ax) + (by - ay) * (by - ay); const w = num(bb.projectile_range, 1.4); const hits = []; for (const e of battle.aliveEnemies()) { diff --git a/server/sim/content/kits/ops/chess_char_4_05-beewax.js b/server/sim/content/kits/ops/chess_char_4_05-beewax.js index f5552993..07d63a93 100644 --- a/server/sim/content/kits/ops/chess_char_4_05-beewax.js +++ b/server/sim/content/kits/ops/chess_char_4_05-beewax.js @@ -5,6 +5,7 @@ import { COLS } from '../../../constants.js'; import { num, tbb, grid, targetsInRange, targetsInGrid, toggleBuff, skillActive, withDefaults, } from '../shared/tier4.js'; +import { hypot } from '../../../detmath.js'; /** * Free tile for a summon/device: in the rect, standable, not reserved (Battle.isReservedTile: empty, no knocked-out @@ -39,7 +40,7 @@ export default withDefaults({ for (const k of unit.rangeKeys || []) { const r = (k / COLS) | 0, c = k % COLS; if (!freeTile(battle, r, c)) continue; - const d = Math.hypot(c - ref.x, r - ref.y) + (battle.grid.groundPassable(r, c) ? 0 : 5); + const d = hypot(c - ref.x, r - ref.y) + (battle.grid.groundPassable(r, c) ? 0 : 5); if (d < bd - 1e-9) { bd = d; best = [r, c]; } } if (!best) return; diff --git a/server/sim/content/kits/ops/chess_char_4_10-aroma.js b/server/sim/content/kits/ops/chess_char_4_10-aroma.js index 54006970..30eb73fa 100644 --- a/server/sim/content/kits/ops/chess_char_4_10-aroma.js +++ b/server/sim/content/kits/ops/chess_char_4_10-aroma.js @@ -3,6 +3,7 @@ import { bodyInKeys } from '../../../body.js'; import { num, tbb, keySet, skillActive, isSel, alt, instantKind, withDefaults } from '../shared/tier4.js'; +import { hypot } from '../../../detmath.js'; /** 阿罗玛's 非首次标记 (talent 起泡性能测试): one per enemy, shared by every 阿罗玛 (PRTS 备注). */ const AROMA_MARK = 'aroma:bubbled'; @@ -71,7 +72,7 @@ export default withDefaults({ const lo = num(tb.min_dist, 0), hi = num(tb.max_dist, 4); battle.on('hit', (c) => { if (c.source !== unit || !c.dmg.isAttack) return; - const d = Math.hypot(c.target.x - unit.x, c.target.y - unit.y); + const d = hypot(c.target.x - unit.x, c.target.y - unit.y); c.dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo))); }, { owner: unit }); } diff --git a/server/sim/content/kits/ops/chess_char_4_12-glady.js b/server/sim/content/kits/ops/chess_char_4_12-glady.js index 53b58e7e..98e7700c 100644 --- a/server/sim/content/kits/ops/chess_char_4_12-glady.js +++ b/server/sim/content/kits/ops/chess_char_4_12-glady.js @@ -6,6 +6,7 @@ import { AURA, num, tbb, moduleBb, grid, batFlat, enemyHasTag, targetsInRange, whileDeployed, pulse, alt, instantKind, pullToFront, withDefaults, } from '../shared/tier4.js'; +import { hypot } from '../../../detmath.js'; const ABYSSAL = new Set(['char_143_ghost', 'char_263_skadi', 'char_474_glady', 'char_4145_ulpia', 'char_1023_ghost2']); const isAbyssal = (u) => u?.def?.raw?.groupId === 'abyssal' || ABYSSAL.has(u?.def?.charId ?? u?.def?.raw?.charId); @@ -28,7 +29,7 @@ export default withDefaults({ }; // module HOK-Y: "向自身拖拽较远的敌人时力度提升一个等级" const farR = num(mb['skill@range_radius'], num(mb['attack@range_radius'], 0)), farF = num(mb['skill@delta_force'], num(mb['attack@delta_force'], 0)); - const selfForce = (unit, e, f) => (farR > 0 && farF > 0 && Math.hypot(e.x - unit.x, e.y - unit.y) > farR + 1e-9 ? f + farF : f); + const selfForce = (unit, e, f) => (farR > 0 && farF > 0 && hypot(e.x - unit.x, e.y - unit.y) > farR + 1e-9 ? f + farF : f); const pullSelf = (battle, unit, e, f) => { if (e && e.alive) dragDmg(battle, unit, e, pullToFront(battle, unit, e, selfForce(unit, e, f))); }; return { skills: alt(def, { diff --git a/server/sim/content/kits/ops/chess_char_4_20-fartth.js b/server/sim/content/kits/ops/chess_char_4_20-fartth.js index bdccdae5..83691ad5 100644 --- a/server/sim/content/kits/ops/chess_char_4_20-fartth.js +++ b/server/sim/content/kits/ops/chess_char_4_20-fartth.js @@ -6,6 +6,7 @@ import { bodyInKeys } from '../../../body.js'; import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js'; import { isHpLoss } from '../../../damage.js'; import { num, tbb, whileDeployed, toggleBuff, skillActive, isSel, alt, withDefaults } from '../shared/tier4.js'; +import { hypot } from '../../../detmath.js'; const LINE = Object.freeze(Array.from({ length: COLS }, (_, i) => Object.freeze([0, i]))); const tileKey = (u) => Math.round(u.y) * COLS + Math.round(u.x); @@ -83,7 +84,7 @@ export default withDefaults({ battle.on('hit', (c) => { if (c.source !== unit || !c.dmg.isAttack) return; if (S3 && skillActive(unit) && unit.baseRangeKeys && !bodyInKeys(c.target, unit.baseRangeKeys)) c.dmg.mul *= num(bb.damage_scale, 1.25); - if (ds > 0) { const d = Math.hypot(c.target.x - unit.x, c.target.y - unit.y); c.dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo))); } + if (ds > 0) { const d = hypot(c.target.x - unit.x, c.target.y - unit.y); c.dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo))); } }, { owner: unit }); }, }; diff --git a/server/sim/content/kits/ops/chess_char_5_09-cetsyr.js b/server/sim/content/kits/ops/chess_char_5_09-cetsyr.js index a165bc29..09052789 100644 --- a/server/sim/content/kits/ops/chess_char_5_09-cetsyr.js +++ b/server/sim/content/kits/ops/chess_char_5_09-cetsyr.js @@ -4,6 +4,7 @@ import { COLS } from '../../../constants.js'; import { rotateOffset } from '../../../dir.js'; import { AURA_IV, AURA_DUR, num, talent, traitBb, skillGrid, isOp, leaderOf, whileOn } from '../shared/tier5.js'; +import { hypot, sin, cos } from '../../../detmath.js'; /** 魔王 T1 微尘: collision radius of one mote (PRTS 备注 0.4); orbit radius / speed come from the talent bb. */ const MOTE_HIT_RADIUS = 0.4; @@ -82,11 +83,11 @@ export default { const ang = spd * t + (2 * Math.PI * k) / cnt; // the orbit turns with his direction: angle 0 = straight ahead, π/2 = his left hand const [fr, fc] = unit.fwd, [lr, lc] = rotateOffset(1, 0, unit.dir); - const mx = unit.x + orbit * (fc * Math.cos(ang) + lc * Math.sin(ang)), my = unit.y + orbit * (fr * Math.cos(ang) + lr * Math.sin(ang)); + const mx = unit.x + orbit * (fc * cos(ang) + lc * sin(ang)), my = unit.y + orbit * (fr * cos(ang) + lr * sin(ang)); let hit = null, hd = Infinity; for (const a of cands) { if (a.findBuff('cetsyr:mote')) continue; - const d = Math.hypot(a.x - mx, a.y - my); + const d = hypot(a.x - mx, a.y - my); if (d <= MOTE_HIT_RADIUS + 1e-9 && d < hd) { hd = d; hit = a; } } if (!hit) continue; diff --git a/server/sim/content/kits/ops/chess_char_5_15-thorn2.js b/server/sim/content/kits/ops/chess_char_5_15-thorn2.js index b0312bcd..5de800cd 100644 --- a/server/sim/content/kits/ops/chess_char_5_15-thorn2.js +++ b/server/sim/content/kits/ops/chess_char_5_15-thorn2.js @@ -8,6 +8,7 @@ import { AURA_IV, AURA_DUR, RING1, num, on, talent, skillGrid, maxCharges, mods, dist, isOp, selectedId, lazySkills, instantKind, whileOn, permBuff, } from '../shared/tier5.js'; +import { hypot } from '../../../detmath.js'; /** Straight-road length through each tile (max of the horizontal and vertical runs of ground-passable tiles). */ const ROAD_CACHE = new WeakMap(); @@ -134,7 +135,7 @@ export default { } const extra = battle.alliesInGrid(unit).some((a) => a !== unit && isOp(a)) ? Math.min(num(t0.projectile_extend), num(t0.projectile_extend_max, Infinity)) : 0; // it drifts away from her deployment tile ("移动方向始终为远离棘刺部署位置中心的方向") - const dx = t.x - unit.x, dy = t.y - unit.y, len = Math.hypot(dx, dy) || 1; + const dx = t.x - unit.x, dy = t.y - unit.y, len = hypot(dx, dy) || 1; // `key`: the fx re-sent every second as the unit drifts and grows is this one zone — the client updates it in // place instead of stacking a new layer each second (render/fx/zones.js; community report of 2026-10-06) const key = `thorn2:${unit.id}:${unit.mem.zoneSeq = (unit.mem.zoneSeq ?? 0) + 1}`; diff --git a/server/sim/content/kits/ops/chess_char_6_02-sbell2.js b/server/sim/content/kits/ops/chess_char_6_02-sbell2.js index 2bc0d456..b5c3f9a4 100644 --- a/server/sim/content/kits/ops/chess_char_6_02-sbell2.js +++ b/server/sim/content/kits/ops/chess_char_6_02-sbell2.js @@ -4,6 +4,7 @@ import { COLS } from '../../../constants.js'; import { bodyInKeys } from '../../../body.js'; import { num, tbb, moduleBb, live, keyOf, enemiesIn, isTok, instantKind, AROUND8, aura } from '../shared/tier6.js'; +import { hypot } from '../../../detmath.js'; // ------------------------------------------------------------------------------------------------------------------ // 圣聆初雪 chess_char_6_02 (阵法术师) — S3 群山俯首; 无垠的雪景; 圣山的祝福; module 千分之一的心 @@ -217,7 +218,7 @@ function lureGoal(battle, unit, e) { for (const [dr, dc] of AROUND8) { const r = unit.tileR + dr, c = unit.tileC + dc; if (!battle.grid.groundPassable(r, c)) continue; - const d = Math.hypot(e.x - c, e.y - r); + const d = hypot(e.x - c, e.y - r); if (d < bd - 1e-9) { bd = d; goal = [r, c]; } } return goal; diff --git a/server/sim/content/kits/ops/chess_char_6_05-pasngr.js b/server/sim/content/kits/ops/chess_char_6_05-pasngr.js index 5b3aa393..e0733833 100644 --- a/server/sim/content/kits/ops/chess_char_6_05-pasngr.js +++ b/server/sim/content/kits/ops/chess_char_6_05-pasngr.js @@ -5,6 +5,7 @@ import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js'; import { CHAIN_RADIUS } from '../../../constants.js'; import { bodyDist, bodyInKeys } from '../../../body.js'; import { num, bv, tbb, live, ANY, enemiesIn, batOf, N4, aura } from '../shared/tier6.js'; +import { powi } from '../../../detmath.js'; // ------------------------------------------------------------------------------------------------------------------ // 异客 chess_char_6_05 (链术师) — S3 辉煌裂片; 机理分析; 孤卒 @@ -25,7 +26,7 @@ function pasngr(bb, chess, def) { for (let i = 0; i < count && prev; i++) { hit.add(prev.id); battle.fx('lightning', { x: prev.x, y: prev.y, id: prev.id, src: unit.id }); - battle.dealDamage(unit, prev, { amount: unit.s.atk * scale * Math.pow(1 - num(ch.falloff, 0.15), i), type: 'arts', isSkill: true, isAttack: true, tags: ['skill', 'storm'] }); + battle.dealDamage(unit, prev, { amount: unit.s.atk * scale * powi(1 - num(ch.falloff, 0.15), i), type: 'arts', isSkill: true, isAttack: true, tags: ['skill', 'storm'] }); if (slug > 0 && prev.alive) battle.applyStatus(prev, 'sluggish', { duration: slug, source: unit }); let best = null, bd = Infinity; for (const x of battle.foesInRadius(prev.x, prev.y, ch.radius || CHAIN_RADIUS)) { diff --git a/server/sim/content/kits/ops/chess_char_6_18-whitw2.js b/server/sim/content/kits/ops/chess_char_6_18-whitw2.js index 057f6309..08efbb74 100644 --- a/server/sim/content/kits/ops/chess_char_6_18-whitw2.js +++ b/server/sim/content/kits/ops/chess_char_6_18-whitw2.js @@ -5,6 +5,7 @@ import { sortEnemyTargets, canTargetEnemy } from '../../../targeting.js'; import { rotateOffset } from '../../../dir.js'; import { bodyDist } from '../../../body.js'; import { num, tbb, hasBond, ANY, selectedSkill, skillGridOf, WHOLE_FIELD, bstate, aura } from '../shared/tier6.js'; +import { hypot, sin, cos, atan2 } from '../../../detmath.js'; // ------------------------------------------------------------------------------------------------------------------ // 荒芜拉普兰德 chess_char_6_18 (驭械术师) — S3 终幕·浩劫; 头狼; 叙拉古的荣幸 @@ -39,10 +40,10 @@ function whitw2(bb, chess, def) { // ① `k` drones leave her evenly spread, the first along her facing (row 0 is the bottom row: angles in the (col, row) // plane, counter-clockwise) const releaseDrones = (unit, k) => { - const a0 = Math.atan2(unit.fwd[0], unit.fwd[1]); + const a0 = atan2(unit.fwd[0], unit.fwd[1]); for (let i = 0; i < k; i++) { const a = a0 + (2 * Math.PI * i) / k; - unit.mem.drones.push({ x: unit.x, y: unit.y, hx: Math.cos(a), hy: Math.sin(a), v: WHITW2_SPREAD.v0, age: 0, phase: 'spread', t: null, cd: 0, rampId: null, ramp: 0, orbit: null }); + unit.mem.drones.push({ x: unit.x, y: unit.y, hx: cos(a), hy: sin(a), v: WHITW2_SPREAD.v0, age: 0, phase: 'spread', t: null, cd: 0, rampId: null, ramp: 0, orbit: null }); } }; // speed v → v + acc·dt (capped); the distance covered at the mean of the two (exact under constant acceleration) @@ -56,7 +57,7 @@ function whitw2(bb, chess, def) { let best = null, bd = Infinity, bh = Infinity; for (const e of battle.enemies) { if (!ok(e)) continue; - const de = Math.hypot(e.x - d.x, e.y - d.y), dh = Math.hypot(e.x - unit.x, e.y - unit.y); + const de = hypot(e.x - d.x, e.y - d.y), dh = hypot(e.x - unit.x, e.y - unit.y); if (de < bd - 1e-9 || (de <= bd + 1e-9 && dh < bh - 1e-9)) { best = e; bd = de; bh = dh; } } return best; @@ -65,9 +66,9 @@ function whitw2(bb, chess, def) { const circle = (d, dt) => { const { r, v } = WHITW2_ORBIT; if (!d.orbit) d.orbit = { cx: d.x - d.hy * r, cy: d.y + d.hx * r }; - const a = Math.atan2(d.y - d.orbit.cy, d.x - d.orbit.cx) + (v / r) * dt; - d.x = d.orbit.cx + r * Math.cos(a); d.y = d.orbit.cy + r * Math.sin(a); - d.hx = -Math.sin(a); d.hy = Math.cos(a); + const a = atan2(d.y - d.orbit.cy, d.x - d.orbit.cx) + (v / r) * dt; + d.x = d.orbit.cx + r * cos(a); d.y = d.orbit.cy + r * sin(a); + d.hx = -sin(a); d.hy = cos(a); }; // one tick of one drone: spread → (pick) → chase → on the target, attacking like a normal drone. [ASSUMED] its attack // clock runs all the time (one attack per interval of hers at most, whatever it chased in between) and its first hit @@ -103,7 +104,7 @@ function whitw2(bb, chess, def) { if (d.phase === 'chase') { const s = accelerate(d, WHITW2_CHASE, dt); if (bodyDist(t, d.x, d.y) > s + 1e-9) { - const dx = t.x - d.x, dy = t.y - d.y, L = Math.hypot(dx, dy); + const dx = t.x - d.x, dy = t.y - d.y, L = hypot(dx, dy); if (L > 1e-9) { d.hx = dx / L; d.hy = dy / L; } d.x += d.hx * s; d.y += d.hy * s; return; diff --git a/server/sim/content/kits/ops/op-aglna2.js b/server/sim/content/kits/ops/op-aglna2.js index 1ec707e7..d1534370 100644 --- a/server/sim/content/kits/ops/op-aglna2.js +++ b/server/sim/content/kits/ops/op-aglna2.js @@ -63,6 +63,7 @@ import { bodyInKeys } from '../../../body.js'; import { hasHp } from '../../../damage.js'; import { RESIST_STATUSES } from '../../../buffs.js'; import { TICK } from '../../../constants.js'; +import { hypot } from '../../../detmath.js'; const S1 = 'skchr_aglna2_1'; const S2 = 'skchr_aglna2_2'; @@ -342,7 +343,7 @@ export default { if (!st || !skillOn(unit, S3) || !up(unit) || st.flight || !unit.canAct || unit.blocking.length) return; const tgt = moveTarget(battle, unit); if (!tgt) return; - const dur = Math.hypot(tgt.r - unit.tileR, tgt.c - unit.tileC) / MOVE_SPEED; + const dur = hypot(tgt.r - unit.tileR, tgt.c - unit.tileC) / MOVE_SPEED; const act = unit.skill.activations; st.flight = tgt; battle.releaseBlocked(unit); diff --git a/server/sim/content/kits/ops/op-cqbw.js b/server/sim/content/kits/ops/op-cqbw.js index c3f73a88..1753b9a9 100644 --- a/server/sim/content/kits/ops/op-cqbw.js +++ b/server/sim/content/kits/ops/op-cqbw.js @@ -62,6 +62,7 @@ import { sortEnemyTargets, canTargetEnemy } from '../../../targeting.js'; import { bodyInKeys } from '../../../body.js'; import { hasHp, isHpLoss } from '../../../damage.js'; import { COLS, PROJECTILE_SPEEDS } from '../../../constants.js'; +import { hypot } from '../../../detmath.js'; const S1 = 'skchr_cqbw_1'; const S2 = 'skchr_cqbw_2'; @@ -133,7 +134,7 @@ function mineTile(battle, unit) { let best = [], bd = Infinity; for (const t of tiles) { let d = Infinity; - for (const e of foes) d = Math.min(d, Math.hypot(e.x - t[1], e.y - t[0])); + for (const e of foes) d = Math.min(d, hypot(e.x - t[1], e.y - t[0])); if (d < bd - 1e-9) { bd = d; best = [t]; } else if (Math.abs(d - bd) <= 1e-9) best.push(t); } return best.length === 1 ? best[0] : battle.rng.pick(best); diff --git a/server/sim/content/kits/ops/op-ebnhlz.js b/server/sim/content/kits/ops/op-ebnhlz.js index 6bbe699a..6ff9a79f 100644 --- a/server/sim/content/kits/ops/op-ebnhlz.js +++ b/server/sim/content/kits/ops/op-ebnhlz.js @@ -67,6 +67,7 @@ import { num, talentBb, moduleBb, traitBb, skillRec, batMod, up, toggleBuff } fr import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js'; import { hasHp } from '../../../damage.js'; import { COLS } from '../../../constants.js'; +import { hypot } from '../../../detmath.js'; const S1 = 'skchr_ebnhlz_1'; const S2 = 'skchr_ebnhlz_2'; @@ -116,7 +117,7 @@ function remnantTiles(battle, unit) { const rank = new Map(); foes.forEach((e, i) => { const k = Math.round(e.y) * COLS + Math.round(e.x); if (!rank.has(k)) rank.set(k, i); }); battle.rng.shuffle(tiles); // the random order of the last group, and of equal distances [ASSUMED] - const dist = (r, c) => { let d = Infinity; for (const e of foes) d = Math.min(d, Math.hypot(e.x - c, e.y - r)); return d; }; + const dist = (r, c) => { let d = Infinity; for (const e of foes) d = Math.min(d, hypot(e.x - c, e.y - r)); return d; }; const keyed = tiles.map(([r, c], i) => ({ r, c, i, on: rank.get(r * COLS + c) ?? Infinity, d: dist(r, c) })); keyed.sort((a, b) => a.on - b.on || a.d - b.d || a.i - b.i); return keyed.map((x) => [x.r, x.c]); diff --git a/server/sim/content/kits/ops/op-haak.js b/server/sim/content/kits/ops/op-haak.js index b4901146..921d923f 100644 --- a/server/sim/content/kits/ops/op-haak.js +++ b/server/sim/content/kits/ops/op-haak.js @@ -50,6 +50,7 @@ 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'; @@ -80,7 +81,7 @@ export function frontAlly(battle, unit, heal = false) { 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, Math.hypot(dr, dc), -(a.s.taunt || 0), -(a.aggroSeq || a.deploySeq || 0), a.id]; + 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; diff --git a/server/sim/content/kits/ops/op-haruka.js b/server/sim/content/kits/ops/op-haruka.js index 1457251d..eec091ea 100644 --- a/server/sim/content/kits/ops/op-haruka.js +++ b/server/sim/content/kits/ops/op-haruka.js @@ -50,6 +50,7 @@ import { num, up, traitBb, talentBb, skillRec, batMod, giveSp, protectMods } from '../shared/tier1.js'; import { absoluteRangeKeys } from '../../../targeting.js'; import { COLS } from '../../../constants.js'; +import { hypot } from '../../../detmath.js'; const S1 = 'skchr_haruka_1'; const S2 = 'skchr_haruka_2'; @@ -217,7 +218,7 @@ export default { if (c.source !== unit || c.opts?.regen || !skillOn(unit, S2) || !(c.amount > 0) || !(scale > 0) || !n) return; const t = c.target; const foes = battle.foesInRadius(t.x, t.y, r); - foes.sort((x, y) => Math.hypot(x.x - t.x, x.y - t.y) - Math.hypot(y.x - t.x, y.y - t.y) || x.spawnSeq - y.spawnSeq); + foes.sort((x, y) => hypot(x.x - t.x, x.y - t.y) - hypot(y.x - t.x, y.y - t.y) || x.spawnSeq - y.spawnSeq); const amount = c.amount * scale; for (const e of foes.slice(0, n)) battle.dealDamage(unit, e, { amount, type: 'arts', isSkill: true, tags: ['skill', 'haruka:s2'] }); if (foes.length) battle.fx('aoe', { x: t.x, y: t.y, radius: r, id: unit.id, skill: 'haruka:s2' }); diff --git a/server/sim/content/kits/ops/op-hsgma2.js b/server/sim/content/kits/ops/op-hsgma2.js index 0aaeb458..dcd1c29c 100644 --- a/server/sim/content/kits/ops/op-hsgma2.js +++ b/server/sim/content/kits/ops/op-hsgma2.js @@ -57,6 +57,7 @@ import { canTargetEnemy } from '../../../targeting.js'; import { bodyInRadius } from '../../../body.js'; import { hasHp } from '../../../damage.js'; import { COLS } from '../../../constants.js'; +import { sin, cos, atan2 } from '../../../detmath.js'; const S1 = 'skchr_hsgma2_1'; const S2 = 'skchr_hsgma2_2'; @@ -98,7 +99,7 @@ export default { /** S2: the shield's centre after `turned` degrees (row 0 is the bottom row: counter-clockwise = a growing angle). */ const shieldAt = (unit, sh) => { const a = sh.a0 + (sh.turned * Math.PI) / 180; - return { x: unit.x + SHIELD_START * Math.cos(a), y: unit.y + SHIELD_START * Math.sin(a) }; + return { x: unit.x + SHIELD_START * cos(a), y: unit.y + SHIELD_START * sin(a) }; }; const touching = (battle, unit, p) => battle.enemies.filter((e) => canTargetEnemy(unit, e, GROUND) && bodyInRadius(e, p.x, p.y, SHIELD_RADIUS)); /** S2: one shield pulse — 停顿, the arts hit, and her heal of heal_ratio × the HP it removed (ignoring her 禁疗). */ @@ -128,7 +129,7 @@ export default { if (!('hits' in s2Attack)) return; plainS2(); // the throw was this attack: the shield flies from now on const [fr, fc] = unit.fwd; - unit.mem.hsShield = { a0: Math.atan2(fr, fc), turned: 0, acc: 0 }; + unit.mem.hsShield = { a0: atan2(fr, fc), turned: 0, acc: 0 }; battle.fx('zone', { x: unit.x, y: unit.y, radius: SHIELD_START + SHIELD_RADIUS, dur: SHIELD_TURN / SHIELD_SPEED, id: unit.id, skill: 'hsgma2:shield' }); shieldPulse(battle, unit, shieldAt(unit, unit.mem.hsShield)); }, diff --git a/server/sim/content/kits/ops/op-ifrit.js b/server/sim/content/kits/ops/op-ifrit.js index 2b6b92df..65457df2 100644 --- a/server/sim/content/kits/ops/op-ifrit.js +++ b/server/sim/content/kits/ops/op-ifrit.js @@ -52,6 +52,7 @@ 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'; @@ -197,7 +198,7 @@ export default { 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 = Math.hypot(e.x - unit.x, e.y - unit.y); + 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 }); } diff --git a/server/sim/content/kits/ops/op-jesca2.js b/server/sim/content/kits/ops/op-jesca2.js index 8fe618bb..41f552b9 100644 --- a/server/sim/content/kits/ops/op-jesca2.js +++ b/server/sim/content/kits/ops/op-jesca2.js @@ -58,6 +58,7 @@ import { num, talentBb, traitBb, moduleOn, skillRec, batMod, up, giveSp, installReveal } from '../shared/tier1.js'; import { offsetTile, dirFromDelta, dirVec } from '../../../dir.js'; import { isHpLoss } from '../../../damage.js'; +import { hypot } from '../../../detmath.js'; const S1 = 'skchr_jesca2_1'; const S2 = 'skchr_jesca2_2'; @@ -182,7 +183,7 @@ function fireBomb(battle, unit, b3) { }; (unit.mem.jescaShells ??= []).push(bomb); unit.atkCd = unit.s.interval; // the shell is her attack: the timer restarts [ASSUMED] - battle.fx('bombardShell', { x: bomb.tx, y: bomb.ty, id: unit.id, r: bomb.radius, t: Math.hypot(bomb.tx - bomb.x, bomb.ty - bomb.y) / BOMB_SPEED }); + battle.fx('bombardShell', { x: bomb.tx, y: bomb.ty, id: unit.id, r: bomb.radius, t: hypot(bomb.tx - bomb.x, bomb.ty - bomb.y) / BOMB_SPEED }); } /** Move the shells one tick; a shell touching an enemy or reaching its end point explodes. */ @@ -192,7 +193,7 @@ function flyShells(battle, unit, dt) { const keep = []; for (const s of list) { const dx = s.tx - s.x, dy = s.ty - s.y; - const d = Math.hypot(dx, dy); + const d = hypot(dx, dy); const step = BOMB_SPEED * dt; let hit = battle.foesInRadius(s.x, s.y, BOMB_HIT_RADIUS).length > 0; if (!hit) { diff --git a/server/sim/content/kits/ops/op-kalts2.js b/server/sim/content/kits/ops/op-kalts2.js index b8419e75..2041934d 100644 --- a/server/sim/content/kits/ops/op-kalts2.js +++ b/server/sim/content/kits/ops/op-kalts2.js @@ -76,6 +76,7 @@ import { num, talentBb, skillRec, batMod, up } from '../shared/tier1.js'; import { releaseSkillSummon } from '../../tokens.js'; import { COLS } from '../../../constants.js'; +import { hypot } from '../../../detmath.js'; const S1 = 'skchr_kalts2_1'; const S2 = 'skchr_kalts2_2'; @@ -235,7 +236,7 @@ function startTransport(battle, unit, anchor) { }, { owner: unit }); } const from = { x: unit.x, y: unit.y }, to = { x: anchor.x, y: anchor.y }; - const T = Math.hypot(to.x - from.x, to.y - from.y) / TRANSPORT_SPEED; + const T = hypot(to.x - from.x, to.y - from.y) / TRANSPORT_SPEED; const f = { t0: battle.time, T, from, to, hit: new Set(), anchor, r: anchor.tileR, c: anchor.tileC, dir: anchor.dir }; unit.mem.k2flight = f; battle.releaseBlocked(unit); diff --git a/server/sim/content/kits/ops/op-mantra.js b/server/sim/content/kits/ops/op-mantra.js index 893e7396..285a6f58 100644 --- a/server/sim/content/kits/ops/op-mantra.js +++ b/server/sim/content/kits/ops/op-mantra.js @@ -46,6 +46,7 @@ import { hasHp } from '../../../damage.js'; import { canTargetEnemy, enemyStealthed } from '../../../targeting.js'; import { bodyInKeys } from '../../../body.js'; import { COLS, PALSY_MAX } from '../../../constants.js'; +import { hypot } from '../../../detmath.js'; const S1 = 'skchr_mantra_1'; const S2 = 'skchr_mantra_2'; @@ -91,7 +92,7 @@ function nearestFoe(battle, unit, x, y, r, skip) { let best = null, bd = Infinity; for (const e of battle.enemiesInRadius(x, y, r)) { if (skip.has(e) || !canTargetEnemy(unit, e, ANY)) continue; - const d = Math.hypot(e.x - x, e.y - y); + const d = hypot(e.x - x, e.y - y); if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; } } return best; diff --git a/server/sim/content/kits/ops/op-mcnist.js b/server/sim/content/kits/ops/op-mcnist.js index ea118724..31cd8bc2 100644 --- a/server/sim/content/kits/ops/op-mcnist.js +++ b/server/sim/content/kits/ops/op-mcnist.js @@ -58,6 +58,7 @@ import { num, skillRec, batMod, up } from '../shared/tier1.js'; import { COLS } from '../../../constants.js'; import { dirVec } from '../../../dir.js'; +import { hypot } from '../../../detmath.js'; const S1 = 'skchr_mcnist_1'; const S2 = 'skchr_mcnist_2'; @@ -197,7 +198,7 @@ function grafKit(owner, { life, t2, b2, b3, aura }) { function chargeGraf(battle, mcn, t, b3) { if (!up(t)) return; const { stop, land } = chargeEnd(battle, t); - const dist = Math.hypot(stop[0] - t.tileR, stop[1] - t.tileC); + const dist = hypot(stop[0] - t.tileR, stop[1] - t.tileC); t.mem.charging = true; battle.fx('charge', { x: t.x, y: t.y, id: t.id, tx: stop[1], ty: stop[0] }); battle.retreat(t, { reason: 'expired', permanent: true }); diff --git a/server/sim/content/kits/ops/op-narant.js b/server/sim/content/kits/ops/op-narant.js index 0695f09c..45316af9 100644 --- a/server/sim/content/kits/ops/op-narant.js +++ b/server/sim/content/kits/ops/op-narant.js @@ -42,6 +42,7 @@ import { num, talentBb, moduleBb, traitBb, skillRec, statBuff, toggleBuff, onHitBy, enemiesInGrid, cheb, up, giveSp } from '../shared/tier1.js'; import { canTargetEnemy } from '../../../targeting.js'; import { isHpLoss } from '../../../damage.js'; +import { hypot } from '../../../detmath.js'; const S1 = 'skchr_narant_1'; const S2 = 'skchr_narant_2'; @@ -138,7 +139,7 @@ export default { if (left <= 0 || !ctx.home()) { ctx.comeBack(x, y); return; } const cands = battle.foesInRadius(x, y, BOUNCE_RADIUS).filter((e) => e !== on && canTargetEnemy(unit, e, ctx.profile)); if (!cands.length) { ctx.comeBack(x, y); return; } - const d2 = (e) => (e.x - x) ** 2 + (e.y - y) ** 2; + const d2 = (e) => (e.x - x) * (e.x - x) + (e.y - y) * (e.y - y); cands.sort((a, b) => (hit.has(a) ? 1 : 0) - (hit.has(b) ? 1 : 0) || d2(a) - d2(b) || a.spawnSeq - b.spawnSeq); const tgt = cands[0]; left--; @@ -158,7 +159,7 @@ export default { const dash = (ctx) => { const { battle, unit } = ctx; let dx = ctx.x - unit.x, dy = ctx.y - unit.y; - const len = Math.hypot(dx, dy); + const len = hypot(dx, dy); if (len > 1e-9) { dx /= len; dy /= len; } else { const [fr, fc] = unit.fwd; dx = fc; dy = fr; } const ahead = DASH_SPEED * Math.max(0, num(b2['attack@move_ahead_time'], 0.5)); battle.addProjectile({ from: { x: ctx.x, y: ctx.y }, to: { x: ctx.x + dx * ahead, y: ctx.y + dy * ahead }, speed: DASH_SPEED, visual: 'boomerang', source: unit, hitDead: true, diff --git a/server/sim/content/kits/ops/op-phatm2.js b/server/sim/content/kits/ops/op-phatm2.js index deab16ea..8011d2b1 100644 --- a/server/sim/content/kits/ops/op-phatm2.js +++ b/server/sim/content/kits/ops/op-phatm2.js @@ -68,6 +68,7 @@ import { absoluteRangeKeys } from '../../../targeting.js'; import { bodyInKeys } from '../../../body.js'; import { hasHp, burstLocked } from '../../../damage.js'; import { MIN_DAMAGE_RATIO } from '../../../constants.js'; +import { hypot } from '../../../detmath.js'; const S1 = 'skchr_phatm2_1'; const S2 = 'skchr_phatm2_2'; @@ -134,7 +135,7 @@ function callKit(t, owner, b2) { const keys = keysAt(grid, tok); const cands = battle.enemiesInKeys([...keys], tok, ANY).filter((e) => !lured.includes(e) && !e.s.flags.attract && (e.motion === 'FLY' || !!battle.grid.waypoints(Math.round(e.y), Math.round(e.x), tok.tileR, tok.tileC))); - cands.sort((a, b) => (isElite(b) - isElite(a)) || (Math.hypot(a.x - tok.x, a.y - tok.y) - Math.hypot(b.x - tok.x, b.y - tok.y)) || a.spawnSeq - b.spawnSeq); + cands.sort((a, b) => (isElite(b) - isElite(a)) || (hypot(a.x - tok.x, a.y - tok.y) - hypot(b.x - tok.x, b.y - tok.y)) || a.spawnSeq - b.spawnSeq); for (const e of cands) { if (lured.length >= cap) break; if (battle.applyStatus(e, 'attract', { duration: Math.max(0.05, endAt - battle.time), source: tok, point: [tok.tileR, tok.tileC] })) { @@ -146,7 +147,7 @@ function callKit(t, owner, b2) { // the first lured enemy within `arrive` of it: it leaves battle.on('tick', () => { if (!up(tok)) return; - if (lured.some((e) => e.alive && mine(e) && Math.hypot(e.x - tok.x, e.y - tok.y) <= arrive + 1e-9)) battle.retreat(tok, { reason: 'expired', permanent: true }); + if (lured.some((e) => e.alive && mine(e) && hypot(e.x - tok.x, e.y - tok.y) <= arrive + 1e-9)) battle.retreat(tok, { reason: 'expired', permanent: true }); }, { owner: tok }); // leaving (any reason): 停顿 + the damage over time on every selectable enemy of its x-1; the 诱导 ends battle.on('death', (c) => { diff --git a/server/sim/content/kits/ops/op-phenxi.js b/server/sim/content/kits/ops/op-phenxi.js index c0530e75..f06c9805 100644 --- a/server/sim/content/kits/ops/op-phenxi.js +++ b/server/sim/content/kits/ops/op-phenxi.js @@ -56,6 +56,7 @@ import { num, talentBb, traitBb, moduleOn, skillRec, up, statBuff } from '../shared/tier1.js'; import { toLocal } from '../../../dir.js'; import { COLS, PROJECTILE_SPEEDS } from '../../../constants.js'; +import { hypot } from '../../../detmath.js'; const S1 = 'skchr_phenxi_1'; const S2 = 'skchr_phenxi_2'; @@ -136,7 +137,7 @@ function s3Shot(battle, unit, b3) { battle.addProjectile({ from: unit, to: { x, y }, speed: S3_SPEED, visual: 'bomb', source: unit, onHit: () => { battle.fx('aoe', { x, y, radius: S3_RADIUS, id: unit.id, skill: 'phenxi:s3' }); for (const e of battle.foesInRadius(x, y, S3_RADIUS, true)) { - const sc = Math.hypot(e.x - x, e.y - y) <= core + 1e-9 ? hi : lo; + const sc = hypot(e.x - x, e.y - y) <= core + 1e-9 ? hi : lo; battle.dealDamage(unit, e, { amount: unit.s.atk * sc * unit.s.atkScaleMul, type: 'phys', isAttack: true, isSkill: true, isSplash: true, tags: ['skill', 'phenxi:s3'] }); } } }); diff --git a/server/sim/content/kits/ops/op-poca.js b/server/sim/content/kits/ops/op-poca.js index 90c01826..77c5dda9 100644 --- a/server/sim/content/kits/ops/op-poca.js +++ b/server/sim/content/kits/ops/op-poca.js @@ -36,6 +36,7 @@ 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'; @@ -161,7 +162,7 @@ export default { if (ds > 0) { onHitBy(battle, unit, ({ target, dmg }) => { if (!dmg.isAttack) return; - const d = Math.hypot(target.x - unit.x, target.y - unit.y); + 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))); }); } diff --git a/server/sim/content/kits/ops/op-radian.js b/server/sim/content/kits/ops/op-radian.js index 02b338ef..d3f7242d 100644 --- a/server/sim/content/kits/ops/op-radian.js +++ b/server/sim/content/kits/ops/op-radian.js @@ -64,6 +64,7 @@ import { acquireTargets } from '../../../ai.js'; import { aggregateMods } from '../../../buffs.js'; import { dirVec } from '../../../dir.js'; import { COLS } from '../../../constants.js'; +import { hypot } from '../../../detmath.js'; const S1 = 'skchr_radian_1'; const S2 = 'skchr_radian_2'; @@ -160,7 +161,7 @@ function flyBullets(battle, t, dt) { const hit = () => { const near = battle.foesInRadius(b.x, b.y, BULLET_RADIUS).filter((e) => e.alive && !e.isFlying); if (!near.length) return false; - near.sort((p, q) => Math.hypot(p.x - b.x, p.y - b.y) - Math.hypot(q.x - b.x, q.y - b.y) || p.spawnSeq - q.spawnSeq); + near.sort((p, q) => hypot(p.x - b.x, p.y - b.y) - hypot(q.x - b.x, q.y - b.y) || p.spawnSeq - q.spawnSeq); battle.dealDamage(t, near[0], { amount: b.atk, type: 'phys', isAttack: true, tags: [TAG_BULLET] }); return true; }; diff --git a/server/sim/content/kits/ops/op-ray.js b/server/sim/content/kits/ops/op-ray.js index 684d2d33..f32a0aaf 100644 --- a/server/sim/content/kits/ops/op-ray.js +++ b/server/sim/content/kits/ops/op-ray.js @@ -63,6 +63,7 @@ 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'; @@ -159,7 +160,7 @@ export default { 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 = Math.hypot(vx, vy); + 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 }); }, diff --git a/server/sim/content/kits/ops/op-sleach.js b/server/sim/content/kits/ops/op-sleach.js index c8ee9388..c555bf94 100644 --- a/server/sim/content/kits/ops/op-sleach.js +++ b/server/sim/content/kits/ops/op-sleach.js @@ -58,6 +58,7 @@ import { num, talentBb, moduleBb, traitBb, moduleOn, skillRec, up } from '../sha import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js'; import { offsetTile } from '../../../dir.js'; import { COLS } from '../../../constants.js'; +import { hypot } from '../../../detmath.js'; const S1 = 'skcom_assist_cost[3]'; const S2 = 'skchr_sleach_2'; @@ -150,7 +151,7 @@ function s3Tile(battle, unit, grid) { for (const k of keys) { const r = (k / COLS) | 0, c = k % COLS; if (!ground(r, c) || battle.unitAt(r, c)) continue; - const d = Math.hypot(c - top.x, r - top.y); + const d = hypot(c - top.x, r - top.y); if (d < bd - 1e-9) { bd = d; best = [r, c]; } } return best ?? [unit.tileR, unit.tileC]; diff --git a/server/sim/content/kits/ops/op-typhon.js b/server/sim/content/kits/ops/op-typhon.js index 48205bff..0ee71708 100644 --- a/server/sim/content/kits/ops/op-typhon.js +++ b/server/sim/content/kits/ops/op-typhon.js @@ -41,6 +41,7 @@ import { num, talentBb, moduleOn, traitBb, skillRec, batMod, onHitBy, skillBusy, up } from '../shared/tier1.js'; import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js'; import { ROWS, COLS } from '../../../constants.js'; +import { hypot } from '../../../detmath.js'; const S1 = 'skcom_quickattack[3]'; const S2 = 'skchr_typhon_2'; @@ -204,7 +205,7 @@ export default { if (ds > 0) { onHitBy(battle, unit, ({ target, dmg }) => { if (!dmg.isAttack) return; - const d = Math.hypot(target.x - unit.x, target.y - unit.y); + 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))); }); } diff --git a/server/sim/content/kits/ops/op-veen.js b/server/sim/content/kits/ops/op-veen.js index 3f7e69c3..6693710b 100644 --- a/server/sim/content/kits/ops/op-veen.js +++ b/server/sim/content/kits/ops/op-veen.js @@ -42,6 +42,7 @@ import { num, talentBb, moduleBb, traitBb, skillRec, batMod, toggleBuff } from '../shared/tier1.js'; import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js'; +import { hypot } from '../../../detmath.js'; const S1 = 'skchr_veen_1'; const S2 = 'skchr_veen_2'; @@ -115,7 +116,7 @@ export default { if (!pool.length) pool = near; let next = null, bd = Infinity; for (const e of pool) { - const d = Math.hypot(e.x - cur.x, e.y - cur.y); + const d = hypot(e.x - cur.x, e.y - cur.y); if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && next && e.spawnSeq < next.spawnSeq)) { bd = d; next = e; } } if (!next) return; // nobody within reach: the bounces left are lost diff --git a/server/sim/content/kits/ops/op-weedy.js b/server/sim/content/kits/ops/op-weedy.js index b3433bfc..2bc59b8b 100644 --- a/server/sim/content/kits/ops/op-weedy.js +++ b/server/sim/content/kits/ops/op-weedy.js @@ -49,6 +49,7 @@ import { num, traitBb, skillRec, up, giveSp } from '../shared/tier1.js'; import { absoluteRangeKeys } from '../../../targeting.js'; import { COLS } from '../../../constants.js'; import { startCountdown } from '../../tokens.js'; +import { hypot } from '../../../detmath.js'; const S1 = 'skchr_weedy_1'; const S2 = 'skchr_weedy_2'; @@ -92,7 +93,7 @@ export function rupture(battle, src, e, { duration, perTile, interval }) { return; } const st = { x: e.x, y: e.y, moved: 0, acc: 0 }; - const step = (u) => { st.moved += Math.hypot(u.x - st.x, u.y - st.y); st.x = u.x; st.y = u.y; }; + const step = (u) => { st.moved += hypot(u.x - st.x, u.y - st.y); st.x = u.x; st.y = u.y; }; const flush = (u) => { const m = st.moved; st.moved = 0; @@ -121,7 +122,7 @@ export function liquidNitrogen(battle, { shooter, src, atk, sbb, grid, force, pi const keys = absoluteRangeKeys(grid ?? GRID_4_1, shooter.tileR, shooter.tileC, shooter.dir, 0); const cands = battle.enemiesInKeys(keys, shooter, { canHitFly: true }); if (!cands.length) return false; - const key = pick === 'nearest' ? (e) => Math.hypot(e.x - shooter.x, e.y - shooter.y) : (e) => battle.remainingDistance(e); + const key = pick === 'nearest' ? (e) => hypot(e.x - shooter.x, e.y - shooter.y) : (e) => battle.remainingDistance(e); let target = null, best = Infinity; for (const e of cands) { const v = key(e); diff --git a/server/sim/content/kits/ops/op-wisdel.js b/server/sim/content/kits/ops/op-wisdel.js index b9241676..01220201 100644 --- a/server/sim/content/kits/ops/op-wisdel.js +++ b/server/sim/content/kits/ops/op-wisdel.js @@ -61,6 +61,7 @@ import { num, talentBb, traitBb, skillRec, up, batMod } from '../shared/tier1.js import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js'; import { hasHp } from '../../../damage.js'; import { COLS } from '../../../constants.js'; +import { hypot } from '../../../detmath.js'; const S1 = 'skchr_wisdel_1'; const S2 = 'skchr_wisdel_2'; @@ -138,7 +139,7 @@ function shadowTile(battle, host) { for (const k of host.rangeKeys ?? []) { const r = Math.floor(k / COLS), c = k % COLS; if (!battle.grid.inRect(r, c) || !battle.grid.canStand(r, c, { ranged: true }) || battle.isReservedTile(r, c)) continue; - const d = Math.hypot(r - host.tileR, c - host.tileC); + const d = hypot(r - host.tileR, c - host.tileC); if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && (r < best[0] || (r === best[0] && c < best[1])))) { bd = d; best = [r, c]; } } return best; diff --git a/server/sim/content/kits/shared/tier5.js b/server/sim/content/kits/shared/tier5.js index 4227cbb2..14b622ae 100644 --- a/server/sim/content/kits/shared/tier5.js +++ b/server/sim/content/kits/shared/tier5.js @@ -38,6 +38,7 @@ import { bodyInKeys } from '../../../body.js'; import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js'; +import { hypot } from '../../../detmath.js'; // ---- text-only constants (the official blackboards carry no key for these) -------------------------------------- /** 华法琳 S1 "只当目标生命值不满一半时才会触发"; 塞雷娅 S1 "血量小于等于一半"; 山 module "生命值高于50%时". */ @@ -70,7 +71,7 @@ const maxCharges = (chess, def) => num(def?.skill?.maxCharges, num(chess?.skill? const batPct = (sec, chess) => { const b = num(chess?.stats?.bat, 1) || 1; const v = num(sec) / b; return v ? Math.max(-0.9, v) : 0; }; /** Drop zero / non-finite entries (a zero mod is noise in the buff list). */ const mods = (m) => { const o = {}; for (const k of Object.keys(m)) { const v = m[k]; if (typeof v === 'number' && Number.isFinite(v) && v !== 0) o[k] = v; } return o; }; -const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); +const dist = (a, b) => hypot(a.x - b.x, a.y - b.y); const nationOf = (u) => u?.def?.raw?.nationId ?? null; const inFaction = (u, bond, nations) => !!u?.def && ((u.def.bonds || []).includes(bond) || nations.includes(nationOf(u))); const isAbyssal = (u) => u?.def?.raw?.groupId === 'abyssal' || ABYSSAL.has(u?.def?.charId); diff --git a/server/sim/content/kits/shared/tier6.js b/server/sim/content/kits/shared/tier6.js index 5e040169..0f9999b7 100644 --- a/server/sim/content/kits/shared/tier6.js +++ b/server/sim/content/kits/shared/tier6.js @@ -111,6 +111,7 @@ import { absoluteRangeKeys } from '../../../targeting.js'; import { COLS, ROWS, PULL_STOP_RADIUS } from '../../../constants.js'; import { hasHp } from '../../../damage.js'; +import { hypot } from '../../../detmath.js'; // ------------------------------------------------------------------------------------------------------------------ // helpers @@ -237,7 +238,7 @@ function bestTile(battle, tiles) { let best = tiles[0], bd = Infinity; for (const t of tiles) { let d = Infinity; - for (const e of en) d = Math.min(d, Math.hypot(e.x - t[1], e.y - t[0])); + for (const e of en) d = Math.min(d, hypot(e.x - t[1], e.y - t[0])); if (d < bd - 1e-9) { bd = d; best = t; } } return best; diff --git a/server/sim/content/tokens.js b/server/sim/content/tokens.js index 9322368b..77f8a7c4 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -92,6 +92,7 @@ import { genericKit } from './generic.js'; import { bardRegen } from '../professions.js'; import { normDir, localOrder } from '../dir.js'; import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js'; +import { hypot } from '../detmath.js'; const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d)); const GRID_3X3 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]); @@ -451,7 +452,7 @@ export function findSummonTile(battle, owner, placement = 'melee', opts = {}) { let cover = 0; if (placement === 'enemy' || placement === 'melee') cover = countNear(enemies, r, c, 1); else if (placement === 'ally') cover = countNear(injured, r, c, 1); - const d = placement === 'ally' ? Math.hypot(c - owner.x, r - owner.y) : Math.hypot(c - focus.x, r - focus.y); + const d = placement === 'ally' ? hypot(c - owner.x, r - owner.y) : hypot(c - focus.x, r - focus.y); // last: the tile's offset in the owner's facing-RIGHT frame (for a RIGHT owner = the tile key order), so equal // candidates resolve the same way whichever direction the owner faces const [lr, lc] = localOrder(r - owner.tileR, c - owner.tileC, owner.dir); @@ -1236,7 +1237,7 @@ function yanyouKit(bb, raw) { const keys = []; const r0 = Math.floor(u.y - radius), r1 = Math.ceil(u.y + radius), c0 = Math.floor(u.x - radius), c1 = Math.ceil(u.x + radius); for (let r = Math.max(0, r0); r <= Math.min(ROWS - 1, r1); r++) { - for (let c = Math.max(0, c0); c <= Math.min(COLS - 1, c1); c++) if (Math.hypot(c - u.x, r - u.y) <= radius + 1e-9) keys.push(r * COLS + c); + for (let c = Math.max(0, c0); c <= Math.min(COLS - 1, c1); c++) if (hypot(c - u.x, r - u.y) <= radius + 1e-9) keys.push(r * COLS + c); } u.rangeKeys = keys; u.rangeKeySet = new Set(keys); @@ -1299,7 +1300,7 @@ function yanyouKit(bb, raw) { const tgt = lockOf(unit) ?? topAggroEnemy(battle, unit); if (tgt) { const dx = tgt.x - unit.x, dy = tgt.y + num(unit.mem.hoverDy, 0) - unit.y; - const d = Math.hypot(dx, dy); + const d = hypot(dx, dy); if (d > YANYOU_STOP + 1e-6) { const step = Math.min(d - YANYOU_STOP, speed * dt); const R = battle.rect; @@ -1351,7 +1352,7 @@ function airTile(battle, playerId, taken) { if (taken.has(r * COLS + c) || held.has(r * COLS + c) || !tileFree(battle, r, c)) continue; const t = battle.grid.tile(r, c); const cls = t.build === 'NONE' && t.pass !== 'ALL' ? 0 : t.build === 'NONE' ? 1 : 2; - const s = [cls, Math.hypot(r - cr, c - cc), r * COLS + c]; + const s = [cls, hypot(r - cr, c - cc), r * COLS + c]; let less = !bs; if (!less) for (let i = 0; i < 3; i++) { if (s[i] < bs[i] - 1e-9) { less = true; break; } if (s[i] > bs[i] + 1e-9) break; } if (less) { best = [r, c]; bs = s; } diff --git a/server/sim/detmath.js b/server/sim/detmath.js new file mode 100644 index 00000000..1aed1596 --- /dev/null +++ b/server/sim/detmath.js @@ -0,0 +1,252 @@ +// server/sim/detmath.js — engine-independent math for the sim. Every function here gives the same bits on every +// conforming JavaScript engine. +// +// ECMA-262 lets Math.hypot, sin, cos, atan2, pow (and **) return "implementation-approximated" values, and the engines +// differ in the last bits: V8's hypot is not the one of SpiderMonkey (fdlibm) or JavaScriptCore (sqrt(x*x + y*y)); +// Chrome 154's sin / cos / atan2 are correctly rounded while Node 24–26's are fdlibm's; JavaScriptCore computes an +// integer power by repeated squaring. + − × ÷ and Math.sqrt are correctly rounded IEEE 754 operations, and the language +// fuses nothing (no FMA, no extended precision), so code built only from them is reproducible bit for bit. That is +// what these functions are. The sim must not call the approximated Math functions (eslint.config.js, and +// test/sim/detmath.test.js scans server/sim/). +// +// hypot(x, y) √(x² + y²) as Math.sqrt(x*x + y*y), rescaled by exact powers of two outside 2^±500 +// powi(x, n) xⁿ for an integer n: square-and-multiply in double-double, one final rounding +// sin(x), cos(x) fdlibm 5.3 (__kernel_sin / __kernel_cos / __ieee754_rem_pio2) +// atan2(y, x) fdlibm (FreeBSD msun e_atan2.c / s_atan.c) +// +// sin / cos / atan2 are the algorithms V8 runs in Node, so they return what the server always computed (its golden +// digests stay); hypot and powi are at least as accurate as V8's. ±∞ / NaN arguments take the exactly specified +// results of the Math functions. + +const F64 = new Float64Array(1); +const I32 = new Int32Array(F64.buffer); +const HW = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1 ? 1 : 0; // index of the high word +const LW = 1 - HW; +/** @param {number} x */ +const hi = (x) => { F64[0] = x; return I32[HW]; }; +/** @param {number} x */ +const lo = (x) => { F64[0] = x; return I32[LW] >>> 0; }; +/** @param {number} h @param {number} l */ +const fromWords = (h, l) => { I32[HW] = h; I32[LW] = l; return F64[0]; }; +/** exact 2^e for a normal e @param {number} e */ +const pow2 = (e) => fromWords((e + 1023) << 20, 0); +const P500 = pow2(500), PM500 = pow2(-500), P600 = pow2(600), PM600 = pow2(-600); + +/** + * √(x² + y²). + * @param {number} x + * @param {number} y + */ +export function hypot(x, y) { + // eslint-disable-next-line no-restricted-properties -- ±∞ / NaN only: those results are exact by the spec + if (!Number.isFinite(x) || !Number.isFinite(y)) return Math.hypot(x, y); + const m = Math.max(Math.abs(x), Math.abs(y)); + if (m > P500) { const a = x * PM600, b = y * PM600; return Math.sqrt(a * a + b * b) * P600; } + if (m < PM500 && m !== 0) { const a = x * P600, b = y * P600; return Math.sqrt(a * a + b * b) * PM600; } + return Math.sqrt(x * x + y * y); +} + +// ---- integer powers ---------------------------------------------------------------------------------------------- + +const SPLIT = 134217729; // 2^27 + 1 (Dekker) +const ACC = [0, 0]; +const BASE = [0, 0]; +/** (ah + al)·(bh + bl) in double-double, into out (TwoProduct by Dekker's split: no FMA) */ +function mul2(ah, al, bh, bl, out) { + const p = ah * bh; + let t = SPLIT * ah; + const ahh = t - (t - ah), ahl = ah - ahh; + t = SPLIT * bh; + const bhh = t - (t - bh), bhl = bh - bhh; + const e = ((ahh * bhh - p) + ahh * bhl + ahl * bhh) + ahl * bhl + (ah * bl + al * bh); + const s = p + e; + out[0] = s; + out[1] = e - (s - p); +} + +/** + * xⁿ for an integer n (the sim's powers are stack counts, chain bounces …). The square-and-multiply runs in + * double-double and rounds once, so the result is correctly rounded in practice; a non-integer exponent or a huge + * base falls back to Math.pow. + * @param {number} x + * @param {number} n + */ +export function powi(x, n) { + // eslint-disable-next-line no-restricted-properties -- outside the integer domain only (never in the sim) + if (!Number.isInteger(n) || !Number.isFinite(x) || Math.abs(x) > P500) return Math.pow(x, n); + if (n < 0) return 1 / powi(x, -n); + ACC[0] = 1; ACC[1] = 0; BASE[0] = x; BASE[1] = 0; + let k = n; + while (k > 0) { + if (k % 2 === 1) mul2(ACC[0], ACC[1], BASE[0], BASE[1], ACC); + k = Math.floor(k / 2); + if (k > 0) mul2(BASE[0], BASE[1], BASE[0], BASE[1], BASE); + } + return ACC[0] + ACC[1]; +} + +// ---- sin / cos (fdlibm 5.3) -------------------------------------------------------------------------------------- + +const S1 = -1.66666666666666324348e-01, S2 = 8.33333333332248946124e-03, S3 = -1.98412698298579493134e-04; +const S4 = 2.75573137070700676789e-06, S5 = -2.50507602534068634195e-08, S6 = 1.58969099521155010221e-10; +/** sin on [-π/4, π/4]; y is the tail of x, iy = 0 when y is 0 */ +function kSin(x, y, iy) { + if ((hi(x) & 0x7fffffff) < 0x3e400000 && Math.trunc(x) === 0) return x; + const z = x * x, v = z * x; + const r = S2 + z * (S3 + z * (S4 + z * (S5 + z * S6))); + if (iy === 0) return x + v * (S1 + z * r); + return x - ((z * (0.5 * y - v * r) - y) - v * S1); +} + +const C1 = 4.16666666666666019037e-02, C2 = -1.38888888888741095749e-03, C3 = 2.48015872894767294178e-05; +const C4 = -2.75573143513906633035e-07, C5 = 2.08757232129817482790e-09, C6 = -1.13596475577881948265e-11; +/** cos on [-π/4, π/4]; y is the tail of x */ +function kCos(x, y) { + const ix = hi(x) & 0x7fffffff; + if (ix < 0x3e400000 && Math.trunc(x) === 0) return 1; + const z = x * x; + const r = z * (C1 + z * (C2 + z * (C3 + z * (C4 + z * (C5 + z * C6))))); + if (ix < 0x3fd33333) return 1 - (0.5 * z - (z * r - x * y)); + const qx = ix > 0x3fe90000 ? 0.28125 : fromWords(ix - 0x00200000, 0); + return (1 - qx) - ((0.5 * z - qx) - (z * r - x * y)); +} + +const NPIO2_HW = [ + 0x3FF921FB, 0x400921FB, 0x4012D97C, 0x401921FB, 0x401F6A7A, 0x4022D97C, 0x4025FDBB, 0x402921FB, 0x402C463A, 0x402F6A7A, + 0x4031475C, 0x4032D97C, 0x40346B9C, 0x4035FDBB, 0x40378FDB, 0x403921FB, 0x403AB41B, 0x403C463A, 0x403DD85A, 0x403F6A7A, + 0x40407E4C, 0x4041475C, 0x4042106C, 0x4042D97C, 0x4043A28C, 0x40446B9C, 0x404534AC, 0x4045FDBB, 0x4046C6CB, 0x40478FDB, + 0x404858EB, 0x404921FB, +]; +const INV_PIO2 = 6.36619772367581382433e-01; +const PIO2_1 = 1.57079632673412561417e+00, PIO2_1T = 6.07710050650619224932e-11; +const PIO2_2 = 6.07710050630396597660e-11, PIO2_2T = 2.02226624879595063154e-21; +const PIO2_3 = 2.02226624871116645580e-21, PIO2_3T = 8.47842766036889956997e-32; +const TWO_PI = 6.283185307179586; +const Y = [0, 0]; + +/** x − n·π/2 into Y (head, tail); returns n. |x| ≤ 2^19·π/2: fdlibm's medium path; beyond that (never in the sim) + * x is first taken modulo the double nearest 2π (exact %), which stays deterministic though not accurate. */ +function remPio2(x0) { + let x = x0; + if ((hi(x) & 0x7fffffff) > 0x413921fb) x = x % TWO_PI; + const hx = hi(x), ix = hx & 0x7fffffff; + if (ix <= 0x3fe921fb) { Y[0] = x; Y[1] = 0; return 0; } + if (ix < 0x4002d97c) { // |x| < 3π/4: n = ±1 + if (hx > 0) { + let z = x - PIO2_1; + if (ix !== 0x3ff921fb) { Y[0] = z - PIO2_1T; Y[1] = (z - Y[0]) - PIO2_1T; } else { z -= PIO2_2; Y[0] = z - PIO2_2T; Y[1] = (z - Y[0]) - PIO2_2T; } + return 1; + } + let z = x + PIO2_1; + if (ix !== 0x3ff921fb) { Y[0] = z + PIO2_1T; Y[1] = (z - Y[0]) + PIO2_1T; } else { z += PIO2_2; Y[0] = z + PIO2_2T; Y[1] = (z - Y[0]) + PIO2_2T; } + return -1; + } + let t = Math.abs(x); + const n = Math.trunc(t * INV_PIO2 + 0.5); + let r = t - n * PIO2_1, w = n * PIO2_1T; + Y[0] = r - w; + if (!(n < 32 && ix !== NPIO2_HW[n - 1])) { + const j = ix >> 20; + if (j - ((hi(Y[0]) >> 20) & 0x7ff) > 16) { // a 2nd step, good to 118 bits + t = r; w = n * PIO2_2; r = t - w; w = n * PIO2_2T - ((t - r) - w); Y[0] = r - w; + if (j - ((hi(Y[0]) >> 20) & 0x7ff) > 49) { // a 3rd, 151 bits + t = r; w = n * PIO2_3; r = t - w; w = n * PIO2_3T - ((t - r) - w); Y[0] = r - w; + } + } + } + Y[1] = (r - Y[0]) - w; + if (hx < 0) { Y[0] = -Y[0]; Y[1] = -Y[1]; return -n; } + return n; +} + +/** @param {number} x radians */ +export function sin(x) { + if (!Number.isFinite(x)) return NaN; + if ((hi(x) & 0x7fffffff) <= 0x3fe921fb) return kSin(x, 0, 0); + switch (remPio2(x) & 3) { + case 0: return kSin(Y[0], Y[1], 1); + case 1: return kCos(Y[0], Y[1]); + case 2: return -kSin(Y[0], Y[1], 1); + default: return -kCos(Y[0], Y[1]); + } +} + +/** @param {number} x radians */ +export function cos(x) { + if (!Number.isFinite(x)) return NaN; + if ((hi(x) & 0x7fffffff) <= 0x3fe921fb) return kCos(x, 0); + switch (remPio2(x) & 3) { + case 0: return kCos(Y[0], Y[1]); + case 1: return -kSin(Y[0], Y[1], 1); + case 2: return -kCos(Y[0], Y[1]); + default: return kSin(Y[0], Y[1], 1); + } +} + +// ---- atan2 (fdlibm, FreeBSD msun) -------------------------------------------------------------------------------- + +const ATAN_HI = [4.63647609000806093515e-01, 7.85398163397448278999e-01, 9.82793723247329054082e-01, 1.57079632679489655800e+00]; +const ATAN_LO = [2.26987774529616870924e-17, 3.06161699786838301793e-17, 1.39033110312309984516e-17, 6.12323399573676603587e-17]; +const AT = [ + 3.33333333333329318027e-01, -1.99999999998764832476e-01, 1.42857142725034663711e-01, -1.11111104054623557880e-01, + 9.09088713343650656196e-02, -7.69187620504482999495e-02, 6.66107313738753120669e-02, -5.83357013379057348645e-02, + 4.97687799461593236017e-02, -3.65315727442169155270e-02, 1.62858201153657823623e-02, +]; +/** @param {number} x0 */ +function atan(x0) { + let x = x0; + const hx = hi(x), ix = hx & 0x7fffffff; + let id; + if (ix >= 0x44100000) { // |x| ≥ 2^66 + if (ix > 0x7ff00000 || (ix === 0x7ff00000 && lo(x) !== 0)) return x + x; + return hx > 0 ? ATAN_HI[3] + ATAN_LO[3] : -ATAN_HI[3] - ATAN_LO[3]; + } + if (ix < 0x3fdc0000) { // |x| < 0.4375 + if (ix < 0x3e200000) return x; + id = -1; + } else { + x = Math.abs(x); + if (ix < 0x3ff30000) { + if (ix < 0x3fe60000) { id = 0; x = (2 * x - 1) / (2 + x); } else { id = 1; x = (x - 1) / (x + 1); } + } else if (ix < 0x40038000) { id = 2; x = (x - 1.5) / (1 + 1.5 * x); } else { id = 3; x = -1 / x; } + } + const z = x * x, w = z * z; + const s1 = z * (AT[0] + w * (AT[2] + w * (AT[4] + w * (AT[6] + w * (AT[8] + w * AT[10]))))); + const s2 = w * (AT[1] + w * (AT[3] + w * (AT[5] + w * (AT[7] + w * AT[9])))); + if (id < 0) return x - x * (s1 + s2); + const r = ATAN_HI[id] - ((x * (s1 + s2) - ATAN_LO[id]) - x); + return hx < 0 ? -r : r; +} + +const PI_O_4 = 7.8539816339744827900E-01, PI_O_2 = 1.5707963267948965580E+00, PI = 3.1415926535897931160E+00; +const PI_LO = 1.2246467991473532e-16; +/** + * The angle of (x, y), in (−π, π]. + * @param {number} y + * @param {number} x + */ +export function atan2(y, x) { + if (Number.isNaN(x) || Number.isNaN(y)) return NaN; + const hx = hi(x), ix = hx & 0x7fffffff, lx = lo(x); + const hy = hi(y), iy = hy & 0x7fffffff, ly = lo(y); + if (((hx - 0x3ff00000) | lx) === 0) return atan(y); // x = 1 + let m = ((hy >>> 31) & 1) | ((hx >>> 30) & 2); // 2·sign(x) + sign(y) + if ((iy | ly) === 0) return m === 2 ? PI : m === 3 ? -PI : y; // y = ±0 + if ((ix | lx) === 0) return hy < 0 ? -PI_O_2 : PI_O_2; // x = ±0 + if (ix === 0x7ff00000) { // x = ±∞ + if (iy === 0x7ff00000) return [PI_O_4, -PI_O_4, 3 * PI_O_4, -3 * PI_O_4][m]; + return [0, -0, PI, -PI][m]; + } + if (iy === 0x7ff00000) return hy < 0 ? -PI_O_2 : PI_O_2; // y = ±∞ + const k = (iy - ix) >> 20; + let z; + if (k > 60) { z = PI_O_2 + 0.5 * PI_LO; m &= 1; } // |y/x| > 2^60 + else if (hx < 0 && k < -60) z = 0; // |y|/x < −2^60 + else z = atan(Math.abs(y / x)); + switch (m) { + case 0: return z; + case 1: return -z; + case 2: return PI - (z - PI_LO); + default: return (z - PI_LO) - PI; + } +} diff --git a/server/sim/fear.js b/server/sim/fear.js index 19314e9a..0c2b93df 100644 --- a/server/sim/fear.js +++ b/server/sim/fear.js @@ -27,6 +27,7 @@ import { MOVE_SCALE, COLS } from './constants.js'; import { straightClear } from './grid.js'; +import { hypot } from './detmath.js'; /** 半径10格 — the fan's radius (tiles). */ export const FEAR_RADIUS = 10; @@ -80,7 +81,7 @@ const passable = (g, fly, r, c) => (fly ? g.flyPassable(r, c) : g.walkable(r, c, */ export function reachableTiles(b, e, st) { if (!st || st.self) return []; - const dx = st.hx - st.sx, dy = st.hy - st.sy, d = Math.hypot(dx, dy); + const dx = st.hx - st.sx, dy = st.hy - st.sy, d = hypot(dx, dy); if (!(d > 1e-9)) return []; const ux = dx / d, uy = dy / d; const g = b.grid, R = g.rect, fly = e.motion === 'FLY'; @@ -90,7 +91,7 @@ export function reachableTiles(b, e, st) { const out = []; for (let r = R.r0; r <= R.r1; r++) { for (let c = R.c0; c <= R.c1; c++) { - const vx = c - st.hx, vy = r - st.hy, L = Math.hypot(vx, vy); + const vx = c - st.hx, vy = r - st.hy, L = hypot(vx, vy); if (L > FEAR_RADIUS + 1e-9) continue; if (L > 1e-9 && vx * ux + vy * uy < FEAR_HALF_COS * L - 1e-9) continue; if (!passable(g, fly, r, c) || g.tile(r, c).special === 'end') continue; @@ -219,7 +220,7 @@ export function moveFeared(b, e, dt) { for (let guard = 8; dist > 1e-9 && guard > 0; guard--) { if (!m.pts || m.i >= m.pts.length) pickCheckpoint(b, e, m); const p = m.pts[m.i]; - const dx = p.x - e.x, dy = p.y - e.y, d = Math.hypot(dx, dy); + const dx = p.x - e.x, dy = p.y - e.y, d = hypot(dx, dy); if (d <= dist) { e.x = p.x; e.y = p.y; dist -= d; m.i++; } else { e.x += (dx / d) * dist; e.y += (dy / d) * dist; dist = 0; } moved = moved || d > 1e-9; } diff --git a/server/sim/grid.js b/server/sim/grid.js index f416a251..8eb6404d 100644 --- a/server/sim/grid.js +++ b/server/sim/grid.js @@ -54,6 +54,7 @@ // off-centre unit may walk straight to a tile centre (ai.js planLeg, fear.js: else it steps back to its tile centre). import { ROWS, COLS } from './constants.js'; +import { hypot } from './detmath.js'; /** Move cost of an obstacle-like tile (client `Tile.get_moveCost`: obstacle-like ? 1000 : 1). */ export const OBSTACLE_COST = 1000; @@ -376,7 +377,7 @@ export class Grid { for (let i = stack.length - 1; i >= 0; i--) { const a = stack[i], b = f.next[a]; const ar = (a / COLS) | 0, ac = a - ar * COLS, br = (b / COLS) | 0, bc = b - br * COLS; - f.len[a] = f.len[b] + Math.hypot(br - ar, bc - ac); + f.len[a] = f.len[b] + hypot(br - ar, bc - ac); } return f.len[k]; } @@ -420,7 +421,7 @@ export class Grid { /** Length (tiles) of a polyline of [r,c] points. */ static pathLength(pts) { let len = 0; - for (let i = 1; i < pts.length; i++) len += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); + for (let i = 1; i < pts.length; i++) len += hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); return len; } } diff --git a/server/sim/projectiles.js b/server/sim/projectiles.js index e60733bb..5ed195a9 100644 --- a/server/sim/projectiles.js +++ b/server/sim/projectiles.js @@ -5,6 +5,7 @@ // mid-flight the projectile fizzles, unless `hitDead: true` (then it lands at the last known position). import { PROJECTILE_SPEED } from './constants.js'; +import { hypot } from './detmath.js'; let seq = 0; const fin = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d); @@ -55,7 +56,7 @@ export class ProjectileSystem { else p.target = null; } const dx = p.tx - p.x, dy = p.ty - p.y; - const d = Math.hypot(dx, dy); + const d = hypot(dx, dy); const step = p.speed * dt; if (d <= step || p.age >= p.maxAge) { p.x = p.tx; p.y = p.ty; diff --git a/server/sim/spec.js b/server/sim/spec.js index 09996ddb..225099a1 100644 --- a/server/sim/spec.js +++ b/server/sim/spec.js @@ -320,7 +320,7 @@ export function resultDigest(result) { const fnum = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d); const cap = (list, n) => (Array.isArray(list) ? list.slice(0, n) : []); const isKey = (v) => typeof v === 'string' && v.length > 0 && v.length <= 64 && /^[A-Za-z0-9_\-.:]+$/.test(v); -const uidOr = (v) => (Number.isInteger(v) && v >= 1 && v <= 2 ** 31 ? v : null); +const uidOr = (v) => (Number.isInteger(v) && v >= 1 && v <= 0x80000000 ? v : null); const keyOr = (v) => (isKey(v) ? v : null); function compactMods(m) { diff --git a/test/golden/diy.json b/test/golden/diy.json index 84a26ecc..c467dac7 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -1085,26 +1085,26 @@ "bossHpLeft": null, "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":111,"total":111,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1987984,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}}, + "players": {"p1":{"killed":111,"total":111,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1988064,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}}, "units": [ ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_narant",2331,1,10,9,139430,0,0,91,1], ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_narant",2331,1,11,5,155980,0,0,88,3], ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_narant",2331,1,1,0,740,0,0,5,0], ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_narant",2541,1,3,1,12484,0,0,4,1], - ["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_narant",2541,1,5,16,222860,0,0,87,3], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_narant",2541,1,7,11,255194,0,0,170,4], - ["p1",7,"op","chess_char_6_diy1_a",0,null,2000,1,9,18,305504,0,0,107,1], - ["p1",8,"op","chess_char_6_diy2_a",1,null,2000,1,12,23,447750,0,0,177,4], + ["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_narant",2541,1,5,18,216963,0,0,85,3], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_narant",2541,1,7,11,262559,0,0,170,4], + ["p1",7,"op","chess_char_6_diy1_a",0,null,2000,1,9,19,302382,0,0,106,1], + ["p1",8,"op","chess_char_6_diy2_a",1,null,2000,1,12,27,452511,0,0,177,4], ["p1",9,"op","chess_char_6_diy1_a",2,null,2000,1,2,0,251,0,0,5,0], ["p1",10,"op","chess_char_6_diy2_b",0,"none",2331,1,4,0,12573,0,0,4,1], - ["p1",11,"op","chess_char_6_diy1_b",1,"none",2331,1,6,18,198679,0,0,90,3], - ["p1",12,"op","chess_char_6_diy2_b",2,"none",2331,1,8,10,236538,0,0,173,4] + ["p1",11,"op","chess_char_6_diy1_b",1,"none",2331,1,6,10,197025,0,0,89,3], + ["p1",12,"op","chess_char_6_diy2_b",2,"none",2331,1,8,11,235164,0,0,173,4] ], "enemies": {"enemy_1010_demon":[54,54,0,0,755827],"enemy_1320_wdrrl_2":[18,18,0,0,839808],"enemy_1422_lrsldr_2":[39,39,0,0,400308]}, - "hooks": [1,123,5886,1001,1001,2296,2296,0,111,111,25,20,0,9735,233,0,111,0,1,111,0,0,0], - "events": {"atk":1001,"deploy":12,"die":111,"dmg":2296,"engage":12,"fx":453,"skill":45,"spawn":123,"status":245}, - "fx": {"aoe":137,"bounce":316}, - "snaps": "b054f186 5f1791df 62fad2b9 772b925e 3c33e5ff 792df80b b43d3d8f 4283ce62 933a9c03 44991c3e b9347ef2 941827a5 73c09893 9ddf9d2c dbaf0a96 63e46fbe d21c76f5 55bdfa26 bfaf6c80 6968f2d8" + "hooks": [1,123,5886,997,997,2292,2292,0,111,111,25,20,0,9744,235,0,111,0,1,111,0,0,0], + "events": {"atk":997,"deploy":12,"die":111,"dmg":2292,"engage":12,"fx":451,"skill":45,"spawn":123,"status":251}, + "fx": {"aoe":137,"bounce":314}, + "snaps": "b054f186 5f1791df 3d017ddc 6caa5d05 8c321828 7e0d74c4 924d8c48 bc44fa99 448a38b4 f74013c9 0e587d4d 2185ab3b 9482eaf7 c6e6f386 7ce17fa6 af9ad4b3 97bb47c5 500c935b 824afe6d 6e3d1bcb" }, "diy-028": { "about": "6_diy1_b=narant/S1/002_narant 6_diy2_b=narant/S2/002_narant 6_diy1_b=narant/S3/002_narant 6_diy2_b=narant/S1/003_narant 6_diy1_b=narant/S2/003_narant 6_diy2_b=narant/S3/003_narant 5_diy1_a=logos/S1 5_diy2_a=logos/S2 5_diy1_a=logos/S3 5_diy2_b=logos/S1 5_diy1_b=logos/S2 5_diy2_b=logos/S3", diff --git a/test/golden/matches.json b/test/golden/matches.json index 092f6a2f..a8c1f602 100644 --- a/test/golden/matches.json +++ b/test/golden/matches.json @@ -755,7 +755,7 @@ "boost": null, "setup": {"stageId":"act2autochess_m03","bossId":"boss_4","hiddenBossId":"boss_9","factions":["FLY","TIMES","REFLECTION"]}, "end": {"victory":false,"roundsPassed":11,"hiddenReached":false,"hiddenCleared":false,"reason":"eliminated","teamLp":null,"bossPoolLeft":null,"virtualMs":115700}, - "rng": {"setup":25,"shop":525,"waves":0,"draft":75,"bots":24735,"meta":10}, + "rng": {"setup":25,"shop":510,"waves":0,"draft":75,"bots":24735,"meta":8}, "errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0}, "rounds": [ { @@ -808,27 +808,27 @@ }, { "round": 9, - "ai_0": [1,3,12,0,1,4,8,"47bf424b",157,"deputShip 2/24, indomShip 1/17, kazimierzShip 1/28, siracusaShip 0/24, skillfulShip 0/19, soloShip 1/14, suntShip 1/14, swiftShip 1/60",36,53,17,76], + "ai_0": [1,3,12,0,0,4,8,"e73e84c6",170,"deputShip 2/24, indomShip 1/29, kazimierzShip 1/29, siracusaShip 0/24, skillfulShip 0/19, soloShip 1/14, suntShip 1/14, swiftShip 1/60",32,48,16,76], "ai_1": [1,25,12,0,0,4,8,"fec52ed0",54,"arcaneShip 1/17, indomShip 1/18, kazimierzShip 0/7, siracusaShip 1/12, soloShip 1/0, suntShip 1/0, swiftShip 0/17, victoriaShip 1/7, visiShip 0/4, yanShip 0/2",19,43,24,76], "ai_2": [1,15,12,0,0,4,8,"549a87ca",142,"deputShip 1/12, indomShip 1/12, siracusaShip 1/88, skillfulShip 1/2, soloShip 1/18, suntShip 1/10, swiftShip 0/6, yanShip 0/1",52,59,7,80] }, { "round": 10, - "ai_0": [0,0,13,0,0,5,0,"811c9dc5",0,"deputShip 0/28, indomShip 0/41, kazimierzShip 0/33, siracusaShip 0/24, skillfulShip 0/19, soloShip 0/18, suntShip 0/18, swiftShip 0/66",8,18,10,88], + "ai_0": [0,0,12,0,0,5,0,"811c9dc5",0,"deputShip 0/28, indomShip 0/53, kazimierzShip 0/34, siracusaShip 0/24, skillfulShip 0/19, soloShip 0/18, suntShip 0/18, swiftShip 0/66",8,18,10,86], "ai_1": [1,15,12,0,0,5,8,"fd30bf27",65,"arcaneShip 1/17, indomShip 1/24, kazimierzShip 0/7, siracusaShip 1/12, soloShip 1/0, suntShip 1/0, swiftShip 0/17, victoriaShip 1/12, visiShip 0/4, yanShip 0/2",8,18,10,88], - "ai_2": [1,10,12,0,0,5,8,"1a4bfd5d",172,"deputShip 1/14, indomShip 1/16, siracusaShip 1/106, skillfulShip 1/4, soloShip 1/20, suntShip 1/12, swiftShip 0/6, yanShip 0/1",13,18,5,91] + "ai_2": [1,10,12,0,2,5,8,"1a4bfd5d",172,"deputShip 1/14, indomShip 1/16, siracusaShip 1/106, skillfulShip 1/4, soloShip 1/20, suntShip 1/12, swiftShip 0/6, yanShip 0/1",13,18,5,92] }, { "round": 11, - "ai_0": [0,0,0,0,0,5,0,"811c9dc5",0,"deputShip 0/28, indomShip 0/41, kazimierzShip 0/33, siracusaShip 0/24, skillfulShip 0/19, soloShip 0/18, suntShip 0/18, swiftShip 0/66",null,null,null,88], - "ai_1": [1,5,12,0,0,5,8,"6eebc22c",74,"arcaneShip 2/31, indomShip 1/28, kazimierzShip 0/11, siracusaShip 0/12, soloShip 1/0, suntShip 1/0, swiftShip 0/21, victoriaShip 1/15, visiShip 0/4, yanShip 0/2",5,25,20,101], - "ai_2": [1,3,12,0,0,5,8,"a5108c6b",202,"deputShip 1/16, indomShip 1/20, kazimierzShip 0/8, kjeragShip 0/8, siracusaShip 1/130, skillfulShip 0/4, soloShip 1/22, suntShip 2/14, swiftShip 0/14, yanShip 0/1",18,25,7,103] + "ai_0": [0,0,0,0,0,5,0,"811c9dc5",0,"deputShip 0/28, indomShip 0/53, kazimierzShip 0/34, siracusaShip 0/24, skillfulShip 0/19, soloShip 0/18, suntShip 0/18, swiftShip 0/66",null,null,null,86], + "ai_1": [1,5,12,0,1,5,8,"38b1f273",92,"arcaneShip 2/29, indomShip 1/38, kazimierzShip 0/7, siracusaShip 0/12, soloShip 1/0, suntShip 1/0, swiftShip 0/17, victoriaShip 1/25, visiShip 0/4, yanShip 0/2",7,25,18,99], + "ai_2": [1,4,14,0,0,5,8,"a5108c6b",208,"deputShip 1/16, indomShip 1/20, kazimierzShip 0/8, kjeragShip 0/8, siracusaShip 1/136, skillfulShip 0/4, soloShip 1/22, suntShip 2/14, swiftShip 0/14, yanShip 0/1",19,25,6,105] }, { "round": 12, - "ai_0": [0,0,0,0,0,5,0,"811c9dc5",0,"deputShip 0/28, indomShip 0/41, kazimierzShip 0/33, siracusaShip 0/24, skillfulShip 0/19, soloShip 0/18, suntShip 0/18, swiftShip 0/66",null,null,null,88], - "ai_1": [0,0,12,0,0,5,0,"811c9dc5",0,"arcaneShip 0/45, indomShip 0/38, kazimierzShip 0/11, siracusaShip 0/12, swiftShip 0/21, victoriaShip 0/24, visiShip 0/4, yanShip 0/2",5,57,52,116], - "ai_2": [0,0,12,0,0,6,0,"811c9dc5",0,"deputShip 0/20, indomShip 0/20, kazimierzShip 0/8, kjeragShip 0/8, siracusaShip 0/136, skillfulShip 0/4, soloShip 0/26, suntShip 0/18, swiftShip 0/14, yanShip 0/1",13,57,44,116] + "ai_0": [0,0,0,0,0,5,0,"811c9dc5",0,"deputShip 0/28, indomShip 0/53, kazimierzShip 0/34, siracusaShip 0/24, skillfulShip 0/19, soloShip 0/18, suntShip 0/18, swiftShip 0/66",null,null,null,86], + "ai_1": [0,0,13,0,0,6,0,"811c9dc5",0,"arcaneShip 0/35, indomShip 0/48, kazimierzShip 0/7, siracusaShip 0/12, swiftShip 0/17, victoriaShip 0/34, visiShip 0/4, yanShip 0/2",5,57,52,110], + "ai_2": [0,0,12,0,0,6,0,"811c9dc5",0,"deputShip 0/20, indomShip 0/20, kazimierzShip 0/8, kjeragShip 0/8, siracusaShip 0/142, skillfulShip 0/4, soloShip 0/26, suntShip 0/18, swiftShip 0/14, yanShip 0/1",14,57,43,118] } ], "players": { @@ -839,12 +839,12 @@ "eliminatedRound": 10, "lp": 0, "bandId": "band_chiave", - "title": {"id":"comment_5","name":"万事俱备","picId":"comment_icon_5","text":"装备配发数量最多"}, + "title": {"id":"comment_6","name":"挥金如土","picId":"comment_icon_6","text":"消耗资金最多"}, "trophies": 2, "reward": 191, - "stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":1727766,"gold":88,"itemsEquipped":9,"kills":225,"leaks":38,"lpLost":30,"merges":4,"perfectRounds":6,"refreshes":13}, + "stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":1678337,"gold":86,"itemsEquipped":8,"kills":221,"leaks":37,"lpLost":30,"merges":4,"perfectRounds":6,"refreshes":12}, "lineup": [], - "bonds": ["deputShip 0/28","indomShip 0/41","kazimierzShip 0/33","siracusaShip 0/24","skillfulShip 0/19","soloShip 0/18","suntShip 0/18","swiftShip 0/66"] + "bonds": ["deputShip 0/28","indomShip 0/53","kazimierzShip 0/34","siracusaShip 0/24","skillfulShip 0/19","soloShip 0/18","suntShip 0/18","swiftShip 0/66"] }, "ai_1": { "alive": false, @@ -853,12 +853,12 @@ "eliminatedRound": 12, "lp": 0, "bandId": "band_sarkazb", - "title": {"id":"comment_4","name":"精英云集","picId":"comment_icon_4","text":"晋升精锐次数最多"}, + "title": {"id":"comment_5","name":"万事俱备","picId":"comment_icon_5","text":"装备配发数量最多"}, "trophies": 2, "reward": 255, - "stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":2389171,"gold":116,"itemsEquipped":19,"kills":220,"leaks":120,"lpLost":50,"merges":7,"perfectRounds":6,"refreshes":12}, + "stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":2293866,"gold":110,"itemsEquipped":10,"kills":222,"leaks":118,"lpLost":50,"merges":5,"perfectRounds":6,"refreshes":10}, "lineup": [], - "bonds": ["arcaneShip 0/45","indomShip 0/38","kazimierzShip 0/11","siracusaShip 0/12","swiftShip 0/21","victoriaShip 0/24","visiShip 0/4","yanShip 0/2"] + "bonds": ["arcaneShip 0/35","indomShip 0/48","kazimierzShip 0/7","siracusaShip 0/12","swiftShip 0/17","victoriaShip 0/34","visiShip 0/4","yanShip 0/2"] }, "ai_2": { "alive": false, @@ -867,12 +867,12 @@ "eliminatedRound": 12, "lp": 0, "bandId": "band_lmlee", - "title": {"id":"comment_6","name":"挥金如土","picId":"comment_icon_6","text":"消耗资金最多"}, + "title": {"id":"comment_4","name":"精英云集","picId":"comment_icon_4","text":"晋升精锐次数最多"}, "trophies": 2, "reward": 255, - "stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":3734992,"gold":116,"itemsEquipped":8,"kills":216,"leaks":100,"lpLost":34,"merges":6,"perfectRounds":2,"refreshes":16}, + "stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":3450226,"gold":118,"itemsEquipped":9,"kills":218,"leaks":98,"lpLost":33,"merges":6,"perfectRounds":2,"refreshes":17}, "lineup": [], - "bonds": ["deputShip 0/20","indomShip 0/20","kazimierzShip 0/8","kjeragShip 0/8","siracusaShip 0/136","skillfulShip 0/4","soloShip 0/26","suntShip 0/18","swiftShip 0/14","yanShip 0/1"] + "bonds": ["deputShip 0/20","indomShip 0/20","kazimierzShip 0/8","kjeragShip 0/8","siracusaShip 0/142","skillfulShip 0/4","soloShip 0/26","suntShip 0/18","swiftShip 0/14","yanShip 0/1"] } } }, diff --git a/test/sim/detmath.test.js b/test/sim/detmath.test.js new file mode 100644 index 00000000..02d67c3f --- /dev/null +++ b/test/sim/detmath.test.js @@ -0,0 +1,141 @@ +// server/sim/detmath.js — the sim's engine-independent math (hypot, powi, sin, cos, atan2). The functions use only +// + − × ÷ and Math.sqrt, so their results are the same bits on every engine; the digests below pin them (the same +// digests hold in Chrome, Firefox and Safari). Also: the special values of ECMA-262, powi correctly rounded and hypot +// within 1 ulp against exact BigInt references, and no sim file calls an implementation-approximated Math function. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, dirname, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { hypot, powi, sin, cos, atan2 } from '../../server/sim/detmath.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..'); + +const lcg = (seed) => { let s = seed >>> 0; return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; }; }; +const WORDS = new Uint32Array(2), F = new Float64Array(WORDS.buffer); +/** FNV-1a over the IEEE 754 bits of the values */ +const digest = (values) => { + let h = 0x811c9dc5; + for (const v of values) { F[0] = v; for (const w of WORDS) for (let k = 0; k < 4; k++) { h ^= (w >>> (8 * k)) & 0xff; h = Math.imul(h, 16777619); } } + return (h >>> 0).toString(16).padStart(8, '0'); +}; +const N = 20000; + +test('the results are pinned bit for bit (engine-independent digests)', () => { + const runs = { + hypot: () => { const r = lcg(1); return Array.from({ length: N }, () => hypot((r() * 2 - 1) * 20, (r() < 0.3 ? 0 : (r() * 2 - 1) * 20))); }, + powi: () => { const r = lcg(2); return Array.from({ length: N }, () => powi(r() * 3, Math.floor(r() * 41))); }, + sin: () => { const r = lcg(3); return Array.from({ length: N }, () => sin((r() * 2 - 1) * 4 * Math.PI)); }, + cos: () => { const r = lcg(4); return Array.from({ length: N }, () => cos((r() * 2 - 1) * 4 * Math.PI)); }, + atan2: () => { const r = lcg(5); return Array.from({ length: N }, () => atan2((r() * 2 - 1) * 20, (r() * 2 - 1) * 20)); }, + }; + const want = { hypot: '6e3e3a7d', powi: 'f80a3234', sin: '73aedcb1', cos: '51b52432', atan2: 'a0ab93f0' }; + for (const [name, run] of Object.entries(runs)) assert.equal(digest(run()), want[name], name); +}); + +test('values that tell the engines apart', () => { + // V8's Math.hypot(3, 2) is 3.6055512754639896; correctly rounded √13 is 3.605551275463989 (also Firefox, Safari) + assert.equal(hypot(3, 2), 3.605551275463989); + // fdlibm (Node 24–26); Chrome 154's correctly rounded Math.sin / Math.atan2 give 0.7931254945086103 / 0.6023179867387402 + assert.equal(sin(2.22566898269194), 0.7931254945086104); + assert.equal(cos(0.74090688893123), 0.7378567508940902); + assert.equal(atan2(0.5098986645868919, 0.74162210852462), 0.6023179867387403); + // correctly rounded; Safari's Math.pow squares and multiplies in double and gives 2.593742460100002 + assert.equal(powi(1.1, 10), 2.5937424601000023); + assert.equal(powi(0.85, 3), 0.6141249999999999); +}); + +test('special values follow ECMA-262', () => { + const same = (a, b, msg) => assert.ok(Object.is(a, b), `${msg}: ${a} vs ${b}`); + for (const [x, y, want] of [[Infinity, NaN, Infinity], [NaN, -Infinity, Infinity], [NaN, 1, NaN], [0, 0, 0], [-0, -0, 0], [0, -2, 2], [-3, 0, 3]]) same(hypot(x, y), want, `hypot(${x}, ${y})`); + for (const x of [0, -0]) { same(sin(x), x, 'sin ±0'); same(cos(x), 1, 'cos ±0'); } + for (const x of [Infinity, -Infinity, NaN]) { same(sin(x), NaN, `sin(${x})`); same(cos(x), NaN, `cos(${x})`); } + const P = Math.PI, H = Math.PI / 2, Q = Math.PI / 4; + const table = [ + [0, 1, 0], [-0, 1, -0], [0, -1, P], [-0, -1, -P], [0, 0, 0], [-0, 0, -0], [0, -0, P], [-0, -0, -P], + [1, 0, H], [-1, 0, -H], [1, -0, H], [-1, -0, -H], + [1, Infinity, 0], [-1, Infinity, -0], [1, -Infinity, P], [-1, -Infinity, -P], + [Infinity, 1, H], [-Infinity, 1, -H], [Infinity, Infinity, Q], [-Infinity, Infinity, -Q], [Infinity, -Infinity, 3 * Q], [-Infinity, -Infinity, -3 * Q], + [NaN, 1, NaN], [1, NaN, NaN], [1, 1, Q], [-1, -1, -3 * Q], + ]; + for (const [y, x, want] of table) same(atan2(y, x), want, `atan2(${y}, ${x})`); + same(powi(2, 0), 1, 'x^0'); same(powi(0, 0), 1, '0^0'); same(powi(0, 3), 0, '0^3'); same(powi(-2, 3), -8, '(-2)^3'); + same(powi(2, -2), 0.25, 'x^-n'); same(powi(Infinity, 2), Infinity, '∞²'); same(powi(NaN, 0), 1, 'NaN^0'); +}); + +// exact references: x = M·2^E with M an integer +const DV = new DataView(new ArrayBuffer(8)); +const decompose = (x) => { + DV.setFloat64(0, Math.abs(x)); + const hiw = DV.getUint32(0), low = DV.getUint32(4), be = (hiw >>> 20) & 0x7ff; + const M = (BigInt(hiw & 0xfffff) << 32n) | BigInt(low); + return be === 0 ? [M, -1074] : [M | (1n << 52n), be - 1075]; +}; +const bitlen = (n) => n.toString(2).length; +/** round M·2^e (M > 0) to the nearest double, ties to even; `sticky` = the true value is a little above M·2^e */ +const roundBig = (M, e, sticky = false) => { + const L = bitlen(M); + if (L <= 53 && !sticky) return Number(M) * 2 ** e; + const sh = BigInt(Math.max(0, L - 53)); + let q = M >> sh; + const rem = M & ((1n << sh) - 1n), half = sh > 0n ? 1n << (sh - 1n) : 0n; + if (rem > half || (rem === half && (sticky || (q & 1n)))) q++; + return Number(q) * 2 ** (e + Number(sh)); +}; +const isqrt = (n) => { if (n < 2n) return n; let x = BigInt(Math.floor(Math.sqrt(Number(n)))); for (;;) { const y = (x + n / x) >> 1n; if (y >= x) { while (x * x > n) x--; while ((x + 1n) * (x + 1n) <= n) x++; return x; } x = y; } }; +const exactHypot = (x, y) => { + if (x === 0 || y === 0) return Math.abs(x || y); + const [mx, ex] = decompose(x), [my, ey] = decompose(y), E = Math.min(ex, ey); + const S = (mx * mx << BigInt(2 * (ex - E))) + (my * my << BigInt(2 * (ey - E))); + const k = Math.max(0, Math.ceil((130 - bitlen(S)) / 2)); + const q = isqrt(S << BigInt(2 * k)); + return roundBig(q, E - k, q * q !== S << BigInt(2 * k)); +}; +const exactPowi = (x, n) => { if (n === 0) return 1; const [M, E] = decompose(x); return roundBig(M ** BigInt(n), E * n); }; +const ORD = new BigInt64Array(F.buffer); +const ulps = (a, b) => { F[0] = a; const ia = ORD[0]; F[0] = b; return Number(ia - ORD[0]); }; + +test('powi is correctly rounded (exact BigInt reference)', () => { + for (let b = 1; b <= 300; b++) for (let n = 0; n <= 24; n++) { + const x = b / 100; + assert.equal(powi(x, n), exactPowi(x, n), `${x}^${n}`); + } +}); + +test('hypot is within 1 ulp of √(x² + y²), and symmetric', () => { + const r = lcg(7); + for (let i = 0; i < 3000; i++) { + const x = (r() * 2 - 1) * 20, y = (r() * 2 - 1) * 20; + assert.ok(Math.abs(ulps(hypot(x, y), exactHypot(x, y))) <= 1, `hypot(${x}, ${y})`); + assert.equal(hypot(x, y), hypot(y, x)); assert.equal(hypot(-x, y), hypot(x, y)); + } + for (const s of [1e300, 1e-300]) assert.ok(Math.abs(ulps(hypot(3 * s, 4 * s), 5 * s)) <= 1, `rescaled ${s}`); +}); + +test('sin, cos and atan2 stay within 2 ulp of the engine and keep their symmetries', () => { + const r = lcg(9); + for (let i = 0; i < 20000; i++) { + const x = (r() * 2 - 1) * 10; + assert.ok(Math.abs(ulps(sin(x), Math.sin(x))) <= 2 && Math.abs(ulps(cos(x), Math.cos(x))) <= 2, `x = ${x}`); + assert.ok(Object.is(sin(-x), -sin(x)) && Object.is(cos(-x), cos(x)), `symmetry at ${x}`); + const y = (r() * 2 - 1) * 10; + assert.ok(Math.abs(ulps(atan2(y, x), Math.atan2(y, x))) <= 2, `atan2(${y}, ${x})`); + assert.ok(Object.is(atan2(-y, x), -atan2(y, x)), `atan2 odd in y at (${y}, ${x})`); + } +}); + +test('the sim never calls an implementation-approximated Math function', () => { + const APPROX = /\bMath\s*\.\s*(acos|acosh|asin|asinh|atan|atanh|atan2|cbrt|cos|cosh|exp|expm1|hypot|log|log1p|log10|log2|pow|sin|sinh|tan|tanh)\b/; + const files = []; + const walk = (dir) => { for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isDirectory()) walk(p); else if (e.name.endsWith('.js')) files.push(p); } }; + walk(join(ROOT, 'server/sim')); + assert.ok(files.length > 50); + const bad = []; + for (const f of files) { + const rel = relative(ROOT, f).replaceAll('\\', '/'); + if (rel === 'server/sim/detmath.js') continue; + const code = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`])\/\/.*$/gm, '$1'); + if (APPROX.test(code)) bad.push(`${rel}: ${code.match(APPROX)[0]}`); + } + assert.deepEqual(bad, [], 'use server/sim/detmath.js'); +});