feedback7 R22-UNBAL: 失衡 review fixes — a pulled 静态刚体 is held for the pull's window, 弧光锋卫 bleeds 400 every 0.066 s while the state lasts, no enemy skill / blink / scripted move during it, a route APPEAR or a 逐火 重生 ends it, every push row holds its exact frames; golden: 32 move, 8 in outcomes (below)

Branch review of fb7-unbalance (MERGE AFTER FIXES):
1. A 静态刚体 pulled got the 0.1 s floor: _staticForce now knows push from pull — a push's force is instant (推与拉), so
   0.1 s; a pull's lasts its window (pullUnbalance: 1 s, 0.5 s below −1) — PRTS 特殊机制 静态刚体 「在失去施力后立刻」 out,
   「0.1 秒保底」. pull() checks the puller's own block before the static case.
2. 弧光锋卫 (PRTS 天赋 「处于失衡状态时,每0.066s受到400点无来源真实持续伤害」, data unbalanced_bleed): a tick bleed of 400 every
   0.066 s while unbalancedNow holds; the per-tile one-shot from the position jump ([ASSUMED], UNBALANCE_SPEED) is gone.
   A 受力等级 0 push (0.8 s) → 12 hits = 4800 (was 2037), a 急停 pull with no movement (1 s) → 15 = 6000 (was 0), a
   wall-shortened slide keeps the 0.8 s row → 4800.
3. 失衡免疫 「失衡期间无法自主移动、发动攻击、使用技能」: enemies/helpers.js unbalancedNow; canCast(e, sil, battle) refuses while it
   runs (content skills; the boss parts' calls pass the battle), blinkForward returns null, 枯朽之种's dive / blast and the
   铳 / 碎铳之簧 charges wait, 乌顶巨角卢鲁's clash waits for its end. A route APPEAR relocation clears it (a forced state
   switch, 失衡位移机制). Left running on purpose (docs/SIM.md): 水遁忍者's damage check (异常效果 「仍然能正常造成伤害」), passive
   auras and terrain / periodic effects, death / damage-taken triggers, skill cooldowns, 弧光锋卫's bleed itself.
4. husk() (逐火 余烬 重生) clears the state like reborn().
5. The 35/30 row held 36 frames (t0 + 35/30 overshoots in floats): every check compares with 1e-9; the frames are asserted.

Tests: test/sim/unbalance.test.js +6 (exact frames 6 / 12 / 24 / 27 / 32 / 35 with the walk on the next frame; static pull
1 s / 0.5 s, push 0.1 s; 弧光锋卫 4800 / 6000 / 4800; canCast / blinkForward refused, 枯朽之种 held then diving; the elk's clash
after the state; APPEAR and a 逐火 重生 clear it — all six fail on the parent); enemies_bosses (失衡 kit test) and
feedback1d-solvent (BUFF damage) updated to the state bleed. test/sim + test/content + test/render pass. Docs: SIM.md
(push / pull row), PLAYING.md, design/engine.md (helpers, enemy AI), constants / displacement comments.

Golden against the previous commit — outcome moves: roster-010 (歌蕾蒂娅 pulls 妖怪 drones, now held 1 s: killed 44→45,
leaks 58→57), roster-040 (static pulls of 妖怪: killed 28→25, leaks 37→40, deaths 29→30), roster-043 / 044 (山: deaths 14→16 /
14→13), hidden-boss_9-pair (锏: boss HP 3636991→3586929), coop4-ABYSS-7 (ai_2 LP +1 from round 5), diy-115 (黑键: leaks
70→75), diy-116 (黑键: killed 2→3, leaks 121→120) — the static pulls (probe) and the 1e-9 end (a frame shorter where the float
sum overshot: the 35/30 row, some pull windows); other moves (damage / snapshots only): roster-008 / 017 / 039, 9 bonds,
boss-boss_4-pair / boss_6-pair / hidden-boss_10-pair, coop2-NORMAL-14-standins / coop2-NORMAL-3 / solo-ABYSS-1 /
solo-HARD-1 / solo-HARD-9-boosted / solo-NORMAL-1, diy-053 / 085 / 102.
This commit is contained in:
sganggs committed 2026-10-08 22:42:26 +08:00
1 parent cdf2bd5c88
commit 1db84d799d
20 files changed
+440 -301

