- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently). - Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision). - 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified). - Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage. - Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue. - Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia). - golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
117 lines
7.4 KiB
JavaScript
117 lines
7.4 KiB
JavaScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||
|
||
test('Clementia S3 ignores allied erosion bursts but still marks enemy bursts', () => {
|
||
const h = makeBattle({ autoFinish: false, hooks: ['elementBurst'],
|
||
units: [
|
||
{ uid: 1, diy: { slot: 6, charId: 'char_4231_clemnt', skillIndex: 2 }, elite: true, row: 10, col: 3 },
|
||
{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 4 },
|
||
], defs: { enemies: { target: enemyRec({ key: 'target', hp: 1e7, atk: 0, speed: 0 }) } },
|
||
});
|
||
h.step();
|
||
const u = h.unit(1), ally = h.unit(2);
|
||
u.skill.gainSp(999, 'test'); assert.ok(u.skill.activate('test'));
|
||
h.b.dealDamage(null, ally, { type: 'element', element: 'erosion', amount: 2000 });
|
||
assert.equal(h.hooksOf('elementBurst').filter(c => c.target === ally).length, 1, 'a real allied burst occurred');
|
||
assert.equal(u.mem.clemntBombs.marks.size, 0, 'friendly injury must not schedule a shot');
|
||
const enemy = h.spawn('target', { pos: [10, 6] });
|
||
h.b.dealDamage(u, enemy, { type: 'element', element: 'erosion', amount: 2000 });
|
||
assert.ok(u.mem.clemntBombs.marks.has(enemy), 'enemy erosion still schedules the bombardment');
|
||
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||
});
|
||
|
||
for (const skillIndex of [0, 1, 2]) test(`Horn S${skillIndex + 1} can attack an unblockable fragment on her own tile`, () => {
|
||
const h = makeBattle({ autoFinish: false, hooks: ['damaged'], captureNoisy: true,
|
||
units: [{ uid: 1, chessId: 'chess_char_5_08_a', skillIndex, row: 10, col: 3 }],
|
||
defs: { enemies: { fragment: enemyRec({ key: 'fragment', hp: 1e7, atk: 0, speed: 0 }) } },
|
||
});
|
||
h.step();
|
||
const u = h.unit(1), e = h.spawn('fragment', { pos: [10, 3] });
|
||
h.b.addBuff(e, { key: 'test:fragment', persist: true, flags: { unblockable: true } });
|
||
u.skill.gainSp(999, 'test'); h.run(4);
|
||
assert.equal(u.blocking.length, 0, 'the target cannot be blocked');
|
||
assert.ok(e.hp < e.s.maxHp, 'her extra melee range includes her own tile');
|
||
assert.ok(u.skill.activations > 0, 'a target on that tile also satisfies skill automation');
|
||
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||
});
|
||
|
||
test('bounty catapult normal triple attack splashes adjacent and diagonal allies within 1.5 tiles', () => {
|
||
const rec = (id, tauntLevel = 0) => chessRec({ id, stats: { maxHp: 1e6, atk: 0, def: 0, blockCnt: 0, tauntLevel }, skill: null });
|
||
const h = makeBattle({ autoFinish: false, flags: { dpInit: 999 },
|
||
defs: { chess: { target: rec('target', 5), bystander: rec('bystander') } },
|
||
units: [
|
||
{ uid: 1, chessId: 'target', row: 10, col: 5 },
|
||
{ uid: 2, chessId: 'bystander', row: 10, col: 6 },
|
||
{ uid: 3, chessId: 'bystander', row: 11, col: 6 },
|
||
{ uid: 4, chessId: 'bystander', row: 12, col: 7 },
|
||
],
|
||
});
|
||
h.step();
|
||
const e = h.spawn('enemy_10162_mnctpt', { pos: [10, 8], mods: { speedMul: 0 } });
|
||
assert.ok(h.runUntil(() => h.unit(1).hp < h.unit(1).s.maxHp, 10), 'the primary attack reaches its target');
|
||
for (const uid of [1, 2, 3]) assert.equal(h.unit(uid).s.maxHp - h.unit(uid).hp, e.s.atk * 3, `unit ${uid}: all three stones`);
|
||
assert.equal(h.unit(4).hp, h.unit(4).s.maxHp, 'beyond the splash');
|
||
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||
});
|
||
|
||
for (const skillIndex of [0, 1, 2]) test(`Horn S${skillIndex + 1} retains melee splash only for S2 and leaves adjacent blind tiles untargeted`, () => {
|
||
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: 'chess_char_5_08_a', skillIndex, row: 10, col: 3 }],
|
||
defs: { enemies: { fragment: enemyRec({ key: 'fragment', hp: 1e7, atk: 0, speed: 0 }) } },
|
||
});
|
||
h.step(); const u = h.unit(1);
|
||
const own = h.spawn('fragment', { pos: [10, 3] }), near = h.spawn('fragment', { pos: [10, 4] });
|
||
for (const e of [own, near]) h.b.addBuff(e, { key: 'test:fragment', persist: true, flags: { unblockable: true } });
|
||
u.skill.gainSp(999, 'test'); h.run(4);
|
||
assert.ok(own.hp < own.s.maxHp);
|
||
assert.equal(near.hp < near.s.maxHp, skillIndex === 1, 'only S2 grants splash to melee attacks');
|
||
h.b.kill(own, null); const hp = near.hp; h.run(4);
|
||
assert.equal(near.hp, hp, 'the adjacent blind tile does not become an attack target');
|
||
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||
});
|
||
|
||
test('Horn prioritizes her ordinary ranged targets over an unblockable enemy on her own tile', () => {
|
||
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: 'chess_char_5_08_a', row: 10, col: 3 }],
|
||
defs: { enemies: { fragment: enemyRec({ key: 'fragment', hp: 1e7, atk: 0, speed: 0 }) } },
|
||
});
|
||
h.step();
|
||
const own = h.spawn('fragment', { pos: [10, 3] }), far = h.spawn('fragment', { pos: [10, 6] });
|
||
h.b.addBuff(own, { key: 'test:fragment', persist: true, flags: { unblockable: true } });
|
||
h.run(4);
|
||
assert.ok(far.hp < far.s.maxHp); assert.equal(own.hp, own.s.maxHp);
|
||
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||
});
|
||
|
||
test('Horn changing to melee does not remove splash from an already flying shell', () => {
|
||
const h = makeBattle({ autoFinish: false, hooks: ['attack'], captureNoisy: true, units: [{ uid: 1, chessId: 'chess_char_5_08_a', skillIndex: 2, row: 10, col: 3 }],
|
||
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e7, atk: 0, speed: 0 }) } },
|
||
});
|
||
h.step(); const u = h.unit(1);
|
||
h.b.addBuff(u, { key: 'test:fast', persist: true, mods: { aspd: 500 } });
|
||
u.skill.gainSp(999, 'test'); assert.ok(u.skill.activate('test'));
|
||
const far = h.spawn('dummy', { pos: [10, 8] }), side = h.spawn('dummy', { pos: [11, 8] });
|
||
assert.ok(h.runUntil(() => h.hooksOf('attack').some(c => c.attacker === u), 1));
|
||
h.spawn('dummy', { pos: [10, 3] }); h.run(0.4);
|
||
assert.ok(h.hooksOf('attack').filter(c => c.attacker === u).length >= 2, 'a melee attack follows before the shell lands');
|
||
assert.ok(far.hp < far.s.maxHp); assert.equal(side.s.maxHp - side.hp, far.s.maxHp - far.hp);
|
||
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||
});
|
||
|
||
for (const hidden of [false, true]) test(`${hidden ? 'hidden' : 'final'} Helm actually fires a shell which stuns the target's 3×3 area`, () => {
|
||
const rec = (id, atk) => chessRec({ id, stats: { atk, maxHp: 1e7, blockCnt: 0 }, skill: null });
|
||
const h = makeBattle({ kind: hidden ? 'hidden' : 'boss', autoFinish: false, timeLimit: 90, flags: { dpInit: 999 },
|
||
hooks: ['statusApplied'], defs: { chess: { main: rec('main', 900), bystander: rec('bystander', 0) } },
|
||
kits: { main: () => ({ trait: { noAttack: true } }), bystander: () => ({ trait: { noAttack: true } }) },
|
||
units: [{ uid: 1, chessId: 'main', row: 10, col: 6 }, { uid: 2, chessId: 'bystander', row: 9, col: 5 },
|
||
{ uid: 3, chessId: 'bystander', row: 11, col: 7 }, { uid: 4, chessId: 'bystander', row: 12, col: 3 }],
|
||
});
|
||
h.step();
|
||
const e = h.spawn(hidden ? 'enemy_9013_acstmk_2' : 'enemy_9013_acstmk', { pos: [3, 10], tag: 'boss', mods: { speedMul: 0 } });
|
||
e.profile.noAttack = true;
|
||
assert.ok(h.runUntil(() => h.b.enemies.some(e => e.defId === 'enemy_9016_acstmr'), 60), 'shell was fired');
|
||
assert.ok(h.runUntil(() => h.hooksOf('statusApplied').some(c => c.target === h.unit(1) && c.status === 'stun'), 25), 'shell arrived');
|
||
const stunned = new Set(h.hooksOf('statusApplied').filter(c => c.status === 'stun').map(c => c.target.uid));
|
||
for (const uid of [1, 2, 3]) assert.ok(stunned.has(uid), `3×3 includes ${uid}`);
|
||
assert.ok(!stunned.has(4), 'outside the 3×3'); checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||
});
|