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:
commit
31fee55c6c
18 files changed
+819
-336
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 魔王
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user