feedback7: the battle sim uses engine-independent math — Chrome, Firefox, Safari and the server agree bit for bit (PR #373 by @Cloudnyco)

- server/sim/detmath.js: hypot (sqrt of the squares, rescaled outside 2^±500), powi (integer powers in double-double,
  one rounding), sin / cos (fdlibm 5.3) and atan2 (fdlibm / FreeBSD msun) built only from correctly rounded IEEE
  operations; the sim's Math.hypot / sin / cos / atan2 / pow calls go through it (65 files); eslint.config.js forbids
  the approximated Math functions under server/sim and test/sim/detmath.test.js scans for them.
- sin / cos / atan2 are the algorithms V8 runs in Node, so the server's results stay; only hypot differs from V8's.
  Golden: 2 moved — diy-027 (a hypot distance, damage +80) and coop3-ABYSS-5 (a later card pick from round 9, same end).
- Applied unchanged after review (Grok static review: MERGE; the diff read in full: no network, no new dependency).
- Tests: broad node --test 5155 / 5155 with the new file tracked (detmath 7 / 7, package 10 / 10); lint 0 errors,
  typecheck, check:imports.
This commit is contained in:
Cloudnyco authored and sganggs committed 2026-10-08 18:35:55 +08:00
1 parent 2580152a3a
commit ae22335cfe
65 files changed
+616 -141

No files matched your search

+6 -2
View File
@@ -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 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` 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. 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 - **Determinism** is what makes this work: fixed 1/30 s ticks, a seeded PRNG (`server/sim/rng.js`), no wall clock,
no `Math.random` in the sim, so the same spec and data give the same battle on every machine, bit for bit. The golden 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. results (§5) rely on it.
## 2. The WebSocket protocol ## 2. The WebSocket protocol
+6 -2
View File
@@ -3,7 +3,9 @@
Audience: **content authors** (kits, bonds, garrisons, items, bands, enemies, bosses, devices, choices) and the 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)); **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, 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/ server/sim/
@@ -13,6 +15,8 @@ server/sim/
tiles (relocation, bodies, tactical points), displacement, economy (DP, layers, coins), events tiles (relocation, bodies, tactical points), displacement, economy (DP, layers, coins), events
constants.js TICK, MOVE_SCALE, ATTACK_PAUSE, element numbers, tuning knobs constants.js TICK, MOVE_SCALE, ATTACK_PAUSE, element numbers, tuning knobs
rng.js mulberry32 PRNG (+ int/range/chance/pick/shuffle/weighted) 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 grid.js stage grid, tile semantics, 8-dir A* (no corner cutting), obstacles
units.js Unit model + stat aggregation units.js Unit model + stat aggregation
buffs.js Buff model, mod keys, status catalogue 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) | | `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 | | `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) | | `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 | | `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 | | `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?)` | | | `effectiveProfile(unit)`, `reduceElement(unit, amount, el?)` | |
+18
View File
@@ -39,6 +39,10 @@ const rules = {
'preserve-caught-error': 'warn', '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 = [ const nodeFiles = [
'server/**/*.js', 'server/**/*.js',
'shared/**/*.js', 'shared/**/*.js',
@@ -79,6 +83,20 @@ export default [
}, },
rules, 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. // Node test runner, plus browser tests whose page.evaluate callbacks use DOM globals.
// PIXI is the page global those render tests read inside evaluate(). // PIXI is the page global those render tests read inside evaluate().
+1
View File
@@ -18,6 +18,7 @@
"include": [ "include": [
"shared/**/*.js", "shared/**/*.js",
"server/sim/rng.js", "server/sim/rng.js",
"server/sim/detmath.js",
"server/sim/dir.js", "server/sim/dir.js",
"server/sim/constants.js", "server/sim/constants.js",
"types/**/*.js" "types/**/*.js"
+11 -10
View File
@@ -38,6 +38,7 @@ import { sortEnemyTargets, sortAllyTargets, canTargetEnemy, canTargetAlly, tileK
import { reduceElement } from './damage.js'; import { reduceElement } from './damage.js';
import { straightClear } from './grid.js'; import { straightClear } from './grid.js';
import { moveFeared, endFear } from './fear.js'; import { moveFeared, endFear } from './fear.js';
import { hypot, powi } from './detmath.js';
// --------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------
// profiles // profiles
@@ -325,13 +326,13 @@ export function resolveHit(b, u, prof, target, info, x, y) {
let best = null, bd = Infinity; let best = null, bd = Infinity;
for (const e of b.enemiesInRadius(prev.x, prev.y, prof.chain.radius || CHAIN_RADIUS)) { 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; 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 (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; }
} }
if (!best) break; if (!best) break;
hit.add(best.id); hit.add(best.id);
b._ev(['atk', prev.id, best.id, 'chain']); 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; dealtTotal += d;
if (prof.chain.sluggish && best.alive) b.applyStatus(best, 'sluggish', { duration: prof.chain.sluggish, source: u }); if (prof.chain.sluggish && best.alive) b.applyStatus(best, 'sluggish', { duration: prof.chain.sluggish, source: u });
if (each) each(best, d, 'chain'); if (each) each(best, d, 'chain');
@@ -389,7 +390,7 @@ function doHeal(b, u, prof, t) {
if (!best) break; if (!best) break;
seen.add(best.id); seen.add(best.id);
b._ev(['atk', prev.id, best.id, 'chainHeal']); 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; prev = best;
} }
} }
@@ -475,7 +476,7 @@ function planLeg(b, e, leg) {
R.version = b.grid.version; R.version = b.grid.version;
// suffix lengths for remaining-distance queries // suffix lengths for remaining-distance queries
const suf = new Float64Array(pts.length); 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; R.suffix = suf;
} }
@@ -497,7 +498,7 @@ function tailLength(b, e, idx) {
let len = 0; let len = 0;
if (L.t === 'move') { if (L.t === 'move') {
const [sr, sc] = starts[i]; 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)) { if (e.motion !== 'FLY' && b.grid.inBounds(sr, sc)) {
const f = legField(b, L.r, L.c, sr * COLS + sc); const f = legField(b, L.r, L.c, sr * COLS + sc);
const fl = f ? b.grid.fieldLength(f, sr * COLS + sc) : Infinity; 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]; const leg = R.legs[R.legIdx];
if (leg && leg.t === 'move' && R.pts && R.ptIdx < R.pts.length) { if (leg && leg.t === 'move' && R.pts && R.ptIdx < R.pts.length) {
const p = R.pts[R.ptIdx]; 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') { } 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; return d;
} }
@@ -614,7 +615,7 @@ function moveAttracted(b, e, dt) {
let moved = false; let moved = false;
while (dist > 1e-9 && A.i < A.pts.length) { while (dist > 1e-9 && A.i < A.pts.length) {
const p = A.pts[A.i]; 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; } 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; moved = true;
} }
@@ -667,7 +668,7 @@ function advanceRoute(b, e, dt, R, standing = false) {
if (R.ptIdx >= R.pts.length) break; if (R.ptIdx >= R.pts.length) break;
const p = R.pts[R.ptIdx]; const p = R.pts[R.ptIdx];
const dx = p.x - e.x, dy = p.y - e.y; 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) { if (d <= dist) {
e.x = p.x; e.y = p.y; e.x = p.x; e.y = p.y;
dist -= d; dist -= d;
@@ -848,7 +849,7 @@ function enemyAttack(b, e, prevCd) {
if (!tt || !tt.alive || !e.alive && !rangedShot) return; 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 }); 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) }); b.addProjectile({ from: e, target: t, speed: PROJECTILE_SPEEDS.enemy, visual: 'enemy', source: e, onHit: (c) => hit(c.target, true) });
} else hit(t); } else hit(t);
} }
+5 -4
View File
@@ -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 { 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 { 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 特效 * 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; if (!this._displaceable(e)) return 0;
let level = this.forceLevel(e, force); let level = this.forceLevel(e, force);
const fx0 = fin(from?.x, e.x), fy0 = fin(from?.y, e.y); 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; let ux = 0, uy = 0;
const dirX = dir ? fin(dir.x, 0) : 0, dirY = dir ? fin(dir.y, 0) : 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) { if (dl > 0) {
ux = dirX / dl; uy = dirY / dl; ux = dirX / dl; uy = dirY / dl;
if (from && !fixed && (d < PUSH_DIRECTIONAL_MIN_DIST || (!fixedAngle && vx * ux + vy * uy < d * Math.SQRT1_2))) { 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] // 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; if (center && center.side === 'ally' && e.blockedBy === center) return 0;
const tx = fin(to.x, e.x), ty = fin(to.y, e.y); 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; if (!(d0 > 1e-6)) return 0;
const ux = dx / d0, uy = dy / d0; 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` // 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) { displace(e, dir, distance) {
if (!this._displaceable(e) || !dir) return 0; if (!this._displaceable(e) || !dir) return 0;
const dxv = fin(dir.x, 0), dyv = fin(dir.y, 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; if (!(len > 0)) return 0;
const eff = Math.min(fin(distance, 0), 2 * COLS); const eff = Math.min(fin(distance, 0), 2 * COLS);
if (!(eff > 0)) return 0; if (!(eff > 0)) return 0;
+3 -2
View File
@@ -24,6 +24,7 @@
// `hitRect`). // `hitRect`).
import { ROWS, COLS } from './constants.js'; 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. * 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). */ /** Distance (tiles) from point (x, y) to the unit's body: its position, or its rectangle (0 inside). */
export function bodyDist(u, x, y) { export function bodyDist(u, x, y) {
const R = hitRect(u); 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 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; 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) */ /** Is the unit's body within `r` tiles of point (x, y)? (the same ≤ r + 1e-9 test as Battle.enemiesInRadius) */
+4 -3
View File
@@ -8,6 +8,7 @@
// Additive mod keys scale with stacks (value × stacks); *Mul keys multiply (value ^ stacks). // 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 { 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. */ /** 抵抗: "麻痹等状态每5秒流失1层" — tick of the resist buff. */
function resistPalsyDecay({ battle, unit }) { function resistPalsyDecay({ battle, unit }) {
@@ -200,12 +201,12 @@ export function aggregateMods(buffs) {
const v = m[k]; const v = m[k];
if (typeof v !== 'number' || !Number.isFinite(v)) continue; if (typeof v !== 'number' || !Number.isFinite(v)) continue;
if (k === 'shield') 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) { else if ((k === 'dodgePhys' || k === 'dodgeArts') && v > 0) {
const p = Math.min(1, v); const p = Math.min(1, v);
const d = dodge[k]; const d = dodge[k];
if (!d) dodge[k] = { n: st, p, miss: Math.pow(1 - p, st) }; if (!d) dodge[k] = { n: st, p, miss: powi(1 - p, st) };
else { d.n += st; d.miss *= Math.pow(1 - p, st); } else { d.n += st; d.miss *= powi(1 - p, st); }
} else { } else {
add[k] = (add[k] ?? 0) + v * st; add[k] = (add[k] ?? 0) + v * st;
if (k === 'rangeExtend' && b.persist && b.duration === Infinity) permRangeExtend += v * st; if (k === 'rangeExtend' && b.persist && b.duration === Infinity) permRangeExtend += v * st;
+7 -6
View File
@@ -75,6 +75,7 @@ import {
ensureInstalled, abOf, attach, T, elem, hurt, targetsNear, allTargets, byPriority, areaAllies, areaAlliesInTiles, fieldAllies, ensureInstalled, abOf, attach, T, elem, hurt, targetsNear, allTargets, byPriority, areaAllies, areaAlliesInTiles, fieldAllies,
remainingRoute, stayRoute, stepToward, setHits, hitCount, lpLoss, blinkForward, canCast, absorbArts, nthOf, remainingRoute, stayRoute, stepToward, setHits, hitCount, lpLoss, blinkForward, canCast, absorbArts, nthOf,
} from './enemies.js'; } from './enemies.js';
import { hypot, powi } from '../detmath.js';
// --------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------
// constants // constants
@@ -209,7 +210,7 @@ function rawRouteOf(b, e, tpl) {
function toGoal(b, x, y, motion = 'WALK') { function toGoal(b, x, y, motion = 'WALK') {
const ends = b.grid.specialTiles('end'); const ends = b.grid.specialTiles('end');
let best = ends[0] ?? [Math.round(y), Math.round(x)], bd = Infinity; 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: [] }; 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 opsOnly = (list) => list.filter((u) => u.kind === 'op');
const nearestOf = (b, e, pred) => { const nearestOf = (b, e, pred) => {
let best = null, bd = Infinity; 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; return best;
}; };
/** Apply a floor to an enemy whose data attack speed is 0 (skill-driven leaders: 铳/管/弦). */ /** 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) { function segDist(px, py, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay, L = dx * dx + dy * dy; 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; 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; const d = e2.mem.ab.dash;
if (!d) return; if (!d) return;
const g = d.gun && d.gun.alive ? d.gun : gun(b); 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) // 追逐模式 "对进入自身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'); } 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; } 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++) { for (let k = 0; k < times && t; k++) {
hit.add(t); hit.add(t);
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'springBullet' }); 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; 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 { // 十连击 } else { // 十连击
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t0.id, kind: 'springCombo' }); b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t0.id, kind: 'springCombo' });
+3 -2
View File
@@ -37,6 +37,7 @@ import { COLS, ROWS } from '../constants.js';
import { performAttack } from '../ai.js'; import { performAttack } from '../ai.js';
import { sortEnemyTargets } from '../targeting.js'; import { sortEnemyTargets } from '../targeting.js';
import { DIR_VEC, normDir, oppositeDir } from '../dir.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)); 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). */ /** 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; const exact = r === r0 && c === c0;
if (!exact && battle.grid.tile(r, c).build !== 'NONE') continue; if (!exact && battle.grid.tile(r, c).build !== 'NONE') continue;
const walk = !exact && battle.grid.groundPassable(r, c, true) ? 1 : 0; 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; 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) 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; } 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 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 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 vx = e.x - px, vy = e.y - py;
const v = Math.hypot(vx, vy); const v = hypot(vx, vy);
if (f && v > 1e-6) { if (f && v > 1e-6) {
const rel = airflowRelation((vx * f.fx + vy * f.fy) / v); 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); if (rel !== 'vertical') mul = 1 + num(f.bb[`blower_s_enemy[${rel}].move_speed`], 0);
+2 -1
View File
@@ -116,6 +116,7 @@ import { REFLECTION_KITS } from './enemies/reflection.js';
import { FLY_KITS } from './enemies/fly.js'; import { FLY_KITS } from './enemies/fly.js';
import { SPECIAL_KITS } from './enemies/special.js'; import { SPECIAL_KITS } from './enemies/special.js';
import { LEADER_KITS } from './enemies/leaders.js'; import { LEADER_KITS } from './enemies/leaders.js';
import { hypot } from '../detmath.js';
export { export {
EROSION, EROSION_BURST, HUSK_REBIRTH, nthOf, abOf, attach, T, silenced, canCast, elem, hurt, alliesInTiles, 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) { for (const o of b.enemies) {
if (!o.alive || o === t || o.defId !== 'enemy_1430_lrrook' || !o.mem.ab) continue; if (!o.alive || o === t || o.defId !== 'enemy_1430_lrrook' || !o.mem.ab) continue;
const a = o.mem.ab.list[0]; 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; const part = c.dmg.amount * a.share;
c.dmg.amount -= part; c.dmg.amount -= part;
// a 无来源 burst's share stays 无来源, credited like the burst (damage.js) // a 无来源 burst's share stays 无来源, credited like the burst (damage.js)
+4 -3
View File
@@ -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, 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, areaAllies, areaAlliesInTiles, zone, spawnChildren, stepToward, setHits, hitCount, setForm, absorbArts, auraBuff,
} from './helpers.js'; } from './helpers.js';
import { hypot } from '../../detmath.js';
// --------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------
// archetypes // archetypes
@@ -105,7 +106,7 @@ function pathKeysAhead(e) {
if (!R || !Array.isArray(R.pts)) return keys; if (!R || !Array.isArray(R.pts)) return keys;
let x = e.x, y = e.y; let x = e.x, y = e.y;
for (let i = R.ptIdx ?? 0; i < R.pts.length; i++) { 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)); 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; 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; a.px = e.x; a.py = e.y; a.hid = e.hidden;
if (px == null || hid || e.hidden) return; if (px == null || hid || e.hidden) return;
const own = e.s.moveSpeed * MOVE_SCALE * dt * 1.5 + 1e-3; 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) a.lx = px; a.ly = py; // where the move started (direction for onMove)
if (extra > 0.05) onMove(b, e, a, extra); if (extra > 0.05) onMove(b, e, a, extra);
}, },
@@ -605,7 +606,7 @@ export function blinkForward(b, e, dist) {
let i = R.ptIdx; let i = R.ptIdx;
while (left > 1e-9 && i < R.pts.length) { while (left > 1e-9 && i < R.pts.length) {
const p = R.pts[i]; 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; } 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) { } else if (R && R.legs && R.legs[R.legIdx] && R.legs[R.legIdx].r != null) {
+4 -3
View File
@@ -7,6 +7,7 @@ import {
stepToward, setForm, expose, stepToward, setForm, expose,
} from './helpers.js'; } from './helpers.js';
import { enemyAura, allyAura, selfFear, skill, kitSelfFear } from './archetypes.js'; import { enemyAura, allyAura, selfFear, skill, kitSelfFear } from './archetypes.js';
import { hypot } from '../../detmath.js';
// --------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------
// constants (numbers that exist nowhere in the data) // 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)); const prey = (b, e2) => b.enemiesInRadius(e2.x, e2.y, grabR).filter((o) => isPrey(b, e2, o));
return [ return [
skill(k, (b, e2) => { 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; if (!list.length) return;
b.addBuff(e2, { key: 'ab:grabbing', duration: dur, flags: { bind: true, noMove: true } }); // 技能持续4秒,期间持有束缚 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' }); 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) { tick(b, e, a, dt) {
if (a.t && !a.t.alive) { a.t = null; b.removeBuff(e, 'ab:dive'); } if (a.t && !a.t.alive) { a.t = null; b.removeBuff(e, 'ab:dive'); }
if (!a.t) { 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; if (!l.length) return;
a.t = l[0]; a.t = l[0];
b.addBuff(e, { key: 'ab:dive', flags: { noMove: true } }); 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'); hurt(b, e, a.t, e.s.atk, 'phys');
b.fx('explode', { x: e.x, y: e.y, r: 0.5, kind: 'seed' }); b.fx('explode', { x: e.x, y: e.y, r: 0.5, kind: 'seed' });
b.kill(e, null); b.kill(e, null);
+2 -1
View File
@@ -6,6 +6,7 @@
import { ELEMENT } from '../../constants.js'; import { ELEMENT } from '../../constants.js';
import { canTargetAlly, sortAllyTargets, areaSelectable, auraSelectable } from '../../targeting.js'; import { canTargetAlly, sortAllyTargets, areaSelectable, auraSelectable } from '../../targeting.js';
import { mitigate } from '../../damage.js'; import { mitigate } from '../../damage.js';
import { hypot } from '../../detmath.js';
// --------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------
// constants (numbers that exist nowhere in the data) // 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. */ /** Move a unit straight toward (tx, ty) by `dist` tiles. Returns true on arrival. */
export function stepToward(u, tx, ty, dist) { export function stepToward(u, tx, ty, dist) {
const dx = tx - u.x, dy = ty - u.y; 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; } if (d <= dist + 1e-9) { u.x = tx; u.y = ty; return true; }
u.x += (dx / d) * dist; u.x += (dx / d) * dist;
u.y += (dy / d) * dist; u.y += (dy / d) * dist;
+4 -3
View File
@@ -4,6 +4,7 @@
import { canTargetAlly, enemyStealthed } from '../../targeting.js'; import { canTargetAlly, enemyStealthed } from '../../targeting.js';
import { T, elem, hurt, targetsNear, byPriority, auraBuff } from './helpers.js'; import { T, elem, hurt, targetsNear, byPriority, auraBuff } from './helpers.js';
import { stealth, onHitStatus, skill, kitStealth } from './archetypes.js'; import { stealth, onHitStatus, skill, kitStealth } from './archetypes.js';
import { hypot } from '../../detmath.js';
// --------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------
// constants (numbers that exist nowhere in the data) // 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) { function kitCrossbow(ab) {
const s = ab.sk.CrossAttack; 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 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) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y))[0]; 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% // 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 // 的法术伤害与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 // 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) { tick(b, e) {
const partner = b.enemiesInRadius(e.x, e.y, 3).find((o) => /enemy_1174_duholy/.test(o.defId)); 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; 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 });
}, },
}]; }];
} }
+6 -5
View File
@@ -12,6 +12,7 @@ import {
unblockable, maxTargets, onHitStatus, splashAttack, pathKeysAhead, noAirTargets, resist, nthAttackStatus, lowHpBuff, unblockable, maxTargets, onHitStatus, splashAttack, pathKeysAhead, noAirTargets, resist, nthAttackStatus, lowHpBuff,
freeAllPrisoners, reborn, frontGuard, faceCrowd, unbalanced, artsBarrier, skill, blinkForward, freeAllPrisoners, reborn, frontGuard, faceCrowd, unbalanced, artsBarrier, skill, blinkForward,
} from './archetypes.js'; } from './archetypes.js';
import { hypot, powi } from '../../detmath.js';
// --------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------
// constants (numbers that exist nowhere in the data) // 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)); const nx = areaAllies(b, e2, prev.x, prev.y, jr).find((u) => !hit.has(u));
if (!nx) break; if (!nx) break;
hit.add(nx); 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 }); if (nx.alive && cold > 0) b.applyStatus(nx, 'cold', { duration: cold, source: e2 });
prev = nx; prev = nx;
} }
@@ -323,7 +324,7 @@ function kitLeaderMisc(key, ab, e) {
skill(cb, (b, e2) => { skill(cb, (b, e2) => {
const bb = cb.bb, jump = bb.projectile_range ?? jr, max = bb['chain.max_target'] ?? 3; 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)) 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(); const seen = new Set();
let prev = e2, cur = near(e2.x, e2.y, e2.base.rangeRadius || 3.5, seen); let prev = e2, cur = near(e2.x, e2.y, e2.base.rangeRadius || 3.5, seen);
while (cur && seen.size < max) { while (cur && seen.size < max) {
@@ -357,7 +358,7 @@ function kitLeaderMisc(key, ab, e) {
function segDist(px, py, ax, ay, bx, by) { function segDist(px, py, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay, L = dx * dx + dy * dy; 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; 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. */ /** 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) { function kitXi(ab) {
const cross = ab.sk.CrossAttack, sb = ab.sk.ShieldBurst, sb2 = ab.sk.ShieldBurstReborn; const cross = ab.sk.CrossAttack, sb = ab.sk.ShieldBurst, sb2 = ab.sk.ShieldBurstReborn;
const P = { form2: false }; 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 crossAt = (b, e, t) => {
const r0 = t.tileR, c0 = t.tileC; 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 }); 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 }); 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 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; if (!(d > 0)) return;
const r = Math.round(e.y + (dy / d) * 0.6), c = Math.round(e.x + (dx / d) * 0.6); 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 if (b.grid.isLow(r, c) && b.grid.groundPassable(r, c)) return; // stopped by nothing: no collision
+3 -2
View File
@@ -12,6 +12,7 @@ import {
unblockable, runWhenHit, blockWeight, taunt, maxTargets, ep, nthAttackPower, lowHpBuff, deathBoom, setFloat, float, unblockable, runWhenHit, blockWeight, taunt, maxTargets, ep, nthAttackPower, lowHpBuff, deathBoom, setFloat, float,
reborn, frontGuard, faceMove, unbalanced, skill, kitEp, kitPrisoner, kitStun3, reborn, frontGuard, faceMove, unbalanced, skill, kitEp, kitPrisoner, kitStun3,
} from './archetypes.js'; } from './archetypes.js';
import { hypot } from '../../detmath.js';
// --------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------
// constants (numbers that exist nowhere in the data) // 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_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 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) // 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) => { return [skill(ab.sk.Cannon, (b, e) => {
const lock = inRange(b, e).sort((p, q) => q.s.maxHp - p.s.maxHp)[0]; const lock = inRange(b, e).sort((p, q) => q.s.maxHp - p.s.maxHp)[0];
if (!lock) return; if (!lock) return;
@@ -279,7 +280,7 @@ export const SPECIAL_KITS = Object.freeze({
spawn(b, e) { watchDeaths(b, e); }, spawn(b, e) { watchDeaths(b, e); },
otherDeath(c, b, e) { otherDeath(c, b, e) {
const u = c.unit; 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.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 } }); 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 } });
}, },
+2 -1
View File
@@ -47,6 +47,7 @@ import {
alliesAround, passiveBuff, fxOn, battleStore, contentInfo, itemsOf, directMods, alliesAround, passiveBuff, fxOn, battleStore, contentInfo, itemsOf, directMods,
} from '../support/index.js'; } from '../support/index.js';
import { mitigate, hasHp, periodicDamage, isHpLoss } from '../../damage.js'; import { mitigate, hasHp, periodicDamage, isHpLoss } from '../../damage.js';
import { hypot } from '../../detmath.js';
// ===================================================================================================================== // =====================================================================================================================
// data helpers // data helpers
@@ -465,7 +466,7 @@ const BY_ITEM = {
const r = num(p.radius, 3), sc = num(p.damage_scale, 1); const r = num(p.radius, 3), sc = num(p.damage_scale, 1);
S.on('hit', (c) => { S.on('hit', (c) => {
if (c.source !== u || !c.target || c.target.side !== 'enemy' || c.dmg.type === 'element') return; 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 // 炎国短刀: each skill activation +atk (≤ atk_buff_cnt stacks) — one 直接乘算 bonus of atk × stacks
@@ -2,6 +2,7 @@
// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md. // 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 { num, talentBb, traitBb, onHitBy, alliesInGridOf, toggleBuff } from '../shared/tier1.js';
import { hypot } from '../../../detmath.js';
export default { 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); const mn = num(tb.min_dist), mx = num(tb.max_dist, 4), ds = num(tb.damage_scale);
onHitBy(battle, unit, ({ target, dmg }) => { onHitBy(battle, unit, ({ target, dmg }) => {
if (!dmg.isAttack) return; 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); dmg.amount *= 1 + ds * (mx > mn ? Math.max(0, Math.min(1, (d - mn) / (mx - mn))) : 1);
}); });
} }
@@ -7,6 +7,7 @@ import {
num, defOf, talentBb, moduleTalentBb, selectedId, altSkills, alive, gridKeys, fx, copyGrid, textNum, freeTile, num, defOf, talentBb, moduleTalentBb, selectedId, altSkills, alive, gridKeys, fx, copyGrid, textNum, freeTile,
groundTile, enemiesOn, groundTile, enemiesOn,
} from '../shared/tier3.js'; } from '../shared/tier3.js';
import { powi } from '../../../detmath.js';
/** 琳琅诗怀雅 S3's coin range (PRTS 备注 "前方范围2-4"; range_table "2-4", facing right). */ /** 琳琅诗怀雅 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]]); 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) => { battle.on('fatal', (ctx) => {
if (ctx.unit !== unit || ctx.prevented) return; if (ctx.unit !== unit || ctx.prevented) return;
const n = unit.mem.saveCount ?? 0; 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); const pl = battle.getPlayer(unit.ownerId);
if (!pl || pl.dp + 1e-9 < cost) return; if (!pl || pl.dp + 1e-9 < cost) return;
battle.addDp(unit.ownerId, -cost); battle.addDp(unit.ownerId, -cost);
@@ -3,6 +3,7 @@
import { PUSH_DIRECTIONAL_MIN_DIST } from '../../../constants.js'; import { PUSH_DIRECTIONAL_MIN_DIST } from '../../../constants.js';
import { num, defOf, talentBb, traitBb, onTiles, gridKeys, fx, copyGrid } from '../shared/tier3.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. */ /** Two enemy bodies touch within this distance (tiles) — 见行者 collision stun. */
const COLLIDE = 0.6; 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 // (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); // 力度 − 重量 distance of a 特效 push (PRTS 推与拉 names 见行者's skills 特效类; Battle.push / pushDistance);
// stopped short of it ⇒ it hit a wall // 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 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 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; const wall = expect > 0 && moved + 0.05 < expect;
@@ -7,6 +7,7 @@ import {
AURA, num, tbb, grid, nationOf, enemiesOnRange, targetsInRange, whileDeployed, reveal, skillActive, isSel, alt, AURA, num, tbb, grid, nationOf, enemiesOnRange, targetsInRange, whileDeployed, reveal, skillActive, isSel, alt,
instantKind, withDefaults, instantKind, withDefaults,
} from '../shared/tier4.js'; } from '../shared/tier4.js';
import { hypot } from '../../../detmath.js';
/** Is module `id` the active one? (elites; `'none'` / normal chess ⇒ false) */ /** Is module `id` the active one? (elites; `'none'` / normal chess ⇒ false) */
const moduleIs = (def, id) => !!(def?.raw?.module?.active && def.raw.module.id === id); 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 n = num(bb.charge, 1);
const cand = battle.alliesInRadius(unit.x, unit.y, 1.5, unit.ownerId) 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') .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; if (!cand[0] || !(n > 0)) return;
cand[0].skill.addAmmo(n); cand[0].skill.addAmmo(n);
battle.fx('reload', { x: cand[0].x, y: cand[0].y, id: cand[0].id, n }); battle.fx('reload', { x: cand[0].x, y: cand[0].y, id: cand[0].id, n });
@@ -49,7 +49,7 @@ export default withDefaults({
unit.mem.sentry = null; unit.mem.sentry = null;
unit.mem.inesSentryAt = null; unit.mem.inesSentryAt = null;
const ax = from.x, ay = from.y, bx = unit.x, by = unit.y; 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 w = num(bb.projectile_range, 1.4);
const hits = []; const hits = [];
for (const e of battle.aliveEnemies()) { for (const e of battle.aliveEnemies()) {
@@ -5,6 +5,7 @@ import { COLS } from '../../../constants.js';
import { import {
num, tbb, grid, targetsInRange, targetsInGrid, toggleBuff, skillActive, withDefaults, num, tbb, grid, targetsInRange, targetsInGrid, toggleBuff, skillActive, withDefaults,
} from '../shared/tier4.js'; } 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 * 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 || []) { for (const k of unit.rangeKeys || []) {
const r = (k / COLS) | 0, c = k % COLS; const r = (k / COLS) | 0, c = k % COLS;
if (!freeTile(battle, r, c)) continue; 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 (d < bd - 1e-9) { bd = d; best = [r, c]; }
} }
if (!best) return; if (!best) return;
@@ -3,6 +3,7 @@
import { bodyInKeys } from '../../../body.js'; import { bodyInKeys } from '../../../body.js';
import { num, tbb, keySet, skillActive, isSel, alt, instantKind, withDefaults } from '../shared/tier4.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 备注). */ /** 阿罗玛's 非首次标记 (talent 起泡性能测试): one per enemy, shared by every 阿罗玛 (PRTS 备注). */
const AROMA_MARK = 'aroma:bubbled'; 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); const lo = num(tb.min_dist, 0), hi = num(tb.max_dist, 4);
battle.on('hit', (c) => { battle.on('hit', (c) => {
if (c.source !== unit || !c.dmg.isAttack) return; 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))); c.dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo)));
}, { owner: unit }); }, { owner: unit });
} }
@@ -6,6 +6,7 @@ import {
AURA, num, tbb, moduleBb, grid, batFlat, enemyHasTag, targetsInRange, whileDeployed, pulse, alt, instantKind, AURA, num, tbb, moduleBb, grid, batFlat, enemyHasTag, targetsInRange, whileDeployed, pulse, alt, instantKind,
pullToFront, withDefaults, pullToFront, withDefaults,
} from '../shared/tier4.js'; } 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 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); 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: "向自身拖拽较远的敌人时力度提升一个等级" // 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 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))); }; const pullSelf = (battle, unit, e, f) => { if (e && e.alive) dragDmg(battle, unit, e, pullToFront(battle, unit, e, selfForce(unit, e, f))); };
return { return {
skills: alt(def, { skills: alt(def, {
@@ -6,6 +6,7 @@ import { bodyInKeys } from '../../../body.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js'; import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
import { isHpLoss } from '../../../damage.js'; import { isHpLoss } from '../../../damage.js';
import { num, tbb, whileDeployed, toggleBuff, skillActive, isSel, alt, withDefaults } from '../shared/tier4.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 LINE = Object.freeze(Array.from({ length: COLS }, (_, i) => Object.freeze([0, i])));
const tileKey = (u) => Math.round(u.y) * COLS + Math.round(u.x); const tileKey = (u) => Math.round(u.y) * COLS + Math.round(u.x);
@@ -83,7 +84,7 @@ export default withDefaults({
battle.on('hit', (c) => { battle.on('hit', (c) => {
if (c.source !== unit || !c.dmg.isAttack) return; 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 (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 }); }, { owner: unit });
}, },
}; };
@@ -4,6 +4,7 @@
import { COLS } from '../../../constants.js'; import { COLS } from '../../../constants.js';
import { rotateOffset } from '../../../dir.js'; import { rotateOffset } from '../../../dir.js';
import { AURA_IV, AURA_DUR, num, talent, traitBb, skillGrid, isOp, leaderOf, whileOn } from '../shared/tier5.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. */ /** 魔王 T1 微尘: collision radius of one mote (PRTS 备注 0.4); orbit radius / speed come from the talent bb. */
const MOTE_HIT_RADIUS = 0.4; const MOTE_HIT_RADIUS = 0.4;
@@ -82,11 +83,11 @@ export default {
const ang = spd * t + (2 * Math.PI * k) / cnt; const ang = spd * t + (2 * Math.PI * k) / cnt;
// the orbit turns with his direction: angle 0 = straight ahead, π/2 = his left hand // 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 [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; let hit = null, hd = Infinity;
for (const a of cands) { for (const a of cands) {
if (a.findBuff('cetsyr:mote')) continue; 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 (d <= MOTE_HIT_RADIUS + 1e-9 && d < hd) { hd = d; hit = a; }
} }
if (!hit) continue; if (!hit) continue;
@@ -8,6 +8,7 @@ import {
AURA_IV, AURA_DUR, RING1, num, on, talent, skillGrid, maxCharges, mods, dist, isOp, selectedId, lazySkills, AURA_IV, AURA_DUR, RING1, num, on, talent, skillGrid, maxCharges, mods, dist, isOp, selectedId, lazySkills,
instantKind, whileOn, permBuff, instantKind, whileOn, permBuff,
} from '../shared/tier5.js'; } 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). */ /** Straight-road length through each tile (max of the horizontal and vertical runs of ground-passable tiles). */
const ROAD_CACHE = new WeakMap(); 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; 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 ("移动方向始终为远离棘刺部署位置中心的方向") // 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 // `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) // 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}`; const key = `thorn2:${unit.id}:${unit.mem.zoneSeq = (unit.mem.zoneSeq ?? 0) + 1}`;
@@ -4,6 +4,7 @@
import { COLS } from '../../../constants.js'; import { COLS } from '../../../constants.js';
import { bodyInKeys } from '../../../body.js'; import { bodyInKeys } from '../../../body.js';
import { num, tbb, moduleBb, live, keyOf, enemiesIn, isTok, instantKind, AROUND8, aura } from '../shared/tier6.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 千分之一的心 // 圣聆初雪 chess_char_6_02 (阵法术师) — S3 群山俯首; 无垠的雪景; 圣山的祝福; module 千分之一的心
@@ -217,7 +218,7 @@ function lureGoal(battle, unit, e) {
for (const [dr, dc] of AROUND8) { for (const [dr, dc] of AROUND8) {
const r = unit.tileR + dr, c = unit.tileC + dc; const r = unit.tileR + dr, c = unit.tileC + dc;
if (!battle.grid.groundPassable(r, c)) continue; 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]; } if (d < bd - 1e-9) { bd = d; goal = [r, c]; }
} }
return goal; return goal;
@@ -5,6 +5,7 @@ import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
import { CHAIN_RADIUS } from '../../../constants.js'; import { CHAIN_RADIUS } from '../../../constants.js';
import { bodyDist, bodyInKeys } from '../../../body.js'; import { bodyDist, bodyInKeys } from '../../../body.js';
import { num, bv, tbb, live, ANY, enemiesIn, batOf, N4, aura } from '../shared/tier6.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 辉煌裂片; 机理分析; 孤卒 // 异客 chess_char_6_05 (链术师) — S3 辉煌裂片; 机理分析; 孤卒
@@ -25,7 +26,7 @@ function pasngr(bb, chess, def) {
for (let i = 0; i < count && prev; i++) { for (let i = 0; i < count && prev; i++) {
hit.add(prev.id); hit.add(prev.id);
battle.fx('lightning', { x: prev.x, y: prev.y, id: prev.id, src: unit.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 }); if (slug > 0 && prev.alive) battle.applyStatus(prev, 'sluggish', { duration: slug, source: unit });
let best = null, bd = Infinity; let best = null, bd = Infinity;
for (const x of battle.foesInRadius(prev.x, prev.y, ch.radius || CHAIN_RADIUS)) { for (const x of battle.foesInRadius(prev.x, prev.y, ch.radius || CHAIN_RADIUS)) {
@@ -5,6 +5,7 @@ import { sortEnemyTargets, canTargetEnemy } from '../../../targeting.js';
import { rotateOffset } from '../../../dir.js'; import { rotateOffset } from '../../../dir.js';
import { bodyDist } from '../../../body.js'; import { bodyDist } from '../../../body.js';
import { num, tbb, hasBond, ANY, selectedSkill, skillGridOf, WHOLE_FIELD, bstate, aura } from '../shared/tier6.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 终幕·浩劫; 头狼; 叙拉古的荣幸 // 荒芜拉普兰德 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) // ① `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) // plane, counter-clockwise)
const releaseDrones = (unit, k) => { 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++) { for (let i = 0; i < k; i++) {
const a = a0 + (2 * Math.PI * i) / k; 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) // 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; let best = null, bd = Infinity, bh = Infinity;
for (const e of battle.enemies) { for (const e of battle.enemies) {
if (!ok(e)) continue; 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; } if (de < bd - 1e-9 || (de <= bd + 1e-9 && dh < bh - 1e-9)) { best = e; bd = de; bh = dh; }
} }
return best; return best;
@@ -65,9 +66,9 @@ function whitw2(bb, chess, def) {
const circle = (d, dt) => { const circle = (d, dt) => {
const { r, v } = WHITW2_ORBIT; const { r, v } = WHITW2_ORBIT;
if (!d.orbit) d.orbit = { cx: d.x - d.hy * r, cy: d.y + d.hx * r }; 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; const a = 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.x = d.orbit.cx + r * cos(a); d.y = d.orbit.cy + r * sin(a);
d.hx = -Math.sin(a); d.hy = Math.cos(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 // 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 // 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') { if (d.phase === 'chase') {
const s = accelerate(d, WHITW2_CHASE, dt); const s = accelerate(d, WHITW2_CHASE, dt);
if (bodyDist(t, d.x, d.y) > s + 1e-9) { 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; } if (L > 1e-9) { d.hx = dx / L; d.hy = dy / L; }
d.x += d.hx * s; d.y += d.hy * s; d.x += d.hx * s; d.y += d.hy * s;
return; return;
+2 -1
View File
@@ -63,6 +63,7 @@ import { bodyInKeys } from '../../../body.js';
import { hasHp } from '../../../damage.js'; import { hasHp } from '../../../damage.js';
import { RESIST_STATUSES } from '../../../buffs.js'; import { RESIST_STATUSES } from '../../../buffs.js';
import { TICK } from '../../../constants.js'; import { TICK } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_aglna2_1'; const S1 = 'skchr_aglna2_1';
const S2 = 'skchr_aglna2_2'; 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; if (!st || !skillOn(unit, S3) || !up(unit) || st.flight || !unit.canAct || unit.blocking.length) return;
const tgt = moveTarget(battle, unit); const tgt = moveTarget(battle, unit);
if (!tgt) return; 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; const act = unit.skill.activations;
st.flight = tgt; st.flight = tgt;
battle.releaseBlocked(unit); battle.releaseBlocked(unit);
+2 -1
View File
@@ -62,6 +62,7 @@ import { sortEnemyTargets, canTargetEnemy } from '../../../targeting.js';
import { bodyInKeys } from '../../../body.js'; import { bodyInKeys } from '../../../body.js';
import { hasHp, isHpLoss } from '../../../damage.js'; import { hasHp, isHpLoss } from '../../../damage.js';
import { COLS, PROJECTILE_SPEEDS } from '../../../constants.js'; import { COLS, PROJECTILE_SPEEDS } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_cqbw_1'; const S1 = 'skchr_cqbw_1';
const S2 = 'skchr_cqbw_2'; const S2 = 'skchr_cqbw_2';
@@ -133,7 +134,7 @@ function mineTile(battle, unit) {
let best = [], bd = Infinity; let best = [], bd = Infinity;
for (const t of tiles) { for (const t of tiles) {
let d = Infinity; 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); 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); return best.length === 1 ? best[0] : battle.rng.pick(best);
+2 -1
View File
@@ -67,6 +67,7 @@ import { num, talentBb, moduleBb, traitBb, skillRec, batMod, up, toggleBuff } fr
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js'; import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
import { hasHp } from '../../../damage.js'; import { hasHp } from '../../../damage.js';
import { COLS } from '../../../constants.js'; import { COLS } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_ebnhlz_1'; const S1 = 'skchr_ebnhlz_1';
const S2 = 'skchr_ebnhlz_2'; const S2 = 'skchr_ebnhlz_2';
@@ -116,7 +117,7 @@ function remnantTiles(battle, unit) {
const rank = new Map(); 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); }); 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] 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) })); 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); keyed.sort((a, b) => a.on - b.on || a.d - b.d || a.i - b.i);
return keyed.map((x) => [x.r, x.c]); return keyed.map((x) => [x.r, x.c]);
+2 -1
View File
@@ -50,6 +50,7 @@
import { num, talentBb, moduleBb, moduleOn, skillRec, statBuff, toggleBuff, up } from '../shared/tier1.js'; import { num, talentBb, moduleBb, moduleOn, skillRec, statBuff, toggleBuff, up } from '../shared/tier1.js';
import { toLocal } from '../../../dir.js'; import { toLocal } from '../../../dir.js';
import { COLS } from '../../../constants.js'; import { COLS } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_haak_1'; const S1 = 'skchr_haak_1';
const S2 = 'skchr_haak_2'; 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; 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 dr = a.tileR - unit.tileR, dc = a.tileC - unit.tileC;
const [lr, lc] = toLocal(dr, dc, unit.dir); 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; let less = !bk;
for (let i = 0; !less && i < k.length; i++) { for (let i = 0; !less && i < k.length; i++) {
if (k[i] === bk[i]) continue; if (k[i] === bk[i]) continue;
+2 -1
View File
@@ -50,6 +50,7 @@
import { num, up, traitBb, talentBb, skillRec, batMod, giveSp, protectMods } from '../shared/tier1.js'; import { num, up, traitBb, talentBb, skillRec, batMod, giveSp, protectMods } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js'; import { absoluteRangeKeys } from '../../../targeting.js';
import { COLS } from '../../../constants.js'; import { COLS } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_haruka_1'; const S1 = 'skchr_haruka_1';
const S2 = 'skchr_haruka_2'; 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; if (c.source !== unit || c.opts?.regen || !skillOn(unit, S2) || !(c.amount > 0) || !(scale > 0) || !n) return;
const t = c.target; const t = c.target;
const foes = battle.foesInRadius(t.x, t.y, r); 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; 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'] }); 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' }); if (foes.length) battle.fx('aoe', { x: t.x, y: t.y, radius: r, id: unit.id, skill: 'haruka:s2' });
+3 -2
View File
@@ -57,6 +57,7 @@ import { canTargetEnemy } from '../../../targeting.js';
import { bodyInRadius } from '../../../body.js'; import { bodyInRadius } from '../../../body.js';
import { hasHp } from '../../../damage.js'; import { hasHp } from '../../../damage.js';
import { COLS } from '../../../constants.js'; import { COLS } from '../../../constants.js';
import { sin, cos, atan2 } from '../../../detmath.js';
const S1 = 'skchr_hsgma2_1'; const S1 = 'skchr_hsgma2_1';
const S2 = 'skchr_hsgma2_2'; 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). */ /** S2: the shield's centre after `turned` degrees (row 0 is the bottom row: counter-clockwise = a growing angle). */
const shieldAt = (unit, sh) => { const shieldAt = (unit, sh) => {
const a = sh.a0 + (sh.turned * Math.PI) / 180; 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)); 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 禁疗). */ /** 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; if (!('hits' in s2Attack)) return;
plainS2(); // the throw was this attack: the shield flies from now on plainS2(); // the throw was this attack: the shield flies from now on
const [fr, fc] = unit.fwd; 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' }); 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)); shieldPulse(battle, unit, shieldAt(unit, unit.mem.hsShield));
}, },
+2 -1
View File
@@ -52,6 +52,7 @@
import { num, talentBb, moduleBb, traitBb, skillRec, up, giveSp } from '../shared/tier1.js'; import { num, talentBb, moduleBb, traitBb, skillRec, up, giveSp } from '../shared/tier1.js';
import { bodyInKeys } from '../../../body.js'; import { bodyInKeys } from '../../../body.js';
import { hasHp } from '../../../damage.js'; import { hasHp } from '../../../damage.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_ifrit_1'; const S1 = 'skchr_ifrit_1';
const S2 = 'skchr_ifrit_2'; const S2 = 'skchr_ifrit_2';
@@ -197,7 +198,7 @@ export default {
battle.on('hit', (c) => { battle.on('hit', (c) => {
const e = c.target; const e = c.target;
if (c.source !== unit || !e || e.side !== 'enemy' || c.dmg.type === 'element' || !up(unit)) return; 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); c.dmg.mul *= 1 + ds * (hi > lo ? Math.max(0, Math.min(1, (d - lo) / (hi - lo))) : 1);
}, { owner: unit }); }, { owner: unit });
} }
+3 -2
View File
@@ -58,6 +58,7 @@
import { num, talentBb, traitBb, moduleOn, skillRec, batMod, up, giveSp, installReveal } from '../shared/tier1.js'; import { num, talentBb, traitBb, moduleOn, skillRec, batMod, up, giveSp, installReveal } from '../shared/tier1.js';
import { offsetTile, dirFromDelta, dirVec } from '../../../dir.js'; import { offsetTile, dirFromDelta, dirVec } from '../../../dir.js';
import { isHpLoss } from '../../../damage.js'; import { isHpLoss } from '../../../damage.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_jesca2_1'; const S1 = 'skchr_jesca2_1';
const S2 = 'skchr_jesca2_2'; const S2 = 'skchr_jesca2_2';
@@ -182,7 +183,7 @@ function fireBomb(battle, unit, b3) {
}; };
(unit.mem.jescaShells ??= []).push(bomb); (unit.mem.jescaShells ??= []).push(bomb);
unit.atkCd = unit.s.interval; // the shell is her attack: the timer restarts [ASSUMED] 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. */ /** 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 = []; const keep = [];
for (const s of list) { for (const s of list) {
const dx = s.tx - s.x, dy = s.ty - s.y; 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; const step = BOMB_SPEED * dt;
let hit = battle.foesInRadius(s.x, s.y, BOMB_HIT_RADIUS).length > 0; let hit = battle.foesInRadius(s.x, s.y, BOMB_HIT_RADIUS).length > 0;
if (!hit) { if (!hit) {
+2 -1
View File
@@ -76,6 +76,7 @@
import { num, talentBb, skillRec, batMod, up } from '../shared/tier1.js'; import { num, talentBb, skillRec, batMod, up } from '../shared/tier1.js';
import { releaseSkillSummon } from '../../tokens.js'; import { releaseSkillSummon } from '../../tokens.js';
import { COLS } from '../../../constants.js'; import { COLS } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_kalts2_1'; const S1 = 'skchr_kalts2_1';
const S2 = 'skchr_kalts2_2'; const S2 = 'skchr_kalts2_2';
@@ -235,7 +236,7 @@ function startTransport(battle, unit, anchor) {
}, { owner: unit }); }, { owner: unit });
} }
const from = { x: unit.x, y: unit.y }, to = { x: anchor.x, y: anchor.y }; 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 }; 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; unit.mem.k2flight = f;
battle.releaseBlocked(unit); battle.releaseBlocked(unit);
+2 -1
View File
@@ -46,6 +46,7 @@ import { hasHp } from '../../../damage.js';
import { canTargetEnemy, enemyStealthed } from '../../../targeting.js'; import { canTargetEnemy, enemyStealthed } from '../../../targeting.js';
import { bodyInKeys } from '../../../body.js'; import { bodyInKeys } from '../../../body.js';
import { COLS, PALSY_MAX } from '../../../constants.js'; import { COLS, PALSY_MAX } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_mantra_1'; const S1 = 'skchr_mantra_1';
const S2 = 'skchr_mantra_2'; const S2 = 'skchr_mantra_2';
@@ -91,7 +92,7 @@ function nearestFoe(battle, unit, x, y, r, skip) {
let best = null, bd = Infinity; let best = null, bd = Infinity;
for (const e of battle.enemiesInRadius(x, y, r)) { for (const e of battle.enemiesInRadius(x, y, r)) {
if (skip.has(e) || !canTargetEnemy(unit, e, ANY)) continue; 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; } if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; }
} }
return best; return best;
+2 -1
View File
@@ -58,6 +58,7 @@
import { num, skillRec, batMod, up } from '../shared/tier1.js'; import { num, skillRec, batMod, up } from '../shared/tier1.js';
import { COLS } from '../../../constants.js'; import { COLS } from '../../../constants.js';
import { dirVec } from '../../../dir.js'; import { dirVec } from '../../../dir.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_mcnist_1'; const S1 = 'skchr_mcnist_1';
const S2 = 'skchr_mcnist_2'; const S2 = 'skchr_mcnist_2';
@@ -197,7 +198,7 @@ function grafKit(owner, { life, t2, b2, b3, aura }) {
function chargeGraf(battle, mcn, t, b3) { function chargeGraf(battle, mcn, t, b3) {
if (!up(t)) return; if (!up(t)) return;
const { stop, land } = chargeEnd(battle, t); 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; t.mem.charging = true;
battle.fx('charge', { x: t.x, y: t.y, id: t.id, tx: stop[1], ty: stop[0] }); 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 }); battle.retreat(t, { reason: 'expired', permanent: true });
+3 -2
View File
@@ -42,6 +42,7 @@
import { num, talentBb, moduleBb, traitBb, skillRec, statBuff, toggleBuff, onHitBy, enemiesInGrid, cheb, up, giveSp } from '../shared/tier1.js'; import { num, talentBb, moduleBb, traitBb, skillRec, statBuff, toggleBuff, onHitBy, enemiesInGrid, cheb, up, giveSp } from '../shared/tier1.js';
import { canTargetEnemy } from '../../../targeting.js'; import { canTargetEnemy } from '../../../targeting.js';
import { isHpLoss } from '../../../damage.js'; import { isHpLoss } from '../../../damage.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_narant_1'; const S1 = 'skchr_narant_1';
const S2 = 'skchr_narant_2'; const S2 = 'skchr_narant_2';
@@ -138,7 +139,7 @@ export default {
if (left <= 0 || !ctx.home()) { ctx.comeBack(x, y); return; } 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)); 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; } 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); 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]; const tgt = cands[0];
left--; left--;
@@ -158,7 +159,7 @@ export default {
const dash = (ctx) => { const dash = (ctx) => {
const { battle, unit } = ctx; const { battle, unit } = ctx;
let dx = ctx.x - unit.x, dy = ctx.y - unit.y; 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; } 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)); 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, 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,
+3 -2
View File
@@ -68,6 +68,7 @@ import { absoluteRangeKeys } from '../../../targeting.js';
import { bodyInKeys } from '../../../body.js'; import { bodyInKeys } from '../../../body.js';
import { hasHp, burstLocked } from '../../../damage.js'; import { hasHp, burstLocked } from '../../../damage.js';
import { MIN_DAMAGE_RATIO } from '../../../constants.js'; import { MIN_DAMAGE_RATIO } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_phatm2_1'; const S1 = 'skchr_phatm2_1';
const S2 = 'skchr_phatm2_2'; const S2 = 'skchr_phatm2_2';
@@ -134,7 +135,7 @@ function callKit(t, owner, b2) {
const keys = keysAt(grid, tok); const keys = keysAt(grid, tok);
const cands = battle.enemiesInKeys([...keys], tok, ANY).filter((e) => !lured.includes(e) && !e.s.flags.attract 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))); && (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) { for (const e of cands) {
if (lured.length >= cap) break; 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] })) { 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 // the first lured enemy within `arrive` of it: it leaves
battle.on('tick', () => { battle.on('tick', () => {
if (!up(tok)) return; 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 }); }, { owner: tok });
// leaving (any reason): 停顿 + the damage over time on every selectable enemy of its x-1; the 诱导 ends // leaving (any reason): 停顿 + the damage over time on every selectable enemy of its x-1; the 诱导 ends
battle.on('death', (c) => { battle.on('death', (c) => {
+2 -1
View File
@@ -56,6 +56,7 @@
import { num, talentBb, traitBb, moduleOn, skillRec, up, statBuff } from '../shared/tier1.js'; import { num, talentBb, traitBb, moduleOn, skillRec, up, statBuff } from '../shared/tier1.js';
import { toLocal } from '../../../dir.js'; import { toLocal } from '../../../dir.js';
import { COLS, PROJECTILE_SPEEDS } from '../../../constants.js'; import { COLS, PROJECTILE_SPEEDS } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_phenxi_1'; const S1 = 'skchr_phenxi_1';
const S2 = 'skchr_phenxi_2'; 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.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' }); 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)) { 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'] }); battle.dealDamage(unit, e, { amount: unit.s.atk * sc * unit.s.atkScaleMul, type: 'phys', isAttack: true, isSkill: true, isSplash: true, tags: ['skill', 'phenxi:s3'] });
} }
} }); } });
+2 -1
View File
@@ -36,6 +36,7 @@
import { num, talentBb, moduleBb, moduleOn, traitBb, skillRec, statBuff, onHitBy, enemiesInGrid, up } from '../shared/tier1.js'; import { num, talentBb, moduleBb, moduleOn, traitBb, skillRec, statBuff, onHitBy, enemiesInGrid, up } from '../shared/tier1.js';
import { hasHp } from '../../../damage.js'; import { hasHp } from '../../../damage.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skcom_atk_up[3]'; const S1 = 'skcom_atk_up[3]';
const S2 = 'skchr_poca_2'; const S2 = 'skchr_poca_2';
@@ -161,7 +162,7 @@ export default {
if (ds > 0) { if (ds > 0) {
onHitBy(battle, unit, ({ target, dmg }) => { onHitBy(battle, unit, ({ target, dmg }) => {
if (!dmg.isAttack) return; 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))); dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo)));
}); });
} }
+2 -1
View File
@@ -64,6 +64,7 @@ import { acquireTargets } from '../../../ai.js';
import { aggregateMods } from '../../../buffs.js'; import { aggregateMods } from '../../../buffs.js';
import { dirVec } from '../../../dir.js'; import { dirVec } from '../../../dir.js';
import { COLS } from '../../../constants.js'; import { COLS } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_radian_1'; const S1 = 'skchr_radian_1';
const S2 = 'skchr_radian_2'; const S2 = 'skchr_radian_2';
@@ -160,7 +161,7 @@ function flyBullets(battle, t, dt) {
const hit = () => { const hit = () => {
const near = battle.foesInRadius(b.x, b.y, BULLET_RADIUS).filter((e) => e.alive && !e.isFlying); const near = battle.foesInRadius(b.x, b.y, BULLET_RADIUS).filter((e) => e.alive && !e.isFlying);
if (!near.length) return false; 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] }); battle.dealDamage(t, near[0], { amount: b.atk, type: 'phys', isAttack: true, tags: [TAG_BULLET] });
return true; return true;
}; };
+2 -1
View File
@@ -63,6 +63,7 @@ import { sortEnemyTargets } from '../../../targeting.js';
import { bodyInKeys } from '../../../body.js'; import { bodyInKeys } from '../../../body.js';
import { PUSH_DIRECTIONAL_MIN_DIST } from '../../../constants.js'; import { PUSH_DIRECTIONAL_MIN_DIST } from '../../../constants.js';
import { startCountdown } from '../../tokens.js'; import { startCountdown } from '../../tokens.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_ray_1'; const S1 = 'skchr_ray_1';
const S2 = 'skchr_ray_2'; 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; } 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 force = num(b1.force, 1);
const [fr, fc] = unit.fwd; 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 }); 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 }); else battle.push(target, force - 1, { from: unit });
}, },
+2 -1
View File
@@ -58,6 +58,7 @@ import { num, talentBb, moduleBb, traitBb, moduleOn, skillRec, up } from '../sha
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js'; import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
import { offsetTile } from '../../../dir.js'; import { offsetTile } from '../../../dir.js';
import { COLS } from '../../../constants.js'; import { COLS } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skcom_assist_cost[3]'; const S1 = 'skcom_assist_cost[3]';
const S2 = 'skchr_sleach_2'; const S2 = 'skchr_sleach_2';
@@ -150,7 +151,7 @@ function s3Tile(battle, unit, grid) {
for (const k of keys) { for (const k of keys) {
const r = (k / COLS) | 0, c = k % COLS; const r = (k / COLS) | 0, c = k % COLS;
if (!ground(r, c) || battle.unitAt(r, c)) continue; 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]; } if (d < bd - 1e-9) { bd = d; best = [r, c]; }
} }
return best ?? [unit.tileR, unit.tileC]; return best ?? [unit.tileR, unit.tileC];
+2 -1
View File
@@ -41,6 +41,7 @@
import { num, talentBb, moduleOn, traitBb, skillRec, batMod, onHitBy, skillBusy, up } from '../shared/tier1.js'; import { num, talentBb, moduleOn, traitBb, skillRec, batMod, onHitBy, skillBusy, up } from '../shared/tier1.js';
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js'; import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
import { ROWS, COLS } from '../../../constants.js'; import { ROWS, COLS } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skcom_quickattack[3]'; const S1 = 'skcom_quickattack[3]';
const S2 = 'skchr_typhon_2'; const S2 = 'skchr_typhon_2';
@@ -204,7 +205,7 @@ export default {
if (ds > 0) { if (ds > 0) {
onHitBy(battle, unit, ({ target, dmg }) => { onHitBy(battle, unit, ({ target, dmg }) => {
if (!dmg.isAttack) return; 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))); dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo)));
}); });
} }
+2 -1
View File
@@ -42,6 +42,7 @@
import { num, talentBb, moduleBb, traitBb, skillRec, batMod, toggleBuff } from '../shared/tier1.js'; import { num, talentBb, moduleBb, traitBb, skillRec, batMod, toggleBuff } from '../shared/tier1.js';
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js'; import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_veen_1'; const S1 = 'skchr_veen_1';
const S2 = 'skchr_veen_2'; const S2 = 'skchr_veen_2';
@@ -115,7 +116,7 @@ export default {
if (!pool.length) pool = near; if (!pool.length) pool = near;
let next = null, bd = Infinity; let next = null, bd = Infinity;
for (const e of pool) { 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 (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 if (!next) return; // nobody within reach: the bounces left are lost
+3 -2
View File
@@ -49,6 +49,7 @@ import { num, traitBb, skillRec, up, giveSp } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js'; import { absoluteRangeKeys } from '../../../targeting.js';
import { COLS } from '../../../constants.js'; import { COLS } from '../../../constants.js';
import { startCountdown } from '../../tokens.js'; import { startCountdown } from '../../tokens.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_weedy_1'; const S1 = 'skchr_weedy_1';
const S2 = 'skchr_weedy_2'; const S2 = 'skchr_weedy_2';
@@ -92,7 +93,7 @@ export function rupture(battle, src, e, { duration, perTile, interval }) {
return; return;
} }
const st = { x: e.x, y: e.y, moved: 0, acc: 0 }; 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 flush = (u) => {
const m = st.moved; const m = st.moved;
st.moved = 0; 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 keys = absoluteRangeKeys(grid ?? GRID_4_1, shooter.tileR, shooter.tileC, shooter.dir, 0);
const cands = battle.enemiesInKeys(keys, shooter, { canHitFly: true }); const cands = battle.enemiesInKeys(keys, shooter, { canHitFly: true });
if (!cands.length) return false; 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; let target = null, best = Infinity;
for (const e of cands) { for (const e of cands) {
const v = key(e); const v = key(e);
+2 -1
View File
@@ -61,6 +61,7 @@ import { num, talentBb, traitBb, skillRec, up, batMod } from '../shared/tier1.js
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js'; import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
import { hasHp } from '../../../damage.js'; import { hasHp } from '../../../damage.js';
import { COLS } from '../../../constants.js'; import { COLS } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_wisdel_1'; const S1 = 'skchr_wisdel_1';
const S2 = 'skchr_wisdel_2'; const S2 = 'skchr_wisdel_2';
@@ -138,7 +139,7 @@ function shadowTile(battle, host) {
for (const k of host.rangeKeys ?? []) { for (const k of host.rangeKeys ?? []) {
const r = Math.floor(k / COLS), c = k % COLS; 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; 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]; } 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; return best;
+2 -1
View File
@@ -38,6 +38,7 @@
import { bodyInKeys } from '../../../body.js'; import { bodyInKeys } from '../../../body.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js'; import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
import { hypot } from '../../../detmath.js';
// ---- text-only constants (the official blackboards carry no key for these) -------------------------------------- // ---- text-only constants (the official blackboards carry no key for these) --------------------------------------
/** 华法琳 S1 "只当目标生命值不满一半时才会触发"; 塞雷娅 S1 "血量小于等于一半"; 山 module "生命值高于50%时". */ /** 华法琳 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; }; 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). */ /** 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 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 nationOf = (u) => u?.def?.raw?.nationId ?? null;
const inFaction = (u, bond, nations) => !!u?.def && ((u.def.bonds || []).includes(bond) || nations.includes(nationOf(u))); 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); const isAbyssal = (u) => u?.def?.raw?.groupId === 'abyssal' || ABYSSAL.has(u?.def?.charId);
+2 -1
View File
@@ -111,6 +111,7 @@
import { absoluteRangeKeys } from '../../../targeting.js'; import { absoluteRangeKeys } from '../../../targeting.js';
import { COLS, ROWS, PULL_STOP_RADIUS } from '../../../constants.js'; import { COLS, ROWS, PULL_STOP_RADIUS } from '../../../constants.js';
import { hasHp } from '../../../damage.js'; import { hasHp } from '../../../damage.js';
import { hypot } from '../../../detmath.js';
// ------------------------------------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------------------------------------
// helpers // helpers
@@ -237,7 +238,7 @@ function bestTile(battle, tiles) {
let best = tiles[0], bd = Infinity; let best = tiles[0], bd = Infinity;
for (const t of tiles) { for (const t of tiles) {
let d = Infinity; 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; } if (d < bd - 1e-9) { bd = d; best = t; }
} }
return best; return best;
+5 -4
View File
@@ -92,6 +92,7 @@ import { genericKit } from './generic.js';
import { bardRegen } from '../professions.js'; import { bardRegen } from '../professions.js';
import { normDir, localOrder } from '../dir.js'; import { normDir, localOrder } from '../dir.js';
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.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 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]]); 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; let cover = 0;
if (placement === 'enemy' || placement === 'melee') cover = countNear(enemies, r, c, 1); if (placement === 'enemy' || placement === 'melee') cover = countNear(enemies, r, c, 1);
else if (placement === 'ally') cover = countNear(injured, 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 // 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 // candidates resolve the same way whichever direction the owner faces
const [lr, lc] = localOrder(r - owner.tileR, c - owner.tileC, owner.dir); const [lr, lc] = localOrder(r - owner.tileR, c - owner.tileC, owner.dir);
@@ -1236,7 +1237,7 @@ function yanyouKit(bb, raw) {
const keys = []; 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); 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 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.rangeKeys = keys;
u.rangeKeySet = new Set(keys); u.rangeKeySet = new Set(keys);
@@ -1299,7 +1300,7 @@ function yanyouKit(bb, raw) {
const tgt = lockOf(unit) ?? topAggroEnemy(battle, unit); const tgt = lockOf(unit) ?? topAggroEnemy(battle, unit);
if (tgt) { if (tgt) {
const dx = tgt.x - unit.x, dy = tgt.y + num(unit.mem.hoverDy, 0) - unit.y; 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) { if (d > YANYOU_STOP + 1e-6) {
const step = Math.min(d - YANYOU_STOP, speed * dt); const step = Math.min(d - YANYOU_STOP, speed * dt);
const R = battle.rect; 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; if (taken.has(r * COLS + c) || held.has(r * COLS + c) || !tileFree(battle, r, c)) continue;
const t = battle.grid.tile(r, c); const t = battle.grid.tile(r, c);
const cls = t.build === 'NONE' && t.pass !== 'ALL' ? 0 : t.build === 'NONE' ? 1 : 2; 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; 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) 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; } if (less) { best = [r, c]; bs = s; }
+252
View File
@@ -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;
}
}
+4 -3
View File
@@ -27,6 +27,7 @@
import { MOVE_SCALE, COLS } from './constants.js'; import { MOVE_SCALE, COLS } from './constants.js';
import { straightClear } from './grid.js'; import { straightClear } from './grid.js';
import { hypot } from './detmath.js';
/** 半径10格 — the fan's radius (tiles). */ /** 半径10格 — the fan's radius (tiles). */
export const FEAR_RADIUS = 10; 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) { export function reachableTiles(b, e, st) {
if (!st || st.self) return []; 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 []; if (!(d > 1e-9)) return [];
const ux = dx / d, uy = dy / d; const ux = dx / d, uy = dy / d;
const g = b.grid, R = g.rect, fly = e.motion === 'FLY'; const g = b.grid, R = g.rect, fly = e.motion === 'FLY';
@@ -90,7 +91,7 @@ export function reachableTiles(b, e, st) {
const out = []; const out = [];
for (let r = R.r0; r <= R.r1; r++) { for (let r = R.r0; r <= R.r1; r++) {
for (let c = R.c0; c <= R.c1; c++) { 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 > FEAR_RADIUS + 1e-9) continue;
if (L > 1e-9 && vx * ux + vy * uy < FEAR_HALF_COS * L - 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; 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--) { for (let guard = 8; dist > 1e-9 && guard > 0; guard--) {
if (!m.pts || m.i >= m.pts.length) pickCheckpoint(b, e, m); if (!m.pts || m.i >= m.pts.length) pickCheckpoint(b, e, m);
const p = m.pts[m.i]; 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; } 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; moved = moved || d > 1e-9;
} }
+3 -2
View File
@@ -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). // 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 { 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). */ /** Move cost of an obstacle-like tile (client `Tile.get_moveCost`: obstacle-like ? 1000 : 1). */
export const OBSTACLE_COST = 1000; export const OBSTACLE_COST = 1000;
@@ -376,7 +377,7 @@ export class Grid {
for (let i = stack.length - 1; i >= 0; i--) { for (let i = stack.length - 1; i >= 0; i--) {
const a = stack[i], b = f.next[a]; 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; 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]; return f.len[k];
} }
@@ -420,7 +421,7 @@ export class Grid {
/** Length (tiles) of a polyline of [r,c] points. */ /** Length (tiles) of a polyline of [r,c] points. */
static pathLength(pts) { static pathLength(pts) {
let len = 0; 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; return len;
} }
} }
+2 -1
View File
@@ -5,6 +5,7 @@
// mid-flight the projectile fizzles, unless `hitDead: true` (then it lands at the last known position). // mid-flight the projectile fizzles, unless `hitDead: true` (then it lands at the last known position).
import { PROJECTILE_SPEED } from './constants.js'; import { PROJECTILE_SPEED } from './constants.js';
import { hypot } from './detmath.js';
let seq = 0; let seq = 0;
const fin = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d); const fin = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
@@ -55,7 +56,7 @@ export class ProjectileSystem {
else p.target = null; else p.target = null;
} }
const dx = p.tx - p.x, dy = p.ty - p.y; 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; const step = p.speed * dt;
if (d <= step || p.age >= p.maxAge) { if (d <= step || p.age >= p.maxAge) {
p.x = p.tx; p.y = p.ty; p.x = p.tx; p.y = p.ty;
+1 -1
View File
@@ -320,7 +320,7 @@ export function resultDigest(result) {
const fnum = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d); 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 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 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); const keyOr = (v) => (isKey(v) ? v : null);
function compactMods(m) { function compactMods(m) {
+11 -11
View File
@@ -1085,26 +1085,26 @@
"bossHpLeft": null, "bossHpLeft": null,
"rngDraws": 0, "rngDraws": 0,
"errors": {"count":0,"kinds":[]}, "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": [ "units": [
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_narant",2331,1,10,9,139430,0,0,91,1], ["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",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",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",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",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,255194,0,0,170,4], ["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,18,305504,0,0,107,1], ["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,23,447750,0,0,177,4], ["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",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",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",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,10,236538,0,0,173,4] ["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]}, "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], "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":1001,"deploy":12,"die":111,"dmg":2296,"engage":12,"fx":453,"skill":45,"spawn":123,"status":245}, "events": {"atk":997,"deploy":12,"die":111,"dmg":2292,"engage":12,"fx":451,"skill":45,"spawn":123,"status":251},
"fx": {"aoe":137,"bounce":316}, "fx": {"aoe":137,"bounce":314},
"snaps": "b054f186 5f1791df 62fad2b9 772b925e 3c33e5ff 792df80b b43d3d8f 4283ce62 933a9c03 44991c3e b9347ef2 941827a5 73c09893 9ddf9d2c dbaf0a96 63e46fbe d21c76f5 55bdfa26 bfaf6c80 6968f2d8" "snaps": "b054f186 5f1791df 3d017ddc 6caa5d05 8c321828 7e0d74c4 924d8c48 bc44fa99 448a38b4 f74013c9 0e587d4d 2185ab3b 9482eaf7 c6e6f386 7ce17fa6 af9ad4b3 97bb47c5 500c935b 824afe6d 6e3d1bcb"
}, },
"diy-028": { "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", "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",
+19 -19
View File
@@ -755,7 +755,7 @@
"boost": null, "boost": null,
"setup": {"stageId":"act2autochess_m03","bossId":"boss_4","hiddenBossId":"boss_9","factions":["FLY","TIMES","REFLECTION"]}, "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}, "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}, "errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
"rounds": [ "rounds": [
{ {
@@ -808,27 +808,27 @@
}, },
{ {
"round": 9, "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_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] "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, "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_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, "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_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,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_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,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_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, "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_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,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_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/136, skillfulShip 0/4, soloShip 0/26, suntShip 0/18, swiftShip 0/14, yanShip 0/1",13,57,44,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/142, skillfulShip 0/4, soloShip 0/26, suntShip 0/18, swiftShip 0/14, yanShip 0/1",14,57,43,118]
} }
], ],
"players": { "players": {
@@ -839,12 +839,12 @@
"eliminatedRound": 10, "eliminatedRound": 10,
"lp": 0, "lp": 0,
"bandId": "band_chiave", "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, "trophies": 2,
"reward": 191, "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": [], "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": { "ai_1": {
"alive": false, "alive": false,
@@ -853,12 +853,12 @@
"eliminatedRound": 12, "eliminatedRound": 12,
"lp": 0, "lp": 0,
"bandId": "band_sarkazb", "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, "trophies": 2,
"reward": 255, "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": [], "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": { "ai_2": {
"alive": false, "alive": false,
@@ -867,12 +867,12 @@
"eliminatedRound": 12, "eliminatedRound": 12,
"lp": 0, "lp": 0,
"bandId": "band_lmlee", "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, "trophies": 2,
"reward": 255, "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": [], "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"]
} }
} }
}, },
+141
View File
@@ -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');
});