No files matched your search

+107
View File
@@ -9,6 +9,10 @@ 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';
import { hasGeneratedData } from '../../server/sim/simdata.js';
import * as enemiesMod from '../../server/sim/content/enemies.js';
import { canCast } from '../../server/sim/content/enemies/helpers.js';
import { blinkForward } from '../../server/sim/content/enemies/archetypes.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 });
@@ -149,3 +153,106 @@ test('the displace fx carries the state\'s game seconds for the client\'s slide'
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));
});
// ---------------------------------------------------------------------------------------------------------------
// the branch review (fb7-unbalance): exact frames, a 静态刚体 pulled, 弧光锋卫's bleed, skills and scripted moves, the
// forced state switches
test('every push row holds its exact frames at 30 fps (6, 12, 24, 27, 32, 35) — the enemy walks on the next frame', () => {
for (const [force, frames] of [[-1, 6], [0, 12], [1, 24], [2, 27], [3, 32], [4, 35]]) {
const { h, e } = field();
h.b.push(e, force, { from: { x: e.x + 1, y: e.y } });
const x = e.x, y = e.y;
let k = 0;
while (k < 60) { h.step(); k++; if (!near(e.x, x) || !near(e.y, y)) break; }
assert.equal(k, frames + 1, `力度 ${force} (受力等级 ${force - 1}): held ${k - 1} frames, want ${frames}`);
}
});
test('a 静态刚体 pulled is held for the pull\'s force window (1 s; 0.5 s below 受力等级 −1), a pushed one 0.1 s — never moved', () => {
for (const [kind, force, want] of [['pull', 1, PULL_UNBALANCE], ['pull', -1, PULL_UNBALANCE_WEAK], ['push', 3, UNBALANCE_MIN]]) {
const s = field({ enemy: { motion: 'FLY' } });
s.e.def = { ...s.e.def, staticBody: true };
const x0 = s.e.x, t0 = s.h.b.time;
if (kind === 'pull') s.h.b.pull(s.e, force, { to: { x: 3, y: 10 } });
else s.h.b.push(s.e, force, { from: { x: s.e.x - 1, y: s.e.y } });
assert.equal(s.e.x, x0, `${kind}: not moved`);
assert.ok(near(s.e.unbalanceUntil - t0, want), `${kind} 力度 ${force}: ${s.e.unbalanceUntil - t0} s, want ${want}`);
}
});
const REAL = { skip: !hasGeneratedData() };
/** A pinned real enemy (data/enemies.json) on the open stage, with its content kit. */
function real(key, o = {}) {
const h = makeBattle({ content: 'generic', extraContent: [enemiesMod], seed: 7, autoFinish: false, timeLimit: 60, captureNoisy: true,
hooks: ['damaged'], defs: { chess: { t_wall: { ...WALL, stats: { ...WALL.stats, blockCnt: 3 } } } }, ...o });
h.step();
const e = h.spawn(key, { pos: o.pos ?? [10, 7], routeIndex: 0, mods: { speedMul: o.move ? 1 : 0 } });
h.step();
return { h, e };
}
test('弧光锋卫 bleeds 400 true damage every 0.066 s for as long as the state lasts (PRTS 天赋) — a push, a 急停 pull, a wall-shortened slide', REAL, () => {
const bleed = (act) => {
const { h, e } = real('enemy_1328_cbjedi');
const hp0 = e.hp;
act(h, e);
h.run(2);
const hits = h.hooksOf('damaged').filter((c) => c.target === e && c.dmg.tags?.includes('unbalanced'));
return { lost: hp0 - e.hp, hits: hits.length };
};
// weight 1: 力度 1 → 受力等级 0, 0.8 s → 12 × 400; a 急停 pull (no movement) 1 s → 15 × 400; the wall keeps the 0.8 s row
assert.deepEqual(bleed((h, e) => h.b.push(e, 1, { from: { x: e.x + 1, y: e.y } })), { lost: 4800, hits: 12 });
assert.deepEqual(bleed((h, e) => h.b.pull(e, 2, { to: { x: e.x - 0.3, y: e.y }, center: { x: e.x - 0.3, y: e.y } })), { lost: 6000, hits: 15 });
assert.deepEqual(bleed((h, e) => { e.x = 9.6; h.b.push(e, 1, { from: { x: e.x - 1, y: e.y } }); }), { lost: 4800, hits: 12 });
});
test('no skill, blink or scripted move while it lasts (失衡免疫 「失衡期间无法自主移动、发动攻击、使用技能」)', REAL, () => {
// the skill gate and the blink
const { h, e } = field();
h.b.pull(e, 1, { to: { x: e.x - 0.3, y: e.y }, center: { x: e.x - 0.3, y: e.y } }); // 1 s, no movement
assert.equal(canCast(e, false, h.b), false, 'canCast');
const x0 = e.x;
assert.equal(blinkForward(h.b, e, 1.5), null, 'blinkForward');
assert.equal(e.x, x0);
h.run(1.05);
assert.equal(canCast(e, false, h.b), true, 'after the state');
// 枯朽之种 (a 静态刚体 flyer: a pull's window, no movement): no dive, no blast meanwhile
const s = real('enemy_1269_nhfly', { units: [{ chessId: 't_wall', row: 10, col: 6 }], pos: [10, 7] });
const t0 = s.h.b.time;
s.h.b.pull(s.e, 3, { to: { x: 3, y: 10 } });
assert.ok(near(s.e.unbalanceUntil - t0, PULL_UNBALANCE));
const sx = s.e.x, sy = s.e.y;
s.h.run(0.9);
assert.ok(s.e.alive && near(s.e.x, sx) && near(s.e.y, sy), 'still where it was, not exploded');
assert.ok(s.h.runUntil(() => !s.e.alive, 5), 'then it dives and blows up');
});
test('乌顶巨角卢鲁: a 失衡 that does not move it holds its clash until the state ends', REAL, () => {
const { h, e } = real('enemy_10144_xdelk_2', { units: [{ chessId: 't_wall', row: 10, col: 6 }], move: true });
assert.ok(h.runUntil(() => h.eventsOf('fx').some((x) => x[1] === 'telegraph' && x[4]?.kind === 'elkCharge'), 10), 'the charge starts');
const start = h.b.time, dur = 8;
h.run(dur - 0.5);
h.b.pull(e, 5, { to: { x: e.x + 0.2, y: e.y }, center: { x: e.x + 0.2, y: e.y } }); // 急停 at once: 1 s, no movement
const until = e.unbalanceUntil;
assert.ok(until > start + dur, 'the state outlasts the charge timer');
const clash = () => h.eventsOf('fx').some((x) => x[1] === 'explode' && x[4]?.kind === 'elkClash');
assert.ok(h.runUntil(clash, 3), 'it clashes');
assert.ok(h.b.time >= until - 1e-9, `not before the state ends (${h.b.time.toFixed(3)} vs ${until.toFixed(3)})`);
});
test('a route APPEAR (a forced relocation) and a 逐火 重生 end the state', REAL, () => {
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);
e.route.legs.splice(e.route.legIdx, 0, { t: 'appear', r: 10, c: 3 });
h.step();
assert.equal(e.x, 3, 'relocated');
assert.ok(!(e.unbalanceUntil > h.b.time), 'APPEAR ended it');
const w = real('enemy_1288_duskls');
w.h.b.push(w.e, 4, { from: { x: w.e.x + 1, y: w.e.y } });
assert.ok(w.e.unbalanceUntil > w.h.b.time);
w.h.b.dealDamage(null, w.e, { amount: 1e9, type: 'true' });
assert.ok(w.e.alive, 'it fell into its 余烬 (重生)');
assert.ok(!(w.e.unbalanceUntil > w.h.b.time), 'the 重生 ended it');
});