0.2.5-dev: community round 30 and follow-ups
- 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.
This commit is contained in:
1 parent
bc50cac01f
commit
221e6b8dd2
134 files changed
+4590
-1201
No files matched your search
@@ -544,11 +544,12 @@ test('阿戈尔 5: a member devoured by several 阿戈尔 spends one slot at mos
|
||||
});
|
||||
|
||||
test('阿戈尔 5: an operator\'s own save or revive uses no slot (斯卡蒂\'s DRE-Y, M3茧甲, 埃芒加德 [ASSUMED for the band]); the slot waits for its first unanswered knock-out', () => {
|
||||
// 斯卡蒂 (elite, module DRE-Y by default) enters 联防 at half HP in front of 乌尔比安: his mark is lethal, DRE-Y saves her
|
||||
// 斯卡蒂 (elite, module DRE-Y by default) starts a normal field at half HP in front of 乌尔比安: his mark is lethal,
|
||||
// DRE-Y saves her. Half HP is a fixture here; a restored 联防 target no longer pays opening devour damage twice.
|
||||
// (PRTS: once per deployment, full HP with max HP −60 %) — no knock-out, so no slot
|
||||
const SKADI = 'chess_char_3_05_b', ULPIA = 'chess_char_5_05_a', GHOST = 'chess_char_2_07_a', HN = 'chess_char_3_09_a', DEEP = 'chess_char_1_04_a';
|
||||
const h = makeBattle({
|
||||
kind: 'unite', autoFinish: false, timeLimit: 60, hooks: ['damaged', 'death', 'deploy'], captureNoisy: true,
|
||||
kind: 'normal', autoFinish: false, timeLimit: 60, hooks: ['damaged', 'death', 'deploy'], captureNoisy: true,
|
||||
units: [
|
||||
{ chessId: ULPIA, row: 10, col: 3 }, { chessId: SKADI, row: 10, col: 4, dir: 'UP', carryState: { hpPct: 0.5 } },
|
||||
{ chessId: GHOST, row: 12, col: 3 }, { chessId: HN, row: 12, col: 5 }, { chessId: DEEP, row: 12, col: 7 },
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
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, []);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { makeMatch } from '../match/harness.js';
|
||||
import { uniteBattleOpts } from '../../server/match/unite.js';
|
||||
import { FORCED_EXIT } from '../../server/sim/constants.js';
|
||||
|
||||
const egir = (layers = 0, count = 5) => ({ count, active: true, tier: count >= 5 ? 2 : 1, layers });
|
||||
const close = (a, b) => assert.ok(Math.abs(a - b) < 1e-8, `${a} != ${b}`);
|
||||
const defs = { chess: Object.fromEntries([['A', 10000], ['B', 7000], ['C', 20000], ['D', 10000], ['E', 10000]]
|
||||
.map(([id, maxHp]) => [id, chessRec({ id, bonds: ['egirShip'], stats: { atk: 100, def: 0, maxHp, blockCnt: 1 },
|
||||
skill: { spCost: 30, initSp: 5, duration: 10 } })])) };
|
||||
const units = [['A', 10, 3], ['B', 10, 4], ['C', 10, 5], ['D', 12, 3], ['E', 12, 5]]
|
||||
.map(([chessId, row, col], i) => ({ uid: i + 1, kind: 'chess', chessId, row, col, dir: 'RIGHT' }));
|
||||
|
||||
function carryInput(t, own, lineup, bonds) {
|
||||
const m = makeMatch({ mode: 'coop', humans: 3, fake: true }).m;
|
||||
t.after(() => m.dispose());
|
||||
const unitsEnd = own.result().perPlayer.p1.unitsEnd;
|
||||
const ps = { playerId: 'p1', board: new Map(lineup.map(u => [u.uid, { uid: u.uid, kind: 'chess' }])),
|
||||
battleInput: ({ side, colOffset, carry }) => ({ playerId: 'p1', seat: 0, side, colOffset, bonds,
|
||||
units: lineup.map(u => ({ ...u, carryState: carry.get(u.uid) })) }) };
|
||||
const { players } = uniteBattleOpts({ gd: m.gd, round: 3, lastResults: new Map([['p1', { unitsEnd }]]), dispatch() {} },
|
||||
{ helpers: [ps], leaked: [] }, 60);
|
||||
return { players, unitsEnd };
|
||||
}
|
||||
|
||||
test('normal result → unite rebuilds Egir attributes but preserves every living HP ratio and SP', t => {
|
||||
const own = makeBattle({ defs, units, bonds: { egirShip: egir() }, autoFinish: false });
|
||||
own.step(2); own.b.forceEnd();
|
||||
const { players, unitsEnd } = carryInput(t, own, units, { egirShip: egir(own.b.getPlayer('p1').bonds.egirShip.layers) });
|
||||
const next = makeBattle({ kind: 'unite', defs, players, autoFinish: false }); next.b.start();
|
||||
for (const old of unitsEnd) {
|
||||
const u = next.unit(old.uid);
|
||||
assert.equal(u.alive, old.alive);
|
||||
close(u.hpRatio, old.hpPct); close(u.skill.spTotal, old.sp);
|
||||
assert.deepEqual(u.findBuff('bond:egir:devour')?.mods, own.unit(old.uid).findBuff('bond:egir:devour')?.mods);
|
||||
}
|
||||
assert.equal(next.b.result().perPlayer.p1.deaths, 0);
|
||||
assert.deepEqual(next.b.result().perPlayer.p1.layerGains, {});
|
||||
checkInvariants(next.b);
|
||||
});
|
||||
|
||||
test('a previous full-health survivor cannot be newly killed by opening devour during carry restoration', t => {
|
||||
const weak = { chess: { ...defs.chess, B: { ...defs.chess.B, stats: { ...defs.chess.B.stats, maxHp: 2000 } } } };
|
||||
const three = units.slice(0, 3).map(u => u.uid === 3 ? { ...u, row: 12, col: 7 } : u);
|
||||
const own = makeBattle({ defs: weak, units: three, bonds: { egirShip: egir(0, 3) }, autoFinish: false, timeLimit: 120 });
|
||||
own.run(80); own.b.forceEnd();
|
||||
assert.equal(own.unit(2).hpRatio, 1, 'food redeployed before its old battle ended');
|
||||
const { players } = carryInput(t, own, three, { egirShip: egir(own.b.getPlayer('p1').bonds.egirShip.layers, 3) });
|
||||
const next = makeBattle({ kind: 'unite', defs: weak, players, autoFinish: false }); next.b.start();
|
||||
assert.equal(next.unit(2).alive, true); assert.equal(next.unit(2).hpRatio, 1);
|
||||
assert.equal(next.b.result().perPlayer.p1.deaths, 0);
|
||||
close(next.unit(1).findBuff('bond:egir:devour').mods.atkFinal, 100);
|
||||
});
|
||||
|
||||
test('forced-down carry still contributes devour attributes without consuming any of the three later revives', () => {
|
||||
const lineup = units.map(u => ({ ...u, carryState: u.uid === 2 ? { down: true } : { hpPct: 0.4, sp: 17 } }));
|
||||
const next = makeBattle({ kind: 'unite', defs, players: [{ playerId: 'p1', units: lineup, bonds: { egirShip: egir() } }], autoFinish: false });
|
||||
next.b.start();
|
||||
assert.equal(next.unit(2).alive, false); assert.equal(next.unit(2).removeReason, FORCED_EXIT);
|
||||
close(next.unit(1).findBuff('bond:egir:devour').mods.atkFinal, 200);
|
||||
const survived = [];
|
||||
for (const uid of [4, 5, 1, 3]) { next.b.loseHp(next.unit(uid), 1e9); survived.push(next.unit(uid).alive); }
|
||||
assert.deepEqual(survived, [true, true, true, false]);
|
||||
checkInvariants(next.b);
|
||||
});
|
||||
|
||||
test('carry preserves positive ratios below 1% and fractional HP when the new max HP is smaller', t => {
|
||||
const lineup = [{ ...units[0], dir: 'UP' }];
|
||||
const own = makeBattle({ defs, units: lineup, bonds: { egirShip: egir() }, autoFinish: false });
|
||||
own.b.start(); own.unit(1).hp = 1; own.b.forceEnd();
|
||||
const { players, unitsEnd } = carryInput(t, own, lineup, {});
|
||||
close(players[0].units[0].carryState.hpPct, unitsEnd[0].hpPct);
|
||||
const small = { chess: { A: { ...defs.chess.A, stats: { ...defs.chess.A.stats, maxHp: 1000 } } } };
|
||||
const next = makeBattle({ kind: 'unite', defs: small, players, autoFinish: false }); next.b.start();
|
||||
assert.ok(next.unit(1).alive && next.unit(1).hp > 0 && next.unit(1).hp < 1);
|
||||
close(next.unit(1).hpRatio, unitsEnd[0].hpPct);
|
||||
});
|
||||
|
||||
test('ordinary/boss devour and fresh unite units still receive opening devour damage', () => {
|
||||
for (const kind of ['normal', 'unite', 'boss', 'hidden']) {
|
||||
const h = makeBattle({ kind, defs, units, bonds: { egirShip: egir() }, autoFinish: false }); h.b.start();
|
||||
assert.ok(h.unit(2).hpRatio < 0.5, kind);
|
||||
assert.ok(h.unit(3).hpRatio < 0.7, kind);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { DREAD_TAG } from '../../server/sim/content/kits/ops/op-ascln.js';
|
||||
|
||||
for (const [item, status] of [['5_01', 'palsy'], ['5_02', 'cold']]) for (const quality of ['a', 'b']) {
|
||||
const id = `chess_item_${item}_e_${quality}`;
|
||||
test(`${id}: Ascalon poison still procs ${status} while she cannot attack`, () => {
|
||||
const h = makeBattle({ autoFinish: false, hooks: ['damaged', 'attack', 'statusApplied'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: 6, charId: 'char_4132_ascln', skillIndex: 0 }, row: 10, col: 5, items: [id] }],
|
||||
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e7, atk: 0, speed: 0 }) } },
|
||||
});
|
||||
h.step(); const u = h.unit(1), e = h.spawn('dummy', { pos: [10, 6] });
|
||||
const rng = h.b.rng; h.b.rng = Object.assign(() => 0.999, rng);
|
||||
assert.ok(h.runUntil(() => h.hooksOf('damaged').some(c => c.source === u && c.dmg.isAttack), 5));
|
||||
assert.equal(e.findBuff(status), null, 'the attack did not roll the item');
|
||||
const attacks = h.hooksOf('attack').length;
|
||||
h.b.addBuff(u, { key: 'test:noAttack', flags: { disarm: true }, duration: 5 });
|
||||
h.b.rng = Object.assign(() => 0, rng);
|
||||
assert.ok(h.runUntil(() => h.hooksOf('damaged').some(c => c.dmg.tags?.includes(DREAD_TAG)), 2));
|
||||
assert.equal(h.hooksOf('attack').length, attacks, 'only the already-applied poison deals damage');
|
||||
assert.ok(e.findBuff(status), 'a real poison damage tick triggers the item');
|
||||
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
|
||||
test(`${id}: only carrier damage to enemies rolls ${status}; gauge, HP loss and bare attacks do not`, () => {
|
||||
const h = makeBattle({ autoFinish: false, defs: {
|
||||
chess: { quiet: chessRec({ id: 'quiet', stats: { atk: 0 }, skill: null }) },
|
||||
enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e7, atk: 0, speed: 0 }) },
|
||||
}, units: [{ uid: 1, chessId: 'quiet', row: 10, col: 3, items: [id] }, { uid: 2, chessId: 'quiet', row: 10, col: 4 }] });
|
||||
h.step(); const u = h.unit(1), ally = h.unit(2), e = h.spawn('dummy', { pos: [10, 8] });
|
||||
h.b.rng = Object.assign(() => 0, h.b.rng);
|
||||
h.b.emit('attack', { attacker: u, targets: [e] });
|
||||
h.b.dealDamage(u, e, { amount: 1, type: 'element', element: 'erosion' });
|
||||
h.b.loseHp(e, 1, { source: u });
|
||||
h.b.dealDamage(null, e, { amount: 1, type: 'true' });
|
||||
h.b.dealDamage(ally, e, { amount: 1, type: 'true' });
|
||||
h.b.dealDamage(u, ally, { amount: 1, type: 'true' });
|
||||
h.b.dealDamage(u, e, { amount: 0, type: 'true' });
|
||||
assert.equal(e.findBuff(status), null); assert.equal(ally.findBuff(status), null);
|
||||
h.b.dealDamage(u, e, { amount: 1, type: 'arts', canDodge: false, tags: ['skill', 'dot'] });
|
||||
assert.ok(e.findBuff(status));
|
||||
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
}
|
||||
@@ -1249,7 +1249,7 @@ test('忍冬 追凶 never feeds on itself: a hit turned into HP loss credited to
|
||||
const e = h.enemy('enemy_times');
|
||||
const atks = u.stats.attacks;
|
||||
assert.ok(atks >= 3);
|
||||
// first attack marks the enemy (1 HP), every later attack: 1 HP + one 追凶 bonus instance (1 HP)
|
||||
assert.equal(e.s.maxHp - e.hp, 1 + 2 * (atks - 1));
|
||||
// Every attack, the first included: 1 HP + one 追凶 bonus instance (1 HP), never recursive bonuses.
|
||||
assert.equal(e.s.maxHp - e.hp, 2 * atks);
|
||||
done(h);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { rotateOffset } from '../../server/sim/dir.js';
|
||||
|
||||
const CASES = [
|
||||
['char_1043_leizi2', 1, [0, 3]],
|
||||
['char_1044_hsgma2', 2, [0, 2]],
|
||||
['char_1015_aglna2', 2, [1, -1]],
|
||||
];
|
||||
function field(charId, skillIndex, { tier = 6, elite = true, dir = 'RIGHT', cooldown = 0 } = {}) {
|
||||
const h = makeBattle({ units: [{ uid: 1, diy: { slot: tier, charId, skillIndex }, elite, row: 10, col: 5, dir }],
|
||||
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }),
|
||||
flyer: enemyRec({ key: 'flyer', hp: 1e9, atk: 0, speed: 0, motion: 'FLY' }) } },
|
||||
flags: { startOpCooldown: cooldown }, autoFinish: false, timeLimit: 90 });
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
|
||||
for (const [charId, skillIndex, delta] of CASES) {
|
||||
test(`${charId}: all DIY forms cast for a target in the actual skill area outside normal targeting`, () => {
|
||||
for (const tier of [5, 6]) for (const elite of [false, true]) for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
|
||||
for (const key of ['dummy', 'flyer']) {
|
||||
const { h, u } = field(charId, skillIndex, { tier, elite, dir });
|
||||
// Leizi's terrain flood and Hoshiguma's x-2 are symmetric; Angelina's rear diagonal follows her facing.
|
||||
const [dr, dc] = charId === 'char_1015_aglna2' ? rotateOffset(...delta, dir) : delta;
|
||||
h.spawn(key, { pos: [10 + dr, 5 + dc] });
|
||||
u.skill.addCharge(1); h.step();
|
||||
assert.equal(u.skill.activations, 1, `${charId}/${tier}/${elite}/${dir}/${key}`);
|
||||
assert.equal(h.b.errors.length, 0); checkInvariants(h.b);
|
||||
}
|
||||
}
|
||||
});
|
||||
test(`${charId}: expanded auto-cast keeps targetability and operation cooldown`, () => {
|
||||
for (const flag of ['stealth', 'untargetable', 'sleep']) {
|
||||
const { h, u } = field(charId, skillIndex);
|
||||
const e = h.spawn('flyer', { pos: [10 + delta[0], 5 + delta[1]] });
|
||||
h.b.addBuff(e, { key: 'test:hidden', flags: { [flag]: true } });
|
||||
u.skill.addCharge(1); h.run(0.5);
|
||||
assert.equal(u.skill.activations, 0, flag);
|
||||
h.b.removeBuff(e, 'test:hidden'); h.step();
|
||||
assert.equal(u.skill.activations, 1, `${flag} removed`);
|
||||
}
|
||||
const { h, u } = field(charId, skillIndex, { cooldown: 3 });
|
||||
h.spawn('flyer', { pos: [10 + delta[0], 5 + delta[1]] });
|
||||
u.skill.addCharge(1); h.run(1);
|
||||
assert.equal(u.skill.activations, 0);
|
||||
h.runUntil(() => u.skill.activations > 0, 3);
|
||||
assert.equal(u.skill.activations, 1);
|
||||
assert.ok(u.skill.lastStart >= 3);
|
||||
});
|
||||
}
|
||||
|
||||
test('Leizi S2 uses low-terrain reachability and permanent range extension, without changing ordinary range', () => {
|
||||
for (const extended of [false, true]) {
|
||||
const { h, u } = field('char_1043_leizi2', 1);
|
||||
if (extended) h.b.addBuff(u, { key: 'test:range', mods: { rangeExtend: 1 }, persist: true });
|
||||
h.spawn('flyer', { pos: [10, 9] }); u.skill.addCharge(1); h.step();
|
||||
assert.equal(u.skill.activations, extended ? 1 : 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// 追凶's first damage instance also carries its arts addition (PRTS 忍冬, talent remarks).
|
||||
// Real full-content battles with controlled durable enemies; no HP/SP/ATK writes or forced casts.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
const NORMAL = 'chess_char_3_18_a';
|
||||
const ELITE = 'chess_char_3_18_b';
|
||||
const ENEMY = 'enemy_vulpis_probe';
|
||||
const approx = (a, b) => assert.ok(Math.abs(a - b) < 1e-6, `${a} vs ${b}`);
|
||||
const hunt = (c) => c.dmg?.tags?.includes('vulpisHunt');
|
||||
const finished = (h) => { assert.deepEqual(h.b.errors, []); checkInvariants(h.b); };
|
||||
function scenario(entry = {}, enemy = {}) {
|
||||
return makeBattle({
|
||||
units: [{ chessId: NORMAL, row: 10, col: 4, ...entry }],
|
||||
defs: { enemies: { [ENEMY]: enemyRec({ key: ENEMY, hp: 1e7, speed: 0, ...enemy }) } },
|
||||
autoFinish: false, timeLimit: 40, flags: { startOpCooldown: 3 }, hooks: ['damaged'], captureNoisy: true,
|
||||
});
|
||||
}
|
||||
const variants = [
|
||||
{ label: 'normal', chessId: NORMAL },
|
||||
{ label: 'elite without module', chessId: ELITE, moduleId: 'none' },
|
||||
{ label: 'elite SOL-X', chessId: ELITE, moduleId: 'uniequip_002_vulpis' },
|
||||
{ label: 'elite SOL-Y', chessId: ELITE, moduleId: 'uniequip_003_vulpis' },
|
||||
];
|
||||
|
||||
for (const { label, ...entry } of variants) for (const skillIndex of [0, 1, 2]) {
|
||||
test(`追凶: first ordinary hit includes one arts addition (${label}, S${skillIndex + 1})`, () => {
|
||||
const h = scenario({ ...entry, skillIndex });
|
||||
h.step();
|
||||
const u = h.unit(entry.chessId);
|
||||
if (entry.moduleId) assert.equal(u.def.loadout.moduleId, entry.moduleId, 'the requested module is equipped');
|
||||
const e = h.spawn(ENEMY, { pos: [10, 5] });
|
||||
const hp = e.hp;
|
||||
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u), 1));
|
||||
const hits = h.hooksOf('damaged').filter((c) => c.source === u);
|
||||
const main = hits.filter((c) => !hunt(c));
|
||||
assert.equal(main.length, 1, 'one ordinary attack');
|
||||
assert.equal(main[0].dmg.isSkill, false, 'no skill at the first hit');
|
||||
assert.equal(main[0].type, 'phys');
|
||||
assert.ok(e.alive && e.hp > 0, 'the first attack did not kill the target');
|
||||
const riders = hits.filter(hunt);
|
||||
assert.equal(riders.length, 1, 'the very first hit carries 追凶');
|
||||
assert.equal(riders[0].t, main[0].t);
|
||||
assert.equal(riders[0].type, 'arts');
|
||||
assert.ok(riders[0].dmg.tags.includes('addition'));
|
||||
approx(riders[0].amount, u.s.atk * u.def.talents[0].bb.atk_scale);
|
||||
approx(hp - e.hp, main[0].amount + riders[0].amount);
|
||||
assert.equal(h.eventsOf('dmg').filter((c) => c[1] === e.id).length, 2, 'both actual damage events exist');
|
||||
finished(h);
|
||||
});
|
||||
}
|
||||
|
||||
for (const chessId of [NORMAL, ELITE]) for (const skillIndex of [0, 1, 2]) {
|
||||
test(`追凶: first skill damage carries its addition (${chessId}, S${skillIndex + 1})`, () => {
|
||||
const h = scenario({ chessId, skillIndex });
|
||||
h.run(16); // every selected skill is naturally ready, with no enemies yet
|
||||
const u = h.unit(chessId);
|
||||
const e = h.spawn(ENEMY, { pos: [10, skillIndex === 1 ? 7 : skillIndex === 2 ? 6 : 5] });
|
||||
const hp = e.hp;
|
||||
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.dmg.isSkill), 2));
|
||||
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e);
|
||||
const main = hits.filter((c) => !hunt(c));
|
||||
assert.ok(main.some((c) => c.dmg.isSkill));
|
||||
// S1 has a physical attack and a separate arts skill instance; S2/S3 have one main instance each.
|
||||
assert.equal(main.length, skillIndex === 0 ? 2 : 1);
|
||||
const riders = hits.filter(hunt);
|
||||
assert.equal(riders.length, main.length, 'one addition per qualifying damage instance, including the first');
|
||||
for (const c of riders) approx(c.amount, u.s.atk * u.def.talents[0].bb.atk_scale);
|
||||
approx(hp - e.hp, hits.reduce((s, c) => s + c.amount, 0));
|
||||
assert.ok(e.alive && e.hp > 0);
|
||||
finished(h);
|
||||
});
|
||||
}
|
||||
|
||||
test('追凶: each enemy starts its own fixed window; repeated damage does not extend it', () => {
|
||||
const h = scenario();
|
||||
h.step();
|
||||
const u = h.unit(NORMAL);
|
||||
const e = h.spawn(ENEMY, { pos: [10, 5] });
|
||||
h.run(12);
|
||||
const main = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && !hunt(c));
|
||||
const riders = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && hunt(c));
|
||||
assert.ok(main.some((c) => c.t > main[0].t + 10));
|
||||
assert.equal(riders.length, main.filter((c) => c.t - main[0].t <= 10 + 1e-9).length);
|
||||
assert.ok(riders.every((c) => c.t - main[0].t <= 10 + 1e-9));
|
||||
const another = h.spawn(ENEMY, { pos: [10, 6] });
|
||||
// Naturally ready S3 can reach both enemies; the new target gets its own first-hit addition.
|
||||
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.target === another), 10));
|
||||
const first = h.hooksOf('damaged').filter((c) => c.target === another);
|
||||
assert.equal(first.filter(hunt).length, 1);
|
||||
assert.equal(first.filter(hunt)[0].t, first.find((c) => !hunt(c)).t);
|
||||
finished(h);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import * as enemiesMod from '../../server/sim/content/enemies.js';
|
||||
import { canTargetEnemy } from '../../server/sim/targeting.js';
|
||||
|
||||
const grid = [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]];
|
||||
for (const rule of ['DEFAULT', 'ACTIVE_RANGE', 'SKILL_RANGE', 'SP_FULL']) test(`Xi post-rebirth invincibility prevents attacks; ${rule} retains its own trigger semantics`, () => {
|
||||
const h = makeBattle({ content: 'generic', extraContent: [enemiesMod], autoFinish: false, captureNoisy: true,
|
||||
defs: { chess: { probe: chessRec({ id: 'probe', profession: 'SNIPER', projectile: 'none',
|
||||
stats: { atk: 10, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: grid,
|
||||
skill: { spCost: 2, initSp: 0, duration: 0, trigger: { rule, grid } } }) } },
|
||||
kits: { probe: () => ({ skill: { duration: 0, spCost: 2, initSp: 0, trigger: { rule, grid }, onStart() {} } }) },
|
||||
units: [{ chessId: 'probe', row: 10, col: 4 }], enemies: [{ key: 'enemy_1517_xi', pos: [10, 7], mods: { speedMul: 0 } }],
|
||||
});
|
||||
h.step(); const u = h.unit('probe'), e = h.enemy('enemy_1517_xi');
|
||||
h.b.kill(e, null); h.run(4.8);
|
||||
let arrival = null;
|
||||
h.b.addProjectile({ from: u, target: e, speed: 3, onHit: ({ battle, target }) => {
|
||||
arrival = battle.dealDamage(u, target, { type: 'true', amount: 1000 });
|
||||
} });
|
||||
h.run(0.4);
|
||||
assert.equal(e.form, 'form2'); assert.ok(e.s.flags.invulnerable);
|
||||
assert.equal(canTargetEnemy(u, e, u.profile), false);
|
||||
const attacks = u.stats.attacks, casts = u.skill.activations, hp = e.hp;
|
||||
h.run(3);
|
||||
assert.equal(u.stats.attacks, attacks);
|
||||
if (rule === 'DEFAULT' || rule === 'ACTIVE_RANGE') assert.equal(u.skill.activations, casts);
|
||||
else assert.ok(u.skill.activations > casts, 'presence-only and SP-only skills are not forcibly suppressed');
|
||||
assert.equal(arrival, 0, 'an already flying projectile still does no damage');
|
||||
assert.equal(h.b.dealDamage(u, e, { type: 'arts', amount: 1000 }), 0);
|
||||
assert.equal(h.b.dealDamage(u, e, { type: 'element', element: 'neural', amount: 1000 }), 0);
|
||||
assert.equal(e.hp, hp);
|
||||
h.run(7.2);
|
||||
assert.equal(canTargetEnemy(u, e, u.profile), true); assert.ok(u.stats.attacks > attacks);
|
||||
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
Reference in new issue
Block a user