feedback7: merge fb7-gh-sim — PR #348 (take-the-highest chains), PR #293 (REA-Y in the engine), #329 ports (异客 13 tiles, 歌蕾蒂娅 net radius 1, 搅拌机 latest first, 大帝 uncapped), PR #365 (骨刺 forms + PRTS targeting), #298 (instant non-attack skills recast at most once per attack interval, attacks untouched), #364 (先兆者 shell 3×3), #220 (速射手 air first), 余 skips noStack bonds (owner)

- Conflict: docs/PLAYING.md 技能自动释放 bullet — both insertions kept (the 铁钳号 rule from fb7-owner-sim, the #298
  pacing sentence).
- Grok review: MERGE AFTER FIXES — the HIGH #298 finding (the cast replaced the due attack) fixed on the branch
  (db29acf9: per-skill nextCastAt; golden of #298 alone shrank from 84 scenarios to 2).
- Golden union: 4 matches differ from HEAD though the branch alone left them (coop2-NORMAL-3, coop4-ABYSS-7,
  coop2-NORMAL-8-serverrun, solo-HARD-9-boosted): HEAD's side had moved all four (#333's shop RNG …) and on those new
  bot paths the merged rules apply — the diffs are in-battle layer gains / damage (卡西米尔 50 → 45, 精准 80 → 76,
  叙拉古 176 → 168, kills ±2, solo-HARD-9 LP 397 → 399), no outcome change.
- Broad node --test 5571 (5570 pass, 0 fail, 1 skipped); lint 0 errors; typecheck.
This commit is contained in:
sganggs committed 2026-10-08 21:27:04 +08:00
commit 52f56319ac
41 files changed
+2065 -648

No files matched your search

