Files
ark-stronghold-protocol/test/sim/unbalance.test.js
T
sganggs cdf2bd5c88 feedback7 R22-UNBAL: a pushed / pulled enemy is held in the official 失衡 (UNBALANCE) state — no walking, no normal attack — for the push's 位移时间 of its 受力等级 or the pull's force window; a 静态刚体 hit by a force gets the 0.1 s floor; the client's slide takes the same game time (idea from PR #392 by @xcdoge, re-implemented from the sources); golden: 69 scenarios move, 35 with kills / leaks / deaths / boss HP (below)
Seen: the sim moved a displaced enemy at once and it walked / swung again on the next tick; players compared with the
official game (PR #392: 「被推/拉完的敌人会先站住一小会儿再走」). Research (scratchpad report r3): PRTS 失衡位移机制 — a force > 0
puts an enemy in the UNBALANCE state machine with a 0.1 s 失衡硬直 floor, ending when its speed is ≤ 0.1 m/s, on 浮空 or a
forced state change; 异常效果图鉴/失衡免疫 「失衡期间无法自主移动、发动攻击、使用技能」; 异常效果 — not an abnormal status (水遁忍者's
check still runs); 游戏数据基础 推力-位移近似对应表 位移时间 (whole frames at 30 fps); 推与拉 拉力 「作用时间默认为 1 s;若本次受力
等级 < −1,作用时间改为 0.5 s」, 急停 keeps the state to the window's end; 特殊机制 静态刚体 (0.1 s 保底); 术语释义 浮空.

Now: constants.js PUSH_UNBALANCE {−2: 0.2, −1: 0.4, 0: 0.8, 1: 0.9, 2: 32/30, 3: 35/30}, PULL_UNBALANCE 1 /
PULL_UNBALANCE_WEAK 0.5, UNBALANCE_MIN 0.1. battle/displacement.js: push() holds for its level's row (the −2 correction
included), kept when the 特效 column or a wall shortens the slide [ASSUMED for the wall], the 0.1 s floor when a wall
stops it at the first step [ASSUMED]; pull() holds for its window whatever the travel (≤ −3: none; an enemy the puller
blocks: still no pull [ASSUMED, unchanged]); a 静态刚体 (data staticBody, not 失衡免疫 noDisplace) hit by force level
≥ −2 gets 0.1 s and no movement (_staticForce); _unbalance sets enemy.unbalanceUntil (a longer running one kept) and ends
the attack clip's stand. ai.js updateEnemy: no walking (恐惧 / 诱导 too — PR #392's fear exemption is not taken), no new
normal attack, a swing short of its frame cut, 浮空 clears it; a 重生 clears it (archetypes.js reborn). Not a stun: no
status, no immunity, SP and the attack cooldown run. No global skill lock: enemy abilities keep ticking ([ASSUMED] scope).
The move stays instant (the unbalanced() talents keyed off the jump unchanged). The displace fx carries `dur` (game s);
render/units.js slideTo divides it by ctx.animRate (2× default: a 0.8 s push slides 0.4 real s); DISPLACE_SLIDE·√D is
only the fallback for an fx without it. Not taken from #392: UNBALANCE_TRAVEL 0.6387·√tiles / the client's 0.3194 (derived
from the page's μ = 0.5 单位假设, one frame high on every push row, the wrong shape for pulls) and the fear exemption.

Tests: test/sim/unbalance.test.js (8: the push rows and walking after them; no attack in the hold, a feared enemy held;
the −2 correction's 0.2 s; 特效 / wall-shortened rows and the first-step floor; pull windows incl. the 急停 and ≤ −3; 静态刚体
0.1 s without movement, 失衡免疫 never; 浮空 clears; the fx `dur` — the file fails on the parent); test/render/unitview.test.js
(+1: the slide at 2× for a push and a pull, the fallback — fails on the parent); feedback1b-displacement B5 (the drone is
now held 0.1 s per force, never moved). test/sim + test/content + test/render: 3433 pass. Docs: SIM.md (push / pull,
displace, assumptions), PLAYING.md 推开 / 拉拽, research 13 §0 / §5.

