Files
ark-stronghold-protocol/test/sim/playtest6-enemies.test.js
T
sganggs 6464e4b46a Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
2026-10-01 19:08:23 +08:00

298 lines
19 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.
// test/sim/playtest6-enemies.test.js — user playtest #6, workstream WG (enemies), real enemy data in the real sim:
// #13 “萨科塔之翼”'s 恐惧 (the user's "战栗(小范围乱飞)"): below half HP it fears itself for 5 s (SelfFear, ×1.5 speed) —
// official 恐惧 = "无法被阻挡并四散逃跑" (ba.fear); a self-inflicted fear has no 恐惧可达地块, so the unit keeps picking
// a random point of its own tile (±0.25) and flies there (PRTS 诱发移动 §恐惧). It used to hang still. Also
// “萨科塔之眼” / “萨科塔昂首” (same talent) and an operator's fear (runs away from the source, fan of ±45°).
// #12 萨卡兹枯朽战车 / 尖端: initial SP 2 = max ⇒ the FIRST attack is 秽蚀轰击 (100 % + a 【污染秽蚀】 of radius 1.7 for 10 s),
// then every 3rd; 污染秽蚀 = 50 / 25 true damage per second on low / high ground, same-name zones do not stack
// (PRTS 萨卡兹枯朽战车 / 萨卡兹枯朽战士). It used to pollute only from the 3rd attack, radius 1 [ASSUMED], HP loss, stacking.
// Review follow-up: the 恐惧 and 诱导 walks re-plan from where the enemy stands after a push (Battle.displace), an
// obstacle change or (诱导) an outranking 恐惧 — a stale path walked through walls (real stages m01–m04).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
import * as enemiesMod from '../../server/sim/content/enemies.js';
import * as bossesMod from '../../server/sim/content/bosses.js';
import { reachableTiles, FEAR_RADIUS } from '../../server/sim/fear.js';
import { createRng } from '../../server/sim/rng.js';
import { ALLY_COLLIDER_RADIUS } from '../../server/sim/constants.js';
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const tb = (key, k) => E[key].talents.bb[k];
const WALL = (id, o = {}) => chessRec({ id, profession: o.profession ?? 'TANK', position: o.position, stats: { atk: 0, maxHp: 1e7, def: 0, res: 0, blockCnt: o.block ?? 3 }, rangeGrid: [[0, 0]], skill: null });
const NOATK = () => ({ trait: { noAttack: true } });
function arena(o = {}) {
const { chess = {}, kits = {}, ...rest } = o;
return makeBattle({
content: 'generic', extraContent: [enemiesMod, bossesMod], seed: 11, autoFinish: false, timeLimit: 600,
defs: { chess: { t_wall: WALL('t_wall'), t_wall2: WALL('t_wall2'), t_hi: WALL('t_hi', { profession: 'SNIPER', position: 'RANGED', block: 0 }), ...chess } },
kits: { t_wall: NOATK, t_wall2: NOATK, t_hi: NOATK, ...kits }, ...rest,
});
}
/** Positions of `e` over `secs` game seconds (one sample per tick). */
function track(h, e, secs) {
const out = [];
const n = Math.round(secs * 30);
for (let i = 0; i < n; i++) { h.step(); out.push({ t: h.b.time, x: e.x, y: e.y, moving: e.moving, blocked: !!e.blockedBy, atk: e.stats.attacks }); }
return out;
}
const pathLen = (p) => p.reduce((s, q, i) => (i ? s + Math.hypot(q.x - p[i - 1].x, q.y - p[i - 1].y) : 0), 0);
/** Sign changes of the horizontal movement (ignoring standstill). */
function turns(p) {
let n = 0, last = 0;
for (let i = 1; i < p.length; i++) {
const dx = p[i].x - p[i - 1].x;
if (Math.abs(dx) < 1e-6) continue;
const s = dx > 0 ? 1 : -1;
if (last && s !== last) n++;
last = s;
}
return n;
}
describe('#13 “萨科塔” self-fear: flutters at random inside a small area, then flies on', () => {
for (const key of ['enemy_10083_hlbird', 'enemy_10084_hlegle', 'enemy_10085_hllevi_2']) {
test(`${key} ${E[key].name}: 5 s of random flight inside its tile (±0.25), ×1.5 speed, then its route again`, () => {
const h = arena({ units: [{ chessId: 't_hi', row: 11, col: 2 }] });
const e = h.spawn(key, { routeIndex: 2 }); // flat stage fly lane (9,10) → (9,2)
h.run(4); // on its way (x ≈ 10 − 0.45·4)
assert.equal(e.motion, 'FLY');
const x0 = e.x;
h.b.dealDamage(h.unit('t_hi'), e, { amount: e.s.maxHp * 0.6, type: 'true' });
assert.ok(e.s.flags.fear, 'feared');
const tr = Math.round(e.y), tc = Math.round(e.x);
const fear = tb(key, 'SelfFear.fear');
const p = track(h, e, fear - 0.1);
const settled = p.filter((q) => q.t > h.b.time - fear + 1.2);
for (const q of p) assert.ok(Math.abs(q.x - tc) <= 0.5 + 1e-6 && Math.abs(q.y - tr) <= 0.5 + 1e-6, `stays on its tile: ${q.x.toFixed(2)},${q.y.toFixed(2)}`);
for (const q of settled) assert.ok(Math.abs(q.x - tc) <= 0.25 + 1e-6 && Math.abs(q.y - tr) <= 0.25 + 1e-6, `inside the 0.5 square: ${q.x.toFixed(2)},${q.y.toFixed(2)}`);
const len = pathLen(p);
const speed = e.base.moveSpeed * 0.5 * tb(key, 'SelfFear.move_speed');
assert.ok(len > 0.6 * speed * fear, `keeps flying (${len.toFixed(2)} tiles in ${fear} s, speed ${speed})`);
assert.ok(turns(p) >= 3, `changes direction at random (${turns(p)} turns)`);
assert.ok(p.filter((q) => q.moving).length > 0.8 * p.length, 'animated as moving');
assert.ok(Math.abs(p[p.length - 1].x - x0) < 0.6, 'does not advance along its route meanwhile');
h.run(0.2);
assert.ok(!e.s.flags.fear, 'the fear ended');
const xe = e.x;
h.run(3);
assert.ok(e.x < xe - 0.8 * e.s.moveSpeed * 0.5 * 3, `flies on toward the goal (${xe.toFixed(2)} → ${e.x.toFixed(2)})`);
});
}
test('deterministic: the same seed flies the same wander', () => {
const run = () => {
const h = arena({ units: [{ chessId: 't_hi', row: 11, col: 2 }] });
const e = h.spawn('enemy_10083_hlbird', { routeIndex: 2 });
h.run(3);
h.b.dealDamage(null, e, { amount: e.s.maxHp * 0.6, type: 'true' });
return track(h, e, 4).map((q) => `${q.x.toFixed(6)},${q.y.toFixed(6)}`).join(' ');
};
assert.equal(run(), run());
});
});
describe('#13 恐惧 from an operator: unblockable, flees into the fan away from the source', () => {
test('a walking enemy feared by an operator west of it runs east (±45°), never attacks, then walks its route again', () => {
const walker = { key: 'enemy_walker', name: 'w', rank: 'NORMAL', applyWay: 'MELEE', stats: { maxHp: 1e7, atk: 100, def: 0, res: 0, moveSpeed: 1, bat: 1, aspd: 100, rangeRadius: 0, blockCnt: 1, massLevel: 1, lpr: 1, dmgType: 'phys', motion: 'WALK', immunities: {}, otherImmunities: [] }, talents: { bb: {} }, skills: [], abilities: [], tags: [] };
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }], defs: { chess: { t_wall: WALL('t_wall') }, enemies: { enemy_walker: walker } } });
const e = h.spawn('enemy_walker', { routeIndex: 0 });
const u = h.unit('t_wall');
assert.ok(h.runUntil(() => e.blockedBy === u, 20), 'blocked first');
h.b.applyStatus(e, 'fear', { duration: 4, source: u });
assert.equal(e.blockedBy, null, 'released');
const tiles = reachableTiles(h.b, e, e.findBuff('fear').data.fear);
assert.ok(tiles.length > 3, `a fan of reachable tiles (${tiles.length})`);
for (const k of tiles) {
const r = Math.floor(k / 21), c = k % 21;
const vx = c - e.x, vy = r - e.y, L = Math.hypot(vx, vy);
assert.ok(L <= FEAR_RADIUS + 1e-6 && (L < 1e-6 || vx / L >= Math.SQRT1_2 - 1e-6), `(${r},${c}) inside the eastern fan`);
assert.ok(h.b.grid.walkable(r, c, true) && h.b.grid.tile(r, c).special !== 'end');
}
const d0 = Math.hypot(e.x - u.x, e.y - u.y);
const a0 = e.stats.attacks;
const p = track(h, e, 3.9);
assert.ok(p.every((q) => !q.blocked), 'never blocked while feared');
assert.equal(e.stats.attacks, a0, 'no attack while feared');
assert.ok(pathLen(p) > 1.5, 'it runs');
assert.ok(Math.hypot(e.x - u.x, e.y - u.y) > d0 + 0.5, 'farther from the source');
for (const q of p) assert.ok(h.b.grid.walkable(Math.round(q.y), Math.round(q.x), true), 'on walkable ground');
h.run(0.3);
assert.ok(!e.s.flags.fear);
assert.ok(h.runUntil(() => e.blockedBy === u, 20), 'walks back its route and is blocked again');
});
test('恐惧 outranks 诱导 (PRTS 诱移优先级)', () => {
const walker = { key: 'enemy_walker', name: 'w', rank: 'NORMAL', applyWay: 'MELEE', stats: { maxHp: 1e7, atk: 0, def: 0, res: 0, moveSpeed: 1, bat: 1, aspd: 100, rangeRadius: 0, blockCnt: 1, massLevel: 1, lpr: 1, dmgType: 'none', motion: 'WALK', immunities: {}, otherImmunities: [] }, talents: { bb: {} }, skills: [], abilities: [], tags: [] };
const h = arena({ units: [{ chessId: 't_hi', row: 11, col: 2 }], defs: { chess: { t_hi: WALL('t_hi', { profession: 'SNIPER', position: 'RANGED', block: 0 }) }, enemies: { enemy_walker: walker } } });
const e = h.spawn('enemy_walker', { routeIndex: 0, pos: [9, 6], mods: { speedMul: 1 } });
h.step();
h.b.applyStatus(e, 'attract', { duration: 3, point: [12, 6] });
h.b.applyStatus(e, 'fear', { duration: 3, source: null }); // no source: no reachable tile, flutters on its tile
const r0 = Math.round(e.y);
track(h, e, 2.5);
assert.equal(Math.round(e.y), r0, 'stays on its tile instead of walking to the attract point');
});
});
describe('#13 恐惧 / 诱导 walks re-plan when something else moves the enemy (review follow-up)', () => {
const WALKER = { key: 'enemy_walker', name: 'w', rank: 'NORMAL', applyWay: 'MELEE', stats: { maxHp: 1e7, atk: 0, def: 0, res: 0, moveSpeed: 1, bat: 1, aspd: 100, rangeRadius: 0, blockCnt: 1, massLevel: 0, lpr: 1, dmgType: 'none', motion: 'WALK', immunities: {}, otherImmunities: [] }, talents: { bb: {} }, skills: [], abilities: [], tags: [] };
const STAGES = ['act2autochess_m01', 'act2autochess_m02', 'act2autochess_m03', 'act2autochess_m04'];
/** A walker on a real stage at a deterministic walkable tile of the lower lanes. */
function realWalker(stageId, seed, pick) {
const h = makeBattle({ stageId, seed, autoFinish: false, timeLimit: 300, content: 'generic', defs: { enemies: { enemy_walker: WALKER } },
routes: [{ motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] }] });
const g = h.b.grid;
const walk = [];
for (let r = 9; r <= 12; r++) for (let c = 2; c <= 18; c++) if (g.walkable(r, c) && g.tile(r, c).special !== 'end') walk.push([r, c]);
const e = h.spawn('enemy_walker', { routeIndex: 0, pos: walk[pick(walk.length)] });
h.step();
return { h, g, e, walk };
}
/** Ticks (over `secs`) the enemy spent on a tile it cannot walk, `when(e)` filtering the ticks counted. */
function offGround(h, g, e, secs, when = () => true) {
let n = 0;
for (let i = 0; i < Math.round(secs * 30); i++) { h.step(); if (when(e) && !g.walkable(Math.round(e.y), Math.round(e.x), true)) n++; }
return n;
}
test('a feared walker pushed mid-hop (Battle.displace) re-plans from where it lands: it never cuts through walls', () => {
const rng = createRng(5);
let pushed = 0;
const bad = [];
for (const stageId of STAGES) {
for (let n = 0; n < 60; n++) {
const { h, g, e } = realWalker(stageId, n + 1, (len) => (n * 7 + 3) % len);
const a1 = rng() * Math.PI * 2, a2 = rng() * Math.PI * 2;
h.b.applyStatus(e, 'fear', { duration: 4, source: { x: e.x + Math.cos(a1), y: e.y + Math.sin(a1), side: 'ally' } });
h.run(0.3 + rng());
if (h.b.displace(e, { x: Math.cos(a2), y: Math.sin(a2) }, 1.5, { force: 3 }) > 0) pushed++;
if (offGround(h, g, e, 4)) bad.push(`${stageId}#${n + 1}`);
}
}
assert.ok(pushed > 150, `pushes landed (${pushed})`);
assert.deepEqual(bad, [], 'walked onto an unwalkable tile after the push');
});
test('an obstacle appearing on a feared walker\'s path (grid.version) is walked around, not into', () => {
let tried = 0;
for (let seed = 1; seed <= 60; seed++) {
const h = arena({ seed, defs: { enemies: { enemy_walker: { ...WALKER, stats: { ...WALKER.stats, massLevel: 1 } } } } });
const e = h.spawn('enemy_walker', { routeIndex: 0, pos: [10, 8] });
h.step();
h.b.applyStatus(e, 'fear', { duration: 6, source: { x: 6, y: 10, side: 'ally' } }); // flee east
h.step();
const m = e.mem.fearMove;
const p = m && m.pts && m.pts[m.i];
if (!p) continue;
const dx = p.x - e.x, dy = p.y - e.y, d = Math.hypot(dx, dy);
if (d < 1.2) continue;
const r = Math.round(e.y + dy / d), c = Math.round(e.x + dx / d); // the tile one step ahead
if (r === Math.round(e.y) && c === Math.round(e.x)) continue;
h.b.setObstacle(r, c, true);
tried++;
const t = track(h, e, 3);
assert.ok(t.every((q) => Math.round(q.y) !== r || Math.round(q.x) !== c), `seed ${seed}: kept off the new obstacle (${r},${c})`);
assert.ok(pathLen(t) > 1, `seed ${seed}: still running`);
}
assert.ok(tried >= 20, `obstacles placed ahead in ${tried} runs`);
});
test('诱导 after an outranking 恐惧, and 诱导 after a push, re-plan from where the enemy stands (no wall cutting)', () => {
const rng = createRng(77);
const afterFear = [], afterPush = [];
for (const stageId of STAGES) {
for (let n = 0; n < 60; n++) {
{
const { h, g, e, walk } = realWalker(stageId, n + 1, (len) => Math.floor(rng() * len));
h.b.applyStatus(e, 'attract', { duration: 14, point: walk[Math.floor(rng() * walk.length)] });
h.run(0.5 + rng() * 2);
const ang = rng() * Math.PI * 2;
h.b.applyStatus(e, 'fear', { duration: 2 + rng() * 2, source: { x: e.x + Math.cos(ang), y: e.y + Math.sin(ang), side: 'ally' } });
if (offGround(h, g, e, 10, (x) => !x.s.flags.fear)) afterFear.push(`${stageId}#${n + 1}`);
}
{
const { h, g, e, walk } = realWalker(stageId, n + 1, (len) => (n * 7 + 3) % len);
h.b.applyStatus(e, 'attract', { duration: 6, point: walk[(n * 13 + 5) % walk.length] });
h.run(0.3 + rng());
const a = rng() * Math.PI * 2;
h.b.displace(e, { x: Math.cos(a), y: Math.sin(a) }, 1.5, { force: 3 });
if (offGround(h, g, e, 4)) afterPush.push(`${stageId}#${n + 1}`);
}
}
}
assert.deepEqual(afterFear, [], 'the 诱导 walk after the 恐惧 cut through an unwalkable tile');
assert.deepEqual(afterPush, [], 'the 诱导 walk after a push cut through an unwalkable tile');
});
});
describe('#12 萨卡兹枯朽战车: 秽蚀轰击 first, 污染秽蚀 radius 1.7, true damage, no stacking', () => {
for (const key of ['enemy_1272_nhtank', 'enemy_1272_nhtank_2']) {
test(`${key} ${E[key].name}: attacks 1, 4, 7 are 秽蚀轰击 (100 % + zone r 1.7 / 10 s); the zone hits 50 / 25 true per second`, () => {
// (10,7) road = low ground; (11,7) raised to high ground (ranged tile) inside the zone
const flat = { rows: { 11: '##hrrrrhrrfrrrrrrrf##' } };
const h = arena({ flat, units: [{ chessId: 't_wall', row: 10, col: 7 }, { chessId: 't_hi', row: 11, col: 7 }], captureNoisy: true, hooks: ['damaged', 'attack'] });
h.step();
const e = h.spawn(key, { pos: [9, 8], routeIndex: 0, mods: { speedMul: 0 } });
h.runUntil(() => e.stats.attacks >= 7, 60);
h.run(1); // the 7th shot lands
const fx = h.eventsOf('fx').filter((f) => f[1] === 'zone' && f[4].kind === 'pollution');
assert.equal(fx.length, 3, `three zones in seven attacks (${fx.length})`);
for (const f of fx) { assert.equal(f[4].r, 1.7, 'radius 1.7 (PRTS)'); assert.equal(f[4].dur, E[key].skills[0].bb.projectile_life_time); }
const dmg = h.hooksOf('damaged').filter((c) => c.source === e);
const atk = e.s.atk;
const hits = dmg.filter((c) => c.dmg.isAttack && c.target === h.unit('t_wall')).map((c) => c.amount / atk);
assert.deepEqual(hits.slice(0, 7).map((x) => Math.round(x * 100) / 100), [1, 1, 1, 1, 1, 1, 1], 'unblocked: 100 % per attack');
const poll = dmg.filter((c) => (c.dmg.tags || []).includes('pollution'));
assert.ok(poll.length > 0 && poll.every((c) => c.type === 'true'), '真实伤害');
const lo = poll.filter((c) => c.target === h.unit('t_wall')), hi = poll.filter((c) => c.target === h.unit('t_hi'));
assert.ok(lo.length > 5 && lo.every((c) => Math.abs(c.amount - 50) < 1e-6), 'low ground 50 / s');
assert.ok(hi.length > 5 && hi.every((c) => Math.abs(c.amount - 25) < 1e-6), 'high ground 25 / s (it never targets the high ground)');
assert.equal(dmg.filter((c) => c.dmg.isAttack && c.target === h.unit('t_hi')).length, 0);
// the first zone is up before the second attack
const t1 = h.hooksOf('attack').filter((c) => c.attacker === e)[1].t;
assert.ok(lo.some((c) => c.t < t1 + 1e-6), 'polluting from the first attack on');
});
}
test('reach: its 2.2 range circle takes an operator whose 0.25 collider touches it (PRTS 作战机制 §碰撞体积) — 2.45 from the centre', () => {
// user follow-up (2026-10-01): "远程攻击射程挺远、伤害很高". The data range stays 2.2; the official target search
// uses the operators' colliders, which the remake measured from the centre (2.2 reached 13 tiles around it, 2.45 21)
assert.equal(ALLY_COLLIDER_RADIUS, 0.25);
assert.equal(E.enemy_1272_nhtank.stats.rangeRadius, 2.2);
const hitsOn = (r, c) => {
const h = arena({ units: [{ chessId: 't_wall', row: r, col: c }], captureNoisy: true, hooks: ['damaged'] });
h.step();
const e = h.spawn('enemy_1272_nhtank', { pos: [10, 5], routeIndex: 0, mods: { speedMul: 0 } });
h.run(20);
return h.hooksOf('damaged').filter((x) => x.source === e && x.dmg.isAttack).length;
};
assert.ok(hitsOn(10, 7) >= 4, 'd 2.0: in range');
assert.ok(hitsOn(11, 7) >= 4, 'd 2.236 > 2.2: in range through the collider (2.45)');
assert.ok(hitsOn(12, 6) >= 4, 'd 2.236 (the other diagonal)');
assert.equal(hitsOn(12, 7), 0, 'd 2.83 > 2.45: out of range');
assert.equal(hitsOn(10, 8), 0, 'd 3.0: out of range');
});
test('blocked: normal attacks hit ×2 (近战), the skill attack 100 %; overlapping 污染秽蚀 do not stack', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }], captureNoisy: true, hooks: ['damaged'] });
h.step();
const a = h.spawn('enemy_1272_nhtank', { pos: [9, 5.6], routeIndex: 0, mods: { speedMul: 0 } });
const b2 = h.spawn('enemy_1272_nhtank', { pos: [9, 5.7], routeIndex: 0, mods: { speedMul: 0 } });
h.runUntil(() => a.stats.attacks >= 4 && b2.stats.attacks >= 4, 60);
const w = h.unit('t_wall');
const blockedBy = (e) => h.hooksOf('damaged').filter((c) => c.source === e && c.dmg.isAttack && c.target === w).map((c) => Math.round((c.amount / e.s.atk) * 100) / 100);
const blocked = [a, b2].find((e) => e.blockedBy === w);
assert.ok(blocked, 'one of them is blocked');
assert.deepEqual(blockedBy(blocked).slice(0, 4), [1, 2, 2, 1], 'skill 100 %, melee 200 %, 200 %, skill 100 %');
// both tanks' zones cover the wall: one 50 per second, not two
const poll = h.hooksOf('damaged').filter((c) => c.target === w && (c.dmg.tags || []).includes('pollution'));
assert.ok(poll.length > 4);
for (let i = 1; i < poll.length; i++) assert.ok(poll[i].t - poll[i - 1].t > 1 - 1e-6, `one tick per second (${poll[i - 1].t.toFixed(2)} → ${poll[i].t.toFixed(2)})`);
});
});