+191
View File
@@ -19,6 +19,9 @@ import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { UF, ANIM } from '../../shared/constants.js';
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
import * as enemiesMod from '../../server/sim/content/enemies.js';
import { enemyStealthed } from '../../server/sim/targeting.js';
import { flagsOf } from '../../server/sim/snapshot.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
@@ -133,6 +136,194 @@ async function enemy(id, info = {}) {
return v;
}
describe('骨刺 effective stealth drives A/B clips', () => {
const BONE = 'enemy_9008_acbunn';
const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-6, `${a} ≈ ${b}`);
function arena() {
const h = makeBattle({
content: 'generic', extraContent: [enemiesMod], seed: 7, autoFinish: false,
defs: { chess: { wall: chessRec({ id: 'wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, def: 0, res: 0, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null }) } },
kits: { wall: () => ({ trait: { noAttack: true } }) }, units: [{ chessId: 'wall', row: 9, col: 6 }],
});
h.step();
const e = h.spawn(BONE, { pos: [9, 6], routeIndex: 0, mods: { speedMul: 0 } });
return { h, e, wall: h.unit('wall') };
}
function pose(v, h, e, hidden) {
const flags = flagsOf(e);
assert.equal(enemyStealthed(e), hidden);
assert.equal(!!(flags & UF.STEALTH), hidden);
v.sync({ ...sample(flags), x: e.x, y: e.y }, h.b.time);
assert.equal(v.form, hidden ? null : 'revealed');
assert.equal(clip(v), hidden ? 'Idle_A' : 'Idle_B');
v.fadeIn = 1;
v.update(1 / 60, cam(), h.b.time);
assert.equal(v.root.alpha, hidden ? 0.45 : 1);
}
test('骨刺: actual blocking, blocker stun, reveal and expiry give A → B → A → B → A', async () => {
const { h, e, wall } = arena();
const v = await enemy(BONE);
try {
pose(v, h, e, true);
h.step(2); assert.equal(e.blockedBy, wall); pose(v, h, e, false);
h.b.applyStatus(wall, 'stun', { duration: 30, force: true });
h.step(2); assert.equal(e.blockedBy, null); pose(v, h, e, true);
h.b.addBuff(e, { key: 'test:reveal', duration: 1, flags: { reveal: true } });
pose(v, h, e, false);
h.run(1.1); pose(v, h, e, true);
} finally { v.destroy(); }
});
test('骨刺: overlapping block and reveal keep B until both end', async () => {
const { h, e, wall } = arena();
const v = await enemy(BONE);
try {
h.step(2); assert.equal(e.blockedBy, wall);
h.b.addBuff(e, { key: 'test:reveal', duration: 0.1, flags: { reveal: true } });
pose(v, h, e, false);
h.run(0.2); assert.equal(e.blockedBy, wall); pose(v, h, e, false);
h.b.addBuff(e, { key: 'test:reveal', duration: 1, flags: { reveal: true } });
h.b.applyStatus(wall, 'stun', { duration: 30, force: true });
h.step(2); assert.equal(e.blockedBy, null); pose(v, h, e, false);
h.run(1.1); pose(v, h, e, true);
} finally { v.destroy(); }
});
test('骨刺: switching an ongoing attack maps A 0.8 ↔ B 1.0 without another attack or a repeated-snapshot restart', async () => {
const v = await enemy(BONE);
try {
v.sync(sample(UF.STEALTH), 1);
v.atkInterval = 4;
v.onAttack(null, 1.1);
v.actor.clock = 2;
v.actor.spine.state.tracks[0].trackTime = 0.8;
const last = v.lastAtk, interval = v.atkInterval;
v.imp = { dirty: false };
v.sync(sample(0), 2);
assert.equal(clip(v), 'Attack_B');
near(v.actor.spine.state.tracks[0].trackTime, 1);
near(v.actor.attackUntil, 2.5);
assert.equal(v.imp.dirty, true);
const track = v.actor.spine.state.tracks[0];
v.sync(sample(0), 2.1);
assert.equal(v.actor.spine.state.tracks[0], track);
v.sync(sample(UF.STEALTH), 2.2);
assert.equal(clip(v), 'Attack_A');
near(v.actor.spine.state.tracks[0].trackTime, 0.8);
near(v.actor.attackUntil, 2.7);
assert.equal(v.lastAtk, last);
assert.equal(v.atkInterval, interval);
assert.equal(v.actor.wound, false);
v.imp = null;
} finally { v.imp = null; v.destroy(); }
});
for (const fromB of [false, true]) {
test(`骨刺: ${fromB ? 'B → A truncated' : 'A → B'} wind-up retains its deadline and tail speed`, async () => {
const v = await enemy(BONE);
try {
v.sync(sample(fromB ? 0 : UF.STEALTH), 1);
v.actor.clock = 2;
const lead = fromB ? 0.633 : 0.333;
assert.ok(v.actor.windUp(4, lead));
v.actor.spine.state.tracks[0].trackTime = fromB ? 0.1 : 0.2;
const until = v.actor.windUntil, last = v.lastAtk, interval = v.atkInterval;
v.sync(sample(fromB ? UF.STEALTH : 0), 2);
const track = v.actor.spine.state.tracks[0];
assert.equal(clip(v), fromB ? 'Attack_A' : 'Attack_B');
near(track.trackTime, fromB ? 0 : 0.4);
near(track.timeScale, fromB ? 0.533 / lead : 1);
assert.equal(v.actor.windUntil, until);
assert.equal(v.actor.windTs, 1);
assert.equal(v.actor.wound, true);
near(v.actor.attackUntil, 2 + lead + 1.5 - (fromB ? 0.533 : 0.733));
assert.equal(v.lastAtk, last);
assert.equal(v.atkInterval, interval);
v.sync(sample(fromB ? UF.STEALTH : 0), 2.1);
assert.equal(v.actor.spine.state.tracks[0], track);
v.actor.update(lead + 0.001);
assert.equal(track.timeScale, 1, 'the original tail speed resumes');
} finally { v.destroy(); }
});
}
for (const control of [UF.FROZEN, UF.STUNNED, UF.SLEEP]) {
test(`骨刺: control ${control} keeps the skeleton frozen while B/A attachments are applied immediately`, async () => {
const v = await enemy(BONE);
try {
v.sync(sample(UF.STEALTH | control), 1);
let zeroUpdates = 0;
const update = v.actor.spine.update.bind(v.actor.spine);
v.actor.spine.update = (dt) => { if (dt === 0) zeroUpdates++; update(dt); };
for (const hidden of [false, true]) {
v.sync(sample(control | (hidden ? UF.STEALTH : 0)), 2);
assert.equal(clip(v), hidden ? 'Idle_A' : 'Idle_B');
assert.equal(v.actor.mode, 'stun');
assert.equal(v.actor.frozen, true);
assert.equal(v.actor.spine.state.tracks[0].mixDuration, 0);
}
assert.equal(zeroUpdates, 2);
v.sync(sample(UF.STEALTH, ANIM.MOVE), 3);
assert.equal(v.actor.frozen, false);
assert.equal(clip(v), 'Move_A');
} finally { v.destroy(); }
});
}
test('骨刺: a first snapshot with flags = 0 selects B even though previous flags were also 0', async () => {
const v = await enemy(BONE);
try {
assert.equal(v.flags, 0);
v.sync(sample(0), 1);
assert.equal(v.form, 'revealed');
assert.equal(clip(v), 'Idle_B');
} finally { v.destroy(); }
});
for (const finalFlags of [0, UF.STEALTH, UF.FROZEN]) {
test(`骨刺: delayed model loading applies the latest snapshot (${finalFlags}), including frozen B`, async () => {
const a = store(BONE);
let resolve;
a.spine.acquire = () => new Promise((r) => { resolve = r; });
const v = new UnitView(fakeViewCtx(fake.P, { assets: a, cam }), { id: 9, side: 'enemy', kind: 'enemy', defId: BONE, spine: BONE, maxHp: 1000 });
const proto = fake.P.spine.Spine.prototype, update = proto.update;
let zeroUpdates = 0;
proto.update = function (dt) { if (dt === 0) zeroUpdates++; return update.call(this, dt); };
try {
v.sync(sample(0), 1);
assert.equal(v.actor, null);
assert.equal(v.form, 'revealed');
v.sync(sample(finalFlags), 2);
resolve({ animations: Object.keys(a.spineEntry().animations).map((name) => ({ name })) });
await tick(); await tick();
assert.ok(v.actor);
assert.equal(clip(v), finalFlags & UF.STEALTH ? 'Idle_A' : 'Idle_B');
assert.ok(zeroUpdates > 0, 'the loaded pose is applied at zero time');
assert.equal(v.actor.frozen, !!(finalFlags & UF.FROZEN));
} finally { proto.update = update; v.destroy(); }
});
}
for (const hidden of [true, false]) {
test(`骨刺: ${hidden ? 'A' : 'B'} idle, move and death; a DIE snapshot selects its form before dying`, async () => {
const flags = hidden ? UF.STEALTH : 0, suffix = hidden ? 'A' : 'B';
const v = await enemy(BONE), w = await enemy(BONE);
try {
v.sync(sample(flags, ANIM.MOVE), 1); assert.equal(clip(v), `Move_${suffix}`);
v.sync(sample(flags, ANIM.IDLE), 2); assert.equal(clip(v), `Idle_${suffix}`);
v.die(); assert.equal(clip(v), `Die_${suffix}`);
w.sync(sample(hidden ? 0 : UF.STEALTH), 1);
w.sync(sample(flags, ANIM.DIE), 2); assert.equal(clip(w), `Die_${suffix}`);
w.sync(sample(hidden ? 0 : UF.STEALTH, ANIM.DIE), 3);
frames(w, 10);
assert.equal(clip(w), `Die_${suffix}`, 'death keeps its chosen form');
assert.equal(w.form, hidden ? null : 'revealed');
} finally { v.destroy(); w.destroy(); }
});
}
});
describe('转译基底·α forms (user report after 0.1.0, #5)', () => {
const TR = 'enemy_10081_mpplai';
test('every clip of the three forms exists in the skeleton; the change clips last the official 2 s; the manifest keeps the original form', () => {