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

+65
View File
@@ -142,6 +142,47 @@ test('奥术: member arts damage → target arts taken ×(1.2+0.01·L) for 3 s;
checkInvariants(h2.b);
});
test('奥术 with two players on one field: one instance per target, the strongest wins (never multiplied); a weaker one resumes after it', () => {
// DESIGN §20.10: PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"; 巴哈姆特 12316 "共享型buff會跟對面搶 如果對面層數比你高
// 就不需要再特別激活直接吃他的奧術buff". v2.5 kept one instance per player: ×(1.3)·×(1.7) here.
const bb = bondBb('arcaneShip');
const caster = (id) => chessRec({ id, bonds: ['arcaneShip'], profession: 'CASTER', skill: null, rangeGrid: [[0, 0]] });
const defs = { chess: { a_1: caster('a_1'), a_2: caster('a_2'), a_x: op('a_x', []) }, enemies: DUMMY };
const h = makeBattle({
kind: 'boss', defs,
players: [
{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: 'a_1', row: 12, col: 3 }], bonds: { arcaneShip: bond(1, 10) } },
{ playerId: 'p2', seat: 1, side: 'R', colOffset: 0, units: [{ uid: 2, kind: 'chess', chessId: 'a_2', row: 12, col: 3 }, { uid: 3, kind: 'chess', chessId: 'a_x', row: 11, col: 3 }], bonds: { arcaneShip: bond(1, 50) } },
],
enemies: [{ key: 'enemy_addon_dummy', pos: [9, 9] }],
});
h.step(2);
const e = h.enemy('enemy_addon_dummy');
const u1 = h.b.allyUnits.find((u) => u.defId === 'a_1'), u2 = h.b.allyUnits.find((u) => u.defId === 'a_2');
const m1 = bb.base_damage_scale + bb.damage_scale_per_stack * 10, m2 = bb.base_damage_scale + bb.damage_scale_per_stack * 50;
h.b.dealDamage(u1, e, { amount: 100, type: 'arts' });
close(e.s.artsTakenMul, m1, 'p1 alone');
h.b.dealDamage(u2, e, { amount: 100, type: 'arts' });
close(e.s.artsTakenMul, m2, 'the stronger p2 instance takes over — not m1 × m2');
h.b.dealDamage(u1, e, { amount: 100, type: 'arts' });
close(e.s.artsTakenMul, m2, 'a weaker application never overrides it');
assert.equal(e.buffs.filter((b) => String(b.key).startsWith('bond:arcaneShip')).length, 1, 'one buff on the target');
// the partner's non-member arts damage benefits too (it is a debuff on the target)
const x = h.b.allyUnits.find((u) => u.defId === 'a_x');
const hp0 = e.hp;
h.b.dealDamage(x, e, { amount: 100, type: 'arts' });
close(hp0 - e.hp, 100 * m2, 'any arts damage on the target');
// p2 stops: its instance expires at 3 s, p1's later application (≈ 3 s too) resumes for the rest
h.run(1);
h.b.dealDamage(u1, e, { amount: 100, type: 'arts' });
close(e.s.artsTakenMul, m2, 'still the stronger one');
h.run(bb.weak_duration - 1 + 0.1);
close(e.s.artsTakenMul, m1, 'the stronger expired, the weaker (applied later) resumes');
h.run(1.1);
close(e.s.artsTakenMul, 1, 'both expired');
checkInvariants(h.b);
});
test('坚守: all operators HP ×(1.25+0.012·L); tier 2 redirect 40% to members, thorns + fragile, cooldown, no loops', () => {
const bb = bondBb('steadShip');
const defs = { chess: { d_1: op('d_1', ['steadShip']), d_2: op('d_2', ['steadShip']), d_n: op('d_n', []) }, enemies: DUMMY };
@@ -180,6 +221,30 @@ test('坚守: all operators HP ×(1.25+0.012·L); tier 2 redirect 40% to members
checkInvariants(h.b);
});
test('坚守 thorns: 无来源 (no attacker bonus, hooks see no source) but credited to the member — its stats and the shared-pool tally', async () => {
// DESIGN §20.10: v2.5 dealt them with no source at all, so no player's boss damage counted them (19 % of a co-op pool)
const { SharedBossPool } = await import('../../server/match/finalAssault.js');
const bb = bondBb('steadShip');
const defs = { chess: { d_1: op('d_1', ['steadShip']), d_2: op('d_2', ['steadShip']) }, enemies: DUMMY };
const pool = new SharedBossPool(1e6);
const h = makeBattle({ defs, kind: 'boss', sharedBoss: pool, units: [{ chessId: 'd_1', row: 10, col: 3 }, { chessId: 'd_2', row: 10, col: 5 }],
bonds: { steadShip: bond(2, 10) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 9], tag: 'boss' }], hooks: ['damaged'], captureNoisy: true });
h.step(2);
const e = h.enemy('enemy_addon_dummy'), d1 = h.unit('d_1');
assert.ok(e.bossPool, 'the leader drains the pool');
h.b.addBuff(d1, { key: 'test:rage', mods: { dmgDealtMul: 2 } });
const p0 = pool.hp, dmg0 = d1.stats.dmg;
h.b.dealDamage(e, d1, { amount: 100, type: 'true' });
const thorn = bb.base_damage_value + bb.damage_value_per_stack * 10;
close(p0 - pool.hp, thorn, 'thorns hit the pool, the member\'s ×2 does not apply');
const ev = h.hooksOf('damaged').filter((c) => c.target === e);
assert.equal(ev.length, 1);
assert.equal(ev[0].source, null, '无来源: hooks see no source');
assert.equal(ev[0].credit, d1, 'credited to the member');
close(d1.stats.dmg - dmg0, thorn, 'member stats');
close(h.result().perPlayer.p1.bossDamage, thorn, 'its player\'s shared-pool tally');
});
test('坚守 tier 2: the redirected 40 % is the already-scaled damage — the attacker’s damage bonus is not applied twice', () => {
const defs = { chess: { d_1: op('d_1', ['steadShip']), d_2: op('d_2', ['steadShip']), d_n: op('d_n', []) }, enemies: DUMMY };
const units = [{ chessId: 'd_1', row: 10, col: 3 }, { chessId: 'd_2', row: 10, col: 5 }, { chessId: 'd_n', row: 12, col: 3 }];
+2 -1
View File
@@ -782,7 +782,8 @@ test('battle plans come from the blackboards; gates as documented', () => {
assert.deepEqual(gateOf(EFF('allybuff_select_14')), { kind: 'sameRow', count: 3 });
assert.deepEqual(gateOf(EFF('allybuff_select_11')), { kind: 'always', count: 0 });
assert.equal(battlePlanOf(refOf('allybuff_select_11')).heal, 50);
assert.deepEqual(battlePlanOf(refOf('allybuff_select_18')).flawless, { atkMul: 1.3, defMul: 1.3 });
// 无瑕 "攻击力和防御力+30%": 直接乘算 — the additive percentage bucket (constants.js DIRECT_BONUS_STACKING)
assert.deepEqual(battlePlanOf(refOf('allybuff_select_18')).flawless, { atkPct: 0.3, defPct: 0.3 });
assert.deepEqual(battlePlanOf(refOf('allybuff_select_14')).opMods, { redeployMul: 0.5 });
assert.deepEqual(battlePlanOf(refOf('enemydebuff_select_8')).enemy, [{ rank: 'BOSS', mods: { defFlat: -200 } }]);
for (const id of TEAM_PREP) assert.equal(battlePlanOf(refOf(id)), null, `${id}: prep only`);
+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', () => {
+4 -3
View File
@@ -481,9 +481,10 @@ test('attrByBond (26 ids): floor(Σ layers of the listed ACTIVE bonds / divide_n
const k = Math.floor((L * list.length) / div);
const m = h.unit('op').findBuff(`gar:${gid}`).mods;
const want = {};
if (bb.atk) want.atkMul = 1 + bb.atk * k;
if (bb.max_hp) want.hpMul = 1 + bb.max_hp * k;
if (bb.def) want.defMul = 1 + bb.def * k;
// "每叠加N层…+X%" is 直接乘算: the additive percentage bucket (support directMods)
if (bb.atk) want.atkPct = bb.atk * k;
if (bb.max_hp) want.hpPct = bb.max_hp * k;
if (bb.def) want.defPct = bb.def * k;
if (bb.attack_speed) want.aspd = bb.attack_speed * k;
if (bb.hp_recovery_per_sec) want.hpRegen = bb.hp_recovery_per_sec * k;
if (bb.sp_recovery_per_sec) want.spRecoveryFlat = bb.sp_recovery_per_sec * k;
+7 -6
View File
@@ -77,7 +77,7 @@ const STAT_CASES = [
[A('6_11'), { spRecovery: 1.2 }], [B('6_11'), { spRecovery: 1.35 }], // 家族徽章
];
test('stat equipment: every percentage is its own multiplier (normal / golden numbers from data)', () => {
test('stat equipment: percentages are 直接乘算 — additive with each other (normal / golden numbers from data)', () => {
for (const [id, want] of STAT_CASES) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }] });
h.step(1);
@@ -85,13 +85,14 @@ test('stat equipment: every percentage is its own multiplier (normal / golden nu
for (const [k, v] of Object.entries(want)) close(s[k], v, `${id} ${k}`);
cover(id);
}
// two items multiply (维式重锤 ×1.15 × 阿戈尔重刃 ×1.4), a normal + golden copy of one item both apply
// two items add up (PRTS 盟约记录 "装备效果提供的属性加成均为直接乘算"; 直接乘算 values are summed, PRTS 游戏数据基础):
// 维式重锤 +15 % and 阿戈尔重刃 +40 % = +55 %, not ×1.15 × 1.4; a normal + golden copy of one item both apply
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [A('1_01'), A('3_07')] }] });
h.step(1);
close(h.unit('t_op').s.atk, 500 * 1.15 * 1.4, 'stacked');
close(h.unit('t_op').s.atk, 500 * (1 + 0.15 + 0.4), 'stacked');
const h2 = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [A('1_01'), B('1_01')] }] });
h2.step(1);
close(h2.unit('t_op').s.atk, 500 * 1.15 * 1.25, 'normal + golden');
close(h2.unit('t_op').s.atk, 500 * (1 + 0.15 + 0.25), 'normal + golden');
checkInvariants(h2.b);
});
@@ -179,8 +180,8 @@ test('歌利亚头盔: HP +25 % / +45 %, and +15 % / +25 % more when nobody stan
const ops = { t_op: op('t_op'), t_f: op('t_f') };
const free = fight({ ops, units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }] });
free.step(1);
close(free.unit('t_op').s.maxHp, 2000 * base * ex, `${id} free`);
close(free.unit('t_op').hp, 2000 * base * ex, `${id} deployed at full HP`);
close(free.unit('t_op').s.maxHp, 2000 * (base + ex - 1), `${id} free (+25 % and +15 % add up: 直接乘算)`);
close(free.unit('t_op').hp, 2000 * (base + ex - 1), `${id} deployed at full HP`);
const blocked = fight({ ops, units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }, { chessId: 't_f', row: 10, col: 5 }] });
blocked.step(1);
close(blocked.unit('t_op').s.maxHp, 2000 * base, `${id} front occupied`);
+13
View File
@@ -625,6 +625,19 @@ test('缄默德克萨斯 S1 细雨无声: passive ATK up for its duration; hits
}
});
test('缄默德克萨斯 S2: two copies cut the RES of an enemy around both once (同名效果取最高, DESIGN §20.10)', () => {
const id = 'chess_char_4_16_a', bb = D(id, 1).skill.bb;
const h = battle([U(id, 10, 4, 1), U(id, 11, 4, 1)], { enemies: { enemy_res: dummy({ key: 'enemy_res', res: 50 }) } });
h.step();
const two = h.b.allyUnits.filter((u) => u.def.id === id);
assert.equal(two.length, 2);
const e = h.spawn('enemy_res', { pos: [10, 5] });
for (const u of two) { h.b.retreat(u); assert.ok(h.b.redeploy(u)); }
assert.equal(dmgBy(h, two[0], tagged('burst')).length + dmgBy(h, two[1], tagged('burst')).length, 2, 'both bursts hit it');
assert.equal(e.buffs.filter((b) => String(b.key).startsWith('texas2:resDown')).length, 1, 'one RES cut');
approx(e.s.res, 50 * (1 + bb.magic_resistance), 1e-6, 'once, not twice');
});
test('缄默德克萨斯 S2 阵雨连绵: deploy burst atk_scale arts + RES down around her; ATK up; attacks become arts double hits', () => {
for (const id of pair('chess_char_4_16_a')) {
const sk = D(id, 1).skill, bb = sk.bb;
+36 -1
View File
@@ -494,7 +494,7 @@ test('灵知 S2: 130 % ATK arts + 2.5 s cold to all in range; fully charged cast
approx(burst[0].amount, u.s.atk * bb.atk_scale, 1e-6);
assert.ok(a.s.flags.freeze && b.s.flags.freeze, 'charged cast: second cold ⇒ freeze');
h.run(0.2);
approx(a.findBuff(`gnosis:fragile:${u.id}`).mods.dmgTakenMul, t0.damage_scale_freeze, 1e-9, 'frozen ×2 fragile');
approx(a.findBuff('gnosis:fragile').mods.dmgTakenMul, t0.damage_scale_freeze, 1e-9, 'frozen ×2 fragile');
// next (single-charge) cast only chills once per enemy
assert.ok(h.runUntil(() => u.skill.activations >= 2, 20));
const starts = noisy(h, 'skillStart').filter((c) => c.unit === u).map((c) => c.t);
@@ -552,6 +552,41 @@ test('莱恩哈特 S2: 170 % ATK arts to all enemies in the wider skill range +
assert.ok(inR.alive);
});
test('同名效果取最高: two 灵知 never compound 坚冰, two 莱恩哈特 never compound the RES cut (one instance per enemy, the strongest)', () => {
// DESIGN §20.10 (the 奥术 rule): a pair / 联防 partner's copy of the same operator puts the same-named effect on the
// same enemy — one instance, the strongest (PRTS 作战机制 "同名buff…只能表现出一个"), never m × m
const gn = 'chess_char_4_13_a', t0 = D(gn).talents[0].bb;
const g = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: gn, row: 10, col: 3 }, { chessId: gn, row: 11, col: 3 }], timeLimit: 60, hooks: [] });
g.step();
const both = g.b.allyUnits.filter((u) => u.def.id === gn);
assert.equal(both.length, 2, 'two 灵知 on the board');
const e = g.spawn('enemy_dummy', { pos: [10, 4] });
let seen = 0;
for (let i = 0; i < 120; i++) { // 4 s: chilled / frozen by both
g.step();
const fr = e.buffs.filter((b) => String(b.key).startsWith('gnosis:fragile'));
assert.ok(fr.length <= 1, `one 坚冰 instance (${fr.map((b) => b.key)})`);
if (!fr.length) continue;
seen++;
const want = e.s.flags.freeze ? t0.damage_scale_freeze : t0.damage_scale_cold;
approx(e.s.dmgTakenMul, fr[0].mods.dmgTakenMul, 1e-9, 'the only damage-taken modifier');
assert.ok(e.s.dmgTakenMul <= t0.damage_scale_freeze + 1e-9 && fr[0].mods.dmgTakenMul >= want - 1e-9, `never ${t0.damage_scale_cold}² / ${t0.damage_scale_freeze}² (${e.s.dmgTakenMul})`);
}
assert.ok(seen > 30, `坚冰 was up (${seen})`);
const lh = 'chess_char_4_14_a', bb = D(lh).skill.bb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy({ res: 50 }) } }, units: [{ chessId: lh, row: 10, col: 3 }, { chessId: lh, row: 11, col: 3 }], timeLimit: 60, hooks: [] });
h.step();
const two = h.b.allyUnits.filter((u) => u.def.id === lh);
assert.equal(two.length, 2);
const x = h.spawn('enemy_dummy', { pos: [10, 5] });
h.step();
for (const u of two) assert.ok(u.skill.activate('test', { free: true }), 'S2 cast');
assert.equal(x.buffs.filter((b) => String(b.key).startsWith('lionhd:res')).length, 1, 'one RES cut');
approx(x.s.res, 50 * (1 + bb.magic_resistance), 1e-9, 'RES −8 % once, not twice');
h.run(bb.duration + 0.1);
approx(x.s.res, 50, 1e-9, 'expired');
});
test('录武官 S2: healed allies regain 80 HP whenever damaged for 10 s; 学成于聚 SP +1 & ASPD +16 when an operator in range casts', () => {
const id = 'chess_char_4_15_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb;
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 10, col: 4 }], timeLimit: 60, hooks: ['heal'], captureNoisy: true });