Playtest #6 follow-up (v2.5.1): leader HP review — 直接乘算, one debuff instance, 999 layer cap, 限伤; elite to the board; 假想敌:胄 kit

- co-op leaders died within seconds: leader HP is the official data (40 values pinned); bond / strategy / item /
  机变 / 特质 "+X%" bonuses now add up (PRTS 直接乘算) instead of compounding; a same-named enemy debuff keeps one
  (strongest) instance per target (奥术 across a pair field); leader parts pass damage 1:1; fixed co-op pool (the user's call)
- official limits read from the client: each bond stops at 999 layers (MAX_GARRISON_STACK); a leader hit of 300000+ in
  boss / hidden battles is cancelled (MAX_BATTLE_DAMAGE, 限伤) — shared/constants.js switches, research 11
- a merge that consumes a deployed copy puts the elite on that tile and facing (user-confirmed); gold target highlight,
  promotion effect
- 假想敌:胄 per PRTS: one shell, shells within range, any drone death costs 2 % max HP, 剑/锤 sorties and 瘫痪
- DESIGN §20.10–§20.14; 2842 unit tests, all 28 browser suites green; Chrome = Node on boss fields
This commit is contained in:
sganggs committed 2026-10-02 04:19:28 +08:00
1 parent 6464e4b46a
commit 620f88c0d8
81 files changed
+4194 -447

No files matched your search

+178 -46
View File
@@ -14,7 +14,7 @@ import * as bossesMod from '../../server/sim/content/bosses.js';
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8'));
const { KITS, STATS_ONLY, EROSION, EROSION_BURST } = enemiesMod;
const { BOSS_KITS, PART_TRANSFER } = bossesMod;
const { BOSS_KITS, PART_TRANSFER, BLADE_TRANSFER, DRONE_LINK_BASE, droneLinkBase } = bossesMod;
const BIG = [];
for (let dr = -4; dr <= 4; dr++) for (let dc = -12; dc <= 12; dc++) BIG.push([dr, dc]);
@@ -1912,7 +1912,7 @@ test('template overrides of talents/skills are honoured (卢西恩 evade 0.2 in
const bossArena = (o = {}) => arena({ kind: 'boss', sharedBoss: pool(o.hp ?? 1e6), ...o });
const setTpl = (id) => (b) => { b.opts.templateId = id; };
test('假想敌:胄: arts ray on a random target in range; <20 % pool: damage taken ×0.5 and two 刺胄之弹', () => {
test('假想敌:胄: arts ray on a random target in range; <20 % pool: damage taken ×0.5, still one 刺胄之弹 (PRTS 能力修正)', () => {
const h = bossArena({ units: [{ chessId: 't_wall', row: 10, col: 6 }, { chessId: 't_wall2', row: 12, col: 4 }] });
h.step();
const e = put(h, 'enemy_9013_acstmk', [3, 10], { tag: 'boss' });
@@ -1925,7 +1925,8 @@ test('假想敌:胄: arts ray on a random target in range; <20 % pool: damage
approx(e.s.artsTakenMul, tb('enemy_9013_acstmk', '1.damage_scale'));
assert.equal(e.s.trueTakenMul, 1);
h.run(skb('enemy_9013_acstmk', '1').initCooldown - 9);
assert.equal(h.eventsOf('fx').filter((f) => f[1] === 'shell').length, 2);
assert.equal(h.eventsOf('fx').filter((f) => f[1] === 'shell').length, 1);
assert.equal(alive(h, 'enemy_9016_acstmr').length, 1);
});
test('假想敌:胄 + 刺胄之弹: the shell flies to the highest-ATK operator; on arrival 3×3 stun + physical DoT; 8 hits shoot it down', () => {
@@ -1954,64 +1955,189 @@ test('假想敌:胄 + 刺胄之弹: the shell flies to the highest-ATK operato
assert.ok(!s2.alive);
});
test('假想敌:胄 死亡集群: drones from the branch; a drone killed by an operator costs the leader 2 % max HP', () => {
const h = bossArena({ hp: 675000, units: [{ chessId: 't_gun', row: 12, col: 3 }], setup: setTpl('act1autochess_h07_01') });
test('假想敌:胄 死亡集群: drones from the branch; a drone that dies (whoever kills it) costs the leader 2 % max HP, a leak nothing', () => {
const h = bossArena({ hp: 675000, units: [{ chessId: 't_gun', row: 12, col: 3 }], kits: { t_gun: NOATK }, setup: setTpl('act1autochess_h07_01') });
h.step();
put(h, 'enemy_9013_acstmk', [3, 10], { tag: 'boss' });
const boss = put(h, 'enemy_9013_acstmk', [3, 10], { tag: 'boss' });
h.step();
const s2 = skb('enemy_9013_acstmk', '2');
const loss = 675000 * s2.bb.hp_ratio;
const d = alive(h, 'enemy_1005_yokai')[0];
assert.ok(d, 'drone summoned at once (icd 0)');
assert.equal(d.s.maxHp, E.enemy_1005_yokai.stats.maxHp, 'boss_1 drones: no summon.hp_ratio — data HP (max_hp is not read)');
let before = h.b.sharedBoss.hp;
h.b.kill(d, h.unit('t_gun'));
approx(before - h.b.sharedBoss.hp, loss);
const newest = () => alive(h, 'enemy_1005_yokai').sort((a, b) => b.id - a.id)[0];
h.run(s2.cooldown + 0.5);
const d2 = newest();
assert.ok(d2);
before = h.b.sharedBoss.hp;
h.b.kill(d2, null); // 无来源: "该无人机单位死亡时"
approx(before - h.b.sharedBoss.hp, loss);
h.run(s2.cooldown);
const d3 = newest();
assert.notEqual(d3, d2);
assert.ok(d3);
before = h.b.sharedBoss.hp;
h.b.leak(d3);
assert.equal(h.b.sharedBoss.hp, before, 'a leaked drone does not die');
approx(h.result().perPlayer.p1.bossDamage, loss, 1e-6, 'only the operator kill is credited');
assert.ok(boss.alive);
});
test('假想敌:胄 死亡集群: the 2 % reads the leader\'s shown max HP (the pool)', () => {
// PRTS "该妖怪死亡时令假想敌:胄受到最大生命值2%的真实伤害" — which max HP is [ASSUMED] (DESIGN §20.10): default 'pool' = the
// HP the battle shows (synced to the shared pool, the same HP the <20 % talent reads), so 2 % of the bar in every mode:
// 72 000 with the 终极 pool (3 600 000); the 'unit' reading (data 600 000 → 12 000) stays a switch
assert.equal(DRONE_LINK_BASE, 'pool');
const h = bossArena({ hp: 3600000, units: [{ chessId: 't_gun', row: 12, col: 3 }], setup: setTpl('act1autochess_h07_01') });
h.step();
const boss = put(h, 'enemy_9013_acstmk', [3, 10], { tag: 'boss' });
h.step();
assert.equal(boss.s.maxHp, 3600000, 'the unit shows the pool');
assert.equal(droneLinkBase(boss), 3600000, 'the link reads the shown max HP (the pool)');
assert.equal(droneLinkBase(boss, 'unit'), 600000, "the 'unit' switch reads the data max HP");
const d = alive(h, 'enemy_1005_yokai')[0];
assert.ok(d, 'drone summoned at once (icd 0)');
const before = h.b.sharedBoss.hp;
h.b.kill(d, h.unit('t_gun'));
approx(before - h.b.sharedBoss.hp, 675000 * skb('enemy_9013_acstmk', '2').bb.hp_ratio);
assert.ok(h.result().perPlayer.p1.bossDamage >= 675000 * 0.02 - 1e-6);
approx(before - h.b.sharedBoss.hp, 3600000 * skb('enemy_9013_acstmk', '2').bb.hp_ratio);
approx(before - h.b.sharedBoss.hp, 72000);
assert.ok(h.result().perPlayer.p1.bossDamage >= 72000 - 1e-6);
// a solo-sized pool (标准 247 500 × 0.25): still 2 % of the bar, never the 19 % a fixed 12 000 would take
const solo = bossArena({ hp: 61875, units: [{ chessId: 't_gun', row: 12, col: 3 }], setup: setTpl('act1autochess_h07_01') });
solo.step();
put(solo, 'enemy_9013_acstmk', [3, 10], { tag: 'boss' });
solo.step();
const d2 = alive(solo, 'enemy_1005_yokai')[0];
assert.ok(d2);
const b2 = solo.b.sharedBoss.hp;
solo.b.kill(d2, solo.unit('t_gun'));
approx(b2 - solo.b.sharedBoss.hp, 61875 * 0.02);
const hidden = bossArena({ hp: 7200000 });
hidden.step();
const hb = put(hidden, 'enemy_9013_acstmk_2', [3, 10], { tag: 'boss' });
hidden.step();
assert.equal(droneLinkBase(hb), 7200000, '隐秘核心 终极: 144 000 per drone');
assert.equal(droneLinkBase(hb, 'unit'), 1200000);
});
test('假想敌:胄 (隐秘核心): shells only at operators within its range', () => {
const hi = WALL('t_wall', { atk: 900 });
const h = bossArena({ units: [{ chessId: 't_wall', row: 10, col: 2 }, { chessId: 't_wall2', row: 12, col: 6 }], chess: { t_wall: hi } });
test('假想敌:胄 (隐秘核心) 死亡集群: summon.hp_ratio scales the drone HP', () => {
const h = bossArena({ units: [{ chessId: 't_gun', row: 12, col: 3 }], setup: setTpl('act1autochess_h08_01') });
h.step();
const e = put(h, 'enemy_9013_acstmk_2', [3, 12], { tag: 'boss' });
e.profile.noAttack = true;
h.run(skb('enemy_9013_acstmk_2', '1').initCooldown + 0.1);
const f = h.eventsOf('fx').find((x) => x[1] === 'shell');
assert.ok(f);
assert.equal(f[4].tx, h.unit('t_wall2').tileC, 'the high-ATK operator is out of range (col 2 vs 12, range 8)');
put(h, 'enemy_9013_acstmk_2', [3, 10], { tag: 'boss' }).profile.noAttack = true;
const s2 = skb('enemy_9013_acstmk_2', '2');
h.run(s2.initCooldown + 0.1);
const d = alive(h, 'enemy_1005_yokai')[0];
assert.ok(d);
approx(d.s.maxHp, E.enemy_1005_yokai.stats.maxHp * s2.bb['summon.hp_ratio']);
});
for (const key of ['enemy_9014_acstma', 'enemy_9015_acstmb']) {
test(`${nm(key)}: hovers invulnerable; dives at the lowest-ATK operator; ${skb(key, '1').bb.max_hit_cnt} hits shoot it down — then ×${skb(key, '1').bb.damage_scale} damage, ${PART_TRANSFER * 100} % passed to 胄`, () => {
const h = bossArena({ hp: 1e6, units: [{ chessId: 't_wall', row: 12, col: 3 }, { chessId: 't_gun', row: 9, col: 4 }], chess: { t_gun: chessRec({ id: 't_gun', profession: 'SNIPER', projectile: 'none', stats: { atk: 5000, maxHp: 1e7, blockCnt: 0 }, rangeGrid: BIG, skill: null }) }, kits: { t_gun: NOATK } });
for (const key of ['enemy_9013_acstmk', 'enemy_9013_acstmk_2']) {
test(`${nm(key)}: shells only at operators within its range (PRTS 技能 "选择攻击范围内攻击力最高的1名我方干员")`, () => {
const hi = WALL('t_wall', { atk: 900 });
const h = bossArena({ units: [{ chessId: 't_wall', row: 10, col: 2 }, { chessId: 't_wall2', row: 12, col: 6 }], chess: { t_wall: hi } });
h.step();
const boss = put(h, 'enemy_9013_acstmk_2', [3, 10], { tag: 'boss' });
boss.profile.noAttack = true;
const p = put(h, key, [3, 12], { tag: 'part' });
assert.ok(p.s.flags.invulnerable && p.s.flags.untargetable);
const s = skb(key, '1');
h.run(s.initCooldown + 0.1);
assert.ok(!p.s.flags.invulnerable, 'diving');
const g = h.unit('t_gun');
for (let i = 0; i < s.bb.max_hit_cnt; i++) h.b.dealDamage(g, p, { amount: 1, type: 'true' });
assert.equal(p.motion, 'WALK');
approx(p.s.dmgTakenMul, s.bb.damage_scale);
const before = h.b.sharedBoss.hp;
const dealt = h.b.dealDamage(g, p, { amount: 1000, type: 'true' });
approx(before - h.b.sharedBoss.hp, dealt * PART_TRANSFER);
h.run(s.bb.special_stun_duration + 60);
assert.equal(p.motion, 'FLY');
const e = put(h, key, [3, 12], { tag: 'boss' });
e.profile.noAttack = true;
h.run(skb(key, '1').initCooldown + 0.1);
const f = h.eventsOf('fx').find((x) => x[1] === 'shell');
assert.ok(f);
assert.equal(f[4].tx, h.unit('t_wall2').tileC, 'the high-ATK operator is out of range (col 2 vs 12, range 8)');
});
}
test('“斩胄之剑” dive that lands: 3×3 stun + physical DoT at the lowest-ATK operator', () => {
const h = bossArena({ units: [{ chessId: 't_wall', row: 9, col: 11 }], hooks: ['statusApplied'] });
const H08 = 'act1autochess_h08_01';
const handRoute = (key, kind) => {
const t = W[H08];
const s = t.branches[`${key === 'enemy_9014_acstma' ? 'left' : 'right'}_hand_${kind}`][0][0];
return t.extraRoutes[s.routeIndex];
};
for (const key of ['enemy_9014_acstma', 'enemy_9015_acstmb']) {
const s = skb(key, '1');
test(`${nm(key)}: 初始模式 invulnerable; 掷 blinks to its preset route at the lowest-ATK operator, no normal attack; damage passes ${BLADE_TRANSFER * 100} % to 胄; ${s.bb.max_hit_cnt} hits → 【瘫痪】 (stun ${s.bb.stun} s, ground, ×${s.bb.damage_scale}), then a new 初始模式 copy with its HP replaces it`, () => {
const h = bossArena({ hp: 1e7, units: [{ chessId: 't_wall', row: 12, col: 3 }, { chessId: 't_gun', row: 9, col: 4 }], chess: { t_gun: chessRec({ id: 't_gun', profession: 'SNIPER', projectile: 'none', stats: { atk: 5000, maxHp: 1e7, blockCnt: 0 }, rangeGrid: BIG, skill: null }) }, kits: { t_gun: NOATK }, setup: setTpl(H08), hooks: ['statusApplied'] });
h.step();
const boss = put(h, 'enemy_9013_acstmk_2', [3, 10], { tag: 'boss' });
boss.profile.noAttack = true;
const origin = handRoute(key, 'origin'), blink = handRoute(key, 'blink');
const p = put(h, key, origin.start, { tag: 'part' }); // pinned: the dive never lands
assert.ok(p.s.flags.invulnerable && p.s.flags.untargetable && p.s.flags.unblockable);
h.run(s.initCooldown + 0.1);
assert.ok(!p.s.flags.invulnerable && !p.s.flags.untargetable, '出击模式');
assert.deepEqual([p.y, p.x], blink.start, 'blinked to the start of its blink route');
assert.ok(p.s.flags.disarm, '出击模式: no normal attack');
const g = h.unit('t_gun');
const seen = [];
h.b.on('damaged', (c) => { if (c.target === boss) seen.push(c); }, { priority: -1e9 });
let pool0 = h.b.sharedBoss.hp;
const d1 = h.b.dealDamage(g, p, { amount: 1000, type: 'true' });
approx(pool0 - h.b.sharedBoss.hp, d1 * BLADE_TRANSFER, 1e-6, 'passed on during the dive too');
for (let i = 1; i < s.bb.max_hit_cnt - 1; i++) h.b.dealDamage(g, p, { amount: 1, type: 'true' });
assert.equal(p.motion, 'FLY');
h.b.dealDamage(g, p, { amount: 1, type: 'true' }); // the 15th
assert.equal(p.motion, 'WALK', 'a ground unit');
approx(p.s.dmgTakenMul, s.bb.damage_scale);
assert.ok(p.s.flags.stun);
approx(statuses(h, p.id, 'stun')[0].duration, s.bb.stun);
pool0 = h.b.sharedBoss.hp;
const d2 = h.b.dealDamage(g, p, { amount: 1000, type: 'true' });
approx(d2, 1000 * s.bb.damage_scale);
approx(pool0 - h.b.sharedBoss.hp, d2 * BLADE_TRANSFER);
approx(pool0 - h.b.sharedBoss.hp, 1000 * s.bb.damage_scale, 1e-6, '等量: all of the ×1.3 damage');
assert.ok(seen.length && seen.every((c) => c.source === null && c.credit === g), '无来源 (hooks see no source), credited to the attacker');
assert.ok(h.result().perPlayer.p1.bossDamage >= d1 + (s.bb.max_hit_cnt - 2) + 1 + d2 - 1e-6, 'the per-player pool tally');
const hp = p.hp, killed0 = h.b.killed;
h.run(s.bb.stun - 0.2);
assert.ok(p.alive && p.motion === 'WALK', 'still 瘫痪');
h.run(0.3); // the stun ends → 【瘫痪】 ends (not the 20 s)
assert.ok(!p.alive, 'the old one is killed');
const n = alive(h, key);
assert.equal(n.length, 1);
assert.notEqual(n[0].id, p.id);
assert.deepEqual([n[0].y, n[0].x], origin.start, 'summoned on its origin route');
approx(n[0].hp, hp, 1e-9, 'inherits the HP');
assert.ok(n[0].s.flags.invulnerable && n[0].motion === 'FLY' && n[0].tag === 'part');
assert.equal(h.b.killed, killed0, 'a part: not counted');
h.run(s.initCooldown - 0.2);
assert.ok(n[0].s.flags.invulnerable, 'a new unit: 掷 waits for its initial cooldown');
h.run(0.3);
assert.ok(!n[0].s.flags.invulnerable);
});
}
test('“斩胄之剑” dive that lands: 3×3 stun + physical DoT at the lowest-ATK operator, then a new copy with its HP at home', () => {
const h = bossArena({ units: [{ chessId: 't_wall', row: 12, col: 15 }, { chessId: 't_wall2', row: 12, col: 16 }, { chessId: 't_wall3', row: 9, col: 3 }], chess: { t_wall2: WALL('t_wall2', { atk: 50 }), t_wall3: WALL('t_wall3', { atk: 100 }) }, hooks: ['statusApplied'], setup: setTpl(H08) });
h.step();
const p = put(h, 'enemy_9014_acstma', [3, 12], { tag: 'part', move: true });
const origin = handRoute('enemy_9014_acstma', 'origin');
const p = put(h, 'enemy_9014_acstma', origin.start, { tag: 'part', move: true });
const s = skb('enemy_9014_acstma', '1');
h.runUntil(() => statuses(h, h.unit('t_wall').id, 'stun').length > 0, s.initCooldown + 30);
h.run(s.initCooldown + 0.1);
assert.ok(p.alive && !p.s.flags.invulnerable, 'flying from the blink start (5,16) to t_wall (5,15)');
p.hp -= 1000;
h.runUntil(() => statuses(h, h.unit('t_wall').id, 'stun').length > 0, 30);
approx(statuses(h, h.unit('t_wall').id, 'stun')[0].duration, s.bb.stun);
assert.equal(statuses(h, h.unit('t_wall2').id, 'stun').length, 1, 'the 3×3 around the target');
assert.equal(statuses(h, h.unit('t_wall3').id, 'stun').length, 0);
assert.ok(h.unit('t_wall').findBuff('boss:bladeDiveDot'));
h.run(20);
assert.ok(p.s.flags.invulnerable, 'back to hover');
assert.ok(!p.alive);
const n = alive(h, 'enemy_9014_acstma')[0];
assert.ok(n && n.id !== p.id && n.s.flags.invulnerable);
assert.deepEqual([n.y, n.x], origin.start);
approx(n.hp, n.s.maxHp - 1000);
});
test('“斩胄之剑” / “破胄之锤” 初始模式 attack: every ally in range, 锤 150 % ATK (PRTS 天赋)', () => {
for (const [key, scale] of [['enemy_9014_acstma', 1], ['enemy_9015_acstmb', 1.5]]) {
const h = bossArena({ units: [{ chessId: 't_wall', row: 10, col: 9 }, { chessId: 't_wall2', row: 10, col: 7 }] });
h.step();
put(h, key, [3, 8], { tag: 'part' });
h.run(1);
approx(h.unit('t_wall').stats.taken, E[key].stats.atk * scale, 1e-6, key);
approx(h.unit('t_wall2').stats.taken, E[key].stats.atk * scale, 1e-6, key);
}
});
test('假想敌:铳: unblockable, targets the highest DEF in range, ASPD ramps on the same target, erosion on hit', () => {
@@ -2124,7 +2250,9 @@ test('“碎铳之簧” 频次护盾 (9020): negates 5 hits, then comes back 25
approx(w.stats.taken, n * sp.s.atk + 10 * sp.s.atk * sk.bb.atk_scale);
});
test('“碎铳之簧”: damage taken passes PART_TRANSFER to 假想敌:铳 and, split, to the other springs (never passed on twice)', () => {
test('“碎铳之簧”: damage taken costs 假想敌:铳 as much (等量, 无来源) and, split, the other springs (never passed on twice)', () => {
// PRTS 碎铳之簧 "受到伤害时令全场范围内仇恨值最高的1名假想敌:铳受到等量的无来源生命流失" (DESIGN §20.10; v2.5: half)
assert.equal(PART_TRANSFER, 1);
const h = bossArena({ units: [{ chessId: 't_gun', row: 12, col: 3 }], kits: { t_gun: NOATK } });
h.step();
const g = put(h, 'enemy_9017_achunt_2', [3, 18], { tag: 'boss' });
@@ -2132,12 +2260,16 @@ test('“碎铳之簧”: damage taken passes PART_TRANSFER to 假想敌:铳 a
const [a, b, c] = ['enemy_9018_actrpa', 'enemy_9019_actrpb', 'enemy_9020_actrpc'].map((k, i) => put(h, k, [1 + i * 2, 4], { tag: 'part' }));
for (const x of [a, b, c]) x.profile.noAttack = true;
h.step();
const seen = [];
h.b.on('damaged', (x) => { if (x.target === g) seen.push(x); }, { priority: -1e9 });
const pool0 = h.b.sharedBoss.hp, b0 = b.hp, c0 = c.hp;
const dealt = h.b.dealDamage(h.unit('t_gun'), a, { amount: 1e5, type: 'true' });
assert.ok(dealt > 0);
approx(pool0 - h.b.sharedBoss.hp, dealt * PART_TRANSFER);
approx(b0 - b.hp, (dealt * PART_TRANSFER) / 2);
approx(c0 - c.hp, (dealt * PART_TRANSFER) / 2);
approx(pool0 - h.b.sharedBoss.hp, dealt);
approx(b0 - b.hp, dealt / 2);
approx(c0 - c.hp, dealt / 2);
assert.ok(seen.length === 1 && seen[0].source === null && seen[0].credit === h.unit('t_gun'), '无来源, credited to the attacker');
assert.ok(h.result().perPlayer.p1.bossDamage >= dealt - 1e-6, 'the per-player pool tally');
});
test('假想敌:管: strikes dark 余音 (their pulse hurts operators); 裂管之奏 at 40 s; summons 余音 every 40 s', () => {