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:
1 parent
6464e4b46a
commit
620f88c0d8
81 files changed
+4194
-447
No files matched your search
@@ -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 }];
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user