PR #347 by @CXUtk, applied with changes. Kept: 追逐模式 lasts the gain's whole 5 s — PRTS 假想敌:铳 末日布道 "令全场的 “碎铳之簧”获得5秒增益…增益期间切换为追逐模式", “碎铳之簧” 追逐模式 "持续追踪令其进入该形态的场上的假想敌:铳移动" (no arrival clause, unlike the 铳's own 冲锋模式, which ends "到达目标所在位置后"); data skill 2: cooldown 45, initCooldown 20, dog_duration 5, move_speed 8. kitSpring no longer ends the chase when it reaches the gun or comes within 1 tile; with no gun left on the field the spring holds still until the gain ends (until 0.2.1 that ended the chase at once). Not taken (the maintainer's decision on the Grok triage): the second gun's half-cooldown first-call delay and the cond that refused a call while any spring was chasing — neither is in the data, and the lock contradicts the handbook's 「持续召唤场上所有“碎铳之簧”向自身移动」 (a call takes every spring to its caster). Both guns keep their data timers, so on a pair field the mirrored gun's call, a tick after the first, retargets the springs for a new 5 s. Measured (notes repro-347.mjs): a spring 0.2 tile from the gun chased 0.03 s, one 5 tiles away 2.97 s; now 5.03 s both. Tests: test/content/hidden-gun-pursuit.test.js (new, 5; the arena harness and two tests are the PR's): the full 5 s next to a moving gun and with no gun left fail on the parent; the pair timing (20 s after each gun's spawn, then every 45 s, the two calls a tick apart), a mid-chase call retargeting the springs for a new 5 s and the solo timing guard against the stagger / lock. test/sim/feedback1e-healers.test.js E2 audit: the fields whose leaders / parts a healer must block run 200 s instead of 120 — on the solo 铳 field the springs now first meet a healer at 145 s (at 21 s until now). Golden (on top of the portal commit): - fields hidden-boss_9-pair: the springs spend 5 s invulnerable per call, so they take less (actrpa 855522 → 722451, actrpb 798033 → 660498, actrpc 1384683 → 1168947) and pass less on; leader left 3511007 → 3747749; p2's deaths 7 → 12 (longer tramples); still 8 / 8 kills, no leak. - fields hidden-boss_9-solo: leader left 1932856 → 2011732; the springs take less; 隐德来希's candles (5601_entlec, an enemy-side summon) 6 → 8. - matches coop2-ABYSS-10-boosted (hidden boss_9): still cleared at 800 LP; the seats' leader damage shifts (7523075 / 6369365 → 6992346 / 6900614).
157 lines
8.2 KiB
JavaScript
157 lines
8.2 KiB
JavaScript
// 隐秘核心铳'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);
|
|
});
|