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.
152 lines
8.7 KiB
JavaScript
152 lines
8.7 KiB
JavaScript
// 失衡 (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));
|
||
});
|