Files
ark-stronghold-protocol/server/sim/fear.js
T
Cloudnyco ae22335cfe feedback7: the battle sim uses engine-independent math — Chrome, Firefox, Safari and the server agree bit for bit (PR #373 by @Cloudnyco)
- server/sim/detmath.js: hypot (sqrt of the squares, rescaled outside 2^±500), powi (integer powers in double-double,
  one rounding), sin / cos (fdlibm 5.3) and atan2 (fdlibm / FreeBSD msun) built only from correctly rounded IEEE
  operations; the sim's Math.hypot / sin / cos / atan2 / pow calls go through it (65 files); eslint.config.js forbids
  the approximated Math functions under server/sim and test/sim/detmath.test.js scans for them.
- sin / cos / atan2 are the algorithms V8 runs in Node, so the server's results stay; only hypot differs from V8's.
  Golden: 2 moved — diy-027 (a hypot distance, damage +80) and coop3-ABYSS-5 (a later card pick from round 9, same end).
- Applied unchanged after review (Grok static review: MERGE; the diff read in full: no network, no new dependency).
- Tests: broad node --test 5155 / 5155 with the new file tracked (detmath 7 / 7, package 10 / 10); lint 0 errors,
  typecheck, check:imports.
2026-10-08 18:35:55 +08:00

237 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/sim/fear.js — 恐惧 movement of enemies (DESIGN §5.5; user playtest #6 item 13).
//
// Official rule (gamedata_const termDescriptionDict ba.fear "无法被阻挡并四散逃跑"; PRTS 术语释义 恐惧 "受影响的目标大体上会
// 尝试尽可能远离效果来源"; PRTS 诱发移动 §恐惧 — the fear controller):
// * 恐惧可达地块: when a fear is applied the controller records the tiles whose CENTRE lies within 10 tiles of the hit
// position (the feared unit's position then), inside the 90° fan pointing away from the effect source (±45°), that
// can reach the unit's goal, are passable terrain (obstacles or not) and are not the protection objective (no
// 地穴 in this mode). Without a source, with the unit itself as the source (自惧 / "对自身施加…恐惧", e.g. the
// “萨科塔” enemies' SelfFear) or with the source exactly on the hit position there is NO reachable tile.
// * 恐惧移动: the unit picks a random reachable tile as a temporary checkpoint, offset at random inside a square of
// side 0.5 around the tile centre, and moves there; on arrival it picks the next one, until the fear ends. A picked
// tile farther than 5 path steps is dropped from the list for good and the unit's own tile is used instead; with
// no reachable tile the checkpoint is always its own tile (same offset) — so a self-feared unit flutters around
// inside its tile ("小范围乱飞"). A new fear refreshes the list from the current position (Battle.applyStatus
// stamps `buff.data.fear` on every application). Waiting checkpoints keep counting down meanwhile; when the fear
// ends the unit re-plans its route from where it stands ("恐惧效果结束时立刻清除【恐惧移动】" — so does a running
// 诱导, ai.js moveAttracted). 恐惧 outranks 诱导 (PRTS 诱移优先级).
// Path steps (the 5-step limit): 4-neighbour steps over the unit's passable, obstacle-free tiles, the checkpoint tile
// included (ground: walkable, blocks and crates excluded — a crate's move cost 1000 is beyond any 5-step budget and a
// block has no path; flyers: fly-passable), like the official SPFA's unit edges.
// The temporary path: ground units walk the grid's smoothed flow-field waypoints to the checkpoint's tile (stepping
// back to their tile centre first when the straight line from an off-centre position would clip a tile they cannot
// walk, as ai.js planLeg), flyers fly straight. Like every enemy path it re-plans from where the unit stands when
// something else moved it (a push / pull — Battle.displace) or an obstacle changed (grid.version): to the same
// checkpoint while it stays reachable, else to a new pick. Speed = the unit's own (move-speed buffs such as the
// SelfFear ×1.5 included). Deterministic: every draw is battle.rng().
import { MOVE_SCALE, COLS } from './constants.js';
import { straightClear } from './grid.js';
import { hypot } from './detmath.js';
/** 半径10格 — the fan's radius (tiles). */
export const FEAR_RADIUS = 10;
/** 左右各45° — cos of the fan's half angle. */
export const FEAR_HALF_COS = Math.SQRT1_2;
/** 寻路距离5格内 — the farthest checkpoint in path steps. */
export const FEAR_REACH = 5;
/** 0.5边长正方形的随机偏移 — side of the random offset square around a checkpoint tile's centre. */
export const FEAR_JITTER = 0.5;
/**
* Stamp a fear application on its buff (Battle.applyStatus): the hit position, the source's position and whether
* the unit feared itself. Every application gets a new per-battle `seq`, which makes the mover rebuild its tiles.
*/
export function stampFear(b, target, buff, source) {
const self = !source || source === target;
b._fearSeq = (b._fearSeq || 0) + 1;
buff.data = {
...buff.data,
fear: {
seq: b._fearSeq, hx: target.x, hy: target.y, self,
sx: self ? target.x : Number(source.x), sy: self ? target.y : Number(source.y),
},
};
}
/** The latest fear stamp among the unit's fear buffs (null when none carries one — e.g. a raw addBuff). */
function stampOf(e) {
let best = null;
for (const b of e.buffs) {
const f = (b.status === 'fear' || b.key === 'fear') && b.data ? b.data.fear : null;
if (f && (!best || f.seq > best.seq)) best = f;
}
return best;
}
/** The goal tile of the enemy's route (its final leg), or null. */
function goalOf(e) {
const legs = e.route && e.route.legs;
if (!legs) return null;
for (let i = legs.length - 1; i >= 0; i--) if (legs[i].final && legs[i].r != null) return legs[i];
return null;
}
/** Passable terrain for this unit, obstacles or not ("不论该格是否有障碍物"). */
const passable = (g, fly, r, c) => (fly ? g.flyPassable(r, c) : g.walkable(r, c, true));
/**
* 恐惧可达地块 of a stamp: tile keys in row-major order (deterministic).
* @returns {number[]}
*/
export function reachableTiles(b, e, st) {
if (!st || st.self) return [];
const dx = st.hx - st.sx, dy = st.hy - st.sy, d = hypot(dx, dy);
if (!(d > 1e-9)) return [];
const ux = dx / d, uy = dy / d;
const g = b.grid, R = g.rect, fly = e.motion === 'FLY';
const goal = goalOf(e);
let field = null, field2 = null;
if (!fly && goal) { field = g.flowField(goal.r, goal.c); field2 = g.flowField(goal.r, goal.c, { ignoreObstacles: true }); }
const out = [];
for (let r = R.r0; r <= R.r1; r++) {
for (let c = R.c0; c <= R.c1; c++) {
const vx = c - st.hx, vy = r - st.hy, L = hypot(vx, vy);
if (L > FEAR_RADIUS + 1e-9) continue;
if (L > 1e-9 && vx * ux + vy * uy < FEAR_HALF_COS * L - 1e-9) continue;
if (!passable(g, fly, r, c) || g.tile(r, c).special === 'end') continue;
const k = r * COLS + c;
if (field && field.dist[k] < 0 && field2.dist[k] < 0) continue; // 该格可抵达其终点
out.push(k);
}
}
return out;
}
/** Whether the unit can stand on / step through tile key `k` (its passable terrain; ground: no block, no crate). */
function openTile(g, fly, k) {
const r = (k / COLS) | 0, c = k - r * COLS;
return passable(g, fly, r, c) && (fly || !g.isObstacle(r, c));
}
/**
* Path steps (4-neighbour, the unit's open tiles, `to` included) from tile key `from` to `to`, or Infinity beyond
* `limit` — an obstacle tile has no path ("寻路距离"), so a pick on one is dropped like a far one.
*/
function stepsBetween(b, e, from, to, limit) {
if (from === to) return 0;
const g = b.grid, fly = e.motion === 'FLY';
const open = (k) => openTile(g, fly, k);
if (!open(to)) return Infinity;
let frontier = [from];
const seen = new Set(frontier);
for (let d = 1; d <= limit && frontier.length; d++) {
const next = [];
for (const k of frontier) {
const r = (k / COLS) | 0, c = k - r * COLS;
for (const [dr, dc] of [[1, 0], [0, 1], [-1, 0], [0, -1]]) {
const rr = r + dr, cc = c + dc;
if (!g.inRect(rr, cc)) continue;
const kk = rr * COLS + cc;
if (seen.has(kk)) continue;
if (kk === to) return d;
seen.add(kk);
if (open(kk)) next.push(kk);
}
}
frontier = next;
}
return Infinity;
}
/** The unit's tile (row, col, key), clamped to the rect. */
function hereOf(g, e) {
const r = Math.max(g.rect.r0, Math.min(g.rect.r1, Math.round(e.y)));
const c = Math.max(g.rect.c0, Math.min(g.rect.c1, Math.round(e.x)));
return { r, c, k: r * COLS + c };
}
/**
* The temporary path from where the unit stands to its checkpoint (`m.k` tile, `m.goal` point): ground units follow
* the smoothed flow-field waypoints (null when an obstacle now cuts the tile off), flyers fly straight. Records the
* grid version; moveFeared records where each step left the unit and re-plans when either changes behind its back.
* @returns {boolean} false when a ground unit can no longer reach the checkpoint tile
*/
function planPath(b, e, m) {
const g = b.grid, h = hereOf(g, e);
const pts = [];
if (e.motion !== 'FLY' && m.k !== h.k) {
if (!openTile(g, false, m.k)) return false;
const r = (m.k / COLS) | 0, c = m.k - r * COLS;
const wp = g.waypoints(h.r, h.c, r, c);
if (!wp) return false;
for (let j = 1; j < wp.length - 1; j++) pts.push({ x: wp[j][1], y: wp[j][0] });
// the smoothed chain is line-of-sight clear from the tile CENTRE only (as ai.js planLeg)
const first = pts.length ? pts[0] : { x: c, y: r };
if ((e.x !== h.c || e.y !== h.r) && !straightClear(g, e.x, e.y, first)) pts.unshift({ x: h.c, y: h.r });
}
pts.push(m.goal);
m.pts = pts;
m.i = 0;
m.ver = g.version;
return true;
}
/** Choose the next temporary checkpoint (a random reachable tile within 5 path steps, else the own tile) and plan to it. */
function pickCheckpoint(b, e, m) {
const here = hereOf(b.grid, e).k;
let k = here;
if (m.tiles.length) {
const i = Math.floor(b.rng() * m.tiles.length);
const cand = m.tiles[i];
if (stepsBetween(b, e, here, cand, FEAR_REACH) <= FEAR_REACH) k = cand;
else m.tiles.splice(i, 1); // "…格子会永久从当次恐惧的恐惧可达地块中剔除"
}
const ox = (b.rng() - 0.5) * FEAR_JITTER, oy = (b.rng() - 0.5) * FEAR_JITTER;
const aim = (key) => {
const r = (key / COLS) | 0, c = key - r * COLS;
m.k = key;
m.goal = { x: c + ox, y: r + oy };
return planPath(b, e, m);
};
// (a pick within 5 open steps has a path unless the unit itself stands on a block; the own tile needs none)
if (!aim(k)) aim(here);
}
/**
* One tick of fear movement for enemy `e` (ai.js updateEnemy, while `e.s.flags.fear`). Sets `e.moving`.
*/
export function moveFeared(b, e, dt) {
const st = stampOf(e);
let m = e.mem.fearMove;
if (!m || (st && m.seq !== st.seq)) {
m = e.mem.fearMove = { seq: st ? st.seq : -1, tiles: reachableTiles(b, e, st), pts: null, i: 0 };
}
// pushed / pulled (Battle.displace) or an obstacle changed since the last tick: re-plan the walk from here — to the
// same checkpoint while it stays reachable, else a new pick (a stale path would walk through walls)
if (m.pts && (m.ver !== b.grid.version || e.x !== m.px || e.y !== m.py) && !planPath(b, e, m)) m.pts = null;
// a waiting checkpoint of the route keeps counting down ("停驻时间会相应扣除恐惧生效时间")
const R = e.route, leg = R && R.legs ? R.legs[R.legIdx] : null;
if (leg && leg.t === 'wait') {
if (R.waitLeft == null) R.waitLeft = leg.time;
R.waitLeft -= dt;
if (R.waitLeft <= 1e-9) { R.waitLeft = null; R.legIdx++; }
}
if (R) R.pts = null; // the route re-plans from wherever the fear leaves the unit
const speed = e.s.moveSpeed * MOVE_SCALE;
let moved = false;
if (speed > 0) {
let dist = speed * dt;
for (let guard = 8; dist > 1e-9 && guard > 0; guard--) {
if (!m.pts || m.i >= m.pts.length) pickCheckpoint(b, e, m);
const p = m.pts[m.i];
const dx = p.x - e.x, dy = p.y - e.y, d = hypot(dx, dy);
if (d <= dist) { e.x = p.x; e.y = p.y; dist -= d; m.i++; } else { e.x += (dx / d) * dist; e.y += (dy / d) * dist; dist = 0; }
moved = moved || d > 1e-9;
}
}
m.px = e.x; m.py = e.y;
e.moving = moved;
}
/** The fear ended: forget the controller; the route re-plans from here (route.pts was cleared while feared). */
export function endFear(e) {
e.mem.fearMove = null;
if (e.route) e.route.pts = null;
}