Golden — every moved battle has pushes / pulls (probe of the callers) and its enemies are now held; outcome moves:
roster-002 (野鬃: killed 29→33, leaks 15→11), roster-003 (野鬃: deaths 3→7), roster-007 (薄绿: killed 110→118, leaks 53→47),
roster-008 (薄绿: 45→47 / 42→40), roster-010 (歌蕾蒂娅: killed 45→44, leaks 57→58), roster-011 (莫斯提马: 78→79 / 4→3),
roster-031 (钩索师 pulls, tier4: killed 64→72, leaks 12→4, deaths 6→5), roster-041 (钩索师: deaths 5→4), roster-043 (山:
deaths 15→14), roster-044 (山: killed 107→103, leaks 12→16, deaths 15→14); bond-deputShip-low (薄绿: 18→21 killed, leaks
3→0, deaths 1→0), bond-kazimierzShip-high (野鬃: deaths 3→2); boss-boss_3-pair (锏: p2 deaths 3→2), boss-boss_4-pair
(莫斯提马 / 歌蕾蒂娅: killed 11→12, boss HP 7442429→7375628, p2 leaks 2→1, deaths 19→18), boss-boss_6-pair (boss HP
6932482→6865243, p2 deaths 19→17), hidden-boss_8-pair (boss HP 12585770→12585798), hidden-boss_9-pair (锏: boss HP
3630612→3636991); standin-05 / 08 / 10 (预备干员-特种 pulls: killed 5→8 / 42→44 / 28→29, leaks down by 3 / 2 / 1);
diy-101 (傀影: killed 22→21, leaks 92→93), diy-114 / 115 / 116 (黑键: 106→99 & 83→90 / 154→156 & 78→70 / 3→2 & 120→121),
diy-137 (预备干员-特种: 68→79 / 79→68); matches through those battles — coop2-HARD-4, coop2-NORMAL-14-standins,
coop2-NORMAL-3, coop2-NORMAL-70-diy, coop3-ABYSS-5, coop4-ABYSS-7, solo-ABYSS-2 (LP 2→4 at round 10), solo-HARD-2
(ai_0 now survives round 13: eliminated 13 → alive), solo-HARD-9-boosted (LP 399→398), solo-NORMAL-1. Other moves (damage,
heals, snapshots only): roster-009/012/017/019/032/039/040/042/046, 13 bonds, boss-boss_2-pair, hidden-boss_10-pair,
standin-03, diy-053/084/085/102/103/108/109, coop2-ABYSS-10-boosted, solo-ABYSS-1.
2026-10-08 21:54:06 +08:00

