feedback7: merge fb7-fields — PR #337 boss-field portals teleport (route-end portals: hidden 3 s, out of [5,10], leak at the side's door), PR #347 碎铳之簧 chases the full 5 s, #370 耀骑士临光's redeploy reduction floored at 0, #375 the water-map patrol takes the official upper road

- Grok review: MERGE AFTER FIXES — the end-portal wait (0 s → 3 s, the data's modal crossing) fixed on the branch
  (1205336a). solo-NORMAL-1 still fails the Hidden Core (explained in the branch commits).
- Golden union OK; broad node --test 5517 (5516 pass, 0 fail, 1 skipped); lint 0 errors.
This commit is contained in:
sganggs committed 2026-10-08 21:20:37 +08:00
commit 31fee55c6c
18 files changed
+819 -336

No files matched your search

+27
View File
@@ -465,6 +465,33 @@ test('ADD_BOND 耀骑士临光 145 / 160 (real chess): self + front get 144 (red
}
});
test('respawnTimeByBond 144 has no 5% floor (#370): redeployMul is max(0, 1 + respawn_time * steps)', () => {
for (const [gid, rt] of [['garrison_144_a', -0.015], ['garrison_144_b', -0.03]]) {
assert.equal(GR(gid).bb.respawn_time, rt);
assert.deepEqual(Object.keys(GR(gid).bb).sort(), ['divide_num', 'respawn_time']);
// 96 / 192 layers: the old 0.05 floor bound the elite / the normal form (raw 0.04); 300: below zero
for (const layers of [9, 96, 192, 300]) {
const k = Math.floor(layers / 3);
const want = Math.max(0, 1 + rt * k);
const h = battle({
units: [{ id: 'op', g: [gid], row: 10, col: 4, o: { stats: { respawnTime: 100 } } }],
bonds: { kazimierzShip: B(layers) },
});
const u = h.unit('op');
approx(u.findBuff(`gar:${gid}`).mods.redeployMul, want, `${gid} @ ${layers}`);
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.equal(u.alive, false);
approx(u.respawnAt - u.deathAt, 100 * want, `${gid} timer @ ${layers}`);
if (want === 0) {
h.step(2);
assert.ok(u.alive && u.deployed, `${gid} @ ${layers}: a 0 s timer brings it straight back`);
assert.equal(h.b.errorCount, 0);
}
}
cover(gid);
}
});
// ---------------------------------------------------------------------------------------------------------------------
// 魔王
+156
View File
@@ -0,0 +1,156 @@
// 隐秘核心铳's 【末日布道】 (PR #347 by @CXUtk): every 碎铳之簧 chases the casting gun for the gain's whole 5 s — PRTS 末日布道
// "令全场的“碎铳之簧”获得5秒增益:移动速度+800%,无敌,增益期间切换为追逐模式", “碎铳之簧” 追逐模式 "持续追踪令其进入该形态的场上的
// 假想敌:铳移动" (no arrival clause). Until 0.2.1 reaching the gun (or 1 tile from it) ended the chase. Each gun casts on its
// own data timer (initCooldown 20, cooldown 45 — no stagger between the two guns of a pair field, no lock while a chase
// runs: the data has neither, and the handbook's 「向自身」 means the later call takes the springs).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import * as enemies from '../../server/sim/content/enemies.js';
import * as bosses from '../../server/sim/content/bosses.js';
const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8'));
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const GUN = 'enemy_9017_achunt_2';
const SPRING = 'enemy_9020_actrpc';
const wall = chessRec({ id: 'pursuit_wall', profession: 'TANK', stats: { maxHp: 1e9, atk: 0, def: 1000, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
/** The real h08_02(_s) template (leader + parts only, or no spawns with `manual`), one non-attacking wall per side. */
function arena({ solo = false, manual = false, cooldown = null } = {}) {
const tpl = structuredClone(W[solo ? 'act1autochess_h08_02_s' : 'act1autochess_h08_02']);
tpl.spawns = manual ? [] : tpl.spawns.filter((s) => s.tag === 'boss' || s.tag === 'part');
if (cooldown != null) {
tpl.overrides[GUN] ??= { skills: structuredClone(E[GUN].skills) };
const skill = tpl.overrides[GUN].skills.find((s) => s.prefabKey === '2');
skill.initCooldown = 0;
skill.cooldown = cooldown;
}
return makeBattle({
kind: 'hidden', waveTemplate: tpl, content: 'generic', extraContent: [enemies, bosses], seed: 7,
autoFinish: false, timeLimit: 300, recordEvents: false, hooks: [],
defs: { chess: { pursuit_wall: wall } }, kits: { pursuit_wall: () => ({ trait: { noAttack: true } }) },
sharedBoss: { hp: 1e12, maxHp: 1e12, damage(pid, n) { this.hp -= n; } },
players: [
{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, chessId: 'pursuit_wall', row: 3, col: 5, abs: true }] },
...solo ? [] : [{ playerId: 'p2', seat: 1, side: 'R', colOffset: 8, units: [{ uid: 2, chessId: 'pursuit_wall', row: 3, col: 15, abs: true }] }],
],
setup(b) { b.enemyOverrides = tpl.overrides; b.opts.waveTemplate = tpl; b.opts.bossMirror = !manual; },
});
}
/** Every 【末日布道】 call (a new chase object on the spring): { time, gun }. */
function recordCalls(h) {
const calls = [];
let last = null;
h.b.on('tick', () => {
const d = h.enemies().find((e) => e.defId === SPRING)?.mem.ab.dash;
if (!d || d === last) return;
last = d;
calls.push({ time: h.b.time, gun: d.gun });
});
return calls;
}
test('隐秘核心铳 pair: each gun calls on its own data timer (20 s after it spawns, then every 45 s); the later call takes the springs', () => {
const h = arena();
const spawned = new Map();
h.b.on('enemySpawn', (c) => { if (c.enemy.defId === GUN) spawned.set(c.enemy, h.b.time); });
const calls = recordCalls(h);
h.run(150);
assert.equal(spawned.size, 2, 'the pair field has two guns (the mirrored one)');
const skill = W.act1autochess_h08_02.overrides[GUN].skills.find((s) => s.prefabKey === '2');
for (const [gun, t0] of spawned) {
const own = calls.filter((c) => c.gun === gun).map((c) => c.time);
assert.equal(own.length, 3, 'three calls each in 150 s');
assert.ok(Math.abs(own[0] - t0 - skill.initCooldown) < h.TICK * 2, `first call ${skill.initCooldown} s after its spawn`);
for (let i = 1; i < own.length; i++) assert.ok(Math.abs(own[i] - own[i - 1] - skill.cooldown) < h.TICK * 2, `then every ${skill.cooldown} s`);
}
// the two mirrored guns call within a tick or two of each other (no half-cooldown stagger), the later one wins
for (let i = 1; i < calls.length; i += 2) assert.ok(calls[i].time - calls[i - 1].time < h.TICK * 3, 'the two calls come together');
assert.equal(h.b.errorCount, 0);
checkInvariants(h.b);
});
test('隐秘核心铳: reaching a moving gun keeps pursuit, invulnerability and disarm for the full 5 s', () => {
const h = arena({ solo: true, manual: true, cooldown: 45 });
h.step();
const gun = h.spawn(GUN, { pos: [3, 5], tag: 'boss', mods: { speedMul: 0 } });
const spring = h.spawn(SPRING, { pos: [3, 4.8], tag: 'part' });
h.step(2);
const d = spring.mem.ab.dash;
assert.ok(d, 'arriving next to the gun does not end the chase');
h.run(1);
gun.x = 6;
const oldX = spring.x;
h.run(1);
assert.ok(spring.x > oldX, 'it keeps following the gun when the gun moves');
assert.equal(spring.mem.ab.dash, d);
assert.ok(spring.s.flags.invulnerable && spring.s.flags.disarm);
h.run(d.until - h.b.time - h.TICK * 2);
assert.equal(spring.mem.ab.dash, d, 'still chasing just before the 5 s are over');
assert.ok(spring.s.flags.invulnerable && spring.s.flags.disarm);
h.run(h.TICK * 4);
assert.equal(spring.mem.ab.dash, null, 'over once the gain ends');
assert.ok(!spring.s.flags.invulnerable && !spring.s.flags.disarm);
assert.equal(h.b.errorCount, 0);
});
test('隐秘核心铳: the other gun\'s call during a chase retargets every spring to that gun for a new 5 s', () => {
const h = arena({ manual: true, cooldown: 45 });
h.step();
const left = h.spawn(GUN, { pos: [3, 5], tag: 'boss', mods: { speedMul: 0 } });
const right = h.spawn(GUN, { pos: [3, 15], tag: 'boss', mods: { speedMul: 0 } });
const call = right.mem.ab.list.find((a) => a.cd === 45 && a.fire);
call.left = 999; // the right gun's call is not ready yet
const spring = h.spawn(SPRING, { pos: [3, 10], tag: 'part' });
h.step(2);
const first = spring.mem.ab.dash;
assert.ok(first && first.gun === left, 'the first call takes the spring to the left gun');
h.run(1);
assert.equal(spring.mem.ab.dash, first, 'chasing the left gun');
call.left = 0; // the right gun's call comes ready mid-chase
h.step(2);
const second = spring.mem.ab.dash;
assert.ok(second && second !== first && second.gun === right, 'the later call takes the spring at once');
assert.ok(Math.abs(second.until - h.b.time - 5) < h.TICK * 3, 'for a new 5 s');
const oldX = spring.x;
h.run(1);
assert.ok(spring.x > oldX, 'it now runs to the right gun');
assert.ok(spring.s.flags.invulnerable && spring.s.flags.disarm);
assert.equal(h.b.errorCount, 0);
});
test('隐秘核心铳: with no gun left on the field the spring holds still, invulnerable and disarmed, until the gain ends', () => {
const h = arena({ solo: true, manual: true, cooldown: 45 });
h.step();
const gun = h.spawn(GUN, { pos: [3, 5], tag: 'boss', mods: { speedMul: 0 } });
const spring = h.spawn(SPRING, { pos: [3, 1], tag: 'part' });
h.step(2);
const d = spring.mem.ab.dash;
assert.ok(d, 'chasing');
h.b.kill(gun, null);
h.step();
const at = [spring.x, spring.y];
h.run(1);
assert.deepEqual([spring.x, spring.y], at, 'no gun to follow: it stands');
assert.equal(spring.mem.ab.dash, d, 'the chase is not over');
assert.ok(spring.s.flags.invulnerable && spring.s.flags.disarm);
h.run(d.until - h.b.time + h.TICK * 2);
assert.equal(spring.mem.ab.dash, null);
assert.ok(!spring.s.flags.invulnerable);
assert.equal(h.b.errorCount, 0);
});
test('隐秘核心铳: solo keeps the template initial cooldown and repeated call timing', () => {
const h = arena({ solo: true });
const calls = recordCalls(h);
h.run(100);
assert.equal(calls.length, 2);
const tpl = W.act1autochess_h08_02_s;
const spawnTime = tpl.spawns.find((s) => s.tag === 'boss').time;
const skill = (tpl.overrides[GUN]?.skills ?? E[GUN].skills).find((s) => s.prefabKey === '2');
assert.ok(Math.abs(calls[0].time - spawnTime - skill.initCooldown) < h.TICK * 2);
assert.ok(Math.abs(calls[1].time - calls[0].time - skill.cooldown) < h.TICK * 2);
assert.equal(h.b.errorCount, 0);
});