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:
sganggs committed 2026-10-12 14:07:08 +08:00
1 parent bc50cac01f
commit 221e6b8dd2
134 files changed
+4590 -1201

No files matched your search

+3 -2
View File
@@ -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 },
+116
View File
@@ -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, []);
});
+88
View File
@@ -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);
}
});
+45
View File
@@ -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, []);
});
}
+2 -2
View File
@@ -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);
}
});
+94
View File
@@ -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);
});
+37
View File
@@ -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, []);
});