152 lines
8.7 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.
// 失衡 (UNBALANCE) after a push / pull — PR #392 by @xcdoge brought the state; its timing re-done from the sources
// (docs/research/13-knockback-official.md): a state machine, not a status (PRTS 失衡位移机制; 异常效果: 「不属于异常效果」), during
// which the enemy neither walks nor starts a normal attack (异常效果图鉴/失衡免疫 「失衡期间无法自主移动、发动攻击、使用技能」).
// - a push: the 位移时间 of its 受力等级 (PRTS 游戏数据基础 推力-位移近似对应表: 0.2 / 0.4 / 0.8 / 0.9 / 32⁄30 / 35⁄30 s);
// - a pull: its force window (推与拉 §拉力: 1 s, 0.5 s below 受力等级 −1), to its end after the 急停 too;
// - a 静态刚体 hit by a force > 0: the 0.1 s 失衡硬直 floor, no movement (特殊机制 静态刚体); 失衡免疫: never;
// - 浮空 ends it (术语释义 浮空); the move itself stays instant (the unbalanced() talents keyed off the jump).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { PUSH_UNBALANCE, PULL_UNBALANCE, PULL_UNBALANCE_WEAK, UNBALANCE_MIN, PUSH_TILES, TICK } from '../../server/sim/constants.js';
const near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
const WALL = chessRec({ id: 't_wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, blockCnt: 0 }, rangeGrid: [[0, 0]], skill: null });
/** One walker (weight `mass`) on an open stage; `units` / `o` extra makeBattle options. */
function field({ mass = 1, enemy = {}, units = [], ...o } = {}) {
const h = makeBattle({
content: 'none', autoFinish: false, timeLimit: 120, captureNoisy: true, hooks: ['attack'],
defs: { enemies: { enemy_w: enemyRec({ key: 'enemy_w', hp: 1e7, speed: 1, mass, ...enemy }) }, chess: { t_wall: WALL } },
enemies: [{ key: 'enemy_w', pos: [10, 7] }], units, ...o,
});
h.step();
return { h, e: h.enemy('enemy_w') };
}
/** Seconds until `e` moves again (its position changes), up to `max`. */
function stillFor(h, e, max = 3) {
const x = e.x, y = e.y, t0 = h.b.time;
while (h.b.time - t0 < max) {
h.step();
if (!near(e.x, x) || !near(e.y, y)) return h.b.time - t0;
}
return Infinity;
}
test('a push holds the enemy for the 位移时间 of its 受力等级 (PRTS 游戏数据基础), then it walks on', () => {
for (const [force, level] of [[-1, -2], [0, -1], [1, 0], [2, 1], [3, 2], [4, 3], [6, 3]]) {
const { h, e } = field();
const t0 = h.b.time;
const moved = h.b.push(e, force, { from: { x: e.x + 1, y: e.y } }); // to the left: room for 3.53 tiles
assert.ok(near(moved, PUSH_TILES[level], 1e-6), `力度 ${force}: moved ${moved}`);
const want = PUSH_UNBALANCE[level];
assert.ok(near(e.unbalanceUntil - t0, want), `受力等级 ${level}: ${e.unbalanceUntil - t0} s, want ${want}`);
const t = stillFor(h, e);
assert.ok(t >= want - 1e-9 && t <= want + 2 * TICK + 1e-9, `受力等级 ${level}: walks again after ${t.toFixed(3)} s`);
assert.ok(!e.s.flags.stun, 'no stun status');
checkInvariants(h.b);
}
assert.deepEqual([PUSH_UNBALANCE[0], PUSH_UNBALANCE[3]], [0.8, 35 / 30], 'the table: 24 and 35 frames');
});
test('no normal attack while it lasts — a 恐惧 does not walk out of it either (no exemption)', () => {
// a ranged enemy shooting the wall operator: pushed, it holds its fire for 0.8 s, then shoots again
const { h, e } = field({ mass: 0, enemy: { atk: 100, range: 6, bat: 0.5, speed: 0 }, units: [{ chessId: 't_wall', row: 10, col: 3 }] });
assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === e), 5), 'it shoots the wall');
const shots = () => h.hooksOf('attack').filter((c) => c.attacker === e).length;
const n0 = shots(), t0 = h.b.time;
h.b.push(e, 0, { from: { x: e.x - 1, y: e.y } }); // 受力等级 0: 0.8 s
h.run(0.8 - TICK);
assert.equal(shots(), n0, 'no attack in the 0.8 s');
assert.ok(h.runUntil(() => shots() > n0, 3), 'then it attacks again');
assert.ok(h.b.time - t0 >= 0.8 - 1e-9);
// 恐惧: still held
const f = field({ mass: 0 });
f.h.b.applyStatus(f.e, 'fear', { duration: 5, force: true });
f.h.b.push(f.e, 0, { from: { x: f.e.x - 1, y: f.e.y } });
assert.ok(stillFor(f.h, f.e) >= 0.8 - 1e-9, 'a feared enemy stays put through the hold');
});
test('the −2 angle correction picks the row: a directional push off by more than 45° holds 0.2 s', () => {
const { h, e } = field(); // weight 1: 中力 (1) → 受力等级 0, corrected −2
const t0 = h.b.time;
const moved = h.b.push(e, 1, { from: { x: e.x, y: e.y - 1 }, dir: { x: 1, y: 0 } });
assert.ok(near(moved, PUSH_TILES[-2], 1e-6), `the radial −2 push: ${moved}`);
assert.ok(near(e.unbalanceUntil - t0, PUSH_UNBALANCE[-2]));
});
test('the row time stays when the 特效 column or a wall shortens the slide; a wall at the first step gives the 0.1 s floor', () => {
const eff = field({ mass: 0 });
const t0 = eff.h.b.time;
const m = eff.h.b.push(eff.e, 0, { from: { x: eff.e.x - 1, y: eff.e.y }, effect: true });
assert.ok(m < PUSH_TILES[0] && near(eff.e.unbalanceUntil - t0, PUSH_UNBALANCE[0]), '特效 push: shorter, same 0.8 s');
// against the field's edge: 1.7 tiles asked, a little moved, then none
const wall = field({ mass: 0 });
wall.e.x = 9.6;
const t1 = wall.h.b.time;
const m1 = wall.h.b.push(wall.e, 0, { from: { x: wall.e.x - 1, y: wall.e.y } });
assert.ok(m1 > 0 && m1 < PUSH_TILES[0], `stopped by the edge after ${m1}`);
assert.ok(near(wall.e.unbalanceUntil - t1, PUSH_UNBALANCE[0]), 'still the row time [ASSUMED]');
const m2 = wall.h.b.push(wall.e, 0, { from: { x: wall.e.x - 1, y: wall.e.y } });
assert.equal(m2, 0, 'no room left');
assert.ok(near(wall.e.unbalanceUntil - t1, PUSH_UNBALANCE[0]), 'a running longer state is kept');
const w2 = field({ mass: 0 });
w2.e.x = 9.6;
w2.h.b.push(w2.e, 0, { from: { x: w2.e.x - 1, y: w2.e.y } });
w2.h.run(1);
const t2 = w2.h.b.time;
assert.equal(w2.h.b.push(w2.e, 0, { from: { x: w2.e.x - 1, y: w2.e.y } }), 0);
assert.ok(near(w2.e.unbalanceUntil - t2, UNBALANCE_MIN), 'stopped at once: the 0.1 s floor [ASSUMED]');
});
test('a pull holds for its force window (推与拉): 1 s, 0.5 s below 受力等级 −1, to the end after the 急停; ≤ −3 nothing', () => {
for (const [force, mass, want] of [[1, 1, PULL_UNBALANCE], [0, 1, PULL_UNBALANCE], [-1, 1, PULL_UNBALANCE_WEAK], [-2, 1, 0]]) {
const { h, e } = field({ mass });
const t0 = h.b.time;
h.b.pull(e, force, { to: { x: 3, y: 10 } });
const got = Number.isFinite(e.unbalanceUntil) ? e.unbalanceUntil - t0 : 0;
assert.ok(near(got, want), `力度 ${force} − 重量 ${mass}: ${got} s, want ${want}`);
}
// already inside the 急停 circle: no movement, the state for the whole window
const { h, e } = field();
const t0 = h.b.time;
assert.equal(h.b.pull(e, 1, { to: { x: e.x - 0.3, y: e.y }, center: { x: e.x - 0.3, y: e.y } }), 0);
assert.ok(near(e.unbalanceUntil - t0, PULL_UNBALANCE), '急停 at once: still 1 s');
assert.ok(stillFor(h, e) >= PULL_UNBALANCE - 1e-9);
});
test('a 静态刚体 hit by a force > 0 gets the 0.1 s floor and never moves; 失衡免疫 never enters it', () => {
const s = field({ enemy: { motion: 'FLY' } });
s.e.def = { ...s.e.def, staticBody: true };
const x0 = s.e.x, t0 = s.h.b.time;
assert.equal(s.h.b.push(s.e, 3, { from: { x: s.e.x - 1, y: s.e.y } }), 0);
assert.equal(s.e.x, x0, 'not moved');
assert.ok(near(s.e.unbalanceUntil - t0, UNBALANCE_MIN), '0.1 s');
const s2 = field({ enemy: { motion: 'FLY' } });
s2.e.def = { ...s2.e.def, staticBody: true };
s2.h.b.pull(s2.e, -3, { to: { x: 3, y: 10 } }); // 受力等级 −4: force 0
assert.ok(!Number.isFinite(s2.e.unbalanceUntil), 'no force, no state');
const im = field();
im.h.b.addBuff(im.e, { key: 'test:immune', flags: { noDisplace: true } });
im.h.b.push(im.e, 3, { from: { x: im.e.x - 1, y: im.e.y } });
im.h.b.pull(im.e, 3, { to: { x: 3, y: 10 } });
assert.ok(!Number.isFinite(im.e.unbalanceUntil), '失衡免疫: never');
});
test('浮空 ends the state at once (术语释义 浮空)', () => {
const { h, e } = field({ mass: 0 });
h.b.push(e, 3, { from: { x: e.x - 1, y: e.y } });
assert.ok(e.unbalanceUntil > h.b.time);
h.b.applyStatus(e, 'levitate', { duration: 0.5, force: true });
h.step();
assert.ok(!(e.unbalanceUntil > h.b.time), 'cleared');
});
test('the displace fx carries the state\'s game seconds for the client\'s slide', () => {
const { h, e } = field({ mass: 0 });
h.b.drainEvents();
h.b.push(e, 1, { from: { x: e.x - 1, y: e.y } });
const fx = (h.b.drainEvents() || []).find((t) => t[0] === 'fx' && t[1] === 'displace');
assert.ok(fx && near(fx[4].dur, PUSH_UNBALANCE[1]), JSON.stringify(fx));
});