卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance (client-simulated combat, official waves/bonds/items/strategies, 3D board, facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
commit
01a44cd288
408 files changed
+308986
No files matched your search
@@ -0,0 +1,670 @@
|
||||
// The 40 strategies (bands): prep side (server/sim/content/bands/meta.js through the real registry + dispatcher) and
|
||||
// battle side (bands/battle.js with synthetic operators / enemies for exact numbers). Numbers are the band blackboards
|
||||
// (data/bands.json, research 01 §11); every band is covered (last test).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { makeMatch, give, giveItem, DATA } from '../match/harness.js';
|
||||
import { createRegistry } from '../../server/match/effectsMeta.js';
|
||||
import { hasBattlePart, amedicCharsFor, PRIO_BAND_REVIVE } from '../../server/sim/content/bands/battle.js';
|
||||
import { bandMetaHandler, duckReplace } from '../../server/sim/content/bands/meta.js';
|
||||
import { PRIO_REVIVE } from '../../server/sim/content/items/battle.js';
|
||||
import * as bands from '../../server/sim/content/bands.js';
|
||||
|
||||
const QUIET = { warn() {}, error() {}, info() {} };
|
||||
const REG = createRegistry({ log: QUIET });
|
||||
const COVER = new Set();
|
||||
const cover = (...ids) => { for (const id of ids) { assert.ok(DATA.bands[id], id); COVER.add(id); } };
|
||||
const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${msg}: ${a} ≠ ${b}`);
|
||||
const OK = { ok: true };
|
||||
const A = (k) => `chess_item_${k}_e_a`;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// prep helpers
|
||||
|
||||
function setup({ band, mode = 'solo', humans = 1, seed = 41, bands: perPlayer = null } = {}) {
|
||||
const h = makeMatch({ mode, humans, seed, registry: REG, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
for (const ps of m.players.values()) {
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear(); ps.hand.fill(null); ps.temp.fill(null); ps.offers.length = 0;
|
||||
ps.funds = 50; ps.layers = {}; ps.pendingFunds = 0; ps.shop.freeRefreshes = 0; ps.counters = {};
|
||||
ps.bandId = perPlayer ? perPlayer[ps.playerId] ?? null : null;
|
||||
ps.recompute();
|
||||
}
|
||||
const ps = h.ps('p_0');
|
||||
if (band) ps.bandId = band;
|
||||
const at = (r) => { m.round = r; };
|
||||
const roundStart = (r, who = ps) => { at(r); m.dispatch(who, 'onRoundStart', { round: r }); };
|
||||
const prepEnd = (who = ps) => m.dispatch(who, 'onPrepEnd', { round: m.round });
|
||||
return { h, m, ps, at, roundStart, prepEnd };
|
||||
}
|
||||
const handIds = (ps, kind) => [...ps.hand, ...ps.temp].filter((p) => p && (!kind || p.kind === kind)).map((p) => p.id);
|
||||
const plain = (pred = () => true) => Object.values(DATA.chess)
|
||||
.filter((c) => c.visible && !c.isGolden && (c.garrisonIds || []).every((g) => DATA.garrisons[g].eventType === 'IN_BATTLE') && pred(c))
|
||||
.map((c) => c.chessId).sort();
|
||||
/** Chess acquired with a source (band grants use 'band' / the band key). */
|
||||
function spyGrants(ps, pred = (o) => /band/.test(String(o.source || ''))) {
|
||||
const got = [];
|
||||
const orig = ps.acquireChess.bind(ps);
|
||||
ps.acquireChess = (id, o = {}) => { const p = orig(id, o); if (p && pred(o)) got.push(id); return p; };
|
||||
return got;
|
||||
}
|
||||
const inPool = (m, id) => m.pool.has(id) && m.pool.left(id) > 0;
|
||||
const bondPool = (m, bond, maxTier) => Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier <= maxTier && c.bonds.includes(bond) && inPool(m, c.chessId)).map((c) => c.chessId);
|
||||
/** First seed ≥ `from` whose match pool offers ≥ n chess of `bond` up to `maxTier`. */
|
||||
function seedWithBond(bond, maxTier, n, from = 40) {
|
||||
for (let s = from; s < from + 60; s++) {
|
||||
const h = makeMatch({ mode: 'solo', seed: s, registry: REG, fake: true });
|
||||
if (bondPool(h.m, bond, maxTier).length >= n) return s;
|
||||
}
|
||||
throw new Error(`no seed with ${n} ${bond}`);
|
||||
}
|
||||
const slotOf = (id) => ({ kind: 'chess', id, basePrice: DATA.chess[id].tier === 1 ? 2 : DATA.chess[id].tier <= 4 ? 3 : 4, frozen: false, sold: false });
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// data / wiring
|
||||
|
||||
test('bands.js wires both parts: 28 bands with a prep handler, 10 with a battle part, 鸭爵 global, 歌利亚 = LP only', () => {
|
||||
assert.equal(typeof bands.install, 'function');
|
||||
assert.equal(typeof bands.registerMeta, 'function');
|
||||
const ids = Object.keys(DATA.bands);
|
||||
assert.equal(ids.length, 40);
|
||||
const meta = ids.filter((id) => REG.has(`band:${id}`));
|
||||
const battle = ids.filter(hasBattlePart);
|
||||
assert.equal(meta.length, 28);
|
||||
assert.deepEqual(battle.sort(), ['band_amedic', 'band_amiya', 'band_chen', 'band_clementia', 'band_dusk', 'band_emperor', 'band_ermengard', 'band_humus', 'band_ioleta', 'band_mberry', 'band_qalaisa'].sort());
|
||||
assert.ok(REG.has('global:bands_ducklord'));
|
||||
const none = ids.filter((id) => !meta.includes(id) && !battle.includes(id));
|
||||
assert.deepEqual(none.sort(), ['band_ducklord', 'band_sarkazb']);
|
||||
for (const id of ids) if (!meta.includes(id)) assert.equal(bandMetaHandler(id), null, id);
|
||||
});
|
||||
|
||||
test('starting LP = totalHp for every band (歌利亚 坚不可摧 45, 铃兰 20)', () => {
|
||||
const { m } = setup();
|
||||
for (const [id, b] of Object.entries(DATA.bands)) assert.equal(m.gd.startLp(id), b.totalHp, id);
|
||||
assert.equal(m.gd.startLp('band_sarkazb'), 45);
|
||||
assert.equal(m.gd.startLp('band_lisa'), 20);
|
||||
cover('band_sarkazb');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// prep side
|
||||
|
||||
test('华法琳 重点监护: prep end — for every distinct tier on the board, 1 random operator\'s bonds +2 layers (无需激活)', () => {
|
||||
for (let s = 0; s < 6; s++) {
|
||||
const { m, ps, prepEnd } = setup({ band: 'band_bldsk', seed: 50 + s });
|
||||
give(m, ps, 'chess_char_1_04_a', 'board', [10, 3]); // T1 阿戈尔
|
||||
give(m, ps, 'chess_char_1_09_a', 'board', [10, 4]); // T1 精准
|
||||
give(m, ps, 'chess_char_2_06_a', 'board', [10, 5]); // T2 萨尔贡
|
||||
give(m, ps, 'chess_char_3_06_a', 'hand'); // hand: ignored
|
||||
prepEnd();
|
||||
assert.equal(ps.layers.sargonShip, 2, 'the only T2');
|
||||
assert.equal((ps.layers.egirShip || 0) + (ps.layers.preciShip || 0), 2, 'exactly one of the T1 operators');
|
||||
assert.equal(Object.values(ps.layers).reduce((a, b) => a + b, 0), 4);
|
||||
}
|
||||
cover('band_bldsk');
|
||||
});
|
||||
|
||||
test('杜遥夜 广交豪杰: the first 2 manual refreshes of a round show ≥ 1 <炎> operator; the 3rd is a normal refresh', () => {
|
||||
const seed = seedWithBond('yanShip', 3, 2);
|
||||
let forced = 0, third = 0;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
const { m, ps } = setup({ band: 'band_duyaoy', seed });
|
||||
ps.shop.level = 3;
|
||||
const yanIn = () => ps.shop.slots.some((sl) => sl && sl.kind === 'chess' && DATA.chess[sl.id].bonds.includes('yanShip'));
|
||||
for (let i = 0; i < k; i++) { ps.shop.freeRefreshes = 1; m.handle('p_0', { t: 'g.refresh' }); } // randomise the stream
|
||||
ps.round.refreshes = 0;
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), OK);
|
||||
assert.ok(yanIn(), 'refresh 1');
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), OK);
|
||||
assert.ok(yanIn(), 'refresh 2');
|
||||
forced++;
|
||||
m.handle('p_0', { t: 'g.refresh' });
|
||||
if (!yanIn()) third++;
|
||||
}
|
||||
assert.equal(forced, 8);
|
||||
assert.ok(third >= 1, 'the 3rd refresh is not special');
|
||||
cover('band_duyaoy');
|
||||
});
|
||||
|
||||
test('梓兰 猎头顾问: every manual refresh copies the highest-tier shop operator into another slot and freezes the copy; R10 寻呼模块', () => {
|
||||
const { m, ps, roundStart } = setup({ band: 'band_orchid', seed: 12 });
|
||||
ps.shop.level = 4;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), OK);
|
||||
const chess = ps.shop.slots.filter((sl) => sl && sl.kind === 'chess');
|
||||
const top = Math.max(...chess.map((sl) => DATA.chess[sl.id].tier));
|
||||
const pairs = chess.filter((sl) => DATA.chess[sl.id].tier === top);
|
||||
const ids = pairs.map((sl) => sl.id);
|
||||
const dup = ids.find((id, j) => ids.indexOf(id) !== j);
|
||||
assert.ok(dup, 'two identical top-tier operators');
|
||||
assert.ok(chess.some((sl) => sl.id === dup && sl.frozen), 'one of them frozen');
|
||||
assert.equal(ps.shop.frozen, false);
|
||||
}
|
||||
roundStart(9);
|
||||
assert.deepEqual(handIds(ps, 'item'), []);
|
||||
roundStart(10);
|
||||
assert.deepEqual(handIds(ps, 'item'), [A('4_01')]);
|
||||
cover('band_orchid');
|
||||
});
|
||||
|
||||
test('fixed-round grants: 小贾斯汀 / 昆图斯 / 马克维茨 / 变形者集群 / 杰西卡 / 缪尔赛思 / 芬 / Pith / 夕 (进入休整期时)', () => {
|
||||
const CASES = [
|
||||
['band_justin', A('3_12'), [1, 4, 7, 10]],
|
||||
['band_quintus', A('5_08'), [3]],
|
||||
['band_malkie', A('5_07'), [1]],
|
||||
['band_damaztic', A('6_09'), [5, 10, 15]],
|
||||
['band_jesica', A('4_01'), [4, 7, 10, 13]],
|
||||
['band_mlyss', 'chess_item_5_06_e_b', [1]],
|
||||
['band_fang', A('5_04'), [8, 10, 12, 14]],
|
||||
['band_pith', 'chess_char_1_15_a', [1]],
|
||||
['band_dusk', 'chess_item_6_02_m', [1]],
|
||||
];
|
||||
for (const [band, id, rounds] of CASES) {
|
||||
const { ps, roundStart } = setup({ band, seed: 5 });
|
||||
for (let r = 1; r <= 15; r++) {
|
||||
const before = handIds(ps).filter((x) => x === id).length;
|
||||
roundStart(r);
|
||||
const got = handIds(ps).filter((x) => x === id).length - before;
|
||||
assert.equal(got, rounds.includes(r) ? 1 : 0, `${band} R${r}`);
|
||||
ps.hand.fill(null); ps.temp.fill(null);
|
||||
}
|
||||
cover(band);
|
||||
}
|
||||
});
|
||||
|
||||
test('杜宾 加练!: a 教鞭 every 2 rounds (even rounds)', () => {
|
||||
const { ps, roundStart } = setup({ band: 'band_doberm' });
|
||||
const got = [];
|
||||
for (let r = 1; r <= 8; r++) {
|
||||
roundStart(r);
|
||||
if (handIds(ps, 'item').includes('chess_item_6_03_m')) got.push(r);
|
||||
ps.hand.fill(null);
|
||||
}
|
||||
assert.deepEqual(got, [2, 4, 6, 8]);
|
||||
cover('band_doberm');
|
||||
});
|
||||
|
||||
test('老鲤 得闲饮茶: income R1 0, R2 0, R3 15; R3 round start: 1 random tier-II and 1 tier-IV operator', () => {
|
||||
const { m, ps, at, roundStart } = setup({ band: 'band_lmlee', seed: 14 });
|
||||
const got = [];
|
||||
for (const r of [1, 2, 3, 4]) {
|
||||
at(r);
|
||||
ps.funds = 0;
|
||||
ps.startRound(r);
|
||||
got.push(ps.funds);
|
||||
}
|
||||
assert.deepEqual(got, [0, 0, 15, m.gd.income(4)]);
|
||||
const grants = spyGrants(ps);
|
||||
roundStart(3);
|
||||
assert.deepEqual(grants.map((id) => DATA.chess[id].tier).sort(), [2, 4]);
|
||||
roundStart(4);
|
||||
assert.equal(grants.length, 2);
|
||||
cover('band_lmlee');
|
||||
});
|
||||
|
||||
test('绮良 通关奖励: every 20 funds spent ⇒ 1 random operator of tier ≤ shop level', () => {
|
||||
const { ps } = setup({ band: 'band_kirara', seed: 21 });
|
||||
ps.shop.level = 2;
|
||||
const grants = spyGrants(ps);
|
||||
const spend = (n) => { ps.spend(n); ps._afterSpend(n, 'test'); };
|
||||
spend(19);
|
||||
assert.equal(grants.length, 0);
|
||||
spend(1);
|
||||
assert.equal(grants.length, 1);
|
||||
ps.funds = 100;
|
||||
spend(45);
|
||||
assert.equal(grants.length, 3, '40 more ⇒ 2 more (5 carried)');
|
||||
spend(15);
|
||||
assert.equal(grants.length, 4);
|
||||
for (const id of grants) assert.ok(DATA.chess[id].tier <= 2 && !DATA.chess[id].isGolden);
|
||||
cover('band_kirara');
|
||||
});
|
||||
|
||||
test('佩佩 博学多通: upgrading to 2 / 4 / 6 gives 1 special (free) refresh whose operators are <萨尔贡> first', () => {
|
||||
const seed = seedWithBond('sargonShip', 2, 2);
|
||||
const { m, ps } = setup({ band: 'band_pepe', seed });
|
||||
ps.shop.upgradePrice = 0;
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.levelUp' }), OK);
|
||||
assert.equal(ps.shop.level, 2);
|
||||
assert.equal(ps.shop.freeRefreshes, 1);
|
||||
const f0 = ps.funds;
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), OK);
|
||||
assert.equal(ps.funds, f0, 'free');
|
||||
const chess = ps.shop.slots.filter((sl) => sl && sl.kind === 'chess');
|
||||
assert.ok(chess.length >= 3 && chess.every((sl) => DATA.chess[sl.id].bonds.includes('sargonShip')), 'all 萨尔贡');
|
||||
assert.equal(ps.counters['band:pepe:special'], 0, 'consumed');
|
||||
ps.shop.upgradePrice = 0;
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.levelUp' }), OK);
|
||||
assert.equal(ps.shop.level, 3);
|
||||
assert.equal(ps.shop.freeRefreshes, 0, 'level 3 is not in lvlist');
|
||||
cover('band_pepe');
|
||||
});
|
||||
|
||||
test('哈洛德 人才盲盒: from R4 every 2 rounds a <维多利亚> operator', () => {
|
||||
const { ps, roundStart } = setup({ band: 'band_harold', seed: 18 });
|
||||
ps.shop.level = 3;
|
||||
const grants = spyGrants(ps);
|
||||
const by = {};
|
||||
for (let r = 1; r <= 9; r++) { const n = grants.length; roundStart(r); by[r] = grants.length - n; }
|
||||
assert.deepEqual(by, { 1: 0, 2: 0, 3: 0, 4: 1, 5: 0, 6: 1, 7: 0, 8: 1, 9: 0 });
|
||||
for (const id of grants) assert.ok(DATA.chess[id].bonds.includes('victoriaShip'));
|
||||
cover('band_harold');
|
||||
});
|
||||
|
||||
test('休露丝 雪域礼赠: the first <谢拉格> operator bought each round costs 1', () => {
|
||||
const { m, ps, at } = setup({ band: 'band_sciurus', seed: 3 });
|
||||
ps.shop.slots = [slotOf('chess_char_2_05_a'), slotOf('chess_char_3_11_a'), slotOf('chess_char_2_06_a')];
|
||||
assert.equal(ps.priceOf(ps.shop.slots[0]), 1);
|
||||
assert.equal(ps.priceOf(ps.shop.slots[1]), 1);
|
||||
assert.equal(ps.priceOf(ps.shop.slots[2]), 3, 'not 谢拉格');
|
||||
const f0 = ps.funds;
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), OK);
|
||||
assert.equal(f0 - ps.funds, 1);
|
||||
assert.equal(ps.priceOf(ps.shop.slots[0]), 3, 'second one: full price');
|
||||
at(m.round + 1);
|
||||
assert.equal(ps.priceOf(ps.shop.slots[0]), 1, 'new round');
|
||||
cover('band_sciurus');
|
||||
});
|
||||
|
||||
test('潘格尼尼 定制铳械: after 55 funds spent in total, 1 random elite <拉特兰> operator of tier ≥ IV (once)', () => {
|
||||
const seed = seedWithBond('lateranoShip', 6, 3);
|
||||
const { ps } = setup({ band: 'band_paganini', seed });
|
||||
ps.funds = 200;
|
||||
const grants = spyGrants(ps);
|
||||
const spend = (n) => { ps.spend(n); ps._afterSpend(n, 'test'); };
|
||||
spend(30); spend(24);
|
||||
assert.equal(grants.length, 0);
|
||||
spend(1);
|
||||
assert.equal(grants.length, 1);
|
||||
const c = DATA.chess[grants[0]];
|
||||
assert.ok(c.isGolden && c.tier >= 4 && c.bonds.includes('lateranoShip'), grants[0]);
|
||||
spend(60);
|
||||
assert.equal(grants.length, 1, 'once');
|
||||
cover('band_paganini');
|
||||
});
|
||||
|
||||
test('余 文火慢炖: R8 round start — exactly 1 active bond +36 layers, otherwise every active bond +12', () => {
|
||||
let s = setup({ band: 'band_yu' });
|
||||
s.ps.bondCountBonus.yanShip = 20; s.ps.recompute();
|
||||
s.roundStart(7);
|
||||
assert.equal(s.ps.layers.yanShip || 0, 0);
|
||||
s.roundStart(8);
|
||||
assert.equal(s.ps.layers.yanShip, 36);
|
||||
s = setup({ band: 'band_yu' });
|
||||
s.ps.bondCountBonus.yanShip = 20; s.ps.bondCountBonus.sargonShip = 20; s.ps.recompute();
|
||||
s.roundStart(8);
|
||||
assert.equal(s.ps.layers.yanShip, 12);
|
||||
assert.equal(s.ps.layers.sargonShip, 12);
|
||||
assert.equal(s.ps.layers.victoriaShip || 0, 0, 'inactive bonds get nothing');
|
||||
cover('band_yu');
|
||||
});
|
||||
|
||||
test('凯瑟琳 定向投放: every shop upgrade offers 3 different items (tier ≤ new level), 1 free pick', () => {
|
||||
const { m, ps } = setup({ band: 'band_cathy', seed: 9 });
|
||||
for (const lvl of [2, 3]) {
|
||||
ps.shop.upgradePrice = 0;
|
||||
ps.offers.length = 0;
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.levelUp' }), OK);
|
||||
const offer = ps.offers[0];
|
||||
assert.ok(offer, `offer at L${lvl}`);
|
||||
const ids = offer.slots.map((sl) => sl.id);
|
||||
assert.equal(ids.length, 3);
|
||||
assert.equal(new Set(ids).size, 3);
|
||||
for (const id of ids) assert.ok(offer.slots[0].kind === 'item' && DATA.items[id].tier <= lvl && !DATA.items[id].isGolden, id);
|
||||
}
|
||||
const f0 = ps.funds;
|
||||
const pick = ps.offers[0].slots[2].id;
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 2 }), OK);
|
||||
assert.equal(ps.funds, f0);
|
||||
assert.ok(handIds(ps, 'item').includes(pick) || handIds(ps, 'item').includes(DATA.items[pick].goldenId));
|
||||
cover('band_cathy');
|
||||
});
|
||||
|
||||
test('坎诺特 利滚利: leftover funds are kept; ≥ 5 left ⇒ +1 at round start (max 1)', () => {
|
||||
const { m, ps, at } = setup({ band: 'band_cannot' });
|
||||
ps.funds = 13;
|
||||
ps.endPrep();
|
||||
assert.equal(ps.funds, 13, 'kept at prep end');
|
||||
at(4);
|
||||
ps.startRound(4);
|
||||
assert.equal(ps.funds, 13 + m.gd.income(4) + 1);
|
||||
ps.funds = 4;
|
||||
ps.startRound(5);
|
||||
assert.equal(ps.funds, 4 + m.gd.income(5), 'fewer than 5 left: no interest');
|
||||
cover('band_cannot');
|
||||
});
|
||||
|
||||
test('鸭爵 “神秘顾客”: from R5, 0–2 normal ground enemies in the last 40 % of every teammate\'s wave become 鸭爵 & co. worth 1 fund to the killer', () => {
|
||||
const p = DATA.bands.band_ducklord.buffs[0];
|
||||
const ducks = p.bbStr.enemylist.split(',');
|
||||
const normalKey = Object.values(DATA.enemies).find((e) => e.rank === 'NORMAL' && e.stats.motion === 'WALK').key;
|
||||
const flyKey = Object.values(DATA.enemies).find((e) => e.rank === 'NORMAL' && e.stats.motion === 'FLY').key;
|
||||
const wave = () => Array.from({ length: 10 }, (_, i) => ({ time: i * 2, enemyKey: i === 8 ? flyKey : normalKey, routeIndex: 0, count: 1 }));
|
||||
const { h, m } = setup({ mode: 'coop', humans: 2, seed: 4, bands: { p_1: 'band_ducklord' } });
|
||||
const p0 = h.ps('p_0');
|
||||
const counts = {};
|
||||
for (let k = 0; k < 30; k++) {
|
||||
m.round = 5;
|
||||
const ev = { input: p0.battleInput({ side: 'L', colOffset: 0 }), kind: 'normal', round: 5, spawns: wave() };
|
||||
m.dispatch(p0, 'onBattleStart', ev);
|
||||
const dk = ev.spawns.filter((sp) => ducks.includes(sp.enemyKey));
|
||||
counts[dk.length] = (counts[dk.length] || 0) + 1;
|
||||
assert.equal(ev.spawns.length, 10, 'same enemy count');
|
||||
for (const d of dk) {
|
||||
assert.deepEqual(d.bounty, { coins: 1, ownerPlayerId: 'p_0' });
|
||||
assert.ok(d.time >= 0.6 * 18 - 1e-9, `late part of the wave (${d.time})`);
|
||||
}
|
||||
assert.ok(ev.spawns.some((sp) => sp.enemyKey === flyKey), 'flyers are never replaced');
|
||||
}
|
||||
assert.deepEqual(Object.keys(counts).map(Number).sort(), [0, 1, 2]);
|
||||
// before R5, in boss rounds, or when nobody holds the band: nothing
|
||||
for (const [r, kind, band] of [[4, 'normal', 'band_ducklord'], [5, 'boss', 'band_ducklord'], [5, 'normal', null]]) {
|
||||
h.ps('p_1').bandId = band;
|
||||
m.round = r;
|
||||
const ev = { input: p0.battleInput({ side: 'L', colOffset: 0 }), kind, round: r, spawns: wave() };
|
||||
m.dispatch(p0, 'onBattleStart', ev);
|
||||
assert.ok(!ev.spawns.some((sp) => ducks.includes(sp.enemyKey)), `R${r} ${kind} ${band}`);
|
||||
}
|
||||
// duckReplace keeps unreplaced copies of a multi-count spec
|
||||
const ctx = { gd: m.gd, rng: Object.assign(() => 0, m.rngMeta) };
|
||||
ctx.rng.int = () => 2; ctx.rng.shuffle = (a) => a; ctx.rng.pick = (a) => a[0];
|
||||
const spawns = [{ time: 0, enemyKey: normalKey, routeIndex: 0, count: 10, interval: 1 }];
|
||||
const out = duckReplace(ctx, spawns, { ...p.bb, ...p.bbStr }, 'p_0');
|
||||
assert.equal(out.length, 2);
|
||||
assert.equal(spawns.reduce((n, sp) => n + (sp.count || 1), 0), 10);
|
||||
cover('band_ducklord');
|
||||
});
|
||||
|
||||
test('铃兰 御守之力: each round start re-triggers the 获得时 effect of the right-most (then bottom-most) board operator', () => {
|
||||
const run = (swire, yela) => {
|
||||
const { m, ps, roundStart } = setup({ band: 'band_lisa', seed: 2 });
|
||||
give(m, ps, 'chess_char_3_03_a', 'board', swire); // 诗怀雅: <获得时> 1 盟约之币
|
||||
give(m, ps, 'chess_char_3_20_a', 'board', yela); // 耶拉: <获得时> 1 谢拉格不融冰
|
||||
roundStart(2);
|
||||
return handIds(ps, 'item');
|
||||
};
|
||||
assert.deepEqual(run([10, 8], [10, 4]), [A('1_03')], '诗怀雅 is right-most');
|
||||
assert.deepEqual(run([10, 4], [11, 8]), [A('5_02')], '耶拉 is right-most');
|
||||
assert.deepEqual(run([12, 6], [9, 6]), [A('5_02')], 'same column: the bottom one (row 9)');
|
||||
cover('band_lisa');
|
||||
});
|
||||
|
||||
test('巫恋 替身娃娃: the first sale of a normal operator each round swaps it with a random shop operator (no funds)', () => {
|
||||
const { m, ps, at } = setup({ band: 'band_vodfox', seed: 6 });
|
||||
const [a, b, x, y] = plain((c) => c.tier === 1);
|
||||
ps.shop.slots = [slotOf(x), slotOf(y)];
|
||||
const pa = give(m, ps, a, 'hand');
|
||||
const f0 = ps.funds;
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: pa.uid }), OK);
|
||||
assert.equal(ps.funds, f0, 'no funds');
|
||||
const got = handIds(ps, 'chess');
|
||||
assert.equal(got.length, 1);
|
||||
assert.ok([x, y].includes(got[0]));
|
||||
assert.ok(ps.shop.slots.some((sl) => sl && sl.id === a && !sl.sold), 'the sold operator is now in the shop');
|
||||
const pb = give(m, ps, b, 'hand');
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: pb.uid }), OK);
|
||||
assert.equal(ps.funds, f0 + 1, 'second sale this round: normal');
|
||||
const elite = give(m, ps, DATA.chess[b].goldenId, 'hand');
|
||||
at(m.round + 1);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: elite.uid }), OK);
|
||||
assert.equal(ps.funds, f0 + 2, 'elites are not 初始干员: sold normally');
|
||||
cover('band_vodfox');
|
||||
});
|
||||
|
||||
test('松桐 九流之缘: at even rounds\' prep start 1 random shop operator is taken for free (its slot empties)', () => {
|
||||
const { ps, roundStart } = setup({ band: 'band_makiri', seed: 8 });
|
||||
const ids = plain((c) => c.tier === 1).slice(0, 3);
|
||||
for (const r of [1, 2, 3, 4]) {
|
||||
ps.shop.slots = ids.map(slotOf);
|
||||
ps.hand.fill(null);
|
||||
const f0 = ps.funds;
|
||||
roundStart(r);
|
||||
const got = handIds(ps, 'chess');
|
||||
assert.equal(got.length, r % 2 === 0 ? 1 : 0, `R${r}`);
|
||||
assert.equal(ps.shop.slots.filter(Boolean).length, r % 2 === 0 ? 2 : 3);
|
||||
assert.equal(ps.funds, f0);
|
||||
}
|
||||
cover('band_makiri');
|
||||
});
|
||||
|
||||
test('玛恩纳 业务指标: each <卡西米尔> operator bought ⇒ +1 fund next round, at most 3 per round', () => {
|
||||
const seed = seedWithBond('kazimierzShip', 2, 2);
|
||||
const { m, ps, at } = setup({ band: 'band_mlynar', seed });
|
||||
const kaz = bondPool(m, 'kazimierzShip', 2);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
ps.shop.slots = [slotOf(kaz[i % kaz.length]), slotOf(plain((c) => c.tier === 1 && !c.bonds.includes('kazimierzShip'))[0])];
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), OK);
|
||||
}
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), OK);
|
||||
assert.equal(ps.pendingFunds, 3);
|
||||
at(m.round + 1);
|
||||
ps.shop.slots = [slotOf(kaz[0])];
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), OK);
|
||||
assert.equal(ps.pendingFunds, 4, 'new round');
|
||||
cover('band_mlynar');
|
||||
});
|
||||
|
||||
test('贾维 团伙行动: every 6 manual refreshes ⇒ a <叙拉古> operator (≤ shop level), at most 2 per round', () => {
|
||||
const seed = seedWithBond('siracusaShip', 4, 2);
|
||||
const { m, ps, at } = setup({ band: 'band_chiave', seed });
|
||||
ps.shop.level = 4;
|
||||
ps.funds = 100;
|
||||
const grants = spyGrants(ps);
|
||||
const refresh = (n) => { for (let i = 0; i < n; i++) assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), OK); };
|
||||
refresh(5);
|
||||
assert.equal(grants.length, 0);
|
||||
refresh(1);
|
||||
assert.equal(grants.length, 1);
|
||||
refresh(12);
|
||||
assert.equal(grants.length, 2, 'max 2 per round');
|
||||
at(m.round + 1);
|
||||
refresh(6);
|
||||
assert.equal(grants.length, 3);
|
||||
for (const id of grants) assert.ok(DATA.chess[id].bonds.includes('siracusaShip') && DATA.chess[id].tier <= 4);
|
||||
cover('band_chiave');
|
||||
});
|
||||
|
||||
test('娜仁图亚 见者有份: even rounds\' prep start — pick 1 of 2 different items (tier ≤ shop level) for free', () => {
|
||||
const { ps, roundStart } = setup({ band: 'band_narant', seed: 13 });
|
||||
ps.shop.level = 3;
|
||||
for (const r of [1, 2, 3, 4]) {
|
||||
ps.offers.length = 0;
|
||||
roundStart(r);
|
||||
if (r % 2) { assert.equal(ps.offers.length, 0, `R${r}`); continue; }
|
||||
assert.equal(ps.offers.length, 1, `R${r}`);
|
||||
const ids = ps.offers[0].slots.map((sl) => sl.id);
|
||||
assert.equal(ids.length, 2);
|
||||
assert.equal(new Set(ids).size, 2);
|
||||
for (const id of ids) assert.ok(DATA.items[id].itemType === 'EQUIP' && DATA.items[id].tier <= 3);
|
||||
}
|
||||
cover('band_narant');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// battle side
|
||||
|
||||
function op(id, o = {}) {
|
||||
return chessRec({
|
||||
id, profession: o.profession ?? 'WARRIOR', bonds: o.bonds ?? [], tier: o.tier ?? 1, golden: !!o.golden,
|
||||
rangeGrid: o.range ?? [[0, 0], [0, 1]],
|
||||
stats: { maxHp: 2000, atk: 500, def: 200, res: 0, aspd: 100, bat: 1, respawnTime: 20, spRecovery: 1, blockCnt: 2, ...(o.stats || {}) },
|
||||
skill: { spCost: 60, duration: 5, initSp: 0, ...(o.skill || {}) },
|
||||
});
|
||||
}
|
||||
const DUMMY = (key = 'e_d', o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
|
||||
function fight({ band, ops = { t_op: op('t_op') }, units, foes = [], enemies = {}, bonds, players, kind, stageId, flags } = {}) {
|
||||
return makeBattle({
|
||||
defs: { chess: ops, enemies: { e_d: DUMMY(), ...enemies } }, units, players, kind, stageId, flags, bandId: band, bonds,
|
||||
enemies: foes.map(([r, c, key]) => ({ key: key ?? 'e_d', pos: [r, c] })), timeLimit: 999, autoFinish: false,
|
||||
});
|
||||
}
|
||||
const active = (...ids) => Object.fromEntries(ids.map((id) => [id, { count: 3, active: true, tier: 1, layers: 0 }]));
|
||||
const foe = (h, i = 0) => h.b.enemies.filter((e) => e.alive)[i];
|
||||
function forceRng(h, v) { const orig = h.b.rng; h.b.rng = Object.assign(() => v, orig); }
|
||||
|
||||
test('阿米娅 众志合一: 3 / 4 / ≥ 5 active bonds ⇒ every operator ATK and HP ×1.2 / ×1.3 / ×1.4', () => {
|
||||
const ALL = ['yanShip', 'sargonShip', 'victoriaShip', 'kjeragShip', 'lateranoShip', 'egirShip'];
|
||||
for (const [n, mul] of [[2, 1], [3, 1.2], [4, 1.3], [5, 1.4], [6, 1.4]]) {
|
||||
const h = fight({ band: 'band_amiya', bonds: active(...ALL.slice(0, n)), units: [{ chessId: 't_op', row: 10, col: 4 }] });
|
||||
h.step(1);
|
||||
const u = h.unit('t_op');
|
||||
close(u.s.atk, 500 * mul, `${n} bonds ATK`);
|
||||
close(u.s.maxHp, 2000 * mul, `${n} bonds HP`);
|
||||
close(u.hp, u.s.maxHp, 'deployed at full HP');
|
||||
}
|
||||
const inactive = fight({ band: 'band_amiya', bonds: { ...active('yanShip', 'sargonShip'), victoriaShip: { count: 1, active: false, tier: 0, layers: 50 } }, units: [{ chessId: 't_op', row: 10, col: 4 }] });
|
||||
inactive.step(1);
|
||||
close(inactive.unit('t_op').s.atk, 500, 'inactive bonds do not count');
|
||||
cover('band_amiya');
|
||||
});
|
||||
|
||||
test('埃芒加德 命结之秘: the first 3 knock-downs of the battle revive at full HP (after the operators\' own revive items)', () => {
|
||||
const ops = { t_op: op('t_op'), t_b: op('t_b') };
|
||||
const h = fight({ band: 'band_ermengard', ops, units: [{ chessId: 't_op', row: 10, col: 4, items: [A('4_12')] }, { chessId: 't_b', row: 11, col: 4 }], foes: [[10, 9]] });
|
||||
h.step(1);
|
||||
const [a, b] = [h.unit('t_op'), h.unit('t_b')];
|
||||
const kill = (u) => h.b.dealDamage(foe(h), u, { amount: 1e6, type: 'true' });
|
||||
kill(a); // M3茧甲 first
|
||||
assert.ok(a.alive);
|
||||
kill(a); kill(b); kill(b); // band revives 1, 2, 3
|
||||
assert.ok(a.alive && b.alive);
|
||||
close(b.hp, 2000, 'full HP');
|
||||
kill(a);
|
||||
assert.equal(a.alive, false, '4th knock-down of the band: dies');
|
||||
assert.ok(PRIO_BAND_REVIVE < PRIO_REVIVE);
|
||||
cover('band_ermengard');
|
||||
});
|
||||
|
||||
test('克莱门莎 崇高牺牲: a <阿戈尔> operator knocked down ⇒ +its tier <阿戈尔> layers (no activation needed; none in 联防/boss)', () => {
|
||||
const ops = { t_eg: op('t_eg', { bonds: ['egirShip'], tier: 3 }), t_x: op('t_x', { bonds: ['yanShip'], tier: 5 }) };
|
||||
const h = fight({ band: 'band_clementia', ops, units: [{ chessId: 't_eg', row: 10, col: 4 }, { chessId: 't_x', row: 11, col: 4 }], foes: [[10, 9]] });
|
||||
h.step(1);
|
||||
h.b.dealDamage(foe(h), h.unit('t_eg'), { amount: 1e6, type: 'true' });
|
||||
h.b.dealDamage(foe(h), h.unit('t_x'), { amount: 1e6, type: 'true' });
|
||||
h.b.retreat(h.unit('t_eg')); // not knocked down: nothing (already dead anyway)
|
||||
assert.deepEqual(h.b.result().perPlayer.p1.layerGains, { egirShip: 3 });
|
||||
const u = fight({ band: 'band_clementia', kind: 'unite', ops, units: [{ chessId: 't_eg', row: 10, col: 4 }], foes: [[10, 9]] });
|
||||
u.step(1);
|
||||
u.b.dealDamage(foe(u), u.unit('t_eg'), { amount: 1e6, type: 'true' });
|
||||
assert.deepEqual(u.b.result().perPlayer.p1.layerGains, {});
|
||||
cover('band_clementia');
|
||||
});
|
||||
|
||||
test('大帝 加急调派: every deployment halves the operator\'s next redeploy time (cumulative)', () => {
|
||||
const h = fight({ band: 'band_emperor', units: [{ chessId: 't_op', row: 10, col: 4 }] });
|
||||
h.step(1);
|
||||
const u = h.unit('t_op');
|
||||
h.b.kill(u);
|
||||
close(u.respawnAt - h.b.time, 10, 'after the initial deployment');
|
||||
h.runUntil(() => u.alive, 30);
|
||||
h.b.kill(u);
|
||||
close(u.respawnAt - h.b.time, 5, 'after the redeployment');
|
||||
const plainH = fight({ units: [{ chessId: 't_op', row: 10, col: 4 }] });
|
||||
plainH.step(1);
|
||||
plainH.b.kill(plainH.unit('t_op'));
|
||||
close(plainH.unit('t_op').respawnAt - plainH.b.time, 20, 'without the band');
|
||||
cover('band_emperor');
|
||||
});
|
||||
|
||||
test('桑葚 药枚实验: the units on the right-most column get a 25 % chance per attack of 1 shield layer (max 1)', () => {
|
||||
const ops = { t_op: op('t_op'), t_b: op('t_b') };
|
||||
for (const [v, want] of [[0.24, 1], [0.26, 0]]) {
|
||||
const h = fight({ band: 'band_mberry', ops, units: [{ chessId: 't_op', row: 10, col: 7 }, { chessId: 't_b', row: 11, col: 5 }], foes: [[10, 8], [11, 6]] });
|
||||
forceRng(h, v);
|
||||
h.run(3.1);
|
||||
const sh = (u) => { const b = u.findBuff('band:band_mberry:shield'); return b ? b.shieldHits : 0; };
|
||||
assert.equal(sh(h.unit('t_op')), want, `right-most, rng ${v}`);
|
||||
assert.equal(sh(h.unit('t_b')), 0, 'not right-most');
|
||||
}
|
||||
cover('band_mberry');
|
||||
});
|
||||
|
||||
test('休谟斯 回收利用: a ground operator\'s skill end gives a random operator on its 4 neighbouring tiles 3 SP', () => {
|
||||
const ops = { t_op: op('t_op'), t_n: op('t_n'), t_far: op('t_far') };
|
||||
const h = fight({ band: 'band_humus', ops, units: [{ chessId: 't_op', row: 10, col: 4 }, { chessId: 't_n', row: 11, col: 4 }, { chessId: 't_far', row: 12, col: 7 }] });
|
||||
h.step(1);
|
||||
const [u, n, far] = [h.unit('t_op'), h.unit('t_n'), h.unit('t_far')];
|
||||
const n0 = n.skill.sp, f0 = far.skill.sp;
|
||||
u.skill.activate('test', { free: true });
|
||||
u.skill.end('test');
|
||||
close(n.skill.sp - n0, 3, 'neighbour +3');
|
||||
close(far.skill.sp - f0, 0, 'not adjacent');
|
||||
cover('band_humus');
|
||||
});
|
||||
|
||||
test('卡莱莎 食腐之蝶: an operator knocked down ⇒ the others on the field ATK +20 % per knock-down (additive, max +200 %), lost when they fall', () => {
|
||||
const ops = { t_a: op('t_a'), t_b: op('t_b'), t_c: op('t_c') };
|
||||
const h = fight({ band: 'band_qalaisa', ops, units: [{ chessId: 't_a', row: 10, col: 4 }, { chessId: 't_b', row: 11, col: 4 }, { chessId: 't_c', row: 12, col: 4 }], foes: [[10, 9]] });
|
||||
h.step(1);
|
||||
const [a, b, c] = [h.unit('t_a'), h.unit('t_b'), h.unit('t_c')];
|
||||
const kill = (u) => h.b.dealDamage(foe(h), u, { amount: 1e6, type: 'true' });
|
||||
kill(a);
|
||||
close(b.s.atk, 600, '+20 %');
|
||||
kill(b);
|
||||
close(c.s.atk, 700, '+40 %');
|
||||
h.runUntil(() => a.alive, 30); // a redeploys: its bonus was lost when it fell
|
||||
close(a.s.atk, 500);
|
||||
for (let i = 0; i < 12; i++) { kill(a); h.runUntil(() => a.alive, 30); }
|
||||
close(c.s.atk, 500 * 3, 'max +200 %');
|
||||
cover('band_qalaisa');
|
||||
});
|
||||
|
||||
test('陈 以己之长: the player\'s operators deal physical / arts damage as 弱点伤害', () => {
|
||||
const h = fight({ band: 'band_chen', units: [{ chessId: 't_op', row: 10, col: 3 }], foes: [[10, 9, 'e_def']], enemies: { e_def: DUMMY('e_def', { def: 900 }) } });
|
||||
h.step(1);
|
||||
close(h.b.dealDamage(h.unit('t_op'), foe(h), { amount: 1000, type: 'phys', canDodge: false }), 1000, 'phys → arts');
|
||||
const p = fight({ units: [{ chessId: 't_op', row: 10, col: 3 }], foes: [[10, 9, 'e_def']], enemies: { e_def: DUMMY('e_def', { def: 900 }) } });
|
||||
p.step(1);
|
||||
close(p.b.dealDamage(p.unit('t_op'), foe(p), { amount: 1000, type: 'phys', canDodge: false }), 100, 'without the band');
|
||||
cover('band_chen');
|
||||
});
|
||||
|
||||
test('夕 墨色真颜: operators with a same-name partner on the field ATK +30 %', () => {
|
||||
const ops = { t_op: op('t_op'), t_b: op('t_b') };
|
||||
const h = fight({ band: 'band_dusk', ops, units: [{ chessId: 't_op', row: 10, col: 4 }, { chessId: 't_op', row: 11, col: 4 }, { chessId: 't_b', row: 12, col: 4 }] });
|
||||
h.step(1);
|
||||
const [x, y, z] = [h.unit(1), h.unit(2), h.unit(3)];
|
||||
close(x.s.atk, 650); close(y.s.atk, 650);
|
||||
close(z.s.atk, 500, 'single');
|
||||
});
|
||||
|
||||
test('伊奥莱塔 统御号令: n elite operators on the field ⇒ each elite ATK and HP ×(1 + 0.1 n)', () => {
|
||||
const ops = { t_e1_b: op('t_e1_b', { golden: true }), t_e2_b: op('t_e2_b', { golden: true }), t_n: op('t_n') };
|
||||
const h = fight({ band: 'band_ioleta', ops, units: [{ chessId: 't_e1_b', row: 10, col: 4 }, { chessId: 't_e2_b', row: 11, col: 4 }, { chessId: 't_n', row: 12, col: 4 }] });
|
||||
h.step(1);
|
||||
for (const id of ['t_e1_b', 't_e2_b']) { close(h.unit(id).s.atk, 600, id); close(h.unit(id).s.maxHp, 2400, id); }
|
||||
close(h.unit('t_n').s.atk, 500, 'normal operators unchanged');
|
||||
checkInvariants(h.b);
|
||||
cover('band_ioleta');
|
||||
});
|
||||
|
||||
test('Touch 外勤医疗: every player of a match with the band gets 预备干员-医疗 (≤ 1 elite) or Touch (≥ 2 elites) on its stage slot', () => {
|
||||
assert.deepEqual(amedicCharsFor(0), ['char_605_cmedic']);
|
||||
assert.deepEqual(amedicCharsFor(1), ['char_605_cmedic']);
|
||||
assert.deepEqual(amedicCharsFor(2), ['char_613_acmedc']);
|
||||
const ops = { t_e1_b: op('t_e1_b', { golden: true }), t_e2_b: op('t_e2_b', { golden: true }), t_n: op('t_n') };
|
||||
const run = (unitsIds, { band = 'band_amedic', matchBands = null } = {}) => {
|
||||
const players = [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, bandId: band, playerEffects: [],
|
||||
units: unitsIds.map((id, i) => ({ uid: i + 1, kind: 'chess', chessId: id, row: 10 + i, col: 5 })),
|
||||
contentInfo: matchBands ? { matchBands } : undefined }];
|
||||
const h = fight({ ops, players, stageId: 'act2autochess_m01' });
|
||||
h.step(1);
|
||||
return h.b.allyUnits.filter((u) => u.kind === 'token' && u.alive).map((u) => [u.defId, u.tileR, u.tileC]);
|
||||
};
|
||||
assert.deepEqual(run(['t_n', 't_e1_b']), [['char_605_cmedic', 10, 2]]);
|
||||
assert.deepEqual(run(['t_e1_b', 't_e2_b']), [['char_613_acmedc', 10, 2]]);
|
||||
assert.deepEqual(run(['t_n'], { band: null, matchBands: ['band_bldsk', 'band_amedic'] }), [['char_605_cmedic', 10, 2]], 'a teammate holds it');
|
||||
assert.deepEqual(run(['t_n'], { band: null }), [], 'nobody holds it');
|
||||
cover('band_amedic');
|
||||
});
|
||||
|
||||
test('coverage: every band is exercised by a test above', () => {
|
||||
cover('band_dusk'); // 夕: 画卷 grant (fixed-round test) + 墨色真颜 (battle test)
|
||||
const missing = Object.keys(DATA.bands).filter((id) => !COVER.has(id)).sort();
|
||||
assert.deepEqual(missing, []);
|
||||
});
|
||||
|
||||
void giveItem;
|
||||
@@ -0,0 +1,595 @@
|
||||
// Add-on bonds (server/sim/content/bonds/addon.js): battle effects of 精准 迅捷 灵巧 奥术 坚守 助力 突袭 不屈 协防干员 独行
|
||||
// 绝技 and the prep side of 助力 远见 奇迹 投资人 调和 (numbers from data/bonds.json, research 02 §3.9–§3.23).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { gainLayers, inRange } from '../../server/sim/content/support/index.js';
|
||||
import { bondBb, procChance } from '../../server/sim/content/bonds/addon/battle.js';
|
||||
import { registerMeta } from '../../server/sim/content/bonds/addon.js';
|
||||
import { createRegistry } from '../../server/match/effectsMeta.js';
|
||||
import { makeMatch, give, legalTileFor } from '../match/harness.js';
|
||||
|
||||
const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${msg ?? ''} expected ${b}, got ${a}`);
|
||||
const op = (id, bonds, extra = {}) => chessRec({ id, bonds, profession: 'WARRIOR', skill: null, ...extra });
|
||||
const ranged = (id, bonds, extra = {}) => chessRec({ id, bonds, profession: 'SNIPER', skill: null, rangeGrid: [[0, 0]], ...extra });
|
||||
const bond = (tier, layers = 0, count = 2) => ({ count, active: tier > 0, tier, layers });
|
||||
const DUMMY = { enemy_addon_dummy: enemyRec({ key: 'enemy_addon_dummy', hp: 1e6, speed: 0 }) };
|
||||
const hasBondBuff = (u) => u.buffs.some((b) => String(b.key).startsWith('bond:'));
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// battle side
|
||||
|
||||
test('精准: members ATK ×(1+0.10+0.012·L), live layers; tier 2 widens to ranged operators with 30% DEF/RES ignore', () => {
|
||||
const bb = bondBb('preciShip');
|
||||
const defs = { chess: { p_m: op('p_m', ['preciShip']), p_r: ranged('p_r', []), p_x: op('p_x', []) } };
|
||||
const units = [{ chessId: 'p_m', row: 10, col: 3 }, { chessId: 'p_r', row: 11, col: 3 }, { chessId: 'p_x', row: 12, col: 3 }];
|
||||
const h = makeBattle({ defs, units, bonds: { preciShip: bond(1, 10) } });
|
||||
h.step();
|
||||
close(h.unit('p_m').s.atk, 500 * (1 + bb.base_atk + bb.atk_per_stack * 10), 'member');
|
||||
close(h.unit('p_r').s.atk, 500, 'ranged non-member (tier 1)');
|
||||
close(h.unit('p_m').s.defIgnorePct, 0);
|
||||
gainLayers(h.b, { playerId: 'p1', bonds: 'preciShip', n: 15 });
|
||||
h.step(2);
|
||||
close(h.unit('p_m').s.atk, 500 * (1 + bb.base_atk + bb.atk_per_stack * 25), 'live layers');
|
||||
checkInvariants(h.b);
|
||||
|
||||
const h2 = makeBattle({ defs, units, bonds: { preciShip: bond(2, 0, 3) } });
|
||||
h2.step();
|
||||
const r = h2.unit('p_r');
|
||||
close(r.s.atk, 500 * (1 + bb.base_atk), 'ranged op in scope');
|
||||
close(r.s.defIgnorePct, bb.power_def_penetrate);
|
||||
close(r.s.resIgnorePct, bb.power_magic_resist_penetrate);
|
||||
close(h2.unit('p_x').s.atk, 500, 'melee non-member');
|
||||
checkInvariants(h2.b);
|
||||
});
|
||||
|
||||
test('迅捷: member skill end +12 SP (p=1 at high L), ≥40 layers every operator +15 extra; inactive → nothing', () => {
|
||||
const bb = bondBb('swiftShip');
|
||||
const sk = { spCost: 30, initSp: 30, trigger: { rule: 'SP_FULL' } };
|
||||
const defs = { chess: {
|
||||
s_m: chessRec({ id: 's_m', bonds: ['swiftShip'], skill: sk }),
|
||||
s_x: chessRec({ id: 's_x', bonds: [], skill: sk }),
|
||||
} };
|
||||
const kits = { s_m: () => ({ skill: { kind: 'instant', trigger: 'SP_FULL' } }), s_x: () => ({ skill: { kind: 'instant', trigger: 'SP_FULL' } }) };
|
||||
const run = (b) => {
|
||||
const seen = {};
|
||||
const h = makeBattle({
|
||||
defs, kits, bonds: { swiftShip: b }, units: [{ chessId: 's_m', row: 10, col: 3 }, { chessId: 's_x', row: 12, col: 3 }],
|
||||
setup: (bt) => bt.on('skillEnd', (c) => { seen[c.unit.defId] ??= c.unit.skill.sp; }, { priority: -1000 }),
|
||||
});
|
||||
h.run(0.2);
|
||||
checkInvariants(h.b);
|
||||
return seen;
|
||||
};
|
||||
const hi = run(bond(1, 1000));
|
||||
close(hi.s_m, bb.normal_sp + bb.power_sp, 'member rolls both');
|
||||
close(hi.s_x, bb.power_sp, 'non-member: 40-layer extra only');
|
||||
const low = run(bond(1, 39));
|
||||
assert.ok(low.s_x === 0, 'no 40-layer effect below the milestone');
|
||||
const off = run(bond(0, 1000));
|
||||
assert.equal(off.s_m, 0);
|
||||
assert.equal(off.s_x, 0);
|
||||
});
|
||||
|
||||
test('迅捷 / 不屈 proc chances: p = min(1, base + per·L) at each layer count', () => {
|
||||
const sw = bondBb('swiftShip'), ind = bondBb('indomShip');
|
||||
close(procChance(sw, 0), 0.20);
|
||||
close(procChance(sw, 40), 0.20 + 0.0035 * 40);
|
||||
close(procChance(sw, 228), 0.20 + 0.0035 * 228);
|
||||
close(procChance(sw, 229), 1, 'swift reaches 100 % at 229');
|
||||
close(procChance(ind, 0), 0.18);
|
||||
close(procChance(ind, 50), 0.18 + 0.004 * 50);
|
||||
close(procChance(ind, 205), 1, 'indom reaches 100 % at 205');
|
||||
close(procChance(ind, 1000), 1, 'capped');
|
||||
});
|
||||
|
||||
test('灵巧: aura on members + 4 neighbours (once), 8 tiles at ≥40 layers, follows death and relocation', () => {
|
||||
const defs = { chess: { k_1: op('k_1', ['skillfulShip']), k_2: op('k_2', ['skillfulShip']), k_n: op('k_n', []), k_d: op('k_d', []), k_f: op('k_f', []) } };
|
||||
const h = makeBattle({
|
||||
defs, bonds: { skillfulShip: bond(1, 5) },
|
||||
units: [
|
||||
{ chessId: 'k_1', row: 10, col: 4 }, { chessId: 'k_2', row: 10, col: 3 }, { chessId: 'k_n', row: 10, col: 5 },
|
||||
{ chessId: 'k_d', row: 11, col: 5 }, { chessId: 'k_f', row: 12, col: 9 },
|
||||
],
|
||||
});
|
||||
h.step(2);
|
||||
const a = (id) => h.unit(id).s.aspd;
|
||||
assert.equal(a('k_1'), 115, 'member, two auras → once');
|
||||
assert.equal(a('k_n'), 115, '4-neighbour');
|
||||
assert.equal(a('k_d'), 100, 'diagonal outside the 4 tiles');
|
||||
assert.equal(a('k_f'), 100);
|
||||
gainLayers(h.b, { playerId: 'p1', bonds: 'skillfulShip', n: 35 });
|
||||
h.step(2);
|
||||
assert.equal(a('k_d'), 150, '8 tiles at 40 layers');
|
||||
assert.equal(a('k_n'), 150);
|
||||
assert.ok(h.b.relocate(h.unit('k_n'), 12, 7));
|
||||
h.run(0.4);
|
||||
assert.equal(a('k_n'), 100, 'relocated out of the aura');
|
||||
h.b.dealDamage(null, h.unit('k_1'), { amount: 1e9, type: 'true' });
|
||||
h.step(2);
|
||||
assert.equal(a('k_d'), 100, 'aura source died');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('奥术: member arts damage → target arts taken ×(1.2+0.01·L) for 3 s; tier 2 below 50% HP ×1.4', () => {
|
||||
const bb = bondBb('arcaneShip');
|
||||
const defs = { chess: { a_m: chessRec({ id: 'a_m', bonds: ['arcaneShip'], profession: 'CASTER', skill: null, rangeGrid: [[0, 0]] }), a_x: op('a_x', []) }, enemies: DUMMY };
|
||||
const mk = (b) => makeBattle({ defs, bonds: { arcaneShip: b }, units: [{ chessId: 'a_m', row: 12, col: 3 }, { chessId: 'a_x', row: 11, col: 3 }], enemies: [{ key: 'enemy_addon_dummy', pos: [9, 9] }] });
|
||||
const h = mk(bond(1, 10));
|
||||
h.step(2);
|
||||
const e = h.enemy('enemy_addon_dummy');
|
||||
h.b.dealDamage(h.unit('a_x'), e, { amount: 100, type: 'arts' });
|
||||
close(e.s.artsTakenMul, 1, 'non-member');
|
||||
h.b.dealDamage(h.unit('a_m'), e, { amount: 100, type: 'phys' });
|
||||
close(e.s.artsTakenMul, 1, 'physical damage');
|
||||
h.b.dealDamage(h.unit('a_m'), e, { amount: 100, type: 'arts' });
|
||||
const m = bb.base_damage_scale + bb.damage_scale_per_stack * 10;
|
||||
close(e.s.artsTakenMul, m, 'vulnerable');
|
||||
e.hp = e.s.maxHp * 0.3;
|
||||
h.b.dealDamage(h.unit('a_m'), e, { amount: 100, type: 'arts' });
|
||||
close(e.s.artsTakenMul, m, 'tier 1 ignores the HP threshold');
|
||||
h.run(bb.weak_duration + 0.2);
|
||||
close(e.s.artsTakenMul, 1, 'expired');
|
||||
checkInvariants(h.b);
|
||||
|
||||
const h2 = mk(bond(2, 10, 3));
|
||||
h2.step(2);
|
||||
const e2 = h2.enemy('enemy_addon_dummy');
|
||||
e2.hp = e2.s.maxHp * 0.3;
|
||||
h2.b.dealDamage(h2.unit('a_m'), e2, { amount: 100, type: 'arts' });
|
||||
close(e2.s.artsTakenMul, m * bb.power_weak_scale, 'low-HP multiplier (+68%+1.4%/L)');
|
||||
close(e2.s.artsTakenMul - 1, bb.base_damage_scale_show_ex + bb.damage_scale_per_stack_show_ex * 10, 'matches the *_show keys');
|
||||
checkInvariants(h2.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 };
|
||||
const units = [{ chessId: 'd_1', row: 10, col: 3 }, { chessId: 'd_2', row: 10, col: 5 }, { chessId: 'd_n', row: 12, col: 3 }];
|
||||
const h1 = makeBattle({ defs, units, bonds: { steadShip: bond(1, 10) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 9] }] });
|
||||
h1.step(2);
|
||||
const hpMul = 1 + bb.base_max_hp + bb.max_hp_per_stack * 10;
|
||||
close(h1.unit('d_n').s.maxHp, 2000 * hpMul, 'non-member HP');
|
||||
const n1 = h1.unit('d_n');
|
||||
h1.b.dealDamage(h1.enemy('enemy_addon_dummy'), n1, { amount: 1000, type: 'true' });
|
||||
close(n1.s.maxHp - n1.hp, 1000, 'tier 1: no redirect');
|
||||
|
||||
const h = makeBattle({ defs, units, bonds: { steadShip: bond(2, 10, 3) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 9] }] });
|
||||
h.step(2);
|
||||
const e = h.enemy('enemy_addon_dummy');
|
||||
const [d1, d2, dn] = ['d_1', 'd_2', 'd_n'].map((id) => h.unit(id));
|
||||
const e0 = e.hp;
|
||||
h.b.dealDamage(e, dn, { amount: 1000, type: 'true' });
|
||||
close(dn.s.maxHp - dn.hp, 600, 'non-member keeps 60%');
|
||||
close(d1.s.maxHp - d1.hp, 200, 'member share');
|
||||
close(d2.s.maxHp - d2.hp, 200, 'member share');
|
||||
close(e.hp, e0, 'redirected damage never thorns');
|
||||
const thorn = bb.base_damage_value + bb.damage_value_per_stack * 10;
|
||||
h.b.dealDamage(e, d1, { amount: 100, type: 'true' });
|
||||
close(e0 - e.hp, thorn, 'thorns');
|
||||
close(e.s.dmgTakenMul, bb.damage_scale, 'fragile');
|
||||
const e1 = e.hp;
|
||||
h.b.dealDamage(e, d1, { amount: 100, type: 'true' });
|
||||
close(e.hp, e1, 'per-member cooldown');
|
||||
h.b.dealDamage(e, d2, { amount: 100, type: 'true' });
|
||||
close(e1 - e.hp, thorn * bb.damage_scale, 'other member thorns, fragile applies');
|
||||
h.run(bb.cd_duration + 0.05);
|
||||
const e2 = e.hp;
|
||||
h.b.dealDamage(null, d1, { amount: 50, type: 'true' });
|
||||
close(e.hp, e2, 'sourceless damage: no thorns');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
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 }];
|
||||
const h = makeBattle({ defs, units, bonds: { steadShip: bond(2, 0, 3) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 9] }] });
|
||||
h.step(2);
|
||||
const e = h.enemy('enemy_addon_dummy');
|
||||
h.b.addBuff(e, { key: 'test:rage', mods: { dmgDealtMul: 2 } });
|
||||
const [d1, d2, dn] = ['d_1', 'd_2', 'd_n'].map((id) => h.unit(id));
|
||||
h.b.dealDamage(e, dn, { amount: 1000, type: 'true' }); // 2000 after the attacker's ×2
|
||||
close(dn.s.maxHp - dn.hp, 1200, 'non-member keeps 60 % of 2000');
|
||||
close(d1.s.maxHp - d1.hp, 400, 'member share: 40 % of 2000 split in two (not ×2 again)');
|
||||
close(d2.s.maxHp - d2.hp, 400);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('助力: all operators DEF ×(1.15+0.012·L) and redeploy time ×0.7', () => {
|
||||
const bb = bondBb('deputShip');
|
||||
const defs = { chess: { z_m: op('z_m', ['deputShip']), z_x: op('z_x', []) } };
|
||||
const h = makeBattle({ defs, bonds: { deputShip: bond(1, 20) }, units: [{ chessId: 'z_m', row: 10, col: 3 }, { chessId: 'z_x', row: 12, col: 3 }] });
|
||||
h.step();
|
||||
const x = h.unit('z_x');
|
||||
close(x.s.def, 200 * (1 + bb.base_def + bb.def_per_stack * 20), 'DEF');
|
||||
const t0 = h.b.time;
|
||||
h.b.dealDamage(null, x, { amount: 1e9, type: 'true' });
|
||||
close(x.respawnAt - t0, 20 * (1 + bb.respawn_time), 'redeploy time');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('突袭: idle member relocates next to a ground enemy (SP kept) with ATK/HP ×(1.25+0.01·L); ≥50 layers all ASPD +50', () => {
|
||||
const bb = bondBb('raidShip');
|
||||
const defs = { chess: { r_m: op('r_m', ['raidShip']), r_x: op('r_x', []) }, enemies: DUMMY };
|
||||
const h = makeBattle({
|
||||
defs, bonds: { raidShip: bond(1, 10) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 8] }],
|
||||
units: [{ chessId: 'r_m', row: 12, col: 3 }, { chessId: 'r_x', row: 11, col: 3 }],
|
||||
});
|
||||
const u = h.unit('r_m');
|
||||
h.run(bb.no_attack_duration - 1);
|
||||
assert.deepEqual([u.tileR, u.tileC], [12, 3], 'still waiting');
|
||||
close(h.unit('r_x').s.aspd, 100, 'no milestone below 50 layers');
|
||||
h.run(1.5);
|
||||
const e = h.enemy('enemy_addon_dummy');
|
||||
assert.notDeepEqual([u.tileR, u.tileC], [12, 3], 'relocated');
|
||||
assert.ok(Math.max(Math.abs(u.tileR - 9), Math.abs(u.tileC - 8)) <= 2, 'next to the enemy');
|
||||
assert.ok(inRange(u, e), 'enemy in range after the jump');
|
||||
close(u.s.atk, 500 * (1 + bb.base_atk + bb.atk_per_stack * 10), 'ATK');
|
||||
close(u.s.maxHp, 2000 * (1 + bb.base_max_hp + bb.max_hp_per_stack * 10), 'HP');
|
||||
assert.deepEqual([h.unit('r_x').tileR, h.unit('r_x').tileC], [11, 3], 'non-member stays');
|
||||
checkInvariants(h.b);
|
||||
|
||||
// skill ready → immediate; 50 layers → every operator ASPD +50
|
||||
const defs2 = { chess: { r_s: chessRec({ id: 'r_s', bonds: ['raidShip'], skill: { spCost: 10, initSp: 10 } }), r_x: op('r_x', []) }, enemies: DUMMY };
|
||||
const h2 = makeBattle({
|
||||
defs: defs2, bonds: { raidShip: bond(1, bb.power_bond_stack_cnt) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 8] }],
|
||||
units: [{ chessId: 'r_s', row: 12, col: 3 }, { chessId: 'r_x', row: 11, col: 3 }],
|
||||
});
|
||||
h2.run(0.6);
|
||||
const s = h2.unit('r_s');
|
||||
assert.notDeepEqual([s.tileR, s.tileC], [12, 3], 'ready skill → relocated at once');
|
||||
close(h2.unit('r_x').s.aspd, 100 + bb.power_attack_speed, 'ASPD milestone');
|
||||
checkInvariants(h2.b);
|
||||
});
|
||||
|
||||
test('突袭: the jump is a redeployment — deploy fires (部署时 effects), full HP, SP kept; later redeploys use the board tile', () => {
|
||||
const bb = bondBb('raidShip');
|
||||
const defs = { chess: { r_m: chessRec({ id: 'r_m', bonds: ['raidShip'], skill: { spCost: 40, initSp: 0, duration: 10 } }) }, enemies: DUMMY };
|
||||
let spAtJump = null;
|
||||
const h = makeBattle({
|
||||
defs, bonds: { raidShip: bond(1, 0) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 8] }], hooks: ['deploy', 'death'],
|
||||
units: [{ chessId: 'r_m', row: 12, col: 3 }],
|
||||
setup: (b) => b.on('death', (c) => { if (c.reason === 'raid') spAtJump = c.unit.skill.sp; }),
|
||||
});
|
||||
h.step(1);
|
||||
const u = h.unit('r_m');
|
||||
h.b.dealDamage(null, u, { amount: 500, type: 'true' });
|
||||
assert.ok(h.runUntil(() => u.tileR !== 12 || u.tileC !== 3, bb.no_attack_duration + 1), 'jumped');
|
||||
const sp = spAtJump;
|
||||
assert.ok(sp > 5 && sp < 40, `SP ${sp} accumulated`);
|
||||
const redeploys = h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial);
|
||||
assert.equal(redeploys.length, 1, 'a deploy event (部署时 traits, 突袭手雷, 卡西米尔 …)');
|
||||
assert.ok(h.hooksOf('death').some((c) => c.unit === u && c.reason === 'raid'), 'left the field as a retreat, not a knock-out');
|
||||
close(u.skill.sp, sp, `SP kept (${u.skill.sp} vs ${sp})`, 0.1);
|
||||
close(u.hp, u.s.maxHp, 'fresh deployment: full HP');
|
||||
close(u.s.maxHp, 2000 * (1 + bb.base_max_hp), 'raid HP bonus');
|
||||
assert.equal(h.result?.().perPlayer?.p1?.deaths ?? 0, 0, 'not counted as a death');
|
||||
// knocked out later: the auto-redeploy goes back to its board tile, without the raid bonus
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
h.b.redeploy(u);
|
||||
assert.deepEqual([u.tileR, u.tileC], [12, 3], 'home tile unchanged');
|
||||
close(u.s.maxHp, 2000, 'bonus gone after leaving the field');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('不屈: knocked-out ground operator redeploys (p=1 at high L); tier 2 every operator +5 SP; inactive / elevated → no', () => {
|
||||
const bb = bondBb('indomShip');
|
||||
const sk = { spCost: 50, initSp: 0 };
|
||||
const defs = { chess: { i_g: op('i_g', ['indomShip']), i_o: chessRec({ id: 'i_o', bonds: [], skill: sk }) } };
|
||||
const units = [{ chessId: 'i_g', row: 10, col: 4 }, { chessId: 'i_o', row: 12, col: 6 }];
|
||||
const h = makeBattle({ defs, units, bonds: { indomShip: bond(2, 300, 3) } });
|
||||
h.step(2);
|
||||
const g = h.unit('i_g'), o = h.unit('i_o');
|
||||
assert.equal(g.ground, true);
|
||||
const sp0 = o.skill.sp;
|
||||
h.b.dealDamage(null, g, { amount: 1e9, type: 'true' });
|
||||
assert.ok(g.alive && g.deployed, 'redeployed immediately');
|
||||
assert.deepEqual([g.tileR, g.tileC], [10, 4]);
|
||||
close(o.skill.sp - sp0, bb.sp, 'tier 2 SP');
|
||||
checkInvariants(h.b);
|
||||
|
||||
const off = makeBattle({ defs, units, bonds: { indomShip: bond(0, 300) } });
|
||||
off.step(2);
|
||||
off.b.dealDamage(null, off.unit('i_g'), { amount: 1e9, type: 'true' });
|
||||
assert.equal(off.unit('i_g').alive, false, 'inactive');
|
||||
|
||||
const t1 = makeBattle({ defs, units, bonds: { indomShip: bond(1, 300) } });
|
||||
t1.step(2);
|
||||
const o1 = t1.unit('i_o'), s1 = o1.skill.sp;
|
||||
t1.b.dealDamage(null, t1.unit('i_g'), { amount: 1e9, type: 'true' });
|
||||
assert.ok(t1.unit('i_g').alive);
|
||||
close(o1.skill.sp, s1, 'tier 1: no SP');
|
||||
checkInvariants(t1.b);
|
||||
|
||||
const hi = makeBattle({ defs, units: [{ chessId: 'i_g', row: 10, col: 2 }], bonds: { indomShip: bond(1, 300) } });
|
||||
hi.step(2);
|
||||
const hg = hi.unit('i_g');
|
||||
assert.equal(hg.ground, false, 'elevated tile');
|
||||
hi.b.dealDamage(null, hg, { amount: 1e9, type: 'true' });
|
||||
assert.equal(hg.alive, false, 'not a ground operator');
|
||||
});
|
||||
|
||||
test('协防干员: all operators take ×0.8 phys/arts; members deal ×1.2 (elite ×1.4)', () => {
|
||||
const bb = bondBb('emptyShip');
|
||||
const defs = { chess: { c_n: op('c_n', ['emptyShip']), c_e_b: op('c_e_b', ['emptyShip'], { golden: true }), c_x: op('c_x', []) } };
|
||||
const h = makeBattle({ defs, bonds: { emptyShip: bond(1) }, units: [{ chessId: 'c_n', row: 10, col: 3 }, { chessId: 'c_e_b', row: 11, col: 3 }, { chessId: 'c_x', row: 12, col: 3 }] });
|
||||
h.step();
|
||||
for (const id of ['c_n', 'c_e_b', 'c_x']) {
|
||||
close(h.unit(id).s.physTakenMul, 1 - bb.damage_resistance, `${id} phys`);
|
||||
close(h.unit(id).s.artsTakenMul, 1 - bb.damage_resistance, `${id} arts`);
|
||||
}
|
||||
close(h.unit('c_n').s.dmgDealtMul, bb.damage_scale_normal);
|
||||
close(h.unit('c_e_b').s.dmgDealtMul, bb.damage_scale_extra);
|
||||
close(h.unit('c_x').s.dmgDealtMul, 1);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('独行: the lone member ATK/HP ×1.6 and +15 SP on every deploy', () => {
|
||||
const bb = bondBb('soloShip');
|
||||
const defs = { chess: { o_m: chessRec({ id: 'o_m', bonds: ['soloShip'], skill: { spCost: 40, initSp: 0 } }), o_x: op('o_x', []) } };
|
||||
const h = makeBattle({ defs, bonds: { soloShip: bond(1, 0, 1) }, units: [{ chessId: 'o_m', row: 10, col: 3 }, { chessId: 'o_x', row: 12, col: 3 }] });
|
||||
h.step();
|
||||
const u = h.unit('o_m');
|
||||
close(u.s.atk, 500 * (1 + bb.atk));
|
||||
close(u.s.maxHp, 2000 * (1 + bb.max_hp));
|
||||
close(h.unit('o_x').s.atk, 500);
|
||||
assert.ok(u.skill.sp >= bb.sp && u.skill.sp < bb.sp + 0.5, `initial SP ${u.skill.sp}`);
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
assert.ok(h.b.redeploy(u));
|
||||
close(u.skill.sp, bb.sp, 'SP again after the redeploy');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('绝技: 2 elites → elite ATK ×1.3; 5 elites → elite SP cost ×0.7 (floored); normal chess untouched', () => {
|
||||
const bb = bondBb('suntShip');
|
||||
const defs = { chess: {
|
||||
j_e_b: chessRec({ id: 'j_e_b', golden: true, skill: { spCost: 10 } }),
|
||||
j_n: chessRec({ id: 'j_n', skill: { spCost: 10 } }),
|
||||
} };
|
||||
const units = [{ chessId: 'j_e_b', row: 10, col: 3 }, { chessId: 'j_n', row: 12, col: 3 }];
|
||||
const h1 = makeBattle({ defs, units, bonds: { suntShip: bond(1, 0, 2) } });
|
||||
h1.step();
|
||||
close(h1.unit('j_e_b').s.atk, 500 * (1 + bb.power_atk));
|
||||
close(h1.unit('j_n').s.atk, 500);
|
||||
assert.equal(h1.unit('j_e_b').skill.spCost, 10, 'tier 1: SP cost unchanged');
|
||||
const h2 = makeBattle({ defs, units, bonds: { suntShip: bond(2, 0, 5) } });
|
||||
h2.step();
|
||||
assert.equal(h2.unit('j_e_b').skill.spCost, Math.floor(10 * bb.sp_ratio));
|
||||
assert.equal(h2.unit('j_n').skill.spCost, 10);
|
||||
checkInvariants(h2.b);
|
||||
});
|
||||
|
||||
test('inactive add-on bonds do nothing', () => {
|
||||
const ids = ['preciShip', 'swiftShip', 'skillfulShip', 'arcaneShip', 'steadShip', 'deputShip', 'raidShip', 'indomShip', 'emptyShip', 'soloShip', 'suntShip'];
|
||||
const bonds = Object.fromEntries(ids.map((id) => [id, { count: 1, active: false, tier: 0, layers: 500 }]));
|
||||
const defs = { chess: { n_1_b: op('n_1_b', ids, { golden: true }), n_2: ranged('n_2', ids) } };
|
||||
const h = makeBattle({ defs, bonds, units: [{ chessId: 'n_1_b', row: 10, col: 3 }, { chessId: 'n_2', row: 10, col: 4 }] });
|
||||
h.step(3);
|
||||
for (const u of h.allies()) assert.ok(!hasBondBuff(u), `${u.defId} has no bond buff`);
|
||||
close(h.unit('n_1_b').s.atk, 500);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('two players on one field: bonds only buff their owner', () => {
|
||||
const defs = { chess: { w_1: op('w_1', ['steadShip']), w_2: op('w_2', []) } };
|
||||
const h = makeBattle({
|
||||
kind: 'boss', defs,
|
||||
players: [
|
||||
{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: 'w_1', row: 10, col: 4 }], bonds: { steadShip: bond(1, 10), deputShip: bond(1, 0) } },
|
||||
{ playerId: 'p2', seat: 1, side: 'R', colOffset: 0, units: [{ uid: 2, kind: 'chess', chessId: 'w_2', row: 10, col: 4 }], bonds: {} },
|
||||
],
|
||||
});
|
||||
h.step(2);
|
||||
const a = h.b.allyUnits.find((u) => u.ownerId === 'p1'), b = h.b.allyUnits.find((u) => u.ownerId === 'p2');
|
||||
assert.ok(a.s.maxHp > 2000 && a.s.def > 200, 'owner buffed');
|
||||
close(b.s.maxHp, 2000, 'partner HP untouched');
|
||||
close(b.s.def, 200, 'partner DEF untouched');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// prep side
|
||||
|
||||
function addonRegistry() {
|
||||
const reg = createRegistry({ content: false });
|
||||
registerMeta(reg);
|
||||
return reg;
|
||||
}
|
||||
function metaMatch(reg, seed = 90) {
|
||||
const h = makeMatch({ mode: 'solo', seed, registry: reg, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const ps = h.ps('p_0');
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
ps.funds = 50;
|
||||
ps.layers = {};
|
||||
ps.recompute();
|
||||
return { h, m: h.m, ps };
|
||||
}
|
||||
const onBoard = (m, ps, id) => give(m, ps, id, 'board', legalTileFor(m, ps, id));
|
||||
|
||||
test('registry: the add-on meta handlers are registered by the real content', () => {
|
||||
const reg = createRegistry();
|
||||
for (const id of ['deputShip', 'visiShip', 'miraShip', 'victoriaShip']) assert.ok(reg.has(`bond:${id}`), id);
|
||||
assert.ok(reg.has('global:bondaddon_prepend'), 'prep-end marker for pending payouts');
|
||||
assert.equal(reg.warnings.filter((w) => w.startsWith('bond:') || w.startsWith('global:bondaddon')).length, 0, 'no misspelt hooks');
|
||||
});
|
||||
|
||||
test('助力 meta: prep end → every active bond +2, +4 with 3 助力 (distinct name or elite state)', () => {
|
||||
const { h, m, ps } = metaMatch(addonRegistry());
|
||||
onBoard(m, ps, 'chess_char_1_13_a'); // 波登可 助力
|
||||
onBoard(m, ps, 'chess_char_6_14_a'); // 流明 助力
|
||||
onBoard(m, ps, 'chess_char_1_09_a'); // 跃跃 精准
|
||||
onBoard(m, ps, 'chess_char_4_06_a'); // 寒芒克洛丝 精准
|
||||
give(m, ps, 'chess_char_4_21_a', 'hand'); // 白面鸮 in the hand: 灵巧 inactive
|
||||
assert.equal(ps.bonds.deputShip.tier, 1);
|
||||
m.dispatch(ps, 'onPrepEnd', { round: m.round });
|
||||
assert.equal(ps.layers.deputShip, 2);
|
||||
assert.equal(ps.layers.preciShip, 2);
|
||||
assert.ok(!ps.layers.skillfulShip, 'inactive bond unchanged');
|
||||
onBoard(m, ps, 'chess_char_1_13_b'); // elite 波登可: 3rd 助力 by elite state
|
||||
assert.equal(ps.bonds.deputShip.tier, 2);
|
||||
m.dispatch(ps, 'onPrepEnd', { round: m.round });
|
||||
assert.equal(ps.layers.deputShip, 6);
|
||||
assert.equal(ps.layers.preciShip, 6);
|
||||
h.invariants();
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('远见 meta: +2 funds per 10 layers, 80 → 远见 chess −1, 150 → every chess −1; catch-up once active', () => {
|
||||
const { h, m, ps } = metaMatch(addonRegistry());
|
||||
give(m, ps, 'chess_char_2_02_a', 'hand'); // 赫默
|
||||
give(m, ps, 'chess_char_5_21_a', 'hand'); // 寒檀 (BOARD_AND_DECK)
|
||||
assert.ok(ps.bonds.visiShip.active);
|
||||
const f0 = ps.funds;
|
||||
ps.addLayers('visiShip', 25);
|
||||
assert.equal(ps.funds, f0 + 4);
|
||||
const visi = { kind: 'chess', id: 'chess_char_4_04_a', basePrice: 3 };
|
||||
const other = { kind: 'chess', id: 'chess_char_1_09_a', basePrice: 3 };
|
||||
assert.equal(ps.priceOf(visi), 3);
|
||||
ps.addLayers('visiShip', 55);
|
||||
assert.equal(ps.funds, f0 + 16);
|
||||
assert.equal(ps.priceOf(visi), 2, '远见 discount at 80');
|
||||
assert.equal(ps.priceOf(other), 3);
|
||||
ps.addLayers('visiShip', 70);
|
||||
assert.equal(ps.priceOf(other), 2, 'every chess at 150');
|
||||
assert.equal(ps.priceOf(visi), 2, 'replaces, does not stack');
|
||||
assert.equal(ps.priceOf({ kind: 'chess', id: 'chess_char_1_09_a', basePrice: 0 }), 0, 'never below 0');
|
||||
h.invariants();
|
||||
m.dispose();
|
||||
|
||||
const b = metaMatch(addonRegistry(), 91);
|
||||
give(b.m, b.ps, 'chess_char_2_02_a', 'hand');
|
||||
const g0 = b.ps.funds;
|
||||
b.ps.addLayers('visiShip', 30);
|
||||
assert.equal(b.ps.funds, g0, 'inactive: no payout');
|
||||
give(b.m, b.ps, 'chess_char_5_21_a', 'hand');
|
||||
b.m.dispatch(b.ps, 'onRoundStart', { round: b.m.round });
|
||||
assert.equal(b.ps.funds, g0 + 6, 'caught up once active');
|
||||
b.m.dispatch(b.ps, 'onPrepEnd', { round: b.m.round });
|
||||
assert.equal(b.ps.funds, g0 + 6, 'paid once');
|
||||
b.m.dispose();
|
||||
});
|
||||
|
||||
test('远见 meta: a milestone crossed after the prep phase ended (助力 +2 at prep end) is paid as pending funds, not wiped', () => {
|
||||
const { h, m, ps } = metaMatch(addonRegistry(), 93);
|
||||
onBoard(m, ps, 'chess_char_1_13_a'); // 波登可 助力
|
||||
onBoard(m, ps, 'chess_char_6_14_a'); // 流明 助力
|
||||
give(m, ps, 'chess_char_2_02_a', 'hand'); // 赫默 远见
|
||||
give(m, ps, 'chess_char_5_21_a', 'hand'); // 寒檀 远见 (BOARD_AND_DECK)
|
||||
assert.ok(ps.bonds.deputShip.active && ps.bonds.visiShip.active);
|
||||
ps.layers.visiShip = 8;
|
||||
ps.recompute();
|
||||
const f0 = ps.funds, p0 = ps.pendingFunds;
|
||||
m.dispatch(ps, 'onPrepEnd', { round: m.round });
|
||||
assert.equal(ps.layers.visiShip, 10, '助力 +2');
|
||||
assert.equal(ps.funds, f0, 'not paid into funds that are wiped right after');
|
||||
assert.equal(ps.pendingFunds, p0 + 2, 'credited at the next round start');
|
||||
h.invariants();
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('远见 meta: the discounts never push a price below 1', () => {
|
||||
const { m, ps } = metaMatch(addonRegistry(), 94);
|
||||
ps.counters['bondaddon:visi:disc'] = 2; // 150 layers reached: every chess −1
|
||||
const at = (basePrice) => ps.priceOf({ kind: 'chess', id: 'chess_char_1_09_a', basePrice });
|
||||
assert.equal(at(3), 2);
|
||||
assert.equal(at(2), 1);
|
||||
assert.equal(at(1), 1, 'a price of 1 (至简 / 休露丝) stays 1');
|
||||
ps.counters['bondaddon:visi:disc'] = 1; // 80: 远见 chess only
|
||||
assert.equal(ps.priceOf({ kind: 'chess', id: 'chess_char_2_02_a', basePrice: 2 }), 1);
|
||||
assert.equal(ps.priceOf({ kind: 'chess', id: 'chess_char_2_02_a', basePrice: 1 }), 1);
|
||||
assert.equal(at(2), 2, 'non-远见 chess unchanged at 80');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('独行 / 绝技 (match side): 独行 active with exactly 1 distinct member (copies ok); 绝技 counts every elite on board', () => {
|
||||
const { m, ps } = metaMatch(addonRegistry(), 95);
|
||||
onBoard(m, ps, 'chess_char_1_08_a'); // 德克萨斯 独行
|
||||
assert.equal(ps.bonds.soloShip.active, true);
|
||||
onBoard(m, ps, 'chess_char_1_08_b'); // its elite copy: still one distinct 独行
|
||||
assert.equal(ps.bonds.soloShip.count, 1);
|
||||
assert.equal(ps.bonds.soloShip.active, true);
|
||||
onBoard(m, ps, 'chess_char_2_17_a'); // 折桠: a second distinct 独行 breaks it
|
||||
assert.equal(ps.bonds.soloShip.active, false);
|
||||
// 绝技: elites on board, duplicates included → 1 (德克萨斯_b) + …
|
||||
assert.equal(ps.bonds.suntShip.count, 1);
|
||||
assert.equal(ps.bonds.suntShip.active, false);
|
||||
onBoard(m, ps, 'chess_char_1_08_b');
|
||||
assert.equal(ps.bonds.suntShip.count, 2, 'duplicate elites each count');
|
||||
assert.equal(ps.bonds.suntShip.tier, 1);
|
||||
onBoard(m, ps, 'chess_char_1_09_b');
|
||||
onBoard(m, ps, 'chess_char_1_13_b');
|
||||
assert.equal(ps.bonds.suntShip.tier, 1);
|
||||
onBoard(m, ps, 'chess_char_2_02_b');
|
||||
assert.equal(ps.bonds.suntShip.count, 5);
|
||||
assert.equal(ps.bonds.suntShip.tier, 2);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('奇迹 meta: refresh → next refresh free (p=1 at 300 layers), +20 funds per 100 layers; inactive → never', () => {
|
||||
const { h, m, ps } = metaMatch(addonRegistry());
|
||||
give(m, ps, 'chess_char_2_11_a', 'hand'); // 风丸
|
||||
give(m, ps, 'chess_char_3_01_a', 'hand'); // 能天使
|
||||
const f0 = ps.funds;
|
||||
ps.addLayers('miraShip', 300);
|
||||
assert.equal(ps.funds, f0 + 60, '3 × 20 funds');
|
||||
const f1 = ps.funds;
|
||||
assert.equal(ps.shop.freeRefreshes, 0);
|
||||
assert.equal(m.handle('p_0', { t: 'g.refresh' }).ok, true);
|
||||
assert.equal(ps.funds, f1 - m.gd.refreshPrice);
|
||||
assert.equal(ps.shop.freeRefreshes, 1);
|
||||
m.handle('p_0', { t: 'g.refresh' });
|
||||
assert.equal(ps.funds, f1 - m.gd.refreshPrice, 'free refresh');
|
||||
assert.equal(ps.shop.freeRefreshes, 1, 'rolled again after the free one');
|
||||
h.invariants();
|
||||
m.dispose();
|
||||
|
||||
const b = metaMatch(addonRegistry(), 92);
|
||||
give(b.m, b.ps, 'chess_char_2_11_a', 'hand');
|
||||
b.ps.layers.miraShip = 1000;
|
||||
b.ps.recompute();
|
||||
for (let i = 0; i < 3; i++) b.m.handle('p_0', { t: 'g.refresh' });
|
||||
assert.equal(b.ps.shop.freeRefreshes, 0, 'inactive');
|
||||
b.m.dispose();
|
||||
});
|
||||
|
||||
test('投资人 meta: 获得时 garrisons run ×2 while active, ×3 at ≥100 layers (dispatcher)', () => {
|
||||
const reg = createRegistry({ content: false });
|
||||
let calls = 0;
|
||||
reg.garrison('SERVER_ADD_BOND_CHESS_ALL', { run: () => { calls++; } }); // 深靛 <获得时>
|
||||
const { m, ps } = metaMatch(reg);
|
||||
give(m, ps, 'chess_char_2_19_a', 'hand'); // 锡人
|
||||
give(m, ps, 'chess_char_3_04_a', 'hand'); // 琳琅诗怀雅
|
||||
ps.acquireChess('chess_char_1_17_a', { source: 'test' });
|
||||
assert.equal(calls, 1, 'inactive: once');
|
||||
give(m, ps, 'chess_char_5_17_a', 'hand'); // 山 → 3 投资人
|
||||
assert.ok(ps.bonds.investShip.active);
|
||||
calls = 0;
|
||||
ps.acquireChess('chess_char_1_17_a', { source: 'test' });
|
||||
assert.equal(calls, 2);
|
||||
ps.layers.investShip = 100;
|
||||
ps.recompute();
|
||||
calls = 0;
|
||||
ps.acquireChess('chess_char_1_03_a', { source: 'test' }); // 惊蛰: no 获得时 trait
|
||||
assert.equal(calls, 0);
|
||||
const g = give(m, ps, 'chess_char_1_17_a', 'temp');
|
||||
m.dispatch(ps, 'onGain', { piece: g, kind: 'chess', source: 'test' });
|
||||
assert.equal(calls, 3, '×3 at 100 layers');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('调和 (match side): +1 to core bonds that already have a real member', () => {
|
||||
const { m, ps } = metaMatch(addonRegistry());
|
||||
onBoard(m, ps, 'chess_char_6_11_a'); // 缪尔赛思 调和
|
||||
assert.ok(ps.bonds.maniShip.active);
|
||||
assert.ok(!ps.bonds.yanShip || ps.bonds.yanShip.count === 0, 'no real 炎 → no +1');
|
||||
onBoard(m, ps, 'chess_char_1_03_a'); // 惊蛰 炎
|
||||
assert.equal(ps.bonds.yanShip.count, 2);
|
||||
assert.equal(ps.bonds.yanShip.active, false);
|
||||
onBoard(m, ps, 'chess_char_4_17_a'); // 星熊 炎
|
||||
assert.equal(ps.bonds.yanShip.count, 3);
|
||||
assert.equal(ps.bonds.yanShip.active, true, '1 调和 + 2 炎 activates 炎');
|
||||
m.dispose();
|
||||
});
|
||||
@@ -0,0 +1,582 @@
|
||||
// The 8 core bonds (server/sim/content/bonds/core.js): per-tier / per-layer formulas of research 02 §3.1–3.8 with the
|
||||
// data/bonds.json numbers, 调和 / 变形同构体 membership, live layers, inactive bonds, two players in a boss field, and
|
||||
// the 维多利亚 hammer milestone in a real match.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { bondBb, deps, prdConstant, reached, yanyouShare, registerMeta as coreRegisterMeta } from '../../server/sim/content/bonds/core.js';
|
||||
import * as items from '../../server/sim/content/items.js';
|
||||
import { TOKEN_IDS } from '../../server/sim/content/tokens.js';
|
||||
import { createRegistry } from '../../server/match/effectsMeta.js';
|
||||
import { makeMatch, DATA, give, legalTileFor } from '../match/harness.js';
|
||||
|
||||
const close = (a, b, eps = 1e-6, msg) => assert.ok(Math.abs(a - b) <= eps, `${msg ?? ''} expected ${b}, got ${a}`);
|
||||
const op = (id, bonds, extra = {}) => chessRec({
|
||||
id, bonds, profession: 'WARRIOR', skill: null, ...extra,
|
||||
stats: { atk: 1000, maxHp: 10000, def: 0, blockCnt: 2, ...(extra.stats || {}) },
|
||||
});
|
||||
const bondOn = (count, layers = 0, tier = null, thresholds = [3, 6, 9]) => ({
|
||||
count, active: count >= thresholds[0], tier: tier ?? thresholds.filter((t) => count >= t).length, layers,
|
||||
});
|
||||
const dummy = enemyRec({ key: 'e_dummy', hp: 1e8, speed: 0, def: 0, res: 0 });
|
||||
const buffOf = (u, key) => u.buffs.find((b) => b.key === key) ?? null;
|
||||
const tagged = (h, tag) => h.hooksOf('damaged').filter((c) => c.dmg && c.dmg.tags && c.dmg.tags.includes(tag));
|
||||
|
||||
/** Row-major units on board rows 9–12, cols 3.. (board coords). */
|
||||
function lineup(ids, { row = 10, col = 3, items: it = {} } = {}) {
|
||||
return ids.map((chessId, i) => ({ chessId, row: row + Math.floor(i / 7), col: col + (i % 7), items: it[i] }));
|
||||
}
|
||||
|
||||
function defsOf(list) {
|
||||
const chess = {};
|
||||
for (const [id, bonds, extra] of list) chess[id] = op(id, bonds, extra);
|
||||
return { chess, enemies: { e_dummy: dummy } };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 炎
|
||||
|
||||
test('炎: members ATK ×(1+0.23+0.009L), 调和 enjoys it, non-members unchanged, live layers', () => {
|
||||
const bb = bondBb('yanShip');
|
||||
assert.equal(bb.base_atk, 0.23);
|
||||
const defs = defsOf([['y1_a', ['yanShip']], ['y2_a', ['yanShip']], ['y3_a', ['yanShip']], ['mani_a', ['maniShip']], ['none_a', ['preciShip']]]);
|
||||
const h = makeBattle({
|
||||
defs, units: lineup(['y1_a', 'y2_a', 'y3_a', 'mani_a', 'none_a']),
|
||||
bonds: { yanShip: bondOn(4, 10), maniShip: { count: 1, active: true, tier: 1, layers: 0 } },
|
||||
});
|
||||
h.step(1);
|
||||
close(h.unit('y1_a').s.atk, 1000 * (1 + 0.23 + 0.009 * 10));
|
||||
close(h.unit('mani_a').s.atk, 1000 * (1 + 0.23 + 0.009 * 10), 1e-6, '调和');
|
||||
close(h.unit('none_a').s.atk, 1000);
|
||||
assert.equal(h.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.yanyou).length, 0, 'no 炎佑 below 6');
|
||||
h.b.addLayers('p1', 'yanShip', 20, 'test');
|
||||
h.step(2);
|
||||
close(h.unit('y2_a').s.atk, 1000 * (1 + 0.23 + 0.009 * 30), 1e-6, 'live layers');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('炎 6 / 9: one / two 炎佑 with 30 % of the real 炎 sums; 9: ATK ×1.5, damage taken ×0.1', () => {
|
||||
assert.equal(yanyouShare(), 0.3);
|
||||
const bb = bondBb('yanShip');
|
||||
for (const n of [6, 9]) {
|
||||
const list = [];
|
||||
for (let i = 0; i < n - 1; i++) list.push([`y${i}_a`, ['yanShip']]);
|
||||
list.push(['mani_a', ['maniShip']]);
|
||||
const h = makeBattle({
|
||||
defs: defsOf(list), units: lineup(list.map((x) => x[0])),
|
||||
bonds: { yanShip: bondOn(n, 0), maniShip: { count: 1, active: true, tier: 1, layers: 0 } },
|
||||
});
|
||||
h.step(1);
|
||||
const yy = h.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.yanyou && u.alive);
|
||||
assert.equal(yy.length, n === 9 ? 2 : 1, `${n} 炎 → 炎佑 count`);
|
||||
const sumAtk = (n - 1) * 1000 * (1 + bb.base_atk); // 调和 excluded from the sums
|
||||
const sumHp = (n - 1) * 10000;
|
||||
for (const y of yy) {
|
||||
close(y.s.atk, sumAtk * 0.3 * (n === 9 ? bb.atk : 1), 1e-6, `炎佑 ATK @${n}`);
|
||||
close(y.s.maxHp, sumHp * 0.3, 1e-6, `炎佑 HP @${n}`);
|
||||
close(y.s.dmgTakenMul, n === 9 ? 1 - bb.damage_resistance : 1, 1e-9, `炎佑 dmgTaken @${n}`);
|
||||
}
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 萨尔贡
|
||||
|
||||
const sargonDefs = (n) => {
|
||||
const list = [];
|
||||
for (let i = 0; i < n; i++) list.push([`s${i}_a`, ['sargonShip'], { skill: { duration: 1000, spCost: 10, initSp: 0 } }]);
|
||||
list.push(['none_a', ['preciShip']]);
|
||||
return list;
|
||||
};
|
||||
|
||||
test('萨尔贡 3: every member skill start → one independent stack on every member (ASPD +12, 5+0.22L s, ≤ 25)', () => {
|
||||
const list = sargonDefs(3);
|
||||
const L = 10;
|
||||
const h = makeBattle({ defs: defsOf(list), units: lineup(list.map((x) => x[0])), bonds: { sargonShip: bondOn(3, L, null, [3, 6]) } });
|
||||
h.step(1);
|
||||
const [a, b, c] = ['s0_a', 's1_a', 's2_a'].map((id) => h.unit(id));
|
||||
a.skill.activate('test', { free: true });
|
||||
b.skill.activate('test', { free: true });
|
||||
assert.equal(c.s.aspd, 100 + 2 * 12, 'two stacks on the non-caster');
|
||||
assert.equal(h.unit('none_a').s.aspd, 100);
|
||||
assert.equal(buffOf(c, 'bond:sargon:atk'), null, 'no ATK stacks below 6');
|
||||
close(buffOf(c, 'bond:sargon').timeLeft, 5 + 0.22 * L, 1e-6);
|
||||
// cap: 30 more activations → 25 stacks
|
||||
for (let i = 0; i < 30; i++) { a.skill.end('test'); a.skill.activate('test', { free: true }); }
|
||||
assert.equal(c.buffs.filter((x) => x.key === 'bond:sargon').length, 25);
|
||||
assert.equal(c.s.aspd, Math.min(600, 100 + 300));
|
||||
h.run(5 + 0.22 * L + 0.2);
|
||||
assert.equal(c.buffs.filter((x) => x.key === 'bond:sargon').length, 0, 'stacks expire');
|
||||
assert.equal(c.s.aspd, 100);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('萨尔贡 6: each stack also ATK +12 % (additive, one multiplier), dropped when the stacks expire', () => {
|
||||
const list = sargonDefs(6);
|
||||
const h = makeBattle({ defs: defsOf(list), units: lineup(list.map((x) => x[0])), bonds: { sargonShip: bondOn(6, 0, null, [3, 6]) } });
|
||||
h.step(1);
|
||||
const a = h.unit('s0_a'), f = h.unit('s5_a');
|
||||
for (let i = 0; i < 3; i++) { a.skill.end('test'); a.skill.activate('test', { free: true }); }
|
||||
close(f.s.atk, 1000 * (1 + 3 * 0.12));
|
||||
for (let i = 0; i < 30; i++) { a.skill.end('test'); a.skill.activate('test', { free: true }); }
|
||||
close(f.s.atk, 1000 * (1 + 25 * 0.12), 1e-6, 'cap +300 %');
|
||||
h.run(5.3);
|
||||
close(f.s.atk, 1000);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('萨尔贡 6 + 娜仁图亚: no ATK stacks; skill start lends the caster’s equipment (tier ≤ 5, 60 s) to its 8 neighbours', () => {
|
||||
const list = sargonDefs(6);
|
||||
const calls = [];
|
||||
deps.lendItemEffects = (battle, from, to, opts) => calls.push([from.defId, to.defId, opts]);
|
||||
try {
|
||||
// caster s0 at (10,4) with an item; neighbours: s1 (10,5), s2 (10,3); none_a far away
|
||||
const units = [
|
||||
{ chessId: 's0_a', row: 10, col: 4, items: ['test_item_a'] }, { chessId: 's1_a', row: 10, col: 5 }, { chessId: 's2_a', row: 11, col: 3 },
|
||||
{ chessId: 's3_a', row: 12, col: 8 }, { chessId: 's4_a', row: 12, col: 9 }, { chessId: 's5_a', row: 12, col: 10 },
|
||||
{ chessId: 'none_a', row: 9, col: 4 },
|
||||
];
|
||||
const h = makeBattle({ defs: defsOf(list), units, bandId: 'band_narant', bonds: { sargonShip: bondOn(6, 0, null, [3, 6]) } });
|
||||
h.step(1);
|
||||
h.unit('s0_a').skill.activate('test', { free: true });
|
||||
assert.deepEqual(calls.map((c) => c[1]).sort(), ['none_a', 's1_a', 's2_a']);
|
||||
assert.deepEqual(calls[0][2], { maxTier: 5, duration: 60 });
|
||||
assert.equal(h.unit('s1_a').s.aspd, 112, 'ASPD stacks still apply');
|
||||
close(h.unit('s1_a').s.atk, 1000, 1e-6, 'no ATK stacks with 娜仁图亚');
|
||||
// a member without equipment lends nothing
|
||||
calls.length = 0;
|
||||
h.unit('s1_a').skill.activate('test', { free: true });
|
||||
assert.equal(calls.length, 0);
|
||||
checkInvariants(h.b);
|
||||
} finally { deps.lendItemEffects = null; }
|
||||
if (typeof items.lendItemEffects === 'function') {
|
||||
const h = makeBattle({ defs: defsOf(list), units: [{ chessId: 's0_a', row: 10, col: 4, items: ['chess_item_1_01_e_a'] }, { chessId: 's1_a', row: 10, col: 5 }],
|
||||
bandId: 'band_narant', bonds: { sargonShip: bondOn(6, 0, null, [3, 6]) } });
|
||||
h.step(1);
|
||||
h.unit('s0_a').skill.activate('test', { free: true });
|
||||
h.run(1);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors));
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 维多利亚
|
||||
|
||||
test('维多利亚: equipment carriers deal ×(1.25+0.008L); 6: ATK +50 % per item, +80 % per golden item', () => {
|
||||
const list = [['v0_a', ['victoriaShip']], ['v1_a', ['victoriaShip']], ['v2_a', ['victoriaShip']], ['v3_a', ['victoriaShip']], ['v4_a', ['victoriaShip']], ['v5_a', ['victoriaShip']]];
|
||||
const L = 30;
|
||||
for (const n of [3, 6]) {
|
||||
const h = makeBattle({
|
||||
defs: defsOf(list), units: lineup(list.slice(0, n).map((x) => x[0]), { items: { 0: ['test_item_a'], 1: ['test_item_a', 'test_item_b'] } }),
|
||||
bonds: { victoriaShip: bondOn(n, L, null, [3, 6]) },
|
||||
});
|
||||
h.step(1);
|
||||
const [a, b, c] = ['v0_a', 'v1_a', 'v2_a'].map((id) => h.unit(id));
|
||||
close(a.s.dmgDealtMul, 1.25 + 0.008 * L);
|
||||
close(b.s.dmgDealtMul, 1.25 + 0.008 * L);
|
||||
close(c.s.dmgDealtMul, 1, 1e-9, 'no equipment → no bonus');
|
||||
if (n === 3) {
|
||||
close(a.s.atk, 1000);
|
||||
} else {
|
||||
close(a.s.atk, 1000 * 1.5);
|
||||
close(b.s.atk, 1000 * (1 + 0.5 + 0.8));
|
||||
close(c.s.atk, 1000);
|
||||
}
|
||||
h.b.addLayers('p1', 'victoriaShip', 10, 'test');
|
||||
h.step(2);
|
||||
close(a.s.dmgDealtMul, 1.25 + 0.008 * (L + 10), 1e-9, 'live layers');
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('维多利亚 hammers: every 25 layers → 1 item of pool_equip_vict, once per milestone, only while active (catch-up)', () => {
|
||||
// only the core bond meta: no 获得时 garrisons (洛洛 would craft items) muddying the item count
|
||||
const reg = createRegistry({ content: false });
|
||||
coreRegisterMeta(reg);
|
||||
const h = makeMatch({ mode: 'solo', seed: 5, registry: reg, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const ps = h.ps('p_0');
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
ps.temp.fill(null);
|
||||
ps.recompute();
|
||||
const pool = new Set(DATA.choices.pools.pool_equip_vict.items.map((id) => id.replace(/_a$/, '')));
|
||||
// two identical normal hammers merge into the golden one (= 2 hammers)
|
||||
const hammers = () => [...ps.hand, ...ps.temp].filter((p) => p && p.kind === 'item' && pool.has(p.id.replace(/_[ab]$/, '')))
|
||||
.reduce((n, p) => n + (/_b$/.test(p.id) ? 2 : 1), 0);
|
||||
assert.equal(ps.bonds.victoriaShip?.active ?? false, false);
|
||||
ps.addLayers('victoriaShip', 51); // "无需激活" gains while inactive: milestones 25 and 50 are owed
|
||||
assert.equal(hammers(), 0, 'inactive: no hammer');
|
||||
for (const id of ['chess_char_1_06_a', 'chess_char_2_05_a', 'chess_char_3_08_a']) give(h.m, ps, id, 'board', legalTileFor(h.m, ps, id));
|
||||
assert.equal(ps.bonds.victoriaShip.active, true);
|
||||
h.m.dispatch(ps, 'onPrepStart', { round: h.m.round });
|
||||
assert.equal(hammers(), 2, 'caught up once active');
|
||||
h.m.dispatch(ps, 'onPrepEnd', { round: h.m.round });
|
||||
ps.addLayers('victoriaShip', 10); // 61
|
||||
assert.equal(hammers(), 2, 'no double payment');
|
||||
ps.addLayers('victoriaShip', 14); // 75
|
||||
assert.equal(hammers(), 3);
|
||||
assert.equal(ps.counters['bond:victoria:hammers'], 3);
|
||||
h.invariants();
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 谢拉格
|
||||
|
||||
test('谢拉格: ×1.25, vs cold / frozen ×(1.35+0.01L); 6: a cold wind every 25 s for 20+0.1L s', () => {
|
||||
const list = [];
|
||||
for (let i = 0; i < 6; i++) list.push([`k${i}_a`, ['kjeragShip']]);
|
||||
const L = 40;
|
||||
for (const n of [3, 6]) {
|
||||
const h = makeBattle({
|
||||
defs: defsOf(list), units: lineup(list.slice(0, n).map((x) => x[0])),
|
||||
enemies: [{ key: 'e_dummy', pos: [9, 8] }, { key: 'e_dummy', pos: [12, 8] }],
|
||||
bonds: { kjeragShip: bondOn(n, L, null, [3, 6]) }, autoFinish: false, timeLimit: 200,
|
||||
});
|
||||
h.step(1);
|
||||
const k = h.unit('k0_a');
|
||||
const [e1, e2] = h.enemies();
|
||||
if (n === 3) {
|
||||
const d1 = h.b.dealDamage(k, e1, { amount: 1000, type: 'true' });
|
||||
close(d1, 1250);
|
||||
h.b.applyStatus(e2, 'cold', { duration: 5 });
|
||||
const d2 = h.b.dealDamage(k, e2, { amount: 1000, type: 'true' });
|
||||
close(d2, 1000 * (1.35 + 0.01 * L));
|
||||
h.b.applyStatus(e2, 'cold', { duration: 5 }); // second cold ⇒ frozen
|
||||
assert.ok(e2.s.flags.freeze);
|
||||
close(h.b.dealDamage(k, e2, { amount: 1000, type: 'true' }), 1000 * (1.35 + 0.01 * L), 1e-6, 'frozen');
|
||||
h.run(30);
|
||||
assert.ok(!e1.s.flags.cold, 'no wind below 6');
|
||||
} else {
|
||||
h.run(24.8 - h.b.time);
|
||||
assert.ok(!e1.s.flags.cold);
|
||||
h.run(0.4);
|
||||
assert.ok(e1.s.flags.cold && e2.s.flags.cold, 'first gust at 25 s');
|
||||
h.run(20 + 0.1 * L - 0.6);
|
||||
assert.ok(e1.s.flags.cold, `cold lasts ${20 + 0.1 * L} s`);
|
||||
h.run(0.6);
|
||||
assert.ok(!e1.s.flags.cold && !e1.s.flags.freeze, 'cold over');
|
||||
}
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 拉特兰
|
||||
|
||||
test('拉特兰: ammo ×(1.05+0.015L) floored; 6: every ammo used → all members ATK +4 %, ≤ +200 %', () => {
|
||||
const list = [];
|
||||
for (let i = 0; i < 6; i++) list.push([`l${i}_a`, ['lateranoShip'], { profession: 'SNIPER' }]);
|
||||
list.push(['none_a', ['preciShip'], { profession: 'SNIPER' }]);
|
||||
const kits = {};
|
||||
for (const [id] of list) kits[id] = () => ({ skill: { kind: 'ammo', ammo: 14 } });
|
||||
const L = 10;
|
||||
for (const n of [3, 6]) {
|
||||
const ids = [...list.slice(0, n).map((x) => x[0]), 'none_a'];
|
||||
const h = makeBattle({ defs: defsOf(list), kits, units: lineup(ids), bonds: { lateranoShip: bondOn(n, L, null, [3, 6]) } });
|
||||
h.step(1);
|
||||
const a = h.unit('l0_a'), b = h.unit('l1_a'), x = h.unit('none_a');
|
||||
a.skill.activate('test', { free: true });
|
||||
x.skill.activate('test', { free: true });
|
||||
assert.equal(a.skill.ammoLeft, Math.floor(14 * (1 + 0.05 + 0.015 * L)));
|
||||
assert.equal(x.skill.ammoLeft, 14, 'non-member unchanged');
|
||||
for (let i = 0; i < 3; i++) a.skill.onAttackPerformed([], null);
|
||||
if (n === 3) {
|
||||
close(b.s.atk, 1000);
|
||||
} else {
|
||||
close(b.s.atk, 1000 * (1 + 3 * 0.04));
|
||||
close(x.s.atk, 1000, 1e-9, 'non-member gets nothing');
|
||||
for (let k = 0; k < 80; k++) {
|
||||
if (!a.skill.active) a.skill.activate('test', { free: true });
|
||||
a.skill.onAttackPerformed([], null);
|
||||
}
|
||||
close(b.s.atk, 1000 * 3, 1e-6, 'cap +200 %');
|
||||
}
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 阿戈尔
|
||||
|
||||
test('阿戈尔: HP ×(1.35+0.01L); devour chain (left first): 5000 phys, base ATK + block, layers = devoured tiers', () => {
|
||||
const list = [
|
||||
['g1_a', ['egirShip']], ['g2_a', ['egirShip']], ['g3_a', ['egirShip']],
|
||||
['fod_a', ['preciShip'], { tier: 4, stats: { atk: 700, blockCnt: 3, maxHp: 20000, def: 900 } }],
|
||||
['tok_free', ['preciShip'], { tier: 6 }],
|
||||
];
|
||||
const L = 10;
|
||||
// g1 (10,3) → g2 (10,4) → fodder (10,5); g3 (11,3) has an empty front tile
|
||||
const units = [{ chessId: 'g1_a', row: 10, col: 3 }, { chessId: 'g2_a', row: 10, col: 4 }, { chessId: 'fod_a', row: 10, col: 5 }, { chessId: 'g3_a', row: 11, col: 3 }];
|
||||
const h = makeBattle({ defs: defsOf(list), units, bonds: { egirShip: bondOn(3, L, null, [3, 5]) }, hooks: ['damaged', 'layerGain'], captureNoisy: true });
|
||||
h.step(1);
|
||||
const [g1, g2, g3, f] = ['g1_a', 'g2_a', 'g3_a', 'fod_a'].map((id) => h.unit(id));
|
||||
const devours = tagged(h, 'bond:egir:devour');
|
||||
assert.deepEqual(devours.map((c) => [c.source.defId, c.target.defId]), [['g1_a', 'g2_a'], ['g1_a', 'fod_a'], ['g2_a', 'fod_a']]);
|
||||
for (const c of devours) close(c.amount, 5000);
|
||||
close(g1.s.atk, 1000 + 1000 + 700, 1e-6, 'g1 gains g2 + fodder base ATK');
|
||||
close(g2.s.atk, 1000 + 700);
|
||||
close(g3.s.atk, 1000);
|
||||
assert.equal(g1.s.blockCnt, 2 + 2 + 3);
|
||||
assert.equal(g2.s.blockCnt, 2 + 3);
|
||||
close(f.hp, 20000 - 10000, 1e-6, '流失: DEF 900 ignored');
|
||||
const Lnow = L + 1 + 4; // g2 tier 1 + fodder tier 4, once each
|
||||
assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, Lnow);
|
||||
h.step(2);
|
||||
close(g3.s.maxHp, 10000 * (1 + 0.35 + 0.01 * Lnow), 1e-6, 'HP multiplier follows the devour layers');
|
||||
close(f.s.maxHp, 20000, 1e-9, 'non-member HP unchanged');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('阿戈尔 devour: 流失 ignores the marker’s damage bonuses and the target’s shields; a dead marker’s marks are cancelled; kill → marker', () => {
|
||||
const list = [
|
||||
['g1_a', ['egirShip', 'kjeragShip']], ['g2_a', ['egirShip'], { stats: { maxHp: 3000 } }], ['g3_a', ['egirShip']],
|
||||
['fod_a', ['preciShip'], { tier: 2, stats: { maxHp: 20000, def: 500 } }],
|
||||
];
|
||||
// g1 (10,3) → g2 (10,4, 3000×1.35 HP: dies to the first mark) → fodder (10,5); g3 elsewhere
|
||||
const units = [{ chessId: 'g1_a', row: 10, col: 3 }, { chessId: 'g2_a', row: 10, col: 4 }, { chessId: 'fod_a', row: 10, col: 5 }, { chessId: 'g3_a', row: 12, col: 3 }];
|
||||
const h = makeBattle({
|
||||
defs: defsOf(list), units, hooks: ['damaged', 'kill'], captureNoisy: true,
|
||||
bonds: { egirShip: bondOn(3, 0, null, [3, 5]), kjeragShip: bondOn(3, 0, null, [3, 6]) }, // g1 deals ×1.25
|
||||
setup: (b) => b.on('battleStart', () => b.addBuff(b.allyUnits.find((u) => u.defId === 'fod_a'), { key: 'test:shield', shield: 3000 }), { priority: 100 }),
|
||||
});
|
||||
h.step(1);
|
||||
const [g1, g2, f] = ['g1_a', 'g2_a', 'fod_a'].map((id) => h.unit(id));
|
||||
close(g1.s.dmgDealtMul, 1.25, 1e-9, '谢拉格 bonus present');
|
||||
assert.ok(!g2.alive, 'g2 devoured');
|
||||
assert.ok(h.hooksOf('kill').some((c) => c.victim === g2 && c.killer === g1), 'kill credited to the marker');
|
||||
const dv = tagged(h, 'bond:egir:devour');
|
||||
assert.deepEqual(dv.map((c) => [c.source.defId, c.target.defId]), [['g1_a', 'g2_a'], ['g1_a', 'fod_a']], 'g2’s own mark on the fodder is cancelled');
|
||||
close(f.hp, 20000 - 5000, 1e-6, 'exactly 5000: no ×1.25, no DEF, shield untouched');
|
||||
assert.equal(f.buffs.find((b) => b.key === 'test:shield')?.shield, 3000);
|
||||
close(g1.s.atk, 1000 + 1000 + 1000, 1e-6, 'the base ATK of everything it marked stays');
|
||||
assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, 1 + 2, 'layers = tiers of the devoured units');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('阿戈尔 devour order: left first, then top first (row 0 is the bottom: the top board row is 12)', () => {
|
||||
const list = [['g1_a', ['egirShip']], ['g2_a', ['egirShip']], ['g3_a', ['egirShip']], ['fod_a', ['preciShip']]];
|
||||
const units = [
|
||||
{ uid: 'low', chessId: 'g1_a', row: 9, col: 3 }, { uid: 'f_low', chessId: 'fod_a', row: 9, col: 4 },
|
||||
{ uid: 'high', chessId: 'g2_a', row: 12, col: 3 }, { uid: 'f_high', chessId: 'fod_a', row: 12, col: 4 },
|
||||
{ uid: 'right', chessId: 'g3_a', row: 11, col: 2 }, { uid: 'f_right', chessId: 'fod_a', row: 11, col: 3 },
|
||||
];
|
||||
const h = makeBattle({ defs: defsOf(list), units, bonds: { egirShip: bondOn(3, 0, null, [3, 5]) }, hooks: ['damaged'], captureNoisy: true });
|
||||
h.step(1);
|
||||
assert.deepEqual(tagged(h, 'bond:egir:devour').map((c) => c.source.uid), ['right', 'high', 'low'], 'col 2 first, then row 12 before row 9');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('阿戈尔 5: the first 3 members knocked out for the first time redeploy at once (death + deploy fire); a second knock-out is final', () => {
|
||||
const list = [];
|
||||
for (let i = 0; i < 5; i++) list.push([`g${i}_a`, ['egirShip'], { skill: { spCost: 30, initSp: 5, duration: 10 } }]);
|
||||
// one per row pair so nobody devours anybody
|
||||
const units = list.map(([id], i) => ({ chessId: id, row: 9 + (i % 4), col: 3 + 2 * Math.floor(i / 4) }));
|
||||
const h = makeBattle({ defs: defsOf(list), units, bonds: { egirShip: bondOn(5, 0, null, [3, 5]) }, hooks: ['death', 'deploy'] });
|
||||
h.step(1);
|
||||
const us = list.map(([id]) => h.unit(id));
|
||||
const kill = (u) => h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
us[0].skill.gainSp(20, 'test');
|
||||
const tiles = us.map((u) => [u.tileR, u.tileC]);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const redeploys = () => h.hooksOf('deploy').filter((c) => c.unit === us[i] && !c.initial).length;
|
||||
const before = redeploys();
|
||||
kill(us[i]);
|
||||
assert.ok(us[i].alive && us[i].deployed, `member ${i} back on the field`);
|
||||
close(us[i].hp, us[i].s.maxHp);
|
||||
assert.deepEqual([us[i].tileR, us[i].tileC], tiles[i], 'same tile');
|
||||
assert.ok(h.hooksOf('death').some((c) => c.unit === us[i] && c.reason === 'killed'), 'it was knocked out (被击倒 triggers fire)');
|
||||
assert.equal(redeploys(), before + 1, `member ${i} redeployed`);
|
||||
}
|
||||
assert.equal(us[0].skill.sp, 5, 'a redeploy restarts from the initial SP');
|
||||
kill(us[3]);
|
||||
assert.ok(!us[3].alive, 'the 4th dies');
|
||||
kill(us[0]);
|
||||
assert.ok(!us[0].alive, 'second knock-out is final');
|
||||
checkInvariants(h.b);
|
||||
// a member moved off its board tile (突袭 / relocation) revives where it was knocked out; later redeploys go home
|
||||
const hm = makeBattle({ defs: defsOf(list), units, bonds: { egirShip: bondOn(5, 0, null, [3, 5]) }, hooks: [] });
|
||||
hm.step(1);
|
||||
const m = hm.unit('g4_a');
|
||||
const home = [m.homeR, m.homeC];
|
||||
assert.ok(hm.b.relocate(m, 12, 8));
|
||||
hm.b.dealDamage(null, m, { amount: 1e9, type: 'true' });
|
||||
assert.ok(m.alive && m.tileR === 12 && m.tileC === 8, 'revived on the tile it fell on');
|
||||
assert.deepEqual([m.homeR, m.homeC], home, 'home unchanged');
|
||||
checkInvariants(hm.b);
|
||||
// 3 members: no revive
|
||||
const h3 = makeBattle({ defs: defsOf(list), units: units.slice(0, 3), bonds: { egirShip: bondOn(3, 0, null, [3, 5]) } });
|
||||
h3.step(1);
|
||||
h3.b.dealDamage(null, h3.unit('g0_a'), { amount: 1e9, type: 'true' });
|
||||
assert.ok(!h3.unit('g0_a').alive);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 叙拉古
|
||||
|
||||
test('叙拉古: after each deployment ASPD +(25+0.8L) for 32+0.4L s; 3 members: no stealth', () => {
|
||||
const list = [['r0_a', ['siracusaShip']], ['r1_a', ['siracusaShip']], ['r2_a', ['siracusaShip']], ['none_a', ['preciShip']]];
|
||||
const L = 10;
|
||||
const h = makeBattle({ defs: defsOf(list), units: lineup(list.map((x) => x[0])), bonds: { siracusaShip: bondOn(3, L, null, [3, 6]) } });
|
||||
h.step(1);
|
||||
const a = h.unit('r0_a');
|
||||
assert.equal(a.s.aspd, 100 + 25 + 0.8 * L);
|
||||
assert.equal(h.unit('none_a').s.aspd, 100);
|
||||
assert.ok(!a.s.flags.stealth);
|
||||
h.run(32 + 0.4 * L - h.b.time - 0.1);
|
||||
assert.equal(a.s.aspd, 100 + 25 + 0.8 * L);
|
||||
h.run(0.3);
|
||||
assert.equal(a.s.aspd, 100);
|
||||
// redeploy → again
|
||||
h.b.retreat(a);
|
||||
a.respawnAt = 0;
|
||||
h.b.redeploy(a);
|
||||
assert.equal(a.s.aspd, 100 + 25 + 0.8 * L);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('叙拉古 6: 隐匿 for the same time; attacks while hidden / ≤ 10 s after proc (PRD) 5000+50L true damage + fear 3 s', () => {
|
||||
close(prdConstant(0.03), 0.00139, 2e-5);
|
||||
const list = [];
|
||||
for (let i = 0; i < 6; i++) list.push([`r${i}_a`, ['siracusaShip']]);
|
||||
const L = 20;
|
||||
const h = makeBattle({
|
||||
defs: defsOf(list), units: lineup(list.map((x) => x[0])), enemies: [{ key: 'e_dummy', pos: [12, 9] }],
|
||||
bonds: { siracusaShip: bondOn(6, L, null, [3, 6]) }, autoFinish: false, timeLimit: 300, hooks: ['damaged', 'statusApplied'], captureNoisy: true,
|
||||
});
|
||||
h.step(1);
|
||||
const a = h.unit('r0_a');
|
||||
const e = h.enemies()[0];
|
||||
assert.ok(a.s.flags.stealth, 'stealthed after deploy');
|
||||
const attackN = (n) => { for (let i = 0; i < n; i++) h.b.dealDamage(a, e, { amount: 1, type: 'phys', isAttack: true }); };
|
||||
attackN(750); // PRD guarantees a proc within ⌈1/C⌉ attempts
|
||||
const procs = tagged(h, 'bond:siracusa');
|
||||
assert.ok(procs.length >= 1 && procs.length < 60, `procs ${procs.length}`);
|
||||
for (const p of procs) close(p.amount, 5000 + 50 * L);
|
||||
assert.ok(h.hooksOf('statusApplied').some((c) => c.status === 'fear' && c.target === e && Math.abs(c.duration - 3) < 1e-9));
|
||||
const dur = 32 + 0.4 * L;
|
||||
h.run(dur + 5 - h.b.time);
|
||||
assert.ok(!a.s.flags.stealth, 'stealth over');
|
||||
const before = tagged(h, 'bond:siracusa').length;
|
||||
attackN(750);
|
||||
assert.ok(tagged(h, 'bond:siracusa').length > before, 'still procs within 10 s after stealth');
|
||||
h.run(6);
|
||||
const after = tagged(h, 'bond:siracusa').length;
|
||||
attackN(750);
|
||||
assert.equal(tagged(h, 'bond:siracusa').length, after, 'no proc after the 10 s window');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 卡西米尔
|
||||
|
||||
test('卡西米尔: every own deployment (initial included) → members ATK +20 %, capped at 50+1×L %', () => {
|
||||
const list = [['z0_a', ['kazimierzShip']], ['z1_a', ['kazimierzShip']], ['z2_a', ['kazimierzShip']], ['none_a', ['preciShip']], ['none2_a', ['preciShip']]];
|
||||
for (const [L, expect] of [[20, 0.7], [100, 1.0]]) {
|
||||
const h = makeBattle({ defs: defsOf(list), units: lineup(list.map((x) => x[0])), bonds: { kazimierzShip: bondOn(3, L, null, [3, 6]) } });
|
||||
h.step(1);
|
||||
close(h.unit('z0_a').s.atk, 1000 * (1 + expect), 1e-6, `L=${L}`);
|
||||
close(h.unit('none_a').s.atk, 1000);
|
||||
if (L === 100) {
|
||||
const x = h.unit('none_a');
|
||||
h.b.retreat(x);
|
||||
h.b.redeploy(x);
|
||||
close(h.unit('z1_a').s.atk, 1000 * 2.2, 1e-6, 'a redeploy adds +20 %');
|
||||
}
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('卡西米尔 6: blocking members pulse 120 % ATK true damage + 0.1 s stun every 2 s; non-blocking attacks add 30 % ATK true', () => {
|
||||
const list = [];
|
||||
for (let i = 0; i < 6; i++) list.push([`z${i}_a`, ['kazimierzShip']]);
|
||||
const walker = enemyRec({ key: 'e_walk', hp: 1e8, speed: 1, atk: 0 });
|
||||
const defs = defsOf(list);
|
||||
defs.enemies.e_walk = walker;
|
||||
// z0 on the walking lane (row 9); the others on row 12 behind the lane's end
|
||||
const units = list.map(([id], i) => ({ chessId: id, row: i === 0 ? 9 : 12, col: i === 0 ? 5 : 2 + i }));
|
||||
const h = makeBattle({
|
||||
defs, units, enemies: [{ key: 'e_walk', route: 0 }], bonds: { kazimierzShip: bondOn(6, 0, null, [3, 6]) },
|
||||
autoFinish: false, timeLimit: 120, hooks: ['damaged', 'statusApplied'], captureNoisy: true,
|
||||
});
|
||||
const z0 = h.unit('z0_a'), z1 = h.unit('z1_a');
|
||||
assert.ok(h.runUntil(() => z0.blocking.length > 0, 30), 'blocked');
|
||||
const t0 = h.b.time;
|
||||
h.run(4.1);
|
||||
const pulses = tagged(h, 'bond:kazimierz').filter((c) => c.source === z0 && c.dmg.isSplash && c.t >= t0);
|
||||
assert.ok(pulses.length >= 1 && pulses.length <= 3, `pulses ${pulses.length}`);
|
||||
close(pulses[0].amount, 1.2 * z0.s.atk, 1e-6);
|
||||
assert.ok(h.hooksOf('statusApplied').some((c) => c.status === 'stun' && c.source === z0 && Math.abs(c.duration - 0.1) < 1e-9));
|
||||
const onHitExtra = tagged(h, 'bond:kazimierz').filter((c) => c.source === z0 && !c.dmg.isSplash && c.t >= t0);
|
||||
assert.equal(onHitExtra.length, 0, 'no on-hit true damage while blocking');
|
||||
// z1 does not block: its attacks add 30 % ATK true damage
|
||||
const e = h.enemies()[0];
|
||||
const n0 = tagged(h, 'bond:kazimierz').filter((c) => c.source === z1).length;
|
||||
h.b.dealDamage(z1, e, { amount: 100, type: 'phys', isAttack: true });
|
||||
const extra = tagged(h, 'bond:kazimierz').filter((c) => c.source === z1);
|
||||
assert.equal(extra.length, n0 + 1);
|
||||
close(extra[extra.length - 1].amount, 0.3 * z1.s.atk, 1e-6);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// cross-cutting
|
||||
|
||||
test('inactive core bonds do nothing (layers alone are not enough)', () => {
|
||||
const bonds = ['yanShip', 'sargonShip', 'victoriaShip', 'kjeragShip', 'lateranoShip', 'egirShip', 'siracusaShip', 'kazimierzShip'];
|
||||
const list = [['all_a', bonds], ['all2_a', bonds]];
|
||||
const h = makeBattle({
|
||||
defs: defsOf(list), units: [{ chessId: 'all_a', row: 10, col: 3, items: ['test_item_a'] }, { chessId: 'all2_a', row: 10, col: 4 }],
|
||||
bonds: Object.fromEntries(bonds.map((b) => [b, { count: 2, active: false, tier: 0, layers: 50 }])),
|
||||
hooks: ['damaged'], captureNoisy: true,
|
||||
});
|
||||
h.step(1);
|
||||
const u = h.unit('all_a');
|
||||
assert.deepEqual(u.buffs.filter((b) => b.key.startsWith('bond:')).map((b) => b.key), []);
|
||||
close(u.s.atk, 1000);
|
||||
assert.equal(tagged(h, 'bond:egir:devour').length, 0);
|
||||
assert.ok(!reached(h.b, 'p1', 'yanShip', 0));
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('变形同构体 makes the wearer a member; 6-tier thresholds also read the tier index', () => {
|
||||
const list = [['y0_a', ['yanShip']], ['y1_a', ['yanShip']], ['x_a', ['preciShip']]];
|
||||
const h = makeBattle({
|
||||
defs: defsOf(list),
|
||||
units: [{ chessId: 'y0_a', row: 10, col: 3 }, { chessId: 'y1_a', row: 10, col: 4 }, { chessId: 'x_a', row: 10, col: 5, items: ['chess_item_6_09_e_a', 'chess_item_3_04_e_a'] }],
|
||||
bonds: { yanShip: { active: true, tier: 1, layers: 0 } },
|
||||
});
|
||||
h.step(1);
|
||||
assert.ok(buffOf(h.unit('x_a'), 'bond:yan'), 'granted member');
|
||||
assert.ok(reached(h.b, 'p1', 'yanShip', 3) && !reached(h.b, 'p1', 'yanShip', 6));
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('boss field, two players: bonds only touch their owner’s operators; devour layers are disabled there', () => {
|
||||
const list = [['g0_a', ['egirShip', 'yanShip']], ['g1_a', ['egirShip', 'yanShip']], ['g2_a', ['egirShip', 'yanShip']], ['fod_a', ['preciShip'], { tier: 3 }]];
|
||||
const lineupFor = (pid) => [
|
||||
{ uid: `${pid}1`, chessId: 'g0_a', row: 10, col: 3 }, { uid: `${pid}2`, chessId: 'g1_a', row: 10, col: 4 },
|
||||
{ uid: `${pid}3`, chessId: 'g2_a', row: 11, col: 3 }, { uid: `${pid}4`, chessId: 'fod_a', row: 11, col: 4 },
|
||||
];
|
||||
const on = { yanShip: bondOn(3, 0), egirShip: bondOn(3, 0, null, [3, 5]) };
|
||||
const h = makeBattle({
|
||||
kind: 'boss', defs: defsOf(list), hooks: ['damaged', 'layerGain'], captureNoisy: true,
|
||||
players: [
|
||||
{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: lineupFor('p1'), bonds: on, bandId: null, playerEffects: [] },
|
||||
{ playerId: 'p2', seat: 1, side: 'R', colOffset: 0, units: lineupFor('p2'), bonds: {}, bandId: null, playerEffects: [] },
|
||||
],
|
||||
});
|
||||
h.step(1);
|
||||
const mine = h.unit('p12'), theirs = h.unit('p22'); // g1: devoured by g0, devours nothing itself
|
||||
close(mine.s.atk, 1000 * 1.23, 1e-6);
|
||||
close(theirs.s.atk, 1000);
|
||||
close(h.unit('p23').s.maxHp, 10000);
|
||||
const dv = tagged(h, 'bond:egir:devour');
|
||||
assert.ok(dv.length >= 1 && dv.every((c) => c.source.ownerId === 'p1' && c.target.ownerId === 'p1'));
|
||||
assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, 0, 'no IN_BATTLE layers in a boss field');
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors));
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// Shared wave-B helpers (server/sim/content/support): bond membership (own bonds, 变形同构体 grants, 调和 enjoying
|
||||
// active core bonds), live bond state, IN_BATTLE layer gains (requireActive, per-battle caps, disabled fields) and the
|
||||
// prep→battle `contentInfo` written by the global meta handler.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import {
|
||||
unitBonds, isMember, bondMembers, bondTier, bondLayers, gainLayers, layersUsed, contentInfo, topActiveBond,
|
||||
itemKeyOf, buffParams, bondRecord, isCoreBond, frontTile, alliesAround, N4,
|
||||
} from '../../server/sim/content/support/index.js';
|
||||
import { createRegistry } from '../../server/match/effectsMeta.js';
|
||||
import { makeMatch, give } from '../match/harness.js';
|
||||
import { FakeBattle } from '../match/fakeBattle.js';
|
||||
|
||||
const guard = (id, bonds, extra = {}) => chessRec({ id, bonds, profession: 'WARRIOR', skill: null, ...extra });
|
||||
|
||||
test('support: data helpers', () => {
|
||||
assert.equal(itemKeyOf('chess_item_1_01_e_b'), 'chess_item_1_01_e');
|
||||
assert.equal(itemKeyOf('chess_item_6_02_m'), 'chess_item_6_02_m');
|
||||
assert.ok(isCoreBond('yanShip') && !isCoreBond('preciShip'));
|
||||
const p = buffParams(bondRecord('yanShip'), 'act1autochess_bond_eff_yan');
|
||||
assert.equal(p.base_atk, 0.23);
|
||||
assert.equal(p.atk_per_stack, 0.009);
|
||||
});
|
||||
|
||||
test('support: membership — own bonds, 变形同构体 grants, 调和 enjoys active core bonds only', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: {
|
||||
t_yan_a: guard('t_yan_a', ['yanShip']),
|
||||
t_mani_a: guard('t_mani_a', ['maniShip']),
|
||||
t_none_a: guard('t_none_a', ['preciShip']),
|
||||
} },
|
||||
units: [
|
||||
{ chessId: 't_yan_a', row: 10, col: 3 },
|
||||
{ chessId: 't_mani_a', row: 10, col: 4 },
|
||||
// 变形同构体 + 维式重锤 → also 维多利亚
|
||||
{ chessId: 't_none_a', row: 10, col: 5, items: ['chess_item_6_09_e_a', 'chess_item_1_01_e_a'] },
|
||||
],
|
||||
bonds: {
|
||||
yanShip: { count: 3, active: true, tier: 1, layers: 10 },
|
||||
maniShip: { count: 1, active: true, tier: 1, layers: 0 },
|
||||
victoriaShip: { count: 1, active: false, tier: 0, layers: 4 },
|
||||
preciShip: { count: 1, active: false, tier: 0, layers: 0 },
|
||||
},
|
||||
});
|
||||
h.step();
|
||||
const yan = h.unit('t_yan_a'), mani = h.unit('t_mani_a'), none = h.unit('t_none_a');
|
||||
assert.deepEqual([...unitBonds(none)].sort(), ['preciShip', 'victoriaShip']);
|
||||
assert.ok(isMember(h.b, yan, 'yanShip'));
|
||||
assert.ok(isMember(h.b, mani, 'yanShip'), '调和 enjoys a core bond');
|
||||
assert.ok(!isMember(h.b, mani, 'preciShip'), '调和 does not enjoy add-on bonds');
|
||||
assert.ok(isMember(h.b, none, 'victoriaShip'), '变形同构体 grant');
|
||||
assert.deepEqual(bondMembers(h.b, 'p1', 'yanShip').map((u) => u.defId).sort(), ['t_mani_a', 't_yan_a']);
|
||||
assert.equal(bondTier(h.b, 'p1', 'yanShip'), 1);
|
||||
assert.equal(bondTier(h.b, 'p1', 'victoriaShip'), 0);
|
||||
assert.equal(topActiveBond(h.b, 'p1'), 'yanShip');
|
||||
assert.deepEqual(frontTile(yan), [10, 4]);
|
||||
assert.deepEqual(alliesAround(h.b, mani, N4).map((u) => u.defId).sort(), ['t_none_a', 't_yan_a']);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('support: gainLayers — requireActive, per-battle caps, live layers, recorded in the result', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_a: guard('t_a', ['yanShip']) } },
|
||||
units: [{ chessId: 't_a', row: 10, col: 3 }],
|
||||
bonds: { yanShip: { count: 3, active: true, tier: 1, layers: 0 }, preciShip: { count: 0, active: false, tier: 0, layers: 0 } },
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('t_a');
|
||||
assert.equal(gainLayers(h.b, { playerId: 'p1', bonds: ['yanShip', 'preciShip'], n: 4, source: u, reason: 'garrison' }), 4, 'inactive 精准 skipped');
|
||||
assert.equal(gainLayers(h.b, { playerId: 'p1', bonds: 'preciShip', n: 4, requireActive: false }), 4, '无需激活');
|
||||
for (let i = 0; i < 5; i++) gainLayers(h.b, { playerId: 'p1', bonds: 'yanShip', n: 4, cap: 12, capKey: 'g1' });
|
||||
assert.equal(layersUsed(h.b, 'g1', 'yanShip'), 12);
|
||||
assert.equal(bondLayers(h.b, 'p1', 'yanShip'), 16, 'live layers');
|
||||
h.b.forceEnd('forced');
|
||||
assert.deepEqual(h.result().perPlayer.p1.layerGains, { yanShip: 16, preciShip: 4 });
|
||||
});
|
||||
|
||||
test('support: gainLayers is a no-op when layer gains are disabled (联防 / boss)', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_a: guard('t_a', ['yanShip']) } },
|
||||
units: [{ chessId: 't_a', row: 10, col: 3 }],
|
||||
bonds: { yanShip: { count: 3, active: true, tier: 1, layers: 0 } },
|
||||
flags: { layerGainsEnabled: false },
|
||||
});
|
||||
h.step();
|
||||
assert.equal(gainLayers(h.b, { playerId: 'p1', bonds: 'yanShip', n: 5 }), 0);
|
||||
assert.equal(bondLayers(h.b, 'p1', 'yanShip'), 0);
|
||||
});
|
||||
|
||||
test('support meta: global:contentb_info writes contentInfo into every battle input', () => {
|
||||
const reg = createRegistry();
|
||||
assert.ok(reg.has('global:contentb_info'));
|
||||
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 11, registry: reg, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const ps = h.ps('p_0');
|
||||
give(h.m, ps, 'chess_char_1_01_a', 'hand');
|
||||
h.drive(() => FakeBattle.instances.length > 0, { ready: true });
|
||||
const fb = FakeBattle.instances.find((b) => b.players.includes('p_0'));
|
||||
assert.ok(fb, 'battle built');
|
||||
const inp = fb.opts.players.find((p) => p.playerId === 'p_0');
|
||||
const ci = inp.contentInfo;
|
||||
assert.ok(ci && ci.round === 1 && ci.kind === 'normal');
|
||||
assert.ok(ci.handChess >= 1 && ci.handUnits >= ci.handChess);
|
||||
assert.equal(ci.matchBands.length, 2);
|
||||
assert.equal(ci.teammates.length, 1);
|
||||
assert.equal(typeof ci.roundStats.gainedChess, 'number');
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('support: contentInfo() is {} without meta input (plain harness battle)', () => {
|
||||
const h = makeBattle({ units: [] });
|
||||
assert.deepEqual(contentInfo(h.b, 'p1'), {});
|
||||
});
|
||||
@@ -0,0 +1,805 @@
|
||||
// 机变 (SP draft) card content — server/sim/content/choices.js. Every family is driven end-to-end through the match
|
||||
// harness in virtual time (the draft is generated by the match, cards are picked with g.choice, the effects land on
|
||||
// the picker / teammates and reach the battles); battle-side parts are checked in the real sim (battle harness and
|
||||
// real Battles inside the match).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeMatch, give, giveItem, chessOfTier, legalTileFor, checkInvariants as matchInvariants, DATA } from '../match/harness.js';
|
||||
import { FakeBattle } from '../match/fakeBattle.js';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { createRegistry } from '../../server/match/effectsMeta.js';
|
||||
import { applyCard, generateDraft } from '../../server/match/choices.js';
|
||||
import { withBounties } from '../../server/match/waves.js';
|
||||
import { Battle } from '../../server/sim/Battle.js';
|
||||
import { bountyOf, battlePlanOf, choiceCardIds, gateOf, hasBattlePart, mapAliases, BUILTIN_REFS } from '../../server/sim/content/choices.js';
|
||||
|
||||
const QUIET = { warn() {}, error() {}, info() {} };
|
||||
const REG = createRegistry({ log: QUIET });
|
||||
const EFF = (id) => DATA.effects[id];
|
||||
const BAND = 'band_sarkazb'; // 歌利亚: no prep / battle effects of its own
|
||||
|
||||
/** Data copy whose SP round `round` of `modeId` always drafts `family`, optionally from a restricted card list. */
|
||||
function dataWith(modeId, round, family, { tactic = null, bounty = null, cards = null, extra = {} } = {}) {
|
||||
const ch = DATA.choices;
|
||||
const sch = ch.schedule[modeId];
|
||||
const r = { ...sch.rounds[String(round)], families: [{ family, weight: 1 }], ...extra };
|
||||
if (cards) r.cards = cards;
|
||||
const out = { ...DATA, choices: { ...ch, schedule: { ...ch.schedule, [modeId]: { ...sch, rounds: { ...sch.rounds, [String(round)]: r } } }, cards: { ...ch.cards } } };
|
||||
if (tactic) out.choices.cards.tactic = ch.cards.tactic.filter((c) => tactic.includes(c.effectId));
|
||||
if (bounty) out.choices.cards.bounty = ch.cards.bounty.filter((c) => bounty.includes(c.effectId));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drive the humans (info, band, ready) until the 机变 overlay of round r is open (no card picked yet). */
|
||||
function toDraft(h, r) {
|
||||
const m = h.m;
|
||||
const ok = h.drive(() => m.phase === 'SP_DRAFT' && m.round === r, { band: BAND });
|
||||
assert.ok(ok, `no 机变 at R${r} (phase ${m.phase} R${m.round})`);
|
||||
return m.sp;
|
||||
}
|
||||
|
||||
/** Pick cards in the draft's (random) order: plan = { playerId: cardId }; bots pick by themselves. */
|
||||
function draftPicks(h, plan) {
|
||||
const m = h.m;
|
||||
for (let guard = 0; guard < 1000 && m.phase === 'SP_DRAFT'; guard++) {
|
||||
const pid = m.spTurn();
|
||||
const ps = pid ? m.players.get(pid) : null;
|
||||
if (!ps || ps.isBot || !(pid in plan)) { h.sched.runNext(); continue; }
|
||||
const want = plan[pid];
|
||||
const c = m.sp.cards.find((x) => m.sp.taken[x.idx] == null && (want == null || x.id === want));
|
||||
assert.ok(c, `${pid}: card ${want} not offered (${m.sp.cards.map((x) => x.id).join(', ')})`);
|
||||
assert.deepEqual(m.handle(pid, { t: 'g.choice', idx: c.idx }), { ok: true });
|
||||
}
|
||||
assert.equal(m.phase, 'PREP', '机变 → prep');
|
||||
}
|
||||
|
||||
/** Ready every human and run until `pred` (combat included). */
|
||||
const runTo = (h, pred) => assert.ok(h.drive(pred, { band: BAND }), `condition not reached (phase ${h.m.phase} R${h.m.round})`);
|
||||
|
||||
/** A ref-shaped playerEffects entry of a card (what the match sends to the battle). */
|
||||
const refOf = (effectId, params = {}, id = `choice:${effectId}#1`) => ({ id, key: `choice:${effectId}`, source: 'choice', params, counter: null, data: { effectId } });
|
||||
|
||||
/** Every item a player owns: hand, temp and equipped on its chess. */
|
||||
const ownedItems = (ps) => {
|
||||
const out = [];
|
||||
for (const p of [...ps.hand, ...ps.temp, ...ps.board.values()]) {
|
||||
if (!p) continue;
|
||||
if (p.kind === 'item') out.push(p);
|
||||
if (p.kind === 'chess') for (const it of p.items || []) out.push(it);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const guard = (o = {}) => chessRec({ id: 'test_guard', stats: { maxHp: 2000, atk: 500, def: 200, res: 0, respawnTime: 20 }, skill: null, ...o });
|
||||
|
||||
// =====================================================================================================================
|
||||
// coverage & data
|
||||
|
||||
test('coverage: every 悬赏 / 战术决策 effect and every 道具补给 / 机密商店 item has a content choice: handler', () => {
|
||||
const ids = choiceCardIds(DATA);
|
||||
assert.equal(ids.bounty.length, 129, '129 ENEMY_GAIN effects');
|
||||
assert.equal(ids.tactic.length, 43, '43 BUFF_GAIN effects');
|
||||
assert.equal(ids.items.length, 56, '56 shop-eligible normal EQUIP items');
|
||||
for (const id of [...ids.bounty, ...ids.tactic, ...ids.items]) {
|
||||
const h = REG.get(`choice:${id}`);
|
||||
assert.ok(h && typeof h.onChoicePick === 'function', `choice:${id}`);
|
||||
}
|
||||
// every card of choices.json is covered, every item the supply / shop families can draw too
|
||||
for (const c of DATA.choices.cards.bounty) assert.ok(ids.bounty.includes(c.effectId), c.effectId);
|
||||
for (const c of DATA.choices.cards.tactic) assert.ok(ids.tactic.includes(c.effectId), c.effectId);
|
||||
const m = makeMatch({ mode: 'coop', seed: 1, registry: REG, fake: true }).m;
|
||||
for (const t of [1, 2, 3, 4, 5, 6]) for (const id of m.gd.shopItemsByTier[t] || []) assert.ok(ids.items.includes(id), id);
|
||||
m.dispose();
|
||||
// team cards ("若存在其他队友则他们也获得") are exactly the icon_team_buff cards
|
||||
for (const c of DATA.choices.cards.tactic) {
|
||||
const e = EFF(c.effectId);
|
||||
assert.equal(c.team, e.decoIconId === 'icon_team_buff', `${c.effectId} team flag vs icon`);
|
||||
assert.equal(c.team, e.desc.includes('若存在其他队友'), `${c.effectId} team flag vs text`);
|
||||
}
|
||||
// every official 机变 event belongs to one of the four families
|
||||
for (const ev of Object.values(DATA.choices.events)) assert.ok(['bounty', 'supply', 'shop', 'tactic'].includes(ev.family), ev.id);
|
||||
// every buff key of a tactic card is understood (prep side or battle plan / device aliases)
|
||||
const known = new Set(['global_special_choice_gain_equip', 'global_special_choice_bond_addlayer', 'global_special_choice_gain_coin',
|
||||
'global_special_choice_refresh_free', 'single_special_choice_gloden_equip_chess', 'single_special_choice_gloden_char_chess',
|
||||
'single_special_choice_gain_bond_chess', 'auto_chess_change_map', 'global_special_choice_all_activated',
|
||||
'global_special_choice_prep_finish_bench_at_least', 'global_special_choice_prep_finish_bench_at_most',
|
||||
'global_special_choice_prep_finish_same_row_at_least', 'env_gbuff_new', 'env_gbuff_new_with_verify', 'char_respawntime_mul',
|
||||
'enemy_attribute_mul', 'enemy_attribute_add']);
|
||||
for (const id of ids.tactic) {
|
||||
const e = EFF(id);
|
||||
for (const b of e.buffs) assert.ok(known.has(b.key), `${id}: unknown buff key ${b.key}`);
|
||||
if (hasBattlePart(e)) assert.ok(battlePlanOf(refOf(id)) || Object.keys(mapAliases(e)).length, `${id}: battle part not implemented`);
|
||||
}
|
||||
});
|
||||
|
||||
test('悬赏 data: the bounty read from each effect blackboard matches its card (count, coin, payout, rounds, enemy)', () => {
|
||||
let perfect = 0;
|
||||
for (const c of DATA.choices.cards.bounty) {
|
||||
const b = bountyOf(EFF(c.effectId), c);
|
||||
assert.ok(b, c.effectId);
|
||||
assert.ok(DATA.enemies[b.enemyKey], `${c.effectId}: enemy ${b.enemyKey}`);
|
||||
for (const k of ['enemyKey', 'count', 'coin', 'payout', 'rounds', 'tier']) assert.equal(b[k], c[k], `${c.effectId}.${k}`);
|
||||
assert.equal(b.coin, EFF(c.effectId).enemyPrice, `${c.effectId}: coins = enemyPrice`);
|
||||
if (b.payout === 'perfect') perfect++;
|
||||
}
|
||||
assert.equal(perfect, 20, '20 战术特训 (selfbattle_win) bounties');
|
||||
// enemyeffect_1 lists a stale next_battle_add_enemy_win_gain_coin (count 4) next to its add_enemy_* buff (count 3)
|
||||
const e1 = bountyOf(EFF('enemyeffect_1'));
|
||||
assert.equal(e1.count, 3, 'the text says 添加3只');
|
||||
assert.equal(e1.payout, 'perfect');
|
||||
assert.match(EFF('enemyeffect_1').desc, /添加3只/);
|
||||
// multi-round: 99 = every later battle
|
||||
assert.equal(bountyOf(EFF('enemyInitial_3')).rounds, 99);
|
||||
assert.equal(bountyOf(EFF('enemyeffect_2')).rounds, 99);
|
||||
assert.equal(bountyOf(EFF('enemyeffect_5_1')).coin, 0, '源石虫·特训 pays nothing');
|
||||
});
|
||||
|
||||
// =====================================================================================================================
|
||||
// 悬赏决策
|
||||
|
||||
const KILL = 'enemyeffect_3_4'; // 山海众头目·悬赏: 1 enemy, killer +2, next battle
|
||||
const KILL2 = 'enemyeffect_12_4'; // 悬赏·损伤I: 1 enemy, killer +1, next 2 battles
|
||||
const PERF = 'enemyeffect_11_1'; // 战术特训·频次I: 1 enemy, +1 when the own phase is perfect
|
||||
|
||||
test('悬赏决策 E2E (co-op, real battles): the picker\'s own battle gets the enemy; the killer earns the kill coins, 战术特训 pays a perfect own phase', () => {
|
||||
const data = dataWith('mode_multi_normal', 3, 'bounty', { bounty: [KILL, PERF], extra: { bountyTiers: [1, 2, 3] } });
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 31, data, registry: REG });
|
||||
const m = h.m;
|
||||
// real battles where every enemy of a player is struck down by that player's operator the tick it appears
|
||||
const fields = [];
|
||||
m.BattleClass = class extends Battle {
|
||||
constructor(opts) {
|
||||
const user = opts.setup;
|
||||
super({ ...opts, setup: (b) => {
|
||||
if (typeof user === 'function') user(b);
|
||||
b.on('tick', () => {
|
||||
for (const e of b.enemies) {
|
||||
if (!e.alive || e.bossPool) continue;
|
||||
const op = b.allyUnits.find((u) => u.kind === 'op' && u.ownerId === e.ownerId && u.alive && u.deployed);
|
||||
if (op) b.kill(e, op);
|
||||
}
|
||||
});
|
||||
} });
|
||||
fields.push(this);
|
||||
}
|
||||
};
|
||||
h.start();
|
||||
const sp = toDraft(h, 3);
|
||||
assert.equal(sp.family, 'bounty');
|
||||
assert.equal(m.publicView().sp.name, '悬赏决策');
|
||||
assert.deepEqual(sp.cards.map((c) => c.id).sort(), [KILL, PERF].sort());
|
||||
for (const c of m.publicView().sp.cards) {
|
||||
const card = DATA.choices.cards.bounty.find((x) => x.effectId === c.id);
|
||||
assert.equal(c.kind, 'bounty');
|
||||
assert.deepEqual([c.coin, c.payout, c.rounds, c.enemyKey, c.count], [card.coin, card.payout, card.rounds, card.enemyKey, card.count]);
|
||||
}
|
||||
const [a, b] = sp.order;
|
||||
draftPicks(h, { [a]: KILL, [b]: PERF });
|
||||
const A = h.ps(a);
|
||||
const B = h.ps(b);
|
||||
assert.deepEqual(A.bounties.map((x) => x.card.effectId), [KILL], 'personal: only the picker');
|
||||
assert.deepEqual(B.bounties.map((x) => x.card.effectId), [PERF]);
|
||||
const kill = bountyOf(EFF(KILL));
|
||||
const perf = bountyOf(EFF(PERF));
|
||||
assert.ok(A.privateView().nextEnemies.some((e) => e.enemyKey === kill.enemyKey && e.tag === 'bounty' && e.count >= 1), 'preview');
|
||||
assert.ok(!B.privateView().nextEnemies.some((e) => e.enemyKey === kill.enemyKey && e.tag === 'bounty'));
|
||||
// an operator on each board (the killer)
|
||||
for (const ps of [A, B]) { const id = chessOfTier(1)[0]; give(m, ps, id, 'board', legalTileFor(m, ps, id)); }
|
||||
runTo(h, () => m.phase === 'ROUND_START' && m.round === 4);
|
||||
const r3 = fields.filter((f) => f.round === 3 && f.kind === 'normal');
|
||||
assert.equal(r3.length, 2);
|
||||
const fa = r3.find((f) => f.players[0].playerId === a);
|
||||
const fb = r3.find((f) => f.players[0].playerId === b);
|
||||
const ra = fa.result().perPlayer[a];
|
||||
const rb = fb.result().perPlayer[b];
|
||||
assert.ok(ra.perfect && rb.perfect, 'both own phases perfect');
|
||||
assert.equal(ra.coins, kill.coin, 'the killer (the picker\'s operator) earned the kill bounty');
|
||||
assert.equal(rb.coins, 0, '战术特训 enemies carry no kill bounty');
|
||||
// coins arrive at the next round start with the income (leftover funds were lost at prep end)
|
||||
assert.equal(A.funds, m.gd.income(4) + kill.coin);
|
||||
assert.equal(B.funds, m.gd.income(4) + perf.coin, 'perfect own phase ⇒ 战术特训 coins');
|
||||
assert.equal(A.bounties.length + B.bounties.length, 0, 'one-battle bounties expire');
|
||||
matchInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('悬赏决策: multi-battle bounties persist for `rounds` battles; 战术特训 pays nothing when the own phase leaks', () => {
|
||||
const data = dataWith('mode_multi_normal', 3, 'bounty', { bounty: [KILL2, PERF] });
|
||||
let leaker = null;
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 32, data, registry: REG, fake: true,
|
||||
script: (bt) => (bt.kind === 'normal' && leaker && bt.players.includes(leaker) ? { leaks: { [leaker]: 1 } } : {}) });
|
||||
const m = h.m;
|
||||
h.start();
|
||||
const sp = toDraft(h, 3);
|
||||
const [a, b] = sp.order;
|
||||
leaker = b;
|
||||
draftPicks(h, { [a]: KILL2, [b]: PERF });
|
||||
const k2 = bountyOf(EFF(KILL2));
|
||||
assert.equal(k2.rounds, 2);
|
||||
runTo(h, () => m.phase === 'ROUND_START' && m.round === 4);
|
||||
const spawnsOf = (round, pid) => FakeBattle.instances.filter((f) => f.round === round && f.kind === 'normal' && f.players.includes(pid)).flatMap((f) => f.spawns);
|
||||
const bountyA3 = spawnsOf(3, a).filter((s) => s.tag === 'bounty');
|
||||
assert.equal(bountyA3.length, 1);
|
||||
assert.equal(bountyA3[0].enemyKey, k2.enemyKey);
|
||||
assert.equal(bountyA3[0].count, k2.count);
|
||||
assert.deepEqual(bountyA3[0].bounty, { coins: k2.coin, ownerPlayerId: a }, 'kill bounty → paid by the Battle to the killer');
|
||||
const bountyB3 = spawnsOf(3, b).filter((s) => s.tag === 'bounty');
|
||||
assert.equal(bountyB3.length, 1);
|
||||
assert.equal(bountyB3[0].bounty ?? null, null, '战术特训: no kill payout');
|
||||
assert.equal(h.ps(b).funds, m.gd.income(4), 'leaked ⇒ no 战术特训 coins');
|
||||
assert.equal(h.ps(a).bounties.length, 1, 'KILL2 still has one battle to go');
|
||||
runTo(h, () => m.phase === 'ROUND_START' && m.round === 5);
|
||||
assert.equal(spawnsOf(4, a).filter((s) => s.tag === 'bounty').length, 1, 'second battle');
|
||||
assert.equal(spawnsOf(4, b).filter((s) => s.tag === 'bounty').length, 0);
|
||||
assert.equal(h.ps(a).bounties.length, 0);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('悬赏决策 (solo HARD R3): bounty enemies take the round\'s enemy scale only — no extra solo ×0.7 (research 08 §8 #8)', () => {
|
||||
const run = (mode, modeId, pickId) => {
|
||||
const data = dataWith(modeId, 3, 'bounty', { bounty: [KILL, PERF] });
|
||||
const h = makeMatch({ mode, difficulty: 'HARD', humans: 1, seed: 33, data, registry: REG, fake: true });
|
||||
h.start();
|
||||
toDraft(h, 3);
|
||||
draftPicks(h, { p_0: pickId });
|
||||
runTo(h, () => h.m.phase === 'ROUND_START' && h.m.round === 4);
|
||||
const spec = FakeBattle.instances.find((f) => f.round === 3 && f.kind === 'normal').spawns.find((s) => s.tag === 'bounty');
|
||||
const scale = h.m.gd.enemyScale(3);
|
||||
h.m.dispose();
|
||||
return { spec, scale };
|
||||
};
|
||||
// the official "所有敌人的生命值、攻击力降低至70%" of 独立模拟 is the global solo base already in enemyScale: a bounty
|
||||
// (战术特训 perfect or kill) spawns exactly like a wave enemy of the round, in solo and co-op alike, DEF untouched
|
||||
for (const [mode, modeId] of [['solo', 'mode_single_hard'], ['coop', 'mode_multi_hard']]) {
|
||||
for (const pick of [PERF, KILL]) {
|
||||
const { spec, scale } = run(mode, modeId, pick);
|
||||
const tag = `${mode} ${pick === PERF ? 'perfect' : 'kill'}`;
|
||||
assert.ok(Math.abs(spec.mods.hpMul - scale.hpMul) < 1e-9, `${tag}: hp ${spec.mods.hpMul} = scale ${scale.hpMul}`);
|
||||
assert.ok(Math.abs(spec.mods.atkMul - scale.atkMul) < 1e-9, `${tag}: atk`);
|
||||
assert.ok(spec.mods.defMul == null || spec.mods.defMul === 1, `${tag}: no DEF change`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('悬赏决策: the normal battle runs the client\'s spawn list — bounty units inserted into their host action, which is re-timed (waves.js withBounties)', () => {
|
||||
const data = dataWith('mode_single_hard', 3, 'bounty', { bounty: [KILL] });
|
||||
// seed 34: a ground R3 whose walker action 0 hosts the 山海众头目 (seed 33 is a FLY round: the bounty spawns alone)
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', humans: 1, seed: 34, data, registry: REG, fake: true });
|
||||
h.start();
|
||||
toDraft(h, 3);
|
||||
draftPicks(h, { p_0: KILL });
|
||||
const m = h.m;
|
||||
const ps = h.ps('p_0');
|
||||
assert.equal(ps.bounties.length, 1);
|
||||
const want = withBounties(m.gd, 3, m.wave, ps.bounties, 'p_0');
|
||||
const host = want.find((x) => x.tag === 'bounty').actionIndex;
|
||||
assert.ok(want.filter((x) => x.tag !== 'bounty' && x.actionIndex === host).length >= 1, 'fixture: the host action spawns units of its own');
|
||||
runTo(h, () => m.phase === 'COMBAT' && m.round === 3);
|
||||
const fb = FakeBattle.instances.find((f) => f.round === 3 && f.kind === 'normal');
|
||||
const view = (list) => list.map((x) => [x.enemyKey, +x.time.toFixed(9), x.count, +(x.interval || 0).toFixed(9), x.tag ?? null, x.actionIndex ?? null]);
|
||||
assert.deepEqual(view(fb.spawns), view(want), 'same specs, times and spacing as withBounties');
|
||||
assert.ok(fb.spawns.every((x) => x.ownerPlayerId === 'p_0'));
|
||||
// the preview keeps one entry per action (the host action is not split there)
|
||||
const pv = m.nextEnemiesFor(ps);
|
||||
assert.equal(pv.reduce((n, e) => n + e.count, 0), want.reduce((n, x) => n + x.count, 0), 'same enemy total');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('悬赏 kill payout in the real sim: a 联防 helper that strikes down a leaked bounty enemy takes the coins', () => {
|
||||
const kill = bountyOf(EFF(KILL));
|
||||
const players = [
|
||||
{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: 'test_guard', row: 10, col: 4 }], bonds: {} },
|
||||
{ playerId: 'p2', seat: 1, side: 'L', colOffset: 8, units: [{ uid: 2, kind: 'chess', chessId: 'test_guard', row: 10, col: 4 }], bonds: {} },
|
||||
];
|
||||
const h = makeBattle({ kind: 'unite', defs: { chess: { test_guard: guard() }, enemies: { enemy_b: enemyRec({ key: 'enemy_b', hp: 1000, speed: 0 }) } }, players, autoFinish: false, timeLimit: 30 });
|
||||
h.step();
|
||||
const e = h.spawn('enemy_b', { pos: [11, 14], ownerPlayerId: 'p2', sourcePlayerId: 'p1', bounty: { coins: kill.coin, ownerPlayerId: 'p1' } });
|
||||
const helper = h.b.allyUnits.find((u) => u.ownerId === 'p2' && u.kind === 'op');
|
||||
h.b.dealDamage(helper, e, h.b.makeDamage({ amount: 1e6, type: 'true' }));
|
||||
assert.equal(e.alive, false);
|
||||
const r = h.b.result();
|
||||
assert.equal(r.perPlayer.p2.coins, kill.coin, 'the killer (helper) is paid');
|
||||
assert.equal(r.perPlayer.p1.coins, 0);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// =====================================================================================================================
|
||||
// 道具补给 / 机密商店
|
||||
|
||||
test('道具补给 E2E (co-op): 6 free normal items within the round window; the pick lands in the picker\'s hand (temp when full)', () => {
|
||||
const data = dataWith('mode_multi_normal', 3, 'supply');
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 41, data, registry: REG, fake: true });
|
||||
const m = h.m;
|
||||
h.start();
|
||||
const sp = toDraft(h, 3);
|
||||
assert.equal(sp.family, 'supply');
|
||||
assert.equal(m.publicView().sp.name, '道具补给');
|
||||
const [lo, hi] = data.choices.schedule.mode_multi_normal.rounds['3'].supplyTiers;
|
||||
assert.equal(sp.cards.length, 6);
|
||||
for (const c of m.publicView().sp.cards) {
|
||||
const it = DATA.items[c.id];
|
||||
assert.equal(c.kind, 'item');
|
||||
assert.equal(c.price, 0, '无需消耗资金');
|
||||
assert.ok(it && !it.isGolden && it.itemType === 'EQUIP' && it.tier >= lo && it.tier <= hi, `${c.id} tier ${it && it.tier}`);
|
||||
}
|
||||
const [a, b] = sp.order;
|
||||
const A = h.ps(a);
|
||||
const B = h.ps(b);
|
||||
// B's regular hand is full (10 different operators): the item overflows into the temporary slots
|
||||
const fillers = chessOfTier(1).concat(chessOfTier(2));
|
||||
for (let i = 0; i < B.hand.length; i++) if (!B.hand[i]) give(m, B, fillers[i], 'hand', i);
|
||||
const fundsA = A.funds;
|
||||
const fundsB = B.funds;
|
||||
const cardA = sp.cards[0].id;
|
||||
const cardB = sp.cards[1].id;
|
||||
draftPicks(h, { [a]: cardA, [b]: cardB });
|
||||
const owns = (ps, id) => [...ps.hand, ...ps.temp].some((p) => p && (p.id === id || p.id === DATA.items[id].goldenId));
|
||||
assert.ok(owns(A, cardA), 'picker A got its item');
|
||||
assert.ok(owns(B, cardB), 'picker B got its item');
|
||||
assert.ok(B.temp.some((p) => p && (p.id === cardB || p.id === DATA.items[cardB].goldenId)), 'full hand → temp');
|
||||
assert.equal(A.funds, fundsA, 'free');
|
||||
assert.equal(B.funds, fundsB, 'free');
|
||||
assert.ok(!owns(A, cardB) || cardA === cardB, 'items are personal');
|
||||
matchInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('机密商店 E2E (solo HARD R11): 3 free normal items of any tier I–VI; a second copy merges into the 进阶 item', () => {
|
||||
const data = dataWith('mode_single_hard', 11, 'shop');
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', humans: 1, seed: 42, data, registry: REG, fake: true });
|
||||
const m = h.m;
|
||||
h.start();
|
||||
const sp = toDraft(h, 11);
|
||||
assert.equal(sp.family, 'shop');
|
||||
assert.equal(m.publicView().sp.name, '机密商店');
|
||||
assert.equal(sp.cards.length, 3);
|
||||
for (const c of sp.cards) assert.ok(DATA.items[c.id] && !DATA.items[c.id].isGolden && DATA.items[c.id].itemType === 'EQUIP');
|
||||
const ps = h.ps('p_0');
|
||||
const card = sp.cards[2].id;
|
||||
giveItem(m, ps, card); // an identical normal copy already owned
|
||||
const funds = ps.funds;
|
||||
draftPicks(h, { p_0: card });
|
||||
const golden = DATA.items[card].goldenId;
|
||||
assert.ok([...ps.hand, ...ps.temp].some((p) => p && p.id === golden), 'merged into the 进阶 item');
|
||||
assert.equal(ps.funds, funds);
|
||||
matchInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('机密商店 draws every tier I–VI with duplicates allowed (generation, 30 drafts)', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, seed: 5, data: dataWith('mode_multi_hard', 11, 'shop'), registry: REG, fake: true });
|
||||
const tiers = new Set();
|
||||
let dupes = 0;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const d = generateDraft(h.m.gd, h.m.rngDraft, 11, { stageId: h.m.stageId });
|
||||
assert.equal(d.family, 'shop');
|
||||
assert.equal(d.cards.length, 6);
|
||||
for (const c of d.cards) tiers.add(DATA.items[c.id].tier);
|
||||
if (new Set(d.cards.map((c) => c.id)).size < d.cards.length) dupes++;
|
||||
}
|
||||
h.m.dispose();
|
||||
assert.deepEqual([...tiers].sort(), [1, 2, 3, 4, 5, 6]);
|
||||
assert.ok(dupes > 0, 'duplicates allowed');
|
||||
});
|
||||
|
||||
// =====================================================================================================================
|
||||
// 战术决策 — prep-side cards
|
||||
|
||||
const TEAM_PREP = ['allybuff_select_1', 'allybuff_select_2_2', 'allybuff_select_3', 'allybuff_select_4', 'allybuff_select_5', 'allybuff_select_7_1'];
|
||||
|
||||
test('战术决策 E2E (co-op 3): team cards reach every teammate (AI included), personal cards only the picker', () => {
|
||||
const data = dataWith('mode_multi_normal', 3, 'tactic', { tactic: TEAM_PREP });
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, bots: 1, seed: 51, data, registry: REG, fake: true });
|
||||
const m = h.m;
|
||||
h.start();
|
||||
const sp = toDraft(h, 3);
|
||||
assert.equal(sp.family, 'tactic');
|
||||
assert.equal(m.publicView().sp.name, '战术决策');
|
||||
assert.deepEqual(sp.cards.map((c) => c.id).sort(), [...TEAM_PREP].sort());
|
||||
const view = m.publicView().sp.cards;
|
||||
for (const c of view) assert.equal(c.team, DATA.choices.cards.tactic.find((x) => x.effectId === c.id).team, `${c.id} team flag`);
|
||||
const all = [...m.players.values()];
|
||||
const before = new Map(all.map((ps) => [ps.playerId, { funds: ps.funds, free: ps.shop.freeRefreshes, yan: ps.layers.yanShip || 0, invest: ps.layers.investShip || 0,
|
||||
items: new Set(ownedItems(ps).map((p) => p.uid)), chess: [...ps.hand, ...ps.temp].filter((p) => p && p.kind === 'chess').length }]));
|
||||
// the humans take 列装 (team) and 整备 (personal); the AI picks what it likes
|
||||
draftPicks(h, { p_0: 'allybuff_select_1', p_1: 'allybuff_select_5' });
|
||||
const aiIdx = sp.picks.ai_0;
|
||||
const aiCard = aiIdx != null ? sp.cards[aiIdx].id : null;
|
||||
assert.ok(aiCard && !['allybuff_select_1', 'allybuff_select_5'].includes(aiCard), 'the AI took one of the other cards');
|
||||
const itemsOf = ownedItems;
|
||||
// 列装: 2 random tier-I items for everyone (pool_equip_shop_1)
|
||||
for (const ps of all) {
|
||||
const had = before.get(ps.playerId).items;
|
||||
const now = new Set(itemsOf(ps).map((p) => p.uid));
|
||||
const fresh = itemsOf(ps).filter((p) => !had.has(p.uid));
|
||||
// an identical normal pair merges into the 进阶 item (counts 2); an owned copy consumed by a merge counts −1
|
||||
const n = fresh.reduce((k, p) => k + (DATA.items[p.id].isGolden ? 2 : 1), 0) - [...had].filter((u) => !now.has(u)).length;
|
||||
assert.equal(n, 2, `${ps.playerId}: 列装 gives 2 items`);
|
||||
for (const it of fresh) assert.equal(DATA.items[it.id].tier, 1, `${ps.playerId}: tier-I item ${it.id}`);
|
||||
}
|
||||
// 整备: personal EffectRef on p_1 only
|
||||
assert.ok(h.ps('p_1').effects.some((e) => e.key === BUILTIN_REFS.goldenItem && e.counter === 1));
|
||||
assert.ok(!h.ps('p_0').effects.some((e) => e.key === BUILTIN_REFS.goldenItem));
|
||||
// the AI's card, whatever it was, followed its team flag
|
||||
{
|
||||
const team = DATA.choices.cards.tactic.find((c) => c.effectId === aiCard).team;
|
||||
const check = {
|
||||
allybuff_select_2_2: (ps) => (ps.layers.yanShip || 0) - before.get(ps.playerId).yan === 10 && (ps.layers.investShip || 0) - before.get(ps.playerId).invest === 10,
|
||||
allybuff_select_3: (ps) => ps.funds - before.get(ps.playerId).funds === 1,
|
||||
allybuff_select_4: (ps) => ps.shop.freeRefreshes - before.get(ps.playerId).free === 2,
|
||||
allybuff_select_7_1: (ps) => [...ps.hand, ...ps.temp].filter((p) => p && p.kind === 'chess').length - before.get(ps.playerId).chess === 1,
|
||||
}[aiCard];
|
||||
if (check) for (const ps of all) assert.equal(check(ps), team || ps.playerId === 'ai_0', `${aiCard} on ${ps.playerId}`);
|
||||
}
|
||||
matchInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('战术决策 prep cards through the handlers: 盟誓 layers (无需激活), 财富, 补给, 驰援 (bond chess ≤ shop level)', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 52, registry: REG, fake: true }).start();
|
||||
h.toPrep(1, { band: BAND });
|
||||
const m = h.m;
|
||||
const [a, b] = [h.ps('p_0'), h.ps('p_1')];
|
||||
const tactic = (id) => ({ kind: 'tactic', id, name: EFF(id).name, desc: EFF(id).desc, team: !!DATA.choices.cards.tactic.find((c) => c.effectId === id)?.team, tacticKind: 'ally', family: 'tactic', idx: 0 });
|
||||
for (const id of ['allybuff_select_2_1', 'allybuff_select_2_2', 'allybuff_select_2_3', 'allybuff_select_2_4', 'allybuff_select_2_5', 'allybuff_select_2_6', 'allybuff_select_2_7', 'allybuff_select_2_8']) {
|
||||
const buff = EFF(id).buffs[0];
|
||||
const bonds = buff.bbStr.bond_list.split(',');
|
||||
const la = bonds.map((x) => a.layers[x] || 0);
|
||||
const lb = bonds.map((x) => b.layers[x] || 0);
|
||||
applyCard(m, a, tactic(id));
|
||||
bonds.forEach((x, i) => {
|
||||
assert.equal((a.layers[x] || 0) - la[i], buff.bb.count, `${id} ${x} picker`);
|
||||
assert.equal((b.layers[x] || 0) - lb[i], buff.bb.count, `${id} ${x} teammate`);
|
||||
});
|
||||
}
|
||||
const fa = a.funds;
|
||||
const fb = b.funds;
|
||||
applyCard(m, b, tactic('allybuff_select_3'));
|
||||
assert.deepEqual([a.funds - fa, b.funds - fb], [1, 1], '财富 +1 each');
|
||||
applyCard(m, b, tactic('allybuff_select_4'));
|
||||
applyCard(m, a, tactic('allybuff_select_4'));
|
||||
assert.deepEqual([a.shop.freeRefreshes, b.shop.freeRefreshes], [4, 4], '补给 stacks');
|
||||
// 驰援: one random chess of the bond, personal
|
||||
for (const id of ['allybuff_select_7_1', 'allybuff_select_7_2', 'allybuff_select_7_3', 'allybuff_select_7_4', 'allybuff_select_7_5', 'allybuff_select_7_6', 'allybuff_select_7_7', 'allybuff_select_7_8']) {
|
||||
const bond = EFF(id).buffs[0].bbStr.bond;
|
||||
const chessOf = (ps) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter((p) => p && p.kind === 'chess').map((p) => p.uid);
|
||||
const nb = chessOf(b).length;
|
||||
// clear A's chess first (pool copies go back) so the grant is the only one
|
||||
for (const uid of chessOf(a)) { const l = a.find(uid); if (l) { a._detach(l); a.removeTokensOf(uid); a.returnCopies(l.piece); } }
|
||||
a.recompute();
|
||||
applyCard(m, a, tactic(id));
|
||||
const got = [...a.hand, ...a.temp].filter((p) => p && p.kind === 'chess');
|
||||
assert.equal(got.length, 1, `${id}: one chess`);
|
||||
const rec = DATA.chess[got[0].id];
|
||||
assert.ok(rec.bonds.includes(bond), `${id}: ${rec.name} belongs to ${bond}`);
|
||||
const lowExists = Object.values(DATA.chess).some((c) => c.visible && !c.isGolden && c.bonds.includes(bond) && c.tier <= a.shop.level && m.pool.has(c.chessId));
|
||||
if (lowExists) assert.ok(rec.tier <= a.shop.level, `${id}: tier ${rec.tier} ≤ shop level ${a.shop.level}`);
|
||||
assert.equal(chessOf(b).length, nb, `${id} is personal`);
|
||||
}
|
||||
matchInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('战术决策 升华 / 整备: the next purchased operator becomes elite, the next purchased item 进阶 (one use each)', () => {
|
||||
const data = dataWith('mode_single_normal', 9, 'tactic', { tactic: ['allybuff_select_5', 'allybuff_select_6', 'allybuff_select_3'] });
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', humans: 1, seed: 53, data, registry: REG, fake: true });
|
||||
const m = h.m;
|
||||
h.start();
|
||||
toDraft(h, 9);
|
||||
draftPicks(h, { p_0: 'allybuff_select_6' });
|
||||
const ps = h.ps('p_0');
|
||||
const ref = ps.effects.find((e) => e.key === BUILTIN_REFS.eliteChess);
|
||||
assert.ok(ref && ref.counter === 1 && ref.iconId === EFF('allybuff_select_6').decoIconId);
|
||||
assert.ok(ps.effectsView().some((e) => e.id === ref.id && e.name === '升华'));
|
||||
ps.funds = 50;
|
||||
ps.hand.fill(null);
|
||||
ps.recompute();
|
||||
const slot = ps.shop.slots.findIndex((s) => s && s.kind === 'chess' && m.pool.left(m.gd.baseIdOf(s.id)) >= 3 && !ps.completesChessMerge(s.id));
|
||||
assert.ok(slot >= 0);
|
||||
const id = ps.shop.slots[slot].id;
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot }), { ok: true });
|
||||
assert.ok(ps.hand.some((p) => p && p.id === DATA.chess[id].goldenId), '升华: elite');
|
||||
assert.ok(!ps.effects.some((e) => e.key === BUILTIN_REFS.eliteChess), 'consumed');
|
||||
const slot2 = ps.shop.slots.findIndex((s, i) => i !== slot && s && s.kind === 'chess' && !s.sold && !ps.completesChessMerge(s.id));
|
||||
if (slot2 >= 0) {
|
||||
const id2 = ps.shop.slots[slot2].id;
|
||||
m.handle('p_0', { t: 'g.buy', slot: slot2 });
|
||||
assert.ok(ps.hand.some((p) => p && p.id === id2), 'the next purchase stays normal');
|
||||
}
|
||||
// 整备 via its handler (same draft family on another pick)
|
||||
applyCard(m, ps, { kind: 'tactic', id: 'allybuff_select_5', name: '整备', desc: '', team: false, family: 'tactic', idx: 0 });
|
||||
const islot = ps.shop.slots.findIndex((s) => s && s.kind === 'item' && !s.sold);
|
||||
if (islot >= 0) {
|
||||
const iid = ps.shop.slots[islot].id;
|
||||
ps.funds = 50;
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: islot }), { ok: true });
|
||||
assert.ok([...ps.hand, ...ps.temp].some((p) => p && p.id === DATA.items[iid].goldenId), '整备: 进阶 item');
|
||||
assert.ok(!ps.effects.some((e) => e.key === BUILTIN_REFS.goldenItem), 'consumed');
|
||||
}
|
||||
matchInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
// =====================================================================================================================
|
||||
// 战术决策 — battle cards
|
||||
|
||||
test('战术决策 E2E: 火力 / 锐利 evaluate the 手牌区 at every prep end; the battle input carries the result', () => {
|
||||
const data = dataWith('mode_multi_normal', 3, 'tactic', { tactic: ['allybuff_select_13', 'allybuff_select_19'] });
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 61, data, registry: REG, fake: true });
|
||||
const m = h.m;
|
||||
h.start();
|
||||
const sp = toDraft(h, 3);
|
||||
const [a, b] = sp.order;
|
||||
draftPicks(h, { [a]: 'allybuff_select_13', [b]: 'allybuff_select_19' });
|
||||
const A = h.ps(a);
|
||||
const B = h.ps(b);
|
||||
for (const ps of [A, B]) {
|
||||
for (const id of ['allybuff_select_13', 'allybuff_select_19']) {
|
||||
const ref = ps.effects.find((e) => e.key === `choice:${id}`);
|
||||
assert.ok(ref && ref.battle && ref.iconKind === 'team' && ref.iconId === 'icon_team_buff', `${ps.playerId} ${id} (team card)`);
|
||||
}
|
||||
}
|
||||
// A keeps 3 cards in the 手牌区, B an empty one
|
||||
for (let i = 0; i < 3; i++) giveItem(m, A, 'chess_item_1_02_e_a');
|
||||
A.hand.fill(null);
|
||||
giveItem(m, A, 'chess_item_1_01_e_a');
|
||||
giveItem(m, A, 'chess_item_1_02_e_a');
|
||||
giveItem(m, A, 'chess_item_1_05_e_a');
|
||||
B.hand.fill(null);
|
||||
B.recompute();
|
||||
runTo(h, () => m.phase === 'ROUND_START' && m.round === 4);
|
||||
const inputOf = (round, pid) => FakeBattle.instances.find((f) => f.round === round && f.kind === 'normal' && f.players.includes(pid)).opts.players[0];
|
||||
const okOf = (input, id) => input.playerEffects.find((e) => e.key === `choice:${id}`).params.prepOk;
|
||||
assert.equal(okOf(inputOf(3, a), 'allybuff_select_13'), true, 'A: ≥ 3 cards ⇒ 火力 on');
|
||||
assert.equal(okOf(inputOf(3, a), 'allybuff_select_19'), false, 'A: 锐利 off');
|
||||
assert.equal(okOf(inputOf(3, b), 'allybuff_select_13'), false, 'B: 火力 off');
|
||||
assert.equal(okOf(inputOf(3, b), 'allybuff_select_19'), true, 'B: empty ⇒ 锐利 on');
|
||||
// the next prep end re-evaluates: B now holds 3 cards
|
||||
runTo(h, () => m.phase === 'PREP' && m.round === 4);
|
||||
for (const id of ['chess_item_1_01_e_a', 'chess_item_1_02_e_a', 'chess_item_1_05_e_a']) giveItem(m, B, id);
|
||||
runTo(h, () => m.phase === 'ROUND_START' && m.round === 5);
|
||||
assert.equal(okOf(inputOf(4, b), 'allybuff_select_13'), true, 'B R4: 火力 on');
|
||||
assert.equal(okOf(inputOf(4, b), 'allybuff_select_19'), false, 'B R4: 锐利 off');
|
||||
// those inputs, fought in the real sim
|
||||
const real = (input) => {
|
||||
const players = [{ ...input, units: [{ uid: 1, kind: 'chess', chessId: 'test_guard', row: 10, col: 4 }] }];
|
||||
const hb = makeBattle({ defs: { chess: { test_guard: guard() } }, players, autoFinish: false, timeLimit: 10 });
|
||||
hb.step();
|
||||
const u = hb.b.allyUnits.find((x) => x.kind === 'op');
|
||||
return { res: u.s.resIgnoreFlat, def: u.s.defIgnorePct };
|
||||
};
|
||||
assert.deepEqual(real(inputOf(3, a)), { res: EFF('allybuff_select_13').params.magic_resist_penetrate_fixed, def: 0 });
|
||||
assert.deepEqual(real(inputOf(3, b)), { res: 0, def: EFF('allybuff_select_19').params.def_penetrate });
|
||||
matchInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('战术决策 E2E (real battles): 排斥 debuffs every enemy of the picker\'s own field, not the teammate\'s', () => {
|
||||
const id = 'enemydebuff_select_1';
|
||||
const data = dataWith('mode_multi_normal', 3, 'tactic', { tactic: [id, 'allybuff_select_3'] });
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 62, data, registry: REG });
|
||||
const m = h.m;
|
||||
const seen = [];
|
||||
m.BattleClass = class extends Battle {
|
||||
constructor(opts) {
|
||||
const user = opts.setup;
|
||||
super({ ...opts, setup: (b) => {
|
||||
if (typeof user === 'function') user(b);
|
||||
b.on('enemySpawn', ({ enemy }) => seen.push({ round: b.round, owner: enemy.ownerId, debuffed: enemy.buffs.some((x) => String(x.key).startsWith(`choice:${id}#`)), mul: enemy.s.atk / (enemy.base.atk || 1) }), { priority: -100 });
|
||||
} });
|
||||
}
|
||||
};
|
||||
h.start();
|
||||
const sp = toDraft(h, 3);
|
||||
const [a, b] = sp.order;
|
||||
draftPicks(h, { [a]: id, [b]: 'allybuff_select_3' });
|
||||
assert.ok(h.ps(a).effects.some((e) => e.key === `choice:${id}` && e.iconId === 'icon_enemy_debuff'));
|
||||
assert.ok(!h.ps(b).effects.some((e) => e.key === `choice:${id}`), 'personal card');
|
||||
runTo(h, () => m.phase === 'ROUND_START' && m.round === 4);
|
||||
const r3 = seen.filter((s) => s.round === 3);
|
||||
assert.ok(r3.some((s) => s.owner === a) && r3.some((s) => s.owner === b));
|
||||
for (const s of r3) assert.equal(s.debuffed, s.owner === a, `enemy of ${s.owner}`);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('自愈 (real sim): every damage instance an own unit takes heals 50; not HP loss, not under 禁疗, not the partner\'s units', () => {
|
||||
const val = EFF('allybuff_select_11').buffs[1].bb.value;
|
||||
const players = [
|
||||
{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: 'test_guard', row: 10, col: 4 }], bonds: {}, playerEffects: [refOf('allybuff_select_11')] },
|
||||
{ playerId: 'p2', seat: 1, side: 'L', colOffset: 8, units: [{ uid: 2, kind: 'chess', chessId: 'test_guard', row: 10, col: 4 }], bonds: {} },
|
||||
];
|
||||
const h = makeBattle({ kind: 'unite', defs: { chess: { test_guard: guard() } }, players, autoFinish: false, timeLimit: 30 });
|
||||
h.step();
|
||||
const u1 = h.b.allyUnits.find((u) => u.ownerId === 'p1' && u.kind === 'op');
|
||||
const u2 = h.b.allyUnits.find((u) => u.ownerId === 'p2' && u.kind === 'op');
|
||||
const hit = (u, amount, type = 'true') => h.b.dealDamage(null, u, h.b.makeDamage({ amount, type }));
|
||||
hit(u1, 300);
|
||||
assert.equal(u1.hp, 2000 - 300 + val);
|
||||
hit(u1, 100, 'phys');
|
||||
const afterPhys = u1.hp;
|
||||
assert.ok(afterPhys > 2000 - 300 + val - 100, 'healed after a physical hit too');
|
||||
h.b.loseHp(u1, 200);
|
||||
assert.equal(u1.hp, afterPhys - 200, 'HP loss (流失) is not damage');
|
||||
h.b.applyStatus(u1, 'noHeal', { duration: 5, force: true });
|
||||
hit(u1, 100);
|
||||
assert.equal(u1.hp, afterPhys - 300, '禁疗');
|
||||
hit(u2, 300);
|
||||
assert.equal(u2.hp, 1700, 'the partner has no 自愈');
|
||||
// summons are 我方单位 too
|
||||
const tok = h.b.spawnToken(u1, 'token_10011_beewax_oblisk', 11, 4, { stats: { maxHp: 1000 } });
|
||||
assert.ok(tok, 'summon placed');
|
||||
const before = tok.hp;
|
||||
hit(tok, 200);
|
||||
assert.equal(tok.hp, before - 200 + val, 'summon healed');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('无瑕 (real sim): operators at full HP get ATK / DEF ×1.3, lost on damage, back once healed to full', () => {
|
||||
const bb = EFF('allybuff_select_18').buffs[1].bb;
|
||||
const h = makeBattle({ defs: { chess: { test_guard: guard() } }, units: [{ chessId: 'test_guard', row: 10, col: 4 }], playerEffects: [refOf('allybuff_select_18')], autoFinish: false, timeLimit: 30 });
|
||||
h.step();
|
||||
const u = h.unit('test_guard');
|
||||
assert.ok(Math.abs(u.s.atk - 500 * (1 + bb.atk)) < 1e-6, `atk ${u.s.atk}`);
|
||||
assert.ok(Math.abs(u.s.def - 200 * (1 + bb.def)) < 1e-6, `def ${u.s.def}`);
|
||||
h.b.dealDamage(null, u, h.b.makeDamage({ amount: 100, type: 'true' }));
|
||||
assert.equal(u.s.atk, 500, 'not full ⇒ off at once');
|
||||
assert.equal(u.s.def, 200);
|
||||
h.b.heal(null, u, 1000);
|
||||
h.step();
|
||||
assert.ok(Math.abs(u.s.atk - 650) < 1e-6, 'full again');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('征召 (real sim): a row with 3 of the player\'s operators at battle start ⇒ redeploy time ×0.5; otherwise nothing', () => {
|
||||
const scale = EFF('allybuff_select_14').buffs[1].bb.scale;
|
||||
const run = (rows) => {
|
||||
const h = makeBattle({ defs: { chess: { test_guard: guard() } }, units: rows.map(([r, c], i) => ({ uid: i + 1, chessId: 'test_guard', row: r, col: c })),
|
||||
playerEffects: [refOf('allybuff_select_14')], autoFinish: false, timeLimit: 60 });
|
||||
h.step();
|
||||
return h;
|
||||
};
|
||||
const on = run([[10, 3], [10, 4], [10, 5]]);
|
||||
const u = on.b.allyUnits.find((x) => x.kind === 'op');
|
||||
assert.equal(u.s.redeployMul, scale);
|
||||
on.b.dealDamage(null, u, on.b.makeDamage({ amount: 1e6, type: 'true' }));
|
||||
assert.equal(u.alive, false);
|
||||
assert.ok(Math.abs(u.respawnAt - on.b.time - 20 * scale) < 1e-6, 'respawn after 10 s instead of 20 s');
|
||||
checkInvariants(on.b);
|
||||
const off = run([[10, 3], [10, 4], [11, 5]]);
|
||||
for (const x of off.b.allyUnits.filter((y) => y.kind === 'op')) assert.equal(x.s.redeployMul, 1);
|
||||
});
|
||||
|
||||
test('火力 / 锐利 (real sim): the penetration applies only when the prep condition held; it really lowers the target\'s RES / DEF', () => {
|
||||
const enemies = { enemy_t: enemyRec({ key: 'enemy_t', hp: 1e7, def: 500, res: 20, speed: 0 }) };
|
||||
const run = (refs, info = null) => {
|
||||
const players = [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: 'test_guard', row: 10, col: 4 }], bonds: {}, playerEffects: refs, ...(info ? { contentInfo: info } : {}) }];
|
||||
const h = makeBattle({ defs: { chess: { test_guard: guard() }, enemies }, players, autoFinish: false, timeLimit: 30 });
|
||||
h.step();
|
||||
const u = h.b.allyUnits.find((x) => x.kind === 'op');
|
||||
const e = h.spawn('enemy_t', { pos: [10, 8] });
|
||||
const arts = h.b.dealDamage(u, e, h.b.makeDamage({ amount: 1000, type: 'arts' }));
|
||||
const phys = h.b.dealDamage(u, e, h.b.makeDamage({ amount: 1000, type: 'phys' }));
|
||||
return { arts, phys };
|
||||
};
|
||||
const none = run([]);
|
||||
assert.equal(Math.round(none.arts), 800);
|
||||
assert.equal(Math.round(none.phys), 500);
|
||||
const fire = run([refOf('allybuff_select_13', { prepOk: true })]);
|
||||
assert.equal(Math.round(fire.arts), 1000, 'RES 20 ignored');
|
||||
assert.equal(Math.round(run([refOf('allybuff_select_13', { prepOk: false })]).arts), 800, 'condition failed');
|
||||
const sharp = run([refOf('allybuff_select_19', { prepOk: true })]);
|
||||
assert.equal(Math.round(sharp.phys), 1000 - 500 * 0.7, '30 % of DEF ignored');
|
||||
// without the match's flag the battle falls back on contentInfo (hand count at battle start)
|
||||
assert.equal(Math.round(run([refOf('allybuff_select_13')], { handUnits: 3 }).arts), 1000);
|
||||
assert.equal(Math.round(run([refOf('allybuff_select_13')], { handUnits: 2 }).arts), 800);
|
||||
});
|
||||
|
||||
test('排斥 / 责罚 / 裁决 (real sim): all / ELITE / BOSS enemies of the owner\'s half, blackboard numbers', () => {
|
||||
const enemies = {
|
||||
e_n: enemyRec({ key: 'e_n', rank: 'NORMAL', hp: 1e6, atk: 1000, def: 400, res: 20, speed: 0 }),
|
||||
e_e: enemyRec({ key: 'e_e', rank: 'ELITE', hp: 1e6, atk: 1000, def: 400, res: 20, speed: 0 }),
|
||||
e_b: enemyRec({ key: 'e_b', rank: 'BOSS', hp: 1e6, atk: 1000, def: 400, res: 20, speed: 0 }),
|
||||
};
|
||||
for (let k = 1; k <= 9; k++) {
|
||||
const id = `enemydebuff_select_${k}`;
|
||||
const buff = EFF(id).buffs[1];
|
||||
const rank = buff.bbStr.enemy_level_type ?? null;
|
||||
const players = [
|
||||
{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [], bonds: {}, playerEffects: [refOf(id)] },
|
||||
{ playerId: 'p2', seat: 1, side: 'L', colOffset: 8, units: [], bonds: {} },
|
||||
];
|
||||
const h = makeBattle({ kind: 'unite', defs: { enemies }, players, autoFinish: false, timeLimit: 30 });
|
||||
h.step();
|
||||
for (const key of ['e_n', 'e_e', 'e_b']) {
|
||||
for (const owner of ['p1', 'p2']) {
|
||||
const e = h.spawn(key, { pos: [10, owner === 'p1' ? 6 : 14], ownerPlayerId: owner });
|
||||
const hit = owner === 'p1' && (!rank || enemies[key].rank === rank);
|
||||
const exp = { atk: 1000, def: 400, res: 20 };
|
||||
if (hit) {
|
||||
if (buff.key === 'enemy_attribute_mul') exp.atk = 1000 * buff.bb.atk;
|
||||
if (buff.bb.def != null) exp.def = 400 + buff.bb.def;
|
||||
if (buff.bb.magic_resistance != null) exp.res = 20 + buff.bb.magic_resistance;
|
||||
}
|
||||
assert.ok(Math.abs(e.s.atk - exp.atk) < 1e-6 && Math.abs(e.s.def - exp.def) < 1e-6 && Math.abs(e.s.res - exp.res) < 1e-6,
|
||||
`${id} ${key} of ${owner}: ${e.s.atk}/${e.s.def}/${e.s.res} vs ${JSON.stringify(exp)}`);
|
||||
}
|
||||
}
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('模拟战场演变 E2E (act1 m01): the card clears the picker\'s crates for deployment and in battle, never a teammate\'s', () => {
|
||||
const data = dataWith('mode_multi_normal', 3, 'tactic', { tactic: ['map_m01_1', 'map_m02_1', 'allybuff_select_3'] });
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 63, data, registry: REG, fake: true });
|
||||
const m = h.m;
|
||||
h.start();
|
||||
h.setStage('act1autochess_m01');
|
||||
const sp = toDraft(h, 3);
|
||||
assert.ok(!sp.cards.some((c) => c.id === 'map_m02_1'), 'terrain cards only for the match stage');
|
||||
const [a, b] = sp.order;
|
||||
draftPicks(h, { [a]: 'map_m01_1', [b]: 'allybuff_select_3' });
|
||||
const A = h.ps(a);
|
||||
const B = h.ps(b);
|
||||
const aliases = mapAliases(EFF('map_m01_1'));
|
||||
for (const k of Object.keys(aliases)) assert.equal(A.deviceOverrides[k], false, k);
|
||||
assert.equal(Object.keys(B.deviceOverrides).length, 0, 'the teammate keeps its crates');
|
||||
// an own crate tile (#0xx) becomes deployable for A only
|
||||
const crate = m.stage.devices.find((d) => d.alias === 'trap_1105_accrate#002');
|
||||
const [r, c] = crate.pos ?? [crate.row, crate.col];
|
||||
const id = chessOfTier(1).find((x) => DATA.chess[x].position !== 'RANGED') ?? chessOfTier(1)[0];
|
||||
const pa = give(m, A, id);
|
||||
assert.deepEqual(m.handle(a, { t: 'g.move', uid: pa.uid, to: { area: 'board', row: r, col: c } }), { ok: true }, 'deploy where the crate stood');
|
||||
const pb = give(m, B, id);
|
||||
assert.notDeepEqual(m.handle(b, { t: 'g.move', uid: pb.uid, to: { area: 'board', row: r, col: c } }), { ok: true }, 'still a crate for B');
|
||||
runTo(h, () => m.phase === 'ROUND_START' && m.round === 4);
|
||||
const input = FakeBattle.instances.find((f) => f.round === 3 && f.players.includes(a)).opts.players[0];
|
||||
const ref = input.playerEffects.find((e) => e.key === 'choice:map_m01_1');
|
||||
assert.ok(ref && Object.keys(aliases).every((k) => ref.params[k] === 0));
|
||||
// the real sim with only the EffectRef (devices.js reads its params)
|
||||
const hb = makeBattle({ stageId: 'act1autochess_m01', players: [{ ...input, deviceOverrides: undefined, units: [] }], autoFinish: false, timeLimit: 10 });
|
||||
hb.step();
|
||||
assert.equal(hb.b.allyUnits.filter((u) => u.kind === 'device' && u.alive && u.defId === 'trap_1105_accrate').length, 0, 'no crate left on A\'s field');
|
||||
const plain = makeBattle({ stageId: 'act1autochess_m01', autoFinish: false, timeLimit: 10 });
|
||||
plain.step();
|
||||
assert.ok(plain.b.allyUnits.some((u) => u.kind === 'device' && u.alive && u.defId === 'trap_1105_accrate'));
|
||||
matchInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('battle plans come from the blackboards; gates as documented', () => {
|
||||
assert.deepEqual(gateOf(EFF('allybuff_select_13')), { kind: 'benchAtLeast', count: 3 });
|
||||
assert.deepEqual(gateOf(EFF('allybuff_select_19')), { kind: 'benchAtMost', count: 0 });
|
||||
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 });
|
||||
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`);
|
||||
assert.equal(hasBattlePart(EFF('allybuff_select_1')), false);
|
||||
});
|
||||
|
||||
test('regression: card handlers ignore their EffectRef dispatches (a later pick never re-applies an earlier card); re-picks stack', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 71, registry: REG, fake: true }).start();
|
||||
h.toPrep(1, { band: BAND });
|
||||
const m = h.m;
|
||||
const [a, b] = [h.ps('p_0'), h.ps('p_1')];
|
||||
const tactic = (id) => ({ kind: 'tactic', id, name: EFF(id).name, desc: EFF(id).desc, team: !!DATA.choices.cards.tactic.find((c) => c.effectId === id)?.team, family: 'tactic', idx: 0 });
|
||||
const refs = (ps, id) => ps.effects.filter((e) => e.key === `choice:${id}`).length;
|
||||
applyCard(m, a, tactic('allybuff_select_13'));
|
||||
assert.deepEqual([refs(a, 'allybuff_select_13'), refs(b, 'allybuff_select_13')], [1, 1], '火力 (team) once each');
|
||||
const fa = a.funds;
|
||||
applyCard(m, a, tactic('allybuff_select_3')); // 火力's handler sees this pick as an EffectRef dispatch
|
||||
assert.equal(a.funds, fa + 1);
|
||||
assert.deepEqual([refs(a, 'allybuff_select_13'), refs(b, 'allybuff_select_13')], [1, 1], 'not re-applied by another pick');
|
||||
applyCard(m, b, tactic('allybuff_select_13'));
|
||||
assert.deepEqual([refs(a, 'allybuff_select_13'), refs(b, 'allybuff_select_13')], [2, 2], 're-pick stacks (one ref per pick)');
|
||||
const ids = a.effects.filter((e) => e.key === 'choice:allybuff_select_13').map((e) => e.id);
|
||||
assert.equal(new Set(ids).size, 2, 'distinct ref ids ⇒ distinct battle buffs');
|
||||
// bounties / items never reach teammates even if a team flag were set on the card
|
||||
applyCard(m, a, { kind: 'bounty', id: KILL, name: '', desc: '', team: true, family: 'bounty', idx: 0 });
|
||||
assert.equal(a.bounties.length, 1);
|
||||
assert.equal(b.bounties.length, 0);
|
||||
matchInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,265 @@
|
||||
// 4-direction facing in the content (research 09 §6.1 item 3): every "身前 / 身后 / 左右 / 对着" effect follows the unit's
|
||||
// direction `dir` (UP|RIGHT|DOWN|LEFT, server/sim/dir.js) — bonds (阿戈尔 front tile, devour order), items (叙拉古正装
|
||||
// perpendicular pair, 歌利亚头盔 front tile), devices (blower relation by direction equality / opposition: the m01 DOWN
|
||||
// blowers), garrisons (身前 on the prep board and in battle), bands (最右边 = a board position), kits (push / pull /
|
||||
// anchor / device placement directions).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
import { hasGeneratedData } from '../../server/sim/simdata.js';
|
||||
import { airflowRelationOf } from '../../server/sim/content/devices.js';
|
||||
import { frontPiece, behindPiece } from '../../server/sim/content/support/meta.js';
|
||||
|
||||
const REAL = { skip: !hasGeneratedData() };
|
||||
const ds = getDefaultSource();
|
||||
const close = (a, b, msg = '', eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
|
||||
const dummy = (key = 'e_d', o = {}) => enemyRec({ key, hp: 1e8, speed: 0, def: 0, res: 0, ...o });
|
||||
const fill = (u) => u.skill.gainSp(u.skill.spCost * u.skill.maxCharges + 1, 'test');
|
||||
const op = (id, o = {}) => chessRec({
|
||||
id, profession: o.profession ?? 'WARRIOR', bonds: o.bonds ?? [], tier: o.tier ?? 1, skill: null,
|
||||
rangeGrid: o.range ?? [[0, 0], [0, 1]],
|
||||
stats: { maxHp: 10000, atk: 1000, def: 0, res: 0, aspd: 100, bat: 1, blockCnt: 2, respawnTime: 20, ...(o.stats || {}) },
|
||||
});
|
||||
const bondOn = (count, thresholds = [3, 5]) => ({ count, active: count >= thresholds[0], tier: thresholds.filter((t) => count >= t).length, layers: 0 });
|
||||
const tagged = (h, tag) => h.hooksOf('damaged').filter((c) => c.dmg && c.dmg.tags && c.dmg.tags.includes(tag));
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// bonds
|
||||
|
||||
test('阿戈尔 devour: "身前一格" is one step along each member\'s direction (UP devours the operator above)', () => {
|
||||
const chess = { g1_a: op('g1_a', { bonds: ['egirShip'] }), g2_a: op('g2_a', { bonds: ['egirShip'] }), g3_a: op('g3_a', { bonds: ['egirShip'] }), up_a: op('up_a', { tier: 3 }), right_a: op('right_a', { tier: 2 }) };
|
||||
const units = [
|
||||
{ chessId: 'g1_a', row: 10, col: 4, dir: 'UP' }, { chessId: 'up_a', row: 11, col: 4 }, { chessId: 'right_a', row: 10, col: 5 },
|
||||
{ chessId: 'g2_a', row: 9, col: 8 }, { chessId: 'g3_a', row: 12, col: 8 },
|
||||
];
|
||||
const h = makeBattle({ defs: { chess }, units, bonds: { egirShip: bondOn(3) }, hooks: ['damaged'], captureNoisy: true });
|
||||
h.step(1);
|
||||
const dv = tagged(h, 'bond:egir:devour').map((c) => [c.source.defId, c.target.defId]);
|
||||
assert.deepEqual(dv, [['g1_a', 'up_a']], 'the operator above, not the one to the right');
|
||||
close(h.unit('g1_a').s.atk, 1000 + 1000, 'base ATK of the devoured');
|
||||
assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, 3, 'layers = the devoured tier');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('阿戈尔 devour order is a board position (left first) whatever the members face; the mirrored FA side counts from its own board', () => {
|
||||
const chess = { g1_a: op('g1_a', { bonds: ['egirShip'] }), g2_a: op('g2_a', { bonds: ['egirShip'] }), g3_a: op('g3_a', { bonds: ['egirShip'] }), fod_a: op('fod_a') };
|
||||
// g1 at board col 3 facing LEFT (its front: col 2), g2 at col 5 facing RIGHT — g1 is further left, so it goes first
|
||||
const units = [
|
||||
{ uid: 1, kind: 'chess', chessId: 'g1_a', row: 10, col: 3, dir: 'LEFT' }, { uid: 2, kind: 'chess', chessId: 'fod_a', row: 10, col: 2 },
|
||||
{ uid: 3, kind: 'chess', chessId: 'g2_a', row: 10, col: 5, dir: 'RIGHT' }, { uid: 4, kind: 'chess', chessId: 'fod_a', row: 10, col: 6 },
|
||||
{ uid: 5, kind: 'chess', chessId: 'g3_a', row: 12, col: 9 },
|
||||
];
|
||||
for (const side of ['L', 'R']) {
|
||||
const h = makeBattle({
|
||||
kind: side === 'L' ? 'normal' : 'boss', defs: { chess }, hooks: ['damaged'], captureNoisy: true,
|
||||
players: [{ playerId: 'p1', seat: 0, side, colOffset: side === 'L' ? 0 : 8, units: units.map((u) => ({ ...u })), bonds: { egirShip: bondOn(3) } }],
|
||||
});
|
||||
h.step(1);
|
||||
assert.deepEqual(tagged(h, 'bond:egir:devour').map((c) => c.source.uid), [1, 3], `side ${side}: board col 3 before col 5`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// items
|
||||
|
||||
test('叙拉古正装: the two tiles beside the carrier are perpendicular to ITS direction (an UP carrier: left / right columns)', () => {
|
||||
const id = 'chess_item_3_01_e_a';
|
||||
const chess = { t_op: op('t_op'), t_l: op('t_l'), t_r: op('t_r'), t_f: op('t_f'), t_b: op('t_b') };
|
||||
const h = makeBattle({ defs: { chess }, units: [
|
||||
{ chessId: 't_op', row: 10, col: 5, items: [id], dir: 'UP' }, { chessId: 't_l', row: 10, col: 4 }, { chessId: 't_r', row: 10, col: 6 },
|
||||
{ chessId: 't_f', row: 11, col: 5 }, { chessId: 't_b', row: 9, col: 5 },
|
||||
] });
|
||||
h.run(1);
|
||||
const side = h.unit('t_l').s.aspd;
|
||||
assert.ok(side > 100, 'left column');
|
||||
assert.equal(h.unit('t_r').s.aspd, side, 'right column');
|
||||
assert.equal(h.unit('t_f').s.aspd, 100, 'in front (above): no');
|
||||
assert.equal(h.unit('t_b').s.aspd, 100, 'behind (below): no');
|
||||
});
|
||||
|
||||
test('歌利亚头盔: the tile in front follows the carrier direction (DOWN: the tile below)', () => {
|
||||
const id = 'chess_item_3_06_e_a';
|
||||
const chess = { t_op: op('t_op', { stats: { maxHp: 2000 } }), t_f: op('t_f') };
|
||||
const hp = (units) => { const h = makeBattle({ defs: { chess }, units }); h.step(1); return h.unit('t_op').s.maxHp; };
|
||||
const free = hp([{ chessId: 't_op', row: 11, col: 4, items: [id], dir: 'DOWN' }]);
|
||||
const below = hp([{ chessId: 't_op', row: 11, col: 4, items: [id], dir: 'DOWN' }, { chessId: 't_f', row: 10, col: 4 }]);
|
||||
const right = hp([{ chessId: 't_op', row: 11, col: 4, items: [id], dir: 'DOWN' }, { chessId: 't_f', row: 11, col: 5 }]);
|
||||
assert.ok(below < free, 'an operator below a DOWN carrier occupies its front tile');
|
||||
close(right, free, 'the tile to its right is not in front');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// devices
|
||||
|
||||
test('气流 relation by direction: equal / opposite / perpendicular (vertical)', () => {
|
||||
assert.equal(airflowRelationOf('DOWN', 'DOWN'), 'equal');
|
||||
assert.equal(airflowRelationOf('UP', 'DOWN'), 'opposite');
|
||||
assert.equal(airflowRelationOf('RIGHT', 'DOWN'), 'vertical');
|
||||
assert.equal(airflowRelationOf('LEFT', 'RIGHT'), 'opposite');
|
||||
});
|
||||
|
||||
test('气流 (act2 m01 DOWN blowers): an operator facing DOWN gets ATK +equal, UP +opposite, RIGHT nothing', REAL, () => {
|
||||
const stage = ds.getStage('act2autochess_m01');
|
||||
const bb = stage.devices.find((d) => d.role === 'blower').raw.skill.bb;
|
||||
const atkOf = (dir) => {
|
||||
const h = makeBattle({ stageId: 'act2autochess_m01', defs: { chess: { t_g: op('t_g') } }, units: [{ chessId: 't_g', row: 11, col: 5, dir }], autoFinish: false, timeLimit: 10 });
|
||||
h.run(0.5);
|
||||
return h.unit('t_g').s.atk;
|
||||
};
|
||||
close(atkOf('DOWN'), 1000 * (1 + bb['blower_s_character[equal].atk']), 'with the flow');
|
||||
close(atkOf('UP'), 1000 * (1 + bb['blower_s_character[opposite].atk']), 'against the flow');
|
||||
close(atkOf('RIGHT'), 1000, 'across the flow');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// garrisons (prep and battle)
|
||||
|
||||
test('prep "身前一格" (support/meta frontPiece / behindPiece) follows the board piece dir', () => {
|
||||
const board = [
|
||||
{ uid: 1, kind: 'chess', row: 10, col: 5, dir: 'UP' }, { uid: 2, kind: 'chess', row: 11, col: 5 }, { uid: 3, kind: 'chess', row: 10, col: 6 },
|
||||
{ uid: 4, kind: 'chess', row: 9, col: 5 }, { uid: 5, kind: 'chess', row: 12, col: 5 },
|
||||
];
|
||||
const ctx = { board: () => board };
|
||||
assert.equal(frontPiece(ctx, board[0]).uid, 2, 'UP: the piece above');
|
||||
assert.equal(frontPiece(ctx, board[0], 2).uid, 5, 'two tiles in front');
|
||||
assert.equal(behindPiece(ctx, board[0]).uid, 4, 'behind: below');
|
||||
assert.equal(frontPiece(ctx, { ...board[0], dir: undefined }).uid, 3, 'no dir ⇒ RIGHT');
|
||||
assert.equal(frontPiece(ctx, { ...board[0], dir: 'LEFT' }), null);
|
||||
});
|
||||
|
||||
test('魔王 (garrison_59): the operator "in front" is along its direction', () => {
|
||||
const rec = (id, g) => ({ ...chessRec({ id }), garrisonIds: g });
|
||||
const run = (mwDir, frontAt) => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { mw: rec('mw', ['garrison_59_a']), fr: rec('fr', ['garrison_42_a']) }, enemies: { e_d: dummy() } },
|
||||
units: [{ chessId: 'mw', row: 10, col: 4, dir: mwDir }, { chessId: 'fr', row: frontAt[0], col: frontAt[1] }],
|
||||
bonds: { sargonShip: { count: 3, active: true, tier: 1, layers: 0 } }, autoFinish: false, timeLimit: 30,
|
||||
});
|
||||
h.step(1);
|
||||
const u = h.unit('fr');
|
||||
h.b.emit('skillStart', { unit: u, skill: u.skill, reason: 'test' });
|
||||
return h.result().perPlayer.p1.layerGains.sargonShip;
|
||||
};
|
||||
const up = run('UP', [11, 4]);
|
||||
const side = run('UP', [10, 5]);
|
||||
assert.equal(up, side + 1, 'the operator above an UP-facing 魔王 gets the +1; the one to its right does not');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// bands
|
||||
|
||||
test('桑葚 药枚实验 "最右边" is the right-most board column whatever the units face', () => {
|
||||
const chess = { t_a: op('t_a', { range: [[0, 0], [0, 1], [0, -1], [1, 0], [-1, 0]] }), t_b: op('t_b', { range: [[0, 0], [0, 1], [0, -1], [1, 0], [-1, 0]] }) };
|
||||
const h = makeBattle({
|
||||
defs: { chess, enemies: { e_d: dummy() } }, bandId: 'band_mberry', autoFinish: false, timeLimit: 30,
|
||||
units: [{ chessId: 't_a', row: 10, col: 7, dir: 'LEFT' }, { chessId: 't_b', row: 11, col: 5, dir: 'RIGHT' }],
|
||||
enemies: [{ key: 'e_d', pos: [10, 6] }, { key: 'e_d', pos: [11, 6] }],
|
||||
});
|
||||
const orig = h.b.rng;
|
||||
h.b.rng = Object.assign(() => 0, orig);
|
||||
h.run(3.1);
|
||||
const sh = (u) => { const b = u.findBuff('band:band_mberry:shield'); return b ? b.shieldHits : 0; };
|
||||
assert.equal(sh(h.unit('t_a')), 1, 'col 7 (facing LEFT) is the right-most column');
|
||||
assert.equal(sh(h.unit('t_b')), 0, 'col 5 facing RIGHT is not');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// kits
|
||||
|
||||
test('见行者 (3_07) 惊爆射击 pushes along her direction (DOWN: the enemy below is pushed further down)', () => {
|
||||
const id = 'chess_char_3_07_a';
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_a: dummy('enemy_a', { mass: 1 }) } }, timeLimit: 60,
|
||||
units: [{ chessId: id, row: 12, col: 4, dir: 'DOWN' }], enemies: [{ key: 'enemy_a', pos: [11, 4] }],
|
||||
});
|
||||
const u = h.unit(id);
|
||||
h.step();
|
||||
const e = h.enemy('enemy_a');
|
||||
fill(u);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 5), 'skill cast');
|
||||
assert.ok(e.y < 10.6, `pushed down (${e.y})`);
|
||||
close(e.x, 4, 'no sideways push', 1e-3);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('薄绿 (3_08) 聚能涡旋 pulls toward the tile in front along her direction', () => {
|
||||
const id = 'chess_char_3_08_a';
|
||||
const run = (dir) => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d', { mass: 0 }) } }, timeLimit: 60, units: [{ chessId: id, row: 9, col: 4, dir }], enemies: [{ key: 'enemy_d', pos: [11, 4] }] });
|
||||
const u = h.unit(id);
|
||||
h.step();
|
||||
fill(u);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||
h.run(3);
|
||||
return h.enemy('enemy_d');
|
||||
};
|
||||
const up = run('UP');
|
||||
assert.ok(up.y < 11 - 0.2 && Math.abs(up.x - 4) < 0.05, `UP: pulled straight down toward (10,4) (${up.x}, ${up.y})`);
|
||||
const right = run('RIGHT');
|
||||
assert.ok(right.x > 4.2, `RIGHT: pulled toward (9,5) (${right.x}, ${right.y})`);
|
||||
});
|
||||
|
||||
test('乌尔比安 (5_05) S3 throws the anchor straight ahead along his direction (and triggers on the rotated grid)', () => {
|
||||
const id = 'chess_char_5_05_a';
|
||||
const run = (dir) => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 60, hooks: ['statusApplied', 'skillStart'],
|
||||
units: [{ chessId: id, row: 9, col: 4, dir }], enemies: [{ key: 'enemy_d', pos: [11, 4] }] });
|
||||
const u = h.unit(id);
|
||||
h.step();
|
||||
fill(u);
|
||||
h.run(2);
|
||||
return { h, u };
|
||||
};
|
||||
const { h, u } = run('UP');
|
||||
assert.ok(h.hooksOf('skillStart').some((c) => c.unit === u), 'an enemy two tiles above is in the rotated trigger range');
|
||||
const anchor = h.eventsOf('fx').find((e) => e[1] === 'anchor');
|
||||
assert.ok(anchor, 'anchor fx');
|
||||
assert.deepEqual([anchor[2], anchor[3]], [4, 11], 'the anchor stops on the enemy above');
|
||||
assert.ok(h.hooksOf('statusApplied').some((c) => c.status === 'stun' && c.target.defId === 'enemy_d'));
|
||||
assert.deepEqual([u.tileR, u.tileC], [11, 4], 'he lands on the anchor tile');
|
||||
const r = run('RIGHT');
|
||||
assert.ok(!r.h.hooksOf('skillStart').some((c) => c.unit === r.u), 'facing RIGHT: nothing ahead, no cast');
|
||||
});
|
||||
|
||||
test('凯瑟琳 (4_11) places a device behind her operator along ITS direction, facing the operator', () => {
|
||||
const id = 'chess_char_4_11_a', ally = 'chess_char_1_02_a';
|
||||
const place = (dir) => {
|
||||
const h = makeBattle({ units: [{ chessId: id, row: 12, col: 2 }, { chessId: ally, row: 10, col: 5, dir }], timeLimit: 30, hooks: [] });
|
||||
h.run(0.2);
|
||||
const d = h.b.allyUnits.find((x) => x.defId === 'token_10041_cathy_catsld' && x.alive && x.mem.tr === 10 && x.mem.tc === 5);
|
||||
assert.ok(d, `a device serves the ${dir} operator`);
|
||||
return [d.tileR, d.tileC, d.dir];
|
||||
};
|
||||
assert.deepEqual(place('UP'), [9, 5, 'UP'], 'below an UP operator, pointing up at it');
|
||||
assert.deepEqual(place('RIGHT'), [10, 4, 'RIGHT']);
|
||||
assert.deepEqual(place('LEFT'), [10, 6, 'LEFT']);
|
||||
});
|
||||
|
||||
test('余 (6_03) S3 fire wall runs through his tile perpendicular to his direction (UP / DOWN: his row; RIGHT / LEFT: his column)', REAL, () => {
|
||||
const run = (dir, casterAt, enemyAt) => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_cst: op('t_cst', { profession: 'CASTER', range: [[0, 0]] }) }, enemies: { e_d: dummy() } },
|
||||
units: [{ chessId: 'chess_char_6_03_a', row: 10, col: 5, dir }, { chessId: 't_cst', row: casterAt[0], col: casterAt[1] }],
|
||||
enemies: [{ key: 'e_d', pos: enemyAt }], autoFinish: false, timeLimit: 30, hooks: ['damaged'], captureNoisy: true,
|
||||
});
|
||||
h.run(0.2);
|
||||
const yu = h.unit('chess_char_6_03_a'), c = h.unit('t_cst'), e = h.b.enemies[0];
|
||||
assert.ok(e && e.alive && c.deployed && yu.deployed);
|
||||
yu.skill.activate('test', { free: true });
|
||||
assert.ok(yu.skill.active, 'S3 on');
|
||||
const wall = h.eventsOf('fx').find((f) => f[1] === 'firewall');
|
||||
const n = tagged(h, 'firewall').length;
|
||||
h.b.dealDamage(c, e, { amount: 1000, type: 'arts' });
|
||||
return { burned: tagged(h, 'firewall').length > n, axis: wall && wall[4] && wall[4].axis };
|
||||
};
|
||||
// caster at (12, 3), enemy at (9, 3): same column side, opposite sides of row 10
|
||||
assert.equal(run('UP', [12, 3], [9, 3]).burned, true, 'UP: arts damage crossing his row burns');
|
||||
assert.equal(run('DOWN', [12, 3], [9, 3]).axis, 'row');
|
||||
assert.equal(run('RIGHT', [12, 3], [9, 3]).burned, false, 'RIGHT: the wall is his column — nothing crossed');
|
||||
// caster at (12, 3), enemy at (12, 8): opposite sides of column 5, same side of row 10
|
||||
assert.equal(run('RIGHT', [12, 3], [12, 8]).burned, true, 'RIGHT: crossing his column burns (as before)');
|
||||
assert.equal(run('LEFT', [12, 3], [12, 8]).axis, 'col');
|
||||
assert.equal(run('UP', [12, 3], [12, 8]).burned, false, 'UP: not crossing his row');
|
||||
});
|
||||
@@ -0,0 +1,639 @@
|
||||
// IN_BATTLE 特质 (server/sim/content/garrisons/battle.js). Every garrison id carried by a visible chess (and every id
|
||||
// such a garrison grants) is exercised with its own blackboard numbers; one normal and one elite id per key are also
|
||||
// asserted with literal numbers. The last test checks that coverage.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { getData } from '../../server/data.js';
|
||||
import { GRANTED_CAP_OVERRIDE } from '../../server/sim/content/garrisons/battle.js';
|
||||
|
||||
const D = getData({ log: { warn() {}, error() {}, info() {} } });
|
||||
const GR = (gid) => D.garrisons[gid];
|
||||
const ids = (s) => String(s ?? '').split(',').map((x) => x.trim()).filter(Boolean);
|
||||
const num = (v, d = 0) => (Number.isFinite(+v) && v !== '' && v != null ? +v : d);
|
||||
const capOf = (gid) => GRANTED_CAP_OVERRIDE[gid] ?? (num(GR(gid).bb.max_add_count_per_battle) > 0 ? num(GR(gid).bb.max_add_count_per_battle) : Infinity);
|
||||
|
||||
/** IN_BATTLE garrison ids carried by visible chess, and the ids those grant. */
|
||||
const VISIBLE_IDS = new Set();
|
||||
for (const c of Object.values(D.chess)) if (c.visible) for (const g of c.garrisonIds || []) if (GR(g).eventType === 'IN_BATTLE') VISIBLE_IDS.add(g);
|
||||
for (const g of [...VISIBLE_IDS]) if (GR(g).bbStr.give_garrison_id) VISIBLE_IDS.add(GR(g).bbStr.give_garrison_id);
|
||||
const idsOfKey = (key) => [...VISIBLE_IDS].filter((g) => GR(g).effectKey === key).sort();
|
||||
const COVER = new Set();
|
||||
const cover = (gid) => { assert.ok(GR(gid), gid); COVER.add(gid); };
|
||||
|
||||
const rec = (id, garrisonIds, o = {}) => ({ ...chessRec({ id, ...o }), garrisonIds });
|
||||
const B = (layers = 0, active = true) => ({ count: active ? 3 : 0, active, tier: active ? 1 : 0, layers });
|
||||
const dummy = (o = {}) => ({ ...enemyRec({ key: 'e_dummy', hp: 1e7, speed: 0, ...o }), tags: o.tags ?? [] });
|
||||
const gains = (h, pid = 'p1') => h.result().perPlayer[pid].layerGains;
|
||||
const approx = (a, b, msg = '') => assert.ok(Math.abs(a - b) < 1e-6, `${msg} ${a} ≈ ${b}`);
|
||||
|
||||
/** Battle with synthetic operators: units = [{ id, g: garrisonIds, row, col, bonds?, o? }]. */
|
||||
function battle({ units, bonds = {}, enemies = [], edefs = {}, kind, seed = 3, players } = {}) {
|
||||
const chess = {};
|
||||
for (const u of units) chess[u.id] = rec(u.id, u.g ?? [], { bonds: u.bonds ?? [], ...(u.o || {}) });
|
||||
const h = makeBattle({
|
||||
kind, seed, bonds, players,
|
||||
defs: { chess, enemies: { e_dummy: dummy(), ...edefs } },
|
||||
units: units.map((u) => ({ chessId: u.id, row: u.row, col: u.col })),
|
||||
enemies, autoFinish: false, timeLimit: 400,
|
||||
});
|
||||
h.step(1);
|
||||
return h;
|
||||
}
|
||||
const skill = (h, u) => h.b.emit('skillStart', { unit: u, skill: u.skill, reason: 'test' });
|
||||
const alwaysHit = (h) => { h.b.rng = Object.assign(() => 0, h.b.rng); };
|
||||
|
||||
/**
|
||||
* Layer-gain scenario of a garrison: the holder's own bonds, the player's bond states and the expected target bonds.
|
||||
* bond_by_id listed bonds active (0 layers) + an unrelated active bond that must not move
|
||||
* bond_self holder bonds 炎 / 谢拉格 (active) / 阿戈尔 (inactive) → 炎, 谢拉格
|
||||
* bond_actived_maxstack 炎 5, 坚守 9 (active), 阿戈尔 50 (inactive) → 坚守
|
||||
*/
|
||||
function scenario(gid) {
|
||||
const st = GR(gid).bbStr;
|
||||
if (st.bond_type === 'bond_self') return { unitBonds: ['yanShip', 'kjeragShip', 'egirShip'], bonds: { yanShip: B(0), kjeragShip: B(0), egirShip: B(0, false) }, targets: ['yanShip', 'kjeragShip'] };
|
||||
if (st.bond_type === 'bond_actived_maxstack') return { unitBonds: [], bonds: { yanShip: B(5), steadShip: B(9), egirShip: B(50, false) }, targets: ['steadShip'] };
|
||||
const t = ids(st.bond_id);
|
||||
const bonds = Object.fromEntries(t.map((b) => [b, B(0)]));
|
||||
const other = ['suntShip', 'emptyShip'].find((b) => !t.includes(b));
|
||||
bonds[other] = B(0);
|
||||
return { unitBonds: [], bonds, targets: t };
|
||||
}
|
||||
const expectAll = (h, targets, n, msg) => {
|
||||
const g = gains(h);
|
||||
for (const b of targets) assert.equal(g[b] ?? 0, n, `${msg} ${b}`);
|
||||
for (const b of Object.keys(g)) assert.ok(targets.includes(b), `${msg}: unexpected gain on ${b}`);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// layer events
|
||||
|
||||
test('useskill (莎草 42 / 菲莱 43 / 百炼嘉维尔 128 / 塑心 90 / 纯烬艾雅法拉 140): per-id amount per skill and per-battle cap', () => {
|
||||
for (const gid of idsOfKey('act1autochess_gar_event_useskill')) {
|
||||
if (!D.garrisons[gid].owners.some((o) => D.chess[o].visible)) continue; // granted ids: see the ADD_BOND tests
|
||||
const g = GR(gid);
|
||||
const sc = scenario(gid);
|
||||
const row = g.bbStr.bond_add_type === 'by_charcount_samerow';
|
||||
const units = [{ id: 'op', g: [gid], row: 10, col: 4, bonds: sc.unitBonds }];
|
||||
if (row) units.push({ id: 'm1', row: 10, col: 6 }, { id: 'm2', row: 10, col: 8 }, { id: 'o', row: 11, col: 4 });
|
||||
const h = battle({ units, bonds: sc.bonds });
|
||||
const per = row ? 3 * num(g.bb.bond_add_count_multi) : num(g.bb.bond_add_count);
|
||||
skill(h, h.unit('op'));
|
||||
expectAll(h, sc.targets, Math.min(per, capOf(gid)), gid);
|
||||
for (let i = 0; i < 29; i++) skill(h, h.unit('op'));
|
||||
expectAll(h, sc.targets, Math.min(30 * per, capOf(gid)), `${gid} ×30`);
|
||||
cover(gid);
|
||||
}
|
||||
// literal numbers (normal / elite)
|
||||
const one = (gid, n, bonds, units = []) => {
|
||||
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4 }, ...units], bonds });
|
||||
for (let i = 0; i < n; i++) skill(h, h.unit('op'));
|
||||
return gains(h);
|
||||
};
|
||||
assert.deepEqual(one('garrison_43_a', 2, { sargonShip: B(0) }), { sargonShip: 12 }, '菲莱 +6 per skill');
|
||||
assert.deepEqual(one('garrison_43_b', 1, { sargonShip: B(0) }), { sargonShip: 12 }, '菲莱 精锐 +12');
|
||||
assert.deepEqual(one('garrison_42_a', 3, { sargonShip: B(0) }), { sargonShip: 5 }, '莎草: first skill only');
|
||||
assert.deepEqual(one('garrison_42_b', 3, { sargonShip: B(0) }), { sargonShip: 10 });
|
||||
assert.deepEqual(one('garrison_128_a', 1, { sargonShip: B(0) }), { sargonShip: 9 });
|
||||
assert.deepEqual(one('garrison_128_b', 1, { sargonShip: B(0) }), { sargonShip: 18 });
|
||||
assert.deepEqual(one('garrison_140_a', 1, { yanShip: B(5), steadShip: B(9) }), { steadShip: 3 });
|
||||
assert.deepEqual(one('garrison_140_b', 1, { yanShip: B(5), steadShip: B(9) }), { steadShip: 6 });
|
||||
const mates = [{ id: 'm1', row: 10, col: 6 }];
|
||||
assert.deepEqual(one('garrison_90_a', 1, { yanShip: B(1) }, mates), { yanShip: 2 }, '塑心: 2 in the row × 1');
|
||||
assert.deepEqual(one('garrison_90_a', 9, { yanShip: B(1) }, mates), { yanShip: 10 }, 'cap 10');
|
||||
assert.deepEqual(one('garrison_90_b', 9, { yanShip: B(1) }, mates), { yanShip: 20 }, 'elite: × 2, cap 20');
|
||||
assert.deepEqual(one('garrison_43_a', 1, { sargonShip: B(0, false) }), {}, 'inactive bond: nothing');
|
||||
});
|
||||
|
||||
test('selfkillenemy (送葬人 / 休谟斯 / 斯卡蒂 / 海霓 / 伺夜 / 风笛 / 荒芜拉普兰德): every check_cnt kills, per-id caps', () => {
|
||||
for (const gid of idsOfKey('act1autochess_gar_event_selfkillenemy')) {
|
||||
if (!D.garrisons[gid].owners.some((o) => D.chess[o].visible)) continue; // 117_b: granted (ADD_BOND test)
|
||||
const g = GR(gid);
|
||||
const sc = scenario(gid);
|
||||
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4, bonds: sc.unitBonds }], bonds: sc.bonds, enemies: [{ key: 'e_dummy', pos: [9, 9] }] });
|
||||
const e = h.enemies()[0];
|
||||
const per = num(g.bb.bond_add_count), every = Math.max(1, num(g.bb.check_cnt, 1));
|
||||
for (let k = 1; k <= 12; k++) {
|
||||
h.b.emit('kill', { killer: h.unit('op'), victim: e });
|
||||
expectAll(h, sc.targets, Math.min(Math.floor(k / every) * per, capOf(gid)), `${gid} after ${k} kills`);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
const run = (gid, kills, bonds, victim = 'enemy') => {
|
||||
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4 }, { id: 'ally', row: 11, col: 4 }], bonds, enemies: [{ key: 'e_dummy', pos: [9, 9] }] });
|
||||
for (let i = 0; i < kills; i++) h.b.emit('kill', { killer: h.unit('op'), victim: victim === 'ally' ? h.unit('ally') : h.enemies()[0] });
|
||||
return gains(h);
|
||||
};
|
||||
assert.deepEqual(run('garrison_38_a', 5, { egirShip: B(0), steadShip: B(0), raidShip: B(0) }), { egirShip: 2, steadShip: 2, raidShip: 2 }, '斯卡蒂: every 2 kills +1');
|
||||
assert.deepEqual(run('garrison_38_b', 4, { egirShip: B(0), steadShip: B(0), raidShip: B(0) }), { egirShip: 4, steadShip: 4, raidShip: 4 });
|
||||
assert.deepEqual(run('garrison_50_a', 5, { victoriaShip: B(0), visiShip: B(0), indomShip: B(0) }), { victoriaShip: 6, visiShip: 6, indomShip: 6 }, '风笛: first 3 kills');
|
||||
assert.deepEqual(run('garrison_50_b', 5, { victoriaShip: B(0), visiShip: B(0), indomShip: B(0) }), { victoriaShip: 12, visiShip: 12, indomShip: 12 });
|
||||
assert.deepEqual(run('garrison_152_a', 5, { siracusaShip: B(0) }), { siracusaShip: 6 }, '伺夜 叙拉古 +2 × 3');
|
||||
assert.deepEqual(run('garrison_153_b', 5, { miraShip: B(0) }), { miraShip: 6 }, '伺夜 精锐 奇迹 +2 × 3');
|
||||
assert.deepEqual(run('garrison_131_a', 1, { egirShip: B(0), arcaneShip: B(0) }, 'ally'), { egirShip: 3, arcaneShip: 3 }, '海霓: an ally victim counts');
|
||||
assert.deepEqual(run('garrison_138_a', 1, { preciShip: B(0), lateranoShip: B(0) }, 'ally'), {}, '送葬人: allies do not count');
|
||||
assert.deepEqual(run('garrison_138_b', 3, { preciShip: B(0), lateranoShip: B(0) }), { preciShip: 6, lateranoShip: 6 }, 'first kill only');
|
||||
assert.deepEqual(run('garrison_118_a', 60, { siracusaShip: B(0) }), { siracusaShip: 100 }, '荒芜拉普兰德: +2 per kill, cap 100');
|
||||
});
|
||||
|
||||
test('selfdead (幽灵鲨 46 / 砾 75 / 归溟幽灵鲨 40): per knock-out; 归溟幽灵鲨 also on every substitute ⇄ body swap', () => {
|
||||
for (const gid of idsOfKey('act1autochess_gar_event_selfdead')) {
|
||||
const sc = scenario(gid);
|
||||
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4, bonds: sc.unitBonds }], bonds: sc.bonds });
|
||||
h.b.dealDamage(null, h.unit('op'), { amount: 1e9, type: 'true' });
|
||||
h.step(1);
|
||||
expectAll(h, sc.targets, num(GR(gid).bb.bond_add_count), gid);
|
||||
cover(gid);
|
||||
}
|
||||
const ko = (gid, bonds) => {
|
||||
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4 }], bonds });
|
||||
const u = h.unit('op');
|
||||
h.b.retreat(u, { reason: 'test' });
|
||||
assert.deepEqual(gains(h), {}, `${gid}: a retreat is not a knock-out`);
|
||||
h.b.redeploy(u, { free: true });
|
||||
h.step(1);
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
h.step(1);
|
||||
return gains(h);
|
||||
};
|
||||
assert.deepEqual(ko('garrison_46_a', { egirShip: B(0) }), { egirShip: 3 });
|
||||
assert.deepEqual(ko('garrison_46_b', { egirShip: B(0) }), { egirShip: 6 });
|
||||
assert.deepEqual(ko('garrison_75_a', { indomShip: B(0), kazimierzShip: B(0) }), { indomShip: 2 }, '砾 75: 不屈 only (卡西米尔 is garrison_143)');
|
||||
assert.deepEqual(ko('garrison_75_b', { indomShip: B(0) }), { indomShip: 4 });
|
||||
|
||||
const h = battle({ units: [{ id: 'ghost', g: ['garrison_40_b'], row: 11, col: 4, o: { profession: 'SPECIAL', subProfessionId: 'dollkeeper' } }], bonds: { egirShip: B(0), indomShip: B(0) } });
|
||||
const g = h.unit('ghost');
|
||||
h.b.dealDamage(null, g, { amount: 1e9, type: 'true' });
|
||||
h.step(1);
|
||||
assert.ok(g.alive && g.trait.doll, 'substitute');
|
||||
assert.deepEqual(gains(h), { egirShip: 10, indomShip: 10 }, 'body → substitute');
|
||||
h.run(21);
|
||||
assert.ok(g.alive && !g.trait.doll, 'swapped back');
|
||||
assert.deepEqual(gains(h), { egirShip: 20, indomShip: 20 }, 'substitute → body');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('consume_ammo (莫斯提马 22 x-5 / 圣约送葬人 23+55 / 蕾缪安 24 row of 3): per consume_count ammo, per-id caps', () => {
|
||||
for (const gid of idsOfKey('act1autochess_gar_event_consume_ammo')) {
|
||||
const g = GR(gid);
|
||||
const sc = scenario(gid);
|
||||
const x5 = g.bbStr.range_id === 'x-5';
|
||||
const units = [{ id: 'op', g: [gid], row: 10, col: 5, bonds: sc.unitBonds }, { id: 'nb', row: 11, col: 5 }, { id: 'far', row: 12, col: 8 }];
|
||||
if (g.bbStr.conditionkey === 'character_same_row') units.push({ id: 'r1', row: 10, col: 7 }, { id: 'r2', row: 10, col: 9 });
|
||||
const h = battle({ units, bonds: sc.bonds });
|
||||
const shooter = h.unit(x5 ? 'nb' : 'op');
|
||||
const every = num(g.bb.consume_count, 1), per = num(g.bb.bond_add_count);
|
||||
for (let i = 0; i < every * 3; i++) h.b.emit('ammoUsed', { unit: h.unit('far') });
|
||||
expectAll(h, sc.targets, 0, `${gid}: ammo of an operator out of scope`);
|
||||
for (let k = 1; k <= 12; k++) {
|
||||
for (let i = 0; i < every; i++) h.b.emit('ammoUsed', { unit: shooter, left: 0 });
|
||||
expectAll(h, sc.targets, Math.min(k * per, capOf(gid)), `${gid} × ${k}`);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
const exe = (gids) => {
|
||||
const h = battle({ units: [{ id: 'exe', g: gids, row: 10, col: 4 }], bonds: { lateranoShip: B(0), visiShip: B(0) } });
|
||||
const u = h.unit('exe');
|
||||
const out = [];
|
||||
for (const n of [7, 70]) { for (let i = 0; i < n; i++) h.b.emit('ammoUsed', { unit: u, left: 0 }); out.push({ ...gains(h) }); }
|
||||
return out;
|
||||
};
|
||||
assert.deepEqual(exe(['garrison_23_a', 'garrison_55_a']), [{ lateranoShip: 7, visiShip: 3 }, { lateranoShip: 49, visiShip: 21 }], '圣约送葬人: 7 triggers max');
|
||||
assert.deepEqual(exe(['garrison_23_b', 'garrison_55_b']), [{ lateranoShip: 14, visiShip: 6 }, { lateranoShip: 98, visiShip: 42 }]);
|
||||
const rem = (gid, n) => {
|
||||
const h = battle({ units: [{ id: 'rem', g: [gid], row: 10, col: 3 }, ...Array.from({ length: n - 1 }, (_, i) => ({ id: `m${i}`, row: 10, col: 5 + i * 2 }))], bonds: { lateranoShip: B(0), preciShip: B(0) } });
|
||||
for (let i = 0; i < 10; i++) h.b.emit('ammoUsed', { unit: h.unit('rem') });
|
||||
return gains(h);
|
||||
};
|
||||
assert.deepEqual(rem('garrison_24_a', 2), {}, '蕾缪安: 2 in the row');
|
||||
assert.deepEqual(rem('garrison_24_a', 3), { lateranoShip: 2, preciShip: 2 });
|
||||
assert.deepEqual(rem('garrison_24_b', 3), { lateranoShip: 4, preciShip: 4 });
|
||||
const most = (gid) => {
|
||||
const h = battle({ units: [{ id: 'most', g: [gid], row: 10, col: 5 }, { id: 'nb', row: 10, col: 6 }, { id: 'nb2', row: 9, col: 5 }], bonds: { lateranoShip: B(0) } });
|
||||
for (let i = 0; i < 3; i++) h.b.emit('ammoUsed', { unit: h.unit('nb') });
|
||||
for (let i = 0; i < 3; i++) h.b.emit('ammoUsed', { unit: h.unit('nb2') });
|
||||
return gains(h);
|
||||
};
|
||||
assert.deepEqual(most('garrison_22_a'), { lateranoShip: 1 }, '莫斯提马: ammo of the 4 neighbours is pooled');
|
||||
assert.deepEqual(most('garrison_22_b'), { lateranoShip: 2 });
|
||||
});
|
||||
|
||||
test('enemy_abflag_inrange (初雪 / 银灰 28): entering 冻结 in range × prob; a refreshed freeze is not a new entry', () => {
|
||||
for (const gid of idsOfKey('act1autochess_gar_event_enemy_abflag_inrange')) {
|
||||
if (!D.garrisons[gid].owners.some((o) => D.chess[o].visible)) continue; // 29: granted by 凛御银灰 (ADD_BOND test)
|
||||
const h = battle({ units: [{ id: 'sa', g: [gid], row: 10, col: 4 }], bonds: { kjeragShip: B(0) }, enemies: [{ key: 'e_dummy', pos: [10, 5] }, { key: 'e_dummy', pos: [12, 9] }] });
|
||||
alwaysHit(h);
|
||||
const near = h.enemies().find((e) => Math.round(e.y) === 10), far = h.enemies().find((e) => Math.round(e.y) === 12);
|
||||
h.b.applyStatus(far, 'freeze', { duration: 1 });
|
||||
assert.deepEqual(gains(h), {}, `${gid}: out of range`);
|
||||
h.b.applyStatus(near, 'freeze', { duration: 1 });
|
||||
const per = num(GR(gid).bb.bond_add_count);
|
||||
assert.deepEqual(gains(h), { kjeragShip: per }, gid);
|
||||
h.b.applyStatus(near, 'freeze', { duration: 2 });
|
||||
assert.deepEqual(gains(h), { kjeragShip: per }, `${gid}: already frozen`);
|
||||
h.b.removeStatus(near, 'freeze');
|
||||
h.b.applyStatus(near, 'freeze', { duration: 1 });
|
||||
assert.deepEqual(gains(h), { kjeragShip: 2 * per }, `${gid}: frozen again`);
|
||||
cover(gid);
|
||||
}
|
||||
// probability (deterministic battle rng): ≈ 50 % of 40 entries × 2
|
||||
const h = battle({ units: [{ id: 'sa', g: ['garrison_28_b'], row: 10, col: 4 }], bonds: { kjeragShip: B(0) }, enemies: [{ key: 'e_dummy', pos: [10, 5] }], seed: 11 });
|
||||
const e = h.enemies()[0];
|
||||
for (let i = 0; i < 40; i++) { h.b.removeStatus(e, 'freeze'); h.b.applyStatus(e, 'freeze', { duration: 1 }); }
|
||||
const g = gains(h).kjeragShip;
|
||||
assert.ok(g > 10 && g < 70 && g % 2 === 0, `≈ 50 % of 40 × 2, got ${g}`);
|
||||
// cold → cold ⇒ freeze counts as an entry
|
||||
const c = battle({ units: [{ id: 'sa', g: ['garrison_28_a'], row: 10, col: 4 }], bonds: { kjeragShip: B(0) }, enemies: [{ key: 'e_dummy', pos: [10, 5] }] });
|
||||
alwaysHit(c);
|
||||
c.b.applyStatus(c.enemies()[0], 'cold', { duration: 5 });
|
||||
c.b.applyStatus(c.enemies()[0], 'cold', { duration: 5 });
|
||||
assert.deepEqual(gains(c), { kjeragShip: 1 });
|
||||
checkInvariants(c.b);
|
||||
});
|
||||
|
||||
test('onstart (砾 143 / 瑕光 106 / 史尔特尔 107 / 锏 155): every deploy, per-id caps', () => {
|
||||
for (const gid of idsOfKey('act2autochess_gar_event_onstart')) {
|
||||
if (!D.garrisons[gid].owners.some((o) => D.chess[o].visible)) continue; // 108: granted by 远牙 (ADD_BOND test)
|
||||
const sc = scenario(gid);
|
||||
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4, bonds: sc.unitBonds }], bonds: sc.bonds });
|
||||
const per = num(GR(gid).bb.bond_add_count);
|
||||
expectAll(h, sc.targets, Math.min(per, capOf(gid)), `${gid} first deploy`);
|
||||
const u = h.unit('op');
|
||||
for (let k = 2; k <= 9; k++) {
|
||||
h.b.retreat(u, { reason: 'test' });
|
||||
h.b.redeploy(u, { free: true });
|
||||
h.step(1);
|
||||
expectAll(h, sc.targets, Math.min(k * per, capOf(gid)), `${gid} deploy ${k}`);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
const deploys = (gid, n, bonds, unitBonds = []) => {
|
||||
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4, bonds: unitBonds }], bonds });
|
||||
const u = h.unit('op');
|
||||
for (let i = 1; i < n; i++) { h.b.retreat(u, { reason: 'test' }); h.b.redeploy(u, { free: true }); h.step(1); }
|
||||
return gains(h);
|
||||
};
|
||||
assert.deepEqual(deploys('garrison_143_a', 3, { kazimierzShip: B(0) }), { kazimierzShip: 3 });
|
||||
assert.deepEqual(deploys('garrison_143_b', 3, { kazimierzShip: B(0) }), { kazimierzShip: 6 });
|
||||
assert.deepEqual(deploys('garrison_107_a', 9, { raidShip: B(0) }), { raidShip: 50 }, '史尔特尔 8 × 7 capped at 50');
|
||||
assert.deepEqual(deploys('garrison_107_b', 9, { raidShip: B(0) }), { raidShip: 100 });
|
||||
const self = { yanShip: B(0), kjeragShip: B(0, false) };
|
||||
assert.deepEqual(deploys('garrison_106_a', 5, self, ['yanShip', 'kjeragShip']), { yanShip: 12 }, '瑕光: own active bonds, cap 12');
|
||||
assert.deepEqual(deploys('garrison_155_b', 5, self, ['yanShip', 'kjeragShip']), { yanShip: 48 }, '锏 精锐: +16, cap 48');
|
||||
});
|
||||
|
||||
test('allyenemy_sleepstun_inrange (缇缇 125): enemies or operators entering 沉睡 / 晕眩 in range; cap 24 / 48', () => {
|
||||
for (const gid of idsOfKey('act2autochess_gar_event_allyenemy_sleepstun_inrange')) {
|
||||
const per = num(GR(gid).bb.bond_add_count);
|
||||
const h = battle({ units: [{ id: 'tt', g: [gid], row: 10, col: 4 }, { id: 'ally', row: 10, col: 5 }], bonds: { sargonShip: B(0), preciShip: B(0) }, enemies: [{ key: 'e_dummy', pos: [12, 9] }, { key: 'e_dummy', pos: [10, 5] }] });
|
||||
const far = h.enemies().find((e) => Math.round(e.y) === 12), near = h.enemies().find((e) => Math.round(e.y) === 10);
|
||||
h.b.applyStatus(h.unit('ally'), 'sleep', { duration: 1 });
|
||||
expectAll(h, ['sargonShip', 'preciShip'], per, `${gid}: an operator in range fell asleep`);
|
||||
h.b.applyStatus(far, 'stun', { duration: 1 });
|
||||
expectAll(h, ['sargonShip', 'preciShip'], per, `${gid}: enemy out of range`);
|
||||
h.b.applyStatus(near, 'stun', { duration: 5 });
|
||||
h.b.applyStatus(near, 'stun', { duration: 5 });
|
||||
expectAll(h, ['sargonShip', 'preciShip'], 2 * per, `${gid}: a stun refresh is not a new entry`);
|
||||
for (let i = 0; i < 40; i++) { h.b.removeStatus(near, 'stun'); h.b.applyStatus(near, 'stun', { duration: 1 }); }
|
||||
expectAll(h, ['sargonShip', 'preciShip'], capOf(gid), `${gid}: cap`);
|
||||
cover(gid);
|
||||
}
|
||||
assert.equal(capOf('garrison_125_a'), 24);
|
||||
assert.equal(capOf('garrison_125_b'), 48);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// ADD_BOND grants
|
||||
|
||||
test('ADD_BOND 华法琳 72: the front operator gets garrison_95 (own active bonds +1 / +2), cap overridden to 12 / 24', () => {
|
||||
for (const [gid, per, cap] of [['garrison_72_a', 1, 12], ['garrison_72_b', 2, 24]]) {
|
||||
const give = GR(gid).bbStr.give_garrison_id;
|
||||
const h = battle({
|
||||
units: [{ id: 'warfarin', g: [gid], row: 10, col: 4 }, { id: 'front', row: 10, col: 5, bonds: ['yanShip', 'kjeragShip', 'egirShip'] }, { id: 'side', row: 11, col: 4, bonds: ['yanShip'] }],
|
||||
bonds: { yanShip: B(0), kjeragShip: B(0), egirShip: B(0, false) },
|
||||
});
|
||||
const f = h.unit('front');
|
||||
assert.equal(num(GR(give).bb.bond_add_count), per);
|
||||
skill(h, f);
|
||||
assert.deepEqual(gains(h), { yanShip: per, kjeragShip: per });
|
||||
for (let i = 0; i < 30; i++) skill(h, f);
|
||||
assert.deepEqual(gains(h), { yanShip: cap, kjeragShip: cap }, `${gid}: 12/24 override, not the data's 7/14`);
|
||||
skill(h, h.unit('warfarin'));
|
||||
skill(h, h.unit('side'));
|
||||
assert.equal(gains(h).yanShip, cap, '华法琳 herself / other operators do not carry the trait');
|
||||
assert.ok(h.eventsOf('fx').some((e) => e[1] === 'garrisonGrant' && e[4].id === f.id && e[4].key === give), 'grant fx');
|
||||
checkInvariants(h.b);
|
||||
cover(gid); cover(give);
|
||||
}
|
||||
});
|
||||
|
||||
test('ADD_BOND 寒芒克洛丝 73 (front → 精准 +1 / +2, cap 10 / 20) and 凛御银灰 126 (front 【谢拉格】 → 60 % freeze trait)', () => {
|
||||
for (const [gid, per, cap] of [['garrison_73_a', 1, 10], ['garrison_73_b', 2, 20]]) {
|
||||
const give = GR(gid).bbStr.give_garrison_id;
|
||||
const h = battle({ units: [{ id: 'cross', g: [gid], row: 10, col: 4 }, { id: 'front', row: 10, col: 5 }], bonds: { preciShip: B(0) } });
|
||||
skill(h, h.unit('front'));
|
||||
assert.deepEqual(gains(h), { preciShip: per });
|
||||
for (let i = 0; i < 30; i++) skill(h, h.unit('front'));
|
||||
assert.deepEqual(gains(h), { preciShip: cap }, gid);
|
||||
cover(gid); cover(give);
|
||||
}
|
||||
for (const [gid, per] of [['garrison_126_a', 1], ['garrison_126_b', 2]]) {
|
||||
const give = GR(gid).bbStr.give_garrison_id;
|
||||
assert.equal(num(GR(give).bb.prob), 0.6);
|
||||
const h = battle({
|
||||
units: [{ id: 'svash', g: [gid], row: 10, col: 4 }, { id: 'kj', row: 10, col: 5, bonds: ['kjeragShip'] }],
|
||||
bonds: { kjeragShip: B(0) }, enemies: [{ key: 'e_dummy', pos: [10, 6] }],
|
||||
});
|
||||
alwaysHit(h);
|
||||
assert.deepEqual(h.eventsOf('fx').filter((e) => e[1] === 'garrisonGrant').map((e) => e[4].key), [give]);
|
||||
h.b.applyStatus(h.enemies()[0], 'freeze', { duration: 1 });
|
||||
assert.deepEqual(gains(h), { kjeragShip: per }, `${gid}: the granted trait fires (enemy in the front operator's range)`);
|
||||
const other = battle({ units: [{ id: 'svash', g: [gid], row: 10, col: 4 }, { id: 'o', row: 10, col: 5, bonds: ['yanShip'] }], bonds: { kjeragShip: B(0) } });
|
||||
assert.equal(other.eventsOf('fx').filter((e) => e[1] === 'garrisonGrant').length, 0, 'not a 【谢拉格】 operator');
|
||||
cover(gid); cover(give);
|
||||
}
|
||||
});
|
||||
|
||||
test('ADD_BOND 荒芜拉普兰德 118_b: every 【叙拉古】 operator (herself included) gets "击倒敌人时 叙拉古 +2" (garrison_117_b)', () => {
|
||||
const give = GR('garrison_118_b').bbStr.give_garrison_id;
|
||||
const h = battle({
|
||||
units: [{ id: 'lap', g: ['garrison_118_b'], row: 10, col: 4, bonds: ['siracusaShip'] }, { id: 'sira', row: 11, col: 6, bonds: ['siracusaShip'] }, { id: 'other', row: 12, col: 6 }],
|
||||
bonds: { siracusaShip: B(0) }, enemies: [{ key: 'e_dummy', pos: [9, 9] }],
|
||||
});
|
||||
const e = h.enemies()[0];
|
||||
for (const id of ['lap', 'sira', 'other']) h.b.emit('kill', { killer: h.unit(id), victim: e });
|
||||
assert.deepEqual(gains(h), { siracusaShip: 4 });
|
||||
assert.equal(capOf(give), 200);
|
||||
cover('garrison_118_b'); cover(give);
|
||||
});
|
||||
|
||||
test('ADD_BOND 远牙 148: the rightmost operator of the row gets "<部署时>卡西米尔/精准 +4 / +8 (cap 24 / 48)"', () => {
|
||||
for (const [gid, per, cap] of [['garrison_148_a', 4, 24], ['garrison_148_b', 8, 48]]) {
|
||||
const give = GR(gid).bbStr.give_garrison_id;
|
||||
const h = battle({
|
||||
units: [{ id: 'fang', g: [gid], row: 10, col: 3 }, { id: 'mid', row: 10, col: 5 }, { id: 'right', row: 10, col: 8 }, { id: 'other', row: 11, col: 9 }],
|
||||
bonds: { kazimierzShip: B(0), preciShip: B(0) },
|
||||
});
|
||||
assert.deepEqual(gains(h), { kazimierzShip: per, preciShip: per }, 'initial deployment of the rightmost operator');
|
||||
const r = h.unit('right');
|
||||
for (let i = 0; i < 8; i++) { h.b.retreat(r, { reason: 'test' }); h.b.redeploy(r, { free: true }); h.step(1); }
|
||||
assert.deepEqual(gains(h), { kazimierzShip: cap, preciShip: cap });
|
||||
const alone = battle({ units: [{ id: 'fang', g: [gid], row: 10, col: 8 }, { id: 'left', row: 10, col: 4 }], bonds: { kazimierzShip: B(0), preciShip: B(0) } });
|
||||
assert.deepEqual(gains(alone), { kazimierzShip: per, preciShip: per }, '远牙 herself when she is the rightmost');
|
||||
checkInvariants(h.b);
|
||||
cover(gid); cover(give);
|
||||
}
|
||||
});
|
||||
|
||||
test('ADD_BOND 耀骑士临光 145 / 160 (real chess): self + front get 144 (redeploy) and 159 (ASPD) once each', () => {
|
||||
for (const [id, as, rd] of [['chess_char_6_17_a', 0.5, -0.015], ['chess_char_6_17_b', 1, -0.03]]) {
|
||||
const sfx = id.slice(-1);
|
||||
const h = makeBattle({
|
||||
defs: { chess: { front: rec('front', []) } },
|
||||
units: [{ chessId: id, row: 10, col: 4 }, { chessId: 'front', row: 10, col: 5 }],
|
||||
bonds: { kazimierzShip: B(9) }, autoFinish: false,
|
||||
});
|
||||
h.step(1);
|
||||
const f = h.unit('front'), n = h.unit(id);
|
||||
for (const u of [f, n]) {
|
||||
approx(u.findBuff(`gar:garrison_159_${sfx}`).mods.aspd, 3 * as, '9 layers / 3 × ASPD');
|
||||
approx(u.findBuff(`gar:garrison_144_${sfx}`).mods.redeployMul, 1 + 3 * rd, 'redeploy');
|
||||
assert.equal(u.buffs.filter((b) => b.key === `gar:garrison_159_${sfx}`).length, 1);
|
||||
}
|
||||
assert.ok(!f.findBuff(`gar:garrison_144_${sfx}`).mods.aspd, 'ASPD comes from 159 only (no double count)');
|
||||
checkInvariants(h.b);
|
||||
for (const g of D.chess[id].garrisonIds) cover(g);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 魔王
|
||||
|
||||
test('魔王 59: +1 / +2 on trait gains of the operator in front (not counted toward its cap); not on other reasons', () => {
|
||||
for (const [gid, extra] of [['garrison_59_a', 1], ['garrison_59_b', 2]]) {
|
||||
assert.equal(num(GR(gid).bb.extra_cnt), extra);
|
||||
const h = battle({
|
||||
units: [{ id: 'mw', g: [gid], row: 10, col: 4 }, { id: 'front', g: ['garrison_42_a'], row: 10, col: 5 }, { id: 'side', g: ['garrison_43_a'], row: 11, col: 4 }],
|
||||
bonds: { sargonShip: B(0) },
|
||||
});
|
||||
skill(h, h.unit('front'));
|
||||
skill(h, h.unit('front'));
|
||||
assert.equal(gains(h).sargonShip, 5 + extra, 'first skill only (cap 5 not eaten by the extra)');
|
||||
skill(h, h.unit('side'));
|
||||
assert.equal(gains(h).sargonShip, 11 + extra, 'side operator: no extra');
|
||||
h.b.addLayers('p1', 'sargonShip', 3, 'bond', { source: h.unit('front') });
|
||||
assert.equal(gains(h).sargonShip, 14 + extra, 'non-garrison gain: no extra');
|
||||
cover(gid);
|
||||
}
|
||||
});
|
||||
|
||||
test('regression: 魔王 also boosts the "被击倒时" gain of the operator that was knocked out in front of it', () => {
|
||||
const h = battle({ units: [{ id: 'mw', g: ['garrison_59_a'], row: 10, col: 4 }, { id: 'ghost', g: ['garrison_46_a'], row: 10, col: 5 }], bonds: { egirShip: B(0) } });
|
||||
h.b.dealDamage(null, h.unit('ghost'), { amount: 1e9, type: 'true' });
|
||||
h.step(1);
|
||||
assert.deepEqual(gains(h), { egirShip: 3 + 1 });
|
||||
const far = battle({ units: [{ id: 'mw', g: ['garrison_59_a'], row: 10, col: 4 }, { id: 'ghost', g: ['garrison_46_a'], row: 11, col: 5 }], bonds: { egirShip: B(0) } });
|
||||
far.b.dealDamage(null, far.unit('ghost'), { amount: 1e9, type: 'true' });
|
||||
far.step(1);
|
||||
assert.deepEqual(gains(far), { egirShip: 3 });
|
||||
});
|
||||
|
||||
test('no IN_BATTLE gains in 联防 / boss fields', () => {
|
||||
for (const kind of ['unite', 'boss']) {
|
||||
const h = battle({ kind, units: [{ id: 'surt', g: ['garrison_107_a', 'garrison_43_a'], row: 10, col: 4 }], bonds: { raidShip: B(0), sargonShip: B(0) } });
|
||||
skill(h, h.unit('surt'));
|
||||
assert.deepEqual(gains(h), {}, kind);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// stat traits
|
||||
|
||||
test('GAIN_BUFF (哈洛德 / 锡人 / 迷迭香 03, 流星 137) and 古米 08: direct multipliers / SP regen from the blackboard', () => {
|
||||
for (const gid of [...idsOfKey('GAIN_BUFF'), ...idsOfKey('attr_common_global_buff')]) {
|
||||
const bb = GR(gid).bb;
|
||||
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4 }] });
|
||||
const u = h.unit('op');
|
||||
if (GR(gid).effectKey === 'GAIN_BUFF') {
|
||||
approx(u.s.atk, 500 * bb.atk, `${gid} ATK`);
|
||||
approx(u.s.maxHp, 2000 * bb.max_hp, `${gid} HP`);
|
||||
} else {
|
||||
approx(u.s.spRecovery, 1 + bb.sp_recovery_per_sec, `${gid} SP`);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
const stat = (gid) => { const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4 }] }); return h.unit('op').s; };
|
||||
assert.equal(Math.round(stat('garrison_03_a').atk), 600);
|
||||
assert.equal(Math.round(stat('garrison_03_b').maxHp), 2800);
|
||||
assert.equal(Math.round(stat('garrison_137_a').atk), 625);
|
||||
assert.equal(Math.round(stat('garrison_137_b').maxHp), 3000);
|
||||
approx(stat('garrison_08_a').spRecovery, 1.15);
|
||||
approx(stat('garrison_08_b').spRecovery, 1.3);
|
||||
});
|
||||
|
||||
test('attrByBond (26 ids): floor(Σ layers of the listed ACTIVE bonds / divide_num) × per-step values; live recompute', () => {
|
||||
for (const gid of idsOfKey('act1autochess_gar_eff_attrByBond')) {
|
||||
const { bb, bbStr } = GR(gid);
|
||||
const list = ids(bbStr.bond_id);
|
||||
const div = num(bb.divide_num, 1);
|
||||
const L = 2 * div + 1;
|
||||
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4 }], bonds: Object.fromEntries(list.map((b) => [b, B(L)])) });
|
||||
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;
|
||||
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;
|
||||
assert.deepEqual(Object.keys(m).sort(), Object.keys(want).sort(), gid);
|
||||
for (const [key, v] of Object.entries(want)) approx(m[key], v, `${gid} ${key}`);
|
||||
cover(gid);
|
||||
}
|
||||
const h = battle({
|
||||
units: [{ id: 'mz', g: ['garrison_07_a'], row: 9, col: 4 }, { id: 'ur', g: ['garrison_12_b'], row: 9, col: 6 }, { id: 'rs', g: ['garrison_09_b'], row: 12, col: 6 },
|
||||
{ id: 'yx', g: ['garrison_16_a'], row: 11, col: 4 }, { id: 'yx2', g: ['garrison_16_b'], row: 11, col: 6 }, { id: 'sk', g: ['garrison_84_b'], row: 10, col: 4 }],
|
||||
bonds: { egirShip: B(25), yanShip: B(6), kjeragShip: B(30, false), lateranoShip: B(10) },
|
||||
});
|
||||
approx(h.unit('mz').s.atk, 500 * 1.08, '水月: 25 / 3 = 8 × 1 %');
|
||||
approx(h.unit('ur').s.atk, 500 * 1.24, '乌尔比安 精锐: 25 / 2 = 12 × 2 %');
|
||||
approx(h.unit('rs').s.atk, 500 * (1 + 0.02 * 13), '迷迭香 精锐 core bonds: (25 + 6 + 10) / 3 = 13 × 2 %, inactive 谢拉格 ignored');
|
||||
assert.equal(h.unit('yx').findBuff('gar:garrison_16_a').mods.aspd, 2, '隐现: 10 / 5 × 1');
|
||||
assert.equal(h.unit('yx2').findBuff('gar:garrison_16_b').mods.aspd, 4);
|
||||
assert.deepEqual(h.unit('sk').findBuff('gar:garrison_84_b').mods, { hpRegen: 200, spRecoveryFlat: 0.6 }, '浊心斯卡蒂: 2 steps × (100 HP/s, 0.3 SP/s)');
|
||||
h.b.addLayers('p1', 'egirShip', 2, 'test');
|
||||
h.step(2);
|
||||
approx(h.unit('mz').s.atk, 500 * 1.09, 'recomputed after a layer gain');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('respawnTimeByBond 144, attrByBond_add_onstart 115 (玛恩纳, 100 s after each deploy), ab_damageScaleByBond 101 (仇白)', () => {
|
||||
for (const gid of idsOfKey('act1autochess_gar_eff_respawnTimeByBond')) {
|
||||
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4 }], bonds: { kazimierzShip: B(10) } });
|
||||
approx(h.unit('op').findBuff(`gar:${gid}`).mods.redeployMul, 1 + 3 * GR(gid).bb.respawn_time, gid);
|
||||
cover(gid);
|
||||
}
|
||||
for (const gid of idsOfKey('act2autochess_gar_eff_attrByBond_add_onstart')) {
|
||||
const bb = GR(gid).bb;
|
||||
const h = battle({ units: [{ id: 'mn', g: [gid], row: 9, col: 4 }], bonds: { kazimierzShip: B(12) } });
|
||||
const mn = h.unit('mn');
|
||||
assert.deepEqual(mn.findBuff(`gar:${gid}`).mods, { atkFlat: 2 * bb.atk, hpFlat: 2 * bb.max_hp }, `${gid}: 12 / 5 = 2 steps`);
|
||||
h.b.addLayers('p1', 'kazimierzShip', 3, 'test');
|
||||
h.step(2);
|
||||
assert.deepEqual(mn.findBuff(`gar:${gid}`).mods, { atkFlat: 3 * bb.atk, hpFlat: 3 * bb.max_hp }, 'live');
|
||||
const atk = mn.s.atk;
|
||||
h.run(bb.duration + 1);
|
||||
assert.equal(mn.findBuff(`gar:${gid}`), null, `expired after ${bb.duration} s`);
|
||||
assert.ok(mn.s.atk < atk);
|
||||
h.b.retreat(mn, { reason: 'test' });
|
||||
h.b.redeploy(mn, { free: true });
|
||||
h.step(1);
|
||||
assert.ok(mn.findBuff(`gar:${gid}`), 'again after a redeploy');
|
||||
cover(gid);
|
||||
}
|
||||
const mn = battle({ units: [{ id: 'mn', g: ['garrison_115_a'], row: 9, col: 4 }], bonds: { kazimierzShip: B(10) } }).unit('mn');
|
||||
assert.deepEqual(mn.findBuff('gar:garrison_115_a').mods, { atkFlat: 20, hpFlat: 100 });
|
||||
approx(mn.s.atk, 520, '基础攻击力 +20');
|
||||
|
||||
for (const [gid, per] of [['garrison_101_a', 0.01], ['garrison_101_b', 0.02]]) {
|
||||
const h = battle({ units: [{ id: 'qb', g: [gid], row: 12, col: 4 }], enemies: [{ key: 'e_dummy', pos: [12, 9] }], bonds: { yanShip: B(6), raidShip: B(3) } });
|
||||
const t = h.enemies()[0];
|
||||
const lost = () => { const hp = t.hp; h.b.dealDamage(h.unit('qb'), t, { amount: 1000, type: 'true' }); return Math.round(hp - t.hp); };
|
||||
assert.equal(lost(), 1000);
|
||||
h.b.applyStatus(t, 'bind', { duration: 5 });
|
||||
assert.equal(lost(), Math.round(1000 * (1 + 3 * per)), `${gid}: (6 + 3) / 3 × ${per}`);
|
||||
h.b.removeStatus(t, 'bind');
|
||||
h.b.applyStatus(t, 'sluggish', { duration: 5 });
|
||||
assert.equal(lost(), Math.round(1000 * (1 + 3 * per)));
|
||||
cover(gid);
|
||||
}
|
||||
});
|
||||
|
||||
test('attack_enemy (深巡 18 海怪, 跃跃 19 无人机): ATK ×1.5 / ×2 against tagged enemies only', () => {
|
||||
for (const gid of idsOfKey('act1autochess_gar_eff_attack_enemy')) {
|
||||
const { bb, bbStr } = GR(gid);
|
||||
const h = battle({
|
||||
units: [{ id: 'op', g: [gid], row: 10, col: 4 }],
|
||||
edefs: { e_tag: dummy({ key: 'e_tag', tags: [bbStr.check_tag] }) },
|
||||
enemies: [{ key: 'e_tag', pos: [9, 9] }, { key: 'e_dummy', pos: [12, 9] }],
|
||||
});
|
||||
const tag = h.enemies().find((e) => e.defId.endsWith('e_tag')), plain = h.enemies().find((e) => e.defId.endsWith('e_dummy'));
|
||||
const lost = (e) => { const hp = e.hp; h.b.dealDamage(h.unit('op'), e, { amount: 100, type: 'true' }); return Math.round(hp - e.hp); };
|
||||
assert.equal(lost(tag), Math.round(100 * bb.atk), gid);
|
||||
assert.equal(lost(plain), 100);
|
||||
cover(gid);
|
||||
}
|
||||
assert.equal(GR('garrison_18_a').bb.atk, 1.5);
|
||||
assert.equal(GR('garrison_19_b').bb.atk, 2);
|
||||
assert.deepEqual([GR('garrison_18_b').bbStr.check_tag, GR('garrison_19_a').bbStr.check_tag], ['seamonster', 'drone']);
|
||||
assert.ok(Object.values(D.enemies).some((e) => (e.tags || []).includes('seamonster')) && Object.values(D.enemies).some((e) => (e.tags || []).includes('drone')));
|
||||
});
|
||||
|
||||
test('弱点伤害 (宴 / 流星 / 伺夜 garrison_01): phys / arts become whichever deals more; ties keep the type', () => {
|
||||
for (const gid of idsOfKey('act1autochess_gar_eff_chaos')) {
|
||||
const h = battle({
|
||||
units: [{ id: 'op', g: [gid], row: 11, col: 4 }],
|
||||
edefs: { e_def: dummy({ key: 'e_def', def: 1000 }), e_res: dummy({ key: 'e_res', res: 90 }) },
|
||||
enemies: [{ key: 'e_def', pos: [10, 9] }, { key: 'e_res', pos: [11, 9] }, { key: 'e_dummy', pos: [12, 9] }],
|
||||
});
|
||||
const E = (key) => h.enemies().find((e) => e.defId.endsWith(key));
|
||||
const lost = (e, dmg) => { const hp = e.hp; h.b.dealDamage(h.unit('op'), e, dmg); return Math.round(hp - e.hp); };
|
||||
assert.equal(lost(E('e_def'), { amount: 500, type: 'phys' }), 500, `${gid}: DEF 1000 ⇒ arts`);
|
||||
assert.equal(lost(E('e_res'), { amount: 500, type: 'arts' }), 500, `${gid}: RES 90 ⇒ phys`);
|
||||
assert.equal(lost(E('e_def'), { amount: 500, type: 'true' }), 500, 'true damage untouched');
|
||||
cover(gid);
|
||||
}
|
||||
});
|
||||
|
||||
test('regression: 弱点伤害 keeps the type on a tie, counts the attacker\'s DEF ignore, and runs after ATK multipliers', () => {
|
||||
const h = battle({
|
||||
units: [{ id: 'op', g: ['garrison_01_a'], row: 11, col: 4 }, { id: 'sea', g: ['garrison_01_b', 'garrison_18_b'], row: 12, col: 4 }],
|
||||
edefs: { e_mix: dummy({ key: 'e_mix', def: 300, res: 50 }), e_sea: dummy({ key: 'e_sea', def: 150, res: 50, tags: ['seamonster'] }) },
|
||||
enemies: [{ key: 'e_dummy', pos: [10, 9] }, { key: 'e_mix', pos: [11, 9] }, { key: 'e_sea', pos: [12, 9] }],
|
||||
});
|
||||
const types = [];
|
||||
h.b.on('damaged', (c) => { if (c.dmg) types.push(c.dmg.type); });
|
||||
const zero = h.enemies().find((e) => e.defId.endsWith('e_dummy'));
|
||||
h.b.dealDamage(h.unit('op'), zero, { amount: 500, type: 'arts' });
|
||||
h.b.dealDamage(h.unit('op'), zero, { amount: 500, type: 'phys' });
|
||||
assert.deepEqual(types.splice(0), ['arts', 'phys'], 'DEF 0 / RES 0: a tie keeps the original type');
|
||||
const mix = h.enemies().find((e) => e.defId.endsWith('e_mix'));
|
||||
h.b.dealDamage(h.unit('op'), mix, { amount: 500, type: 'phys' });
|
||||
assert.deepEqual(types.splice(0), ['arts'], '500 − 300 = 200 < 250 ⇒ arts');
|
||||
h.unit('op').s.defIgnoreFlat = 200;
|
||||
h.b.dealDamage(h.unit('op'), mix, { amount: 500, type: 'arts' });
|
||||
assert.deepEqual(types.splice(0), ['phys'], 'with 200 DEF ignore: 400 > 250 ⇒ phys');
|
||||
const sea = h.enemies().find((e) => e.defId.endsWith('e_sea'));
|
||||
h.b.dealDamage(h.unit('sea'), sea, { amount: 200, type: 'arts' });
|
||||
assert.deepEqual(types.splice(0), ['phys'], 'decided on the multiplied amount: 400 − 150 = 250 > 200 ⇒ phys (on 200: 100 = 100 → arts)');
|
||||
});
|
||||
|
||||
test('风丸 93 (NONE): no battle effect (the 2-copy merge is a prep rule, see garrisons_meta)', () => {
|
||||
for (const gid of idsOfKey('NONE')) {
|
||||
const h = battle({ units: [{ id: 'op', g: [gid], row: 10, col: 4 }] });
|
||||
assert.equal(h.unit('op').buffs.filter((b) => String(b.key).startsWith('gar:')).length, 0);
|
||||
assert.deepEqual(h.b.errors, []);
|
||||
cover(gid);
|
||||
}
|
||||
});
|
||||
|
||||
test('real data: every owned IN_BATTLE garrison installs without errors on its real chess', () => {
|
||||
const owners = new Set();
|
||||
for (const g of Object.values(D.garrisons)) if (g.eventType === 'IN_BATTLE') for (const o of g.owners) owners.add(o);
|
||||
const list = [...owners].sort();
|
||||
for (let i = 0; i < list.length; i += 8) {
|
||||
const units = list.slice(i, i + 8).map((id, k) => ({ chessId: id, row: 9 + (k % 4), col: 3 + 2 * Math.floor(k / 4) }));
|
||||
const bonds = Object.fromEntries(Object.keys(D.bonds).map((b) => [b, B(12)]));
|
||||
const h = makeBattle({ units, bonds, enemies: [{ key: 'enemy_1007_slime', time: 0, route: 0, count: 6, interval: 1 }], seed: i + 1, timeLimit: 40 });
|
||||
h.runToEnd(60);
|
||||
assert.deepEqual(h.b.errors.filter((e) => /garrison|content:garrisons/.test(JSON.stringify(e))), [], `batch ${i}`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('coverage: every IN_BATTLE garrison id of a visible chess (and every id they grant) was exercised above', () => {
|
||||
const missing = [...VISIBLE_IDS].filter((g) => !COVER.has(g)).sort();
|
||||
assert.deepEqual(missing, []);
|
||||
assert.ok(VISIBLE_IDS.size >= 100, `${VISIBLE_IDS.size} ids`);
|
||||
});
|
||||
@@ -0,0 +1,830 @@
|
||||
// Prep-side 特质 (server/sim/content/garrisons/meta.js) through the real registry and dispatcher. Every SERVER_* garrison
|
||||
// id carried by a chess (visible ones required, hidden ones where they share a key) is exercised with its own blackboard
|
||||
// numbers; one normal and one elite id per key are also asserted with literal numbers. The last test checks coverage.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeMatch, give, giveItem, DATA } from '../match/harness.js';
|
||||
import { createRegistry } from '../../server/match/effectsMeta.js';
|
||||
import { triggerGainEffects } from '../../server/sim/content/garrisons.js';
|
||||
|
||||
const QUIET = { warn() {}, error() {}, info() {} };
|
||||
const REG = createRegistry({ log: QUIET });
|
||||
const GR = (gid) => DATA.garrisons[gid];
|
||||
const CH = (id) => DATA.chess[id];
|
||||
const ids = (s) => String(s ?? '').split(',').map((x) => x.trim()).filter(Boolean);
|
||||
|
||||
/** SERVER_* garrison ids carried by visible chess (the coverage target). */
|
||||
const VISIBLE_IDS = new Set();
|
||||
for (const c of Object.values(DATA.chess)) if (c.visible) for (const g of c.garrisonIds || []) if (GR(g).eventType !== 'IN_BATTLE') VISIBLE_IDS.add(g);
|
||||
const COVER = new Set();
|
||||
const cover = (gid) => { assert.ok(GR(gid), gid); COVER.add(gid); };
|
||||
/** Ids of an eventType|effectKey owned by any chess, with their owner chess ids (visible first). */
|
||||
function idsOf(eventType, key) {
|
||||
return Object.values(DATA.garrisons).filter((g) => g.eventType === eventType && g.effectKey === key && g.owners.length)
|
||||
.map((g) => ({ gid: g.garrisonId, g, owners: g.owners.slice().sort((a, b) => (CH(b).visible - CH(a).visible) || a.localeCompare(b)) }))
|
||||
.sort((a, b) => a.gid.localeCompare(b.gid));
|
||||
}
|
||||
/** Visible normal chess without any prep-side (SERVER_*) 特质. */
|
||||
const prepFree = (c) => (c.garrisonIds || []).every((g) => GR(g).eventType === 'IN_BATTLE');
|
||||
const plain = (pred = () => true) => Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && prepFree(c) && pred(c)).map((c) => c.chessId).sort();
|
||||
/** Visible normal chess without a 休整期结束时 特质 (fillers for prep-end tests). */
|
||||
const noPrepFin = (pred = () => true) => Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && (c.garrisonIds || []).every((g) => GR(g).eventType !== 'SERVER_PREP_FIN') && pred(c)).map((c) => c.chessId).sort();
|
||||
|
||||
function setup(seed = 70) {
|
||||
const h = makeMatch({ mode: 'solo', seed, registry: REG, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
const ps = h.ps('p_0');
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
ps.temp.fill(null);
|
||||
ps.offers.length = 0;
|
||||
ps.funds = 50;
|
||||
ps.layers = {};
|
||||
ps.pendingFunds = 0;
|
||||
ps.shop.freeRefreshes = 0;
|
||||
ps.bandId = null; // isolate from the band's own prep effects
|
||||
ps.recompute();
|
||||
const gains = {};
|
||||
const addLayers = ps.addLayers.bind(ps);
|
||||
ps.addLayers = (b, n, o = {}) => {
|
||||
const a = addLayers(b, n, o);
|
||||
if (/^(SERVER_|garrison)/.test(String(o.reason || ''))) gains[b] = (gains[b] || 0) + a;
|
||||
return a;
|
||||
};
|
||||
const G = (b) => gains[b] || 0;
|
||||
/** activate bonds through a member-count bonus (独行 is a "downward" bond: never activated here) */
|
||||
const activate = (...bonds) => { for (const b of bonds) if (b !== 'soloShip') ps.bondCountBonus[b] = 20; ps.recompute(); };
|
||||
const active = (b) => !!(ps.bonds[b] && ps.bonds[b].active);
|
||||
const L = (b) => ps.layers[b] || 0;
|
||||
const prepEnd = () => m.dispatch(ps, 'onPrepEnd', { round: m.round });
|
||||
const roundStart = () => m.dispatch(ps, 'onRoundStart', { round: m.round });
|
||||
const acquire = (id) => ps.acquireChess(id, { source: 'test' });
|
||||
const sell = (p) => assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: p.uid }), { ok: true });
|
||||
const gainsNow = () => Object.fromEntries(Object.entries(gains).filter(([, v]) => v > 0));
|
||||
return { h, m, ps, activate, active, L, G, gainsNow, prepEnd, roundStart, acquire, sell };
|
||||
}
|
||||
const handItems = (ps) => [...ps.hand, ...ps.temp].filter((p) => p && p.kind === 'item').map((p) => p.id);
|
||||
const handChess = (ps) => [...ps.hand, ...ps.temp].filter((p) => p && p.kind === 'chess').map((p) => p.id);
|
||||
/** Normal-item equivalents of `itemId` held (a golden item counts as its merge count). */
|
||||
function itemUnits(ps, itemId) {
|
||||
const n = DATA.items[itemId];
|
||||
let u = 0;
|
||||
for (const id of handItems(ps)) {
|
||||
if (id === itemId) u++;
|
||||
else if (n && id === n.goldenId) u += n.upgradeNum || 2;
|
||||
}
|
||||
return u;
|
||||
}
|
||||
const onlyGains = (s, want, msg) => {
|
||||
const got = Object.fromEntries(Object.entries(s.gainsNow()).filter(([, v]) => v > 0));
|
||||
assert.deepEqual(got, Object.fromEntries(Object.entries(want).filter(([, v]) => v > 0)), msg);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 获得时 layer sources
|
||||
|
||||
test('获得时 自身所属盟约 +N without activation (角峰 / 艾丝黛尔 / 深靛 / 野鬃 25, 雪猎 / 灵知 111, 忍冬 71, 星熊 / 锏 65, 崖心 41, 协律 130)', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_GAIN', 'SERVER_ADD_BOND_CHESS_ALL')) {
|
||||
for (const owner of owners) {
|
||||
const s = setup();
|
||||
s.acquire(owner);
|
||||
onlyGains(s, Object.fromEntries(CH(owner).bonds.map((b) => [b, g.bb.count])), `${gid} ${owner}`);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
const s = setup();
|
||||
s.acquire('chess_char_1_02_a');
|
||||
s.acquire('chess_char_4_17_b');
|
||||
const want = {};
|
||||
for (const b of CH('chess_char_1_02_a').bonds) want[b] = (want[b] || 0) + 2;
|
||||
for (const b of CH('chess_char_4_17_b').bonds) want[b] = (want[b] || 0) + 16;
|
||||
onlyGains(s, want, '角峰 +2, 星熊 精锐 +16');
|
||||
});
|
||||
|
||||
test('投资人: 获得时 traits run ×2 while active, ×3 at ≥ 100 layers (dispatcher; handlers add once per call)', () => {
|
||||
const s = setup();
|
||||
s.activate('investShip');
|
||||
s.acquire('chess_char_3_18_b'); // 忍冬 精锐 +12
|
||||
onlyGains(s, Object.fromEntries(CH('chess_char_3_18_b').bonds.map((b) => [b, 24])), '×2');
|
||||
const t = setup();
|
||||
t.activate('investShip');
|
||||
t.ps.layers.investShip = 100;
|
||||
t.ps.recompute();
|
||||
t.acquire('chess_char_1_03_a'); // 惊蛰: shop level × 1
|
||||
assert.equal(t.G('yanShip'), 3 * t.ps.shop.level, '×3');
|
||||
});
|
||||
|
||||
test('SERVER_ADD_BOND 获得时: 烛煌 35 炎/维多利亚 +5 / +10; 录武官 (hidden 4_15) 156 + 157 grant 奇迹 once', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_GAIN', 'SERVER_ADD_BOND')) {
|
||||
for (const owner of owners) {
|
||||
const s = setup();
|
||||
s.acquire(owner);
|
||||
if (gid.startsWith('garrison_157')) {
|
||||
const m = GR(gid.replace('157', '156'));
|
||||
const [yan, mira] = ids(m.bbStr.count).map(Number);
|
||||
onlyGains(s, { yanShip: yan, miraShip: mira }, `${gid}: the shared text grants 奇迹 once`);
|
||||
} else {
|
||||
onlyGains(s, Object.fromEntries(ids(g.bbStr.bond).map((b) => [b, g.bb.count])), `${gid} ${owner}`);
|
||||
}
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
const s = setup();
|
||||
s.acquire('chess_char_5_03_a');
|
||||
s.acquire('chess_char_5_03_b');
|
||||
onlyGains(s, { yanShip: 15, victoriaShip: 15 });
|
||||
});
|
||||
|
||||
test('SERVER_ADD_MULTIPLE_BOND 录武官 156: 炎 +6 / 奇迹 +3 (精锐 12 / 6), no activation needed', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_GAIN', 'SERVER_ADD_MULTIPLE_BOND')) {
|
||||
for (const owner of owners.filter((o) => CH(o).visible)) {
|
||||
const s = setup();
|
||||
s.acquire(owner);
|
||||
const counts = ids(g.bbStr.count).map(Number);
|
||||
onlyGains(s, Object.fromEntries(ids(g.bbStr.bond).map((b, i) => [b, counts[i]])), `${gid} ${owner}`);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
const s = setup();
|
||||
s.acquire('chess_char_5_23_b');
|
||||
onlyGains(s, { yanShip: 12, miraShip: 6 });
|
||||
});
|
||||
|
||||
test('SERVER_ADD_BOND_METHOD shoplv (惊蛰 30): 调度中心等级 × multi, no activation needed', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_GAIN', 'SERVER_ADD_BOND_METHOD')) {
|
||||
for (const owner of owners) {
|
||||
const s = setup();
|
||||
s.ps.shop.level = 4;
|
||||
s.acquire(owner);
|
||||
onlyGains(s, { [g.bbStr.bond]: 4 * g.bb.multi }, gid);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
const s = setup();
|
||||
s.ps.shop.level = 3;
|
||||
s.acquire('chess_char_1_03_a');
|
||||
s.acquire('chess_char_1_03_b');
|
||||
assert.equal(s.G('yanShip'), 3 + 6);
|
||||
});
|
||||
|
||||
test('SERVER_ADD_BOND_ACTIVATED_MOST_LAYER: 灰毫 105 获得时; 波登可 102 / 调香师 81 at prep end, also from the hand', () => {
|
||||
const prime = (s) => { s.activate('yanShip', 'egirShip'); s.ps.layers = { yanShip: 3, egirShip: 7, kjeragShip: 50 }; s.ps.recompute(); };
|
||||
for (const { gid, g, owners } of idsOf('SERVER_GAIN', 'SERVER_ADD_BOND_ACTIVATED_MOST_LAYER')) {
|
||||
for (const owner of owners) {
|
||||
const s = setup();
|
||||
prime(s);
|
||||
s.acquire(owner);
|
||||
onlyGains(s, { egirShip: g.bb.count }, `${gid}: the ACTIVE bond with most layers`);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
for (const { gid, g, owners } of idsOf('SERVER_PREP_FIN', 'SERVER_ADD_BOND_ACTIVATED_MOST_LAYER')) {
|
||||
for (const owner of owners) {
|
||||
for (const where of ['hand', 'board']) {
|
||||
const s = setup();
|
||||
give(s.m, s.ps, owner, where, where === 'board' ? [10, 4] : null);
|
||||
prime(s);
|
||||
s.prepEnd();
|
||||
onlyGains(s, { egirShip: g.bb.count }, `${gid} ${where}`);
|
||||
}
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
const s = setup();
|
||||
s.acquire('chess_char_2_18_b');
|
||||
assert.deepEqual(s.gainsNow(), {}, 'no active bond: nothing');
|
||||
prime(s);
|
||||
give(s.m, s.ps, 'chess_char_1_13_b', 'hand');
|
||||
give(s.m, s.ps, 'chess_char_2_14_a', 'hand');
|
||||
s.prepEnd();
|
||||
onlyGains(s, { egirShip: 2 + 2 }, '波登可 精锐 +2, 调香师 +2');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 进入休整期时 / 休整期结束时 layer sources
|
||||
|
||||
test('SERVER_ADD_BOND at prep start / end (寒檀 141 / 142, 刺玫 154, 雷蛇 158, 折桠 69): active bond, board only', () => {
|
||||
for (const [ev, run] of [['SERVER_PREP_FIN', 'prepEnd'], ['SERVER_PREP_START', 'roundStart']]) {
|
||||
for (const { gid, g, owners } of idsOf(ev, 'SERVER_ADD_BOND')) {
|
||||
for (const owner of owners) {
|
||||
const bonds = ids(g.bbStr.bond);
|
||||
const s = setup();
|
||||
give(s.m, s.ps, owner, 'board', [10, 4]);
|
||||
s[run]();
|
||||
assert.deepEqual(s.gainsNow(), {}, `${gid}: inactive`);
|
||||
s.activate(...bonds);
|
||||
s[run]();
|
||||
onlyGains(s, Object.fromEntries(bonds.map((b) => [b, g.bb.count])), `${gid} ${owner}`);
|
||||
const t = setup();
|
||||
give(t.m, t.ps, owner, 'hand');
|
||||
t.activate(...bonds);
|
||||
t[run]();
|
||||
assert.deepEqual(t.gainsNow(), {}, `${gid}: character_target_inboard`);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
}
|
||||
const w = setup();
|
||||
give(w.m, w.ps, 'chess_char_5_21_b', 'board', [10, 4]); // 寒檀 精锐: +8 at round start and +8 at prep end
|
||||
w.activate('visiShip');
|
||||
w.roundStart();
|
||||
w.prepEnd();
|
||||
assert.equal(w.G('visiShip'), 16);
|
||||
const v = setup();
|
||||
give(v.m, v.ps, 'chess_char_1_06_a', 'board', [10, 4]); // 刺玫 +1
|
||||
give(v.m, v.ps, 'chess_char_1_20_b', 'board', [11, 4]); // 雷蛇 精锐 +2
|
||||
v.activate('victoriaShip', 'indomShip');
|
||||
v.prepEnd();
|
||||
assert.deepEqual([v.G('victoriaShip'), v.G('indomShip')], [1, 2]);
|
||||
});
|
||||
|
||||
test('SERVER_ADD_BOND_CHESS_ALL at prep start / end (赫默 / 蒂比 80, 伊内丝 104, 莱恩哈特 51): own ACTIVE bonds, board only', () => {
|
||||
for (const [ev, run] of [['SERVER_PREP_FIN', 'prepEnd'], ['SERVER_PREP_START', 'roundStart']]) {
|
||||
for (const { gid, g, owners } of idsOf(ev, 'SERVER_ADD_BOND_CHESS_ALL')) {
|
||||
for (const owner of owners) {
|
||||
const bonds = CH(owner).bonds;
|
||||
const s = setup();
|
||||
give(s.m, s.ps, owner, 'board', [10, 4]);
|
||||
s.activate(...bonds.slice(0, 1));
|
||||
s[run]();
|
||||
onlyGains(s, Object.fromEntries(bonds.map((b) => [b, s.active(b) ? g.bb.count : 0])), `${gid} ${owner}`);
|
||||
assert.equal(s.G(bonds[0]), g.bb.count);
|
||||
const t = setup();
|
||||
give(t.m, t.ps, owner, 'hand');
|
||||
t.activate(...bonds);
|
||||
t[run]();
|
||||
assert.deepEqual(t.gainsNow(), {}, `${gid}: board only`);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
}
|
||||
const s = setup();
|
||||
give(s.m, s.ps, 'chess_char_2_02_a', 'board', [10, 4]);
|
||||
give(s.m, s.ps, 'chess_char_4_14_b', 'board', [11, 4]);
|
||||
s.activate(CH('chess_char_2_02_a').bonds[0], CH('chess_char_4_14_b').bonds[0]);
|
||||
s.roundStart();
|
||||
assert.equal(s.G(CH('chess_char_2_02_a').bonds[0]), 2, '赫默 +2 at round start');
|
||||
s.prepEnd();
|
||||
assert.equal(s.G(CH('chess_char_4_14_b').bonds[0]), 10 + (CH('chess_char_2_02_a').bonds[0] === CH('chess_char_4_14_b').bonds[0] ? 2 : 0), '莱恩哈特 精锐 +10 at prep end');
|
||||
});
|
||||
|
||||
test('SERVER_ADD_BOND_METHOD round_gain_char (小满 31, 山 34, 夕 119): operators gained this round × multi', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_PREP_FIN', 'SERVER_ADD_BOND_METHOD').filter((x) => x.g.bbStr.add_method === 'round_gain_char')) {
|
||||
for (const owner of owners) {
|
||||
const bonds = ids(g.bbStr.bond);
|
||||
const s = setup();
|
||||
give(s.m, s.ps, owner, 'board', [10, 4]);
|
||||
s.ps.round.gainedChess = 4;
|
||||
s.prepEnd();
|
||||
assert.deepEqual(s.gainsNow(), {}, `${gid}: inactive`);
|
||||
s.activate(...bonds);
|
||||
s.prepEnd();
|
||||
onlyGains(s, Object.fromEntries(bonds.map((b) => [b, 4 * g.bb.multi])), gid);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
const s = setup();
|
||||
give(s.m, s.ps, 'chess_char_5_12_b', 'board', [10, 4]); // 夕 精锐 +4 per gained operator
|
||||
s.activate('yanShip', 'arcaneShip');
|
||||
s.acquire('chess_char_1_02_a');
|
||||
s.acquire('chess_char_1_03_a');
|
||||
s.prepEnd();
|
||||
const onGain = s.ps.shop.level + (CH('chess_char_1_02_a').bonds.includes('yanShip') ? 2 : 0); // 惊蛰 / 角峰 获得时
|
||||
assert.deepEqual([s.G('arcaneShip'), s.G('yanShip')], [8, 8 + onGain], '2 gained × 4');
|
||||
});
|
||||
|
||||
test('SERVER_ADD_BOND_METHOD same_bond_diff_lv (薄绿 48, 维娜 53 维多利亚 / 54 奇迹): distinct tiers on the board × multi', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_PREP_FIN', 'SERVER_ADD_BOND_METHOD').filter((x) => x.g.bbStr.add_method === 'same_bond_diff_lv')) {
|
||||
for (const owner of owners) {
|
||||
const bond = g.bbStr.bond;
|
||||
const s = setup();
|
||||
give(s.m, s.ps, owner, 'board', [10, 4]);
|
||||
const tiers = new Set([CH(owner).tier]);
|
||||
const fillers = [];
|
||||
for (const t of [1, 2, 3, 4, 5, 6]) {
|
||||
if (tiers.has(t) || fillers.length >= 2) continue;
|
||||
const id = noPrepFin((c) => c.tier === t && c.bonds.includes(bond))[0];
|
||||
if (id) { fillers.push(id); tiers.add(t); }
|
||||
}
|
||||
assert.ok(fillers.length >= 1, `${gid}: fillers`);
|
||||
fillers.forEach((id, i) => give(s.m, s.ps, id, 'board', [11, 4 + i * 2]));
|
||||
give(s.m, s.ps, fillers[0], 'board', [12, 4]); // a second copy of a tier: no extra
|
||||
s.activate(...CH(owner).bonds, bond);
|
||||
s.prepEnd();
|
||||
const counted = new Set([owner, ...fillers].filter((id) => CH(id).bonds.includes(bond)).map((id) => CH(id).tier));
|
||||
const sibling = CH(owner).garrisonIds.map(GR).filter((x) => x.effectKey === g.effectKey && x.eventType === g.eventType && x.bbStr.bond === bond);
|
||||
assert.equal(s.G(bond), counted.size * sibling.reduce((a, x) => a + x.bb.multi, 0), `${gid} ${owner}`);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
const b = setup();
|
||||
give(b.m, b.ps, 'chess_char_6_07_a', 'board', [10, 4]); // 维娜 T6 维多利亚+奇迹
|
||||
give(b.m, b.ps, 'chess_char_3_08_b', 'board', [10, 5]); // 薄绿 精锐 T3 (+4 per tier)
|
||||
const v1 = noPrepFin((c) => ![3, 6].includes(c.tier) && c.bonds.includes('victoriaShip'))[0];
|
||||
give(b.m, b.ps, v1, 'board', [11, 4]);
|
||||
b.activate('victoriaShip', 'miraShip');
|
||||
b.prepEnd();
|
||||
assert.equal(b.G('victoriaShip'), 3 * 3 + 3 * 4, '3 distinct tiers × (维娜 3 + 薄绿 精锐 4)');
|
||||
assert.equal(b.G('miraShip'), new Set(['chess_char_6_07_a', 'chess_char_3_08_b', v1].filter((id) => CH(id).bonds.includes('miraShip')).map((id) => CH(id).tier)).size * 2);
|
||||
});
|
||||
|
||||
test('SERVER_ADD_BOND_METHOD hand_count (妮芙 56) and same_row (蛇屠箱 70)', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_PREP_FIN', 'SERVER_ADD_BOND_METHOD').filter((x) => ['hand_count', 'same_row'].includes(x.g.bbStr.add_method))) {
|
||||
for (const owner of owners) {
|
||||
const s = setup();
|
||||
give(s.m, s.ps, owner, 'board', [10, 4]);
|
||||
const fill = plain((c) => !c.bonds.includes(g.bbStr.bond)).slice(0, 3);
|
||||
if (g.bbStr.add_method === 'hand_count') {
|
||||
for (const id of fill) give(s.m, s.ps, id, 'hand');
|
||||
giveItem(s.m, s.ps, 'chess_item_1_01_e_a'); // items do not count
|
||||
} else {
|
||||
give(s.m, s.ps, fill[0], 'board', [10, 6]);
|
||||
give(s.m, s.ps, fill[1], 'board', [10, 8]);
|
||||
give(s.m, s.ps, fill[2], 'board', [11, 6]); // other row
|
||||
}
|
||||
s.activate(g.bbStr.bond);
|
||||
s.prepEnd();
|
||||
assert.equal(s.G(g.bbStr.bond), 3 * g.bb.multi, `${gid} ${owner}: 3 × ${g.bb.multi}`);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
const c = setup();
|
||||
give(c.m, c.ps, 'chess_char_5_22_b', 'board', [10, 4]);
|
||||
give(c.m, c.ps, 'chess_char_1_02_a', 'hand');
|
||||
give(c.m, c.ps, 'chess_char_1_03_a', 'hand');
|
||||
c.activate('swiftShip');
|
||||
c.prepEnd();
|
||||
assert.equal(c.G('swiftShip'), 8, '妮芙 精锐: 2 hand operators × 4');
|
||||
const d = setup();
|
||||
give(d.m, d.ps, 'chess_char_3_16_a', 'board', [10, 4]);
|
||||
give(d.m, d.ps, 'chess_char_1_02_a', 'board', [10, 6]);
|
||||
d.activate('steadShip');
|
||||
d.prepEnd();
|
||||
assert.equal(d.G('steadShip'), 2, '蛇屠箱: 2 in the row (self included) × 1');
|
||||
});
|
||||
|
||||
test('号角 61: the top active bond + multi per distinct tier of its members on the board', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_PREP_FIN', 'SERVER_ADD_ACT_BOND_DIFF_LV_MOST_LAYER')) {
|
||||
for (const owner of owners) {
|
||||
const bond = CH(owner).bonds[0];
|
||||
const s = setup();
|
||||
give(s.m, s.ps, owner, 'board', [10, 4]);
|
||||
const other = plain((c) => c.tier !== CH(owner).tier && c.bonds.includes(bond))[0];
|
||||
give(s.m, s.ps, other, 'board', [10, 6]);
|
||||
s.activate(bond, 'kjeragShip');
|
||||
s.ps.layers = { [bond]: 10, kjeragShip: 3 };
|
||||
s.ps.recompute();
|
||||
s.prepEnd();
|
||||
onlyGains(s, { [bond]: 2 * g.bb.multi }, `${gid}: 2 distinct tiers`);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
assert.deepEqual([GR('garrison_61_a').bb.multi, GR('garrison_61_b').bb.multi], [2, 4]);
|
||||
});
|
||||
|
||||
test('流明 57: every hand operator adds +N to each of its ACTIVE bonds', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_PREP_FIN', 'SERVER_ADD_BOND_IN_HAND')) {
|
||||
for (const owner of owners) {
|
||||
const s = setup();
|
||||
give(s.m, s.ps, owner, 'board', [10, 4]);
|
||||
give(s.m, s.ps, 'chess_char_1_03_a', 'hand'); // 惊蛰 炎
|
||||
give(s.m, s.ps, 'chess_char_2_04_a', 'hand'); // 小满 炎 + 灵巧 (inactive)
|
||||
s.activate('yanShip');
|
||||
s.prepEnd();
|
||||
onlyGains(s, { yanShip: 2 * g.bb.count }, `${gid} ${owner}`);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
assert.deepEqual([GR('garrison_57_a').bb.count, GR('garrison_57_b').bb.count], [2, 4]);
|
||||
});
|
||||
|
||||
test('SERVER_ADD_BOND_POSITION (断崖 / 见行者 47 behind, 引星棘刺 79 front, 空弦 150 front at round start): each target operator', () => {
|
||||
for (const [ev, run] of [['SERVER_PREP_FIN', 'prepEnd'], ['SERVER_PREP_START', 'roundStart']]) {
|
||||
for (const { gid, g, owners } of idsOf(ev, 'SERVER_ADD_BOND_POSITION')) {
|
||||
for (const owner of owners) {
|
||||
const ob = CH(owner).bonds;
|
||||
const behind = g.bbStr.dir === 'behind';
|
||||
const mate = plain((c) => c.bonds.includes(ob[0]))[0]; // shares a bond with the owner
|
||||
const decoy = plain((c) => !c.bonds.some((b) => ob.includes(b) || CH(mate).bonds.includes(b)))[0];
|
||||
const s = setup();
|
||||
give(s.m, s.ps, owner, 'board', [10, 5]);
|
||||
give(s.m, s.ps, mate, 'board', [10, behind ? 4 : 6]);
|
||||
give(s.m, s.ps, decoy, 'board', [10, behind ? 6 : 4]);
|
||||
s.activate(...ob, ...CH(mate).bonds, ...CH(decoy).bonds);
|
||||
s[run]();
|
||||
const want = {};
|
||||
for (const id of [owner, mate]) for (const b of CH(id).bonds) if (s.active(b)) want[b] = (want[b] || 0) + g.bb.count;
|
||||
onlyGains(s, want, `${gid} ${owner}`);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('regression: POSITION — a bond shared by 断崖 and the operator behind her gets +N from each (per-operator targets)', () => {
|
||||
const s = setup();
|
||||
give(s.m, s.ps, 'chess_char_3_02_b', 'board', [10, 5]); // 断崖 精锐 灵巧 +6
|
||||
const mate = plain((c) => c.bonds.includes('skillfulShip'))[0];
|
||||
give(s.m, s.ps, mate, 'board', [10, 4]);
|
||||
s.activate(...CH(mate).bonds, 'skillfulShip');
|
||||
s.prepEnd();
|
||||
assert.equal(s.G('skillfulShip'), 12);
|
||||
const t = setup();
|
||||
give(t.m, t.ps, 'chess_char_3_21_a', 'board', [10, 5]); // 空弦 拉特兰/灵巧 +3, nobody in front
|
||||
t.activate('lateranoShip', 'skillfulShip');
|
||||
t.roundStart();
|
||||
onlyGains(t, { lateranoShip: 3, skillfulShip: 3 });
|
||||
});
|
||||
|
||||
test('coin / refresh counters: 溯光星源 121 per 3 spent, 阿罗玛 122 / 安洁莉娜 147 refreshes × multiplier (capped), 拉普兰德 123', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_PREP_FIN', 'SERVER_ADD_BOND_ROUND_COIN_COST')) {
|
||||
for (const owner of owners) {
|
||||
const s = setup();
|
||||
give(s.m, s.ps, owner, 'board', [10, 4]);
|
||||
s.activate(...ids(g.bbStr.bond));
|
||||
s.ps.round.spent = 7;
|
||||
s.prepEnd();
|
||||
onlyGains(s, Object.fromEntries(ids(g.bbStr.bond).map((b) => [b, Math.floor(7 / g.bb.count) * g.bb.layer])), gid);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
for (const { gid, g, owners } of idsOf('SERVER_PREP_FIN', 'SERVER_ADD_REFRESH_CNT_MULTIPLIER_BOND_LAYER')) {
|
||||
for (const owner of owners) {
|
||||
for (const n of [1, 5]) {
|
||||
const s = setup();
|
||||
give(s.m, s.ps, owner, 'board', [10, 4]);
|
||||
s.activate(...ids(g.bbStr.bond));
|
||||
s.ps.round.refreshes = n;
|
||||
s.prepEnd();
|
||||
onlyGains(s, Object.fromEntries(ids(g.bbStr.bond).map((b) => [b, Math.min(n * g.bb.multiplier, g.bb.max_layer)])), `${gid} ${n}`);
|
||||
}
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
for (const { gid, g, owners } of idsOf('SERVER_REFRESH_SHOP', 'SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT')) {
|
||||
for (const owner of owners) {
|
||||
for (const where of ['hand', 'board']) {
|
||||
const s = setup();
|
||||
give(s.m, s.ps, owner, where, where === 'board' ? [10, 4] : null);
|
||||
s.activate(g.bbStr.bond);
|
||||
assert.deepEqual(s.m.handle('p_0', { t: 'g.refresh' }), { ok: true });
|
||||
assert.equal(s.G(g.bbStr.bond), g.bb.layer, `${gid} ${where}`);
|
||||
s.m.handle('p_0', { t: 'g.refresh' });
|
||||
assert.equal(s.G(g.bbStr.bond), g.bb.layer, 'only the first refresh of the round');
|
||||
}
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
const s = setup();
|
||||
give(s.m, s.ps, 'chess_char_6_16_b', 'board', [10, 4]);
|
||||
give(s.m, s.ps, 'chess_char_4_10_a', 'board', [11, 4]);
|
||||
give(s.m, s.ps, 'chess_char_5_20_b', 'board', [12, 4]);
|
||||
s.activate('skillfulShip', 'arcaneShip', 'siracusaShip');
|
||||
s.ps.round.spent = 9;
|
||||
s.ps.round.refreshes = 5;
|
||||
s.prepEnd();
|
||||
onlyGains(s, { skillfulShip: 12, arcaneShip: 12 + 6, siracusaShip: 6 + 24 }, '溯光星源 精锐 3 × 4; 阿罗玛 min(10, 6); 安洁莉娜 精锐 min(40, 24)');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// price, items, refreshes, funds
|
||||
|
||||
test('SERVER_CHESS_PRICE: 至简 13 costs its bb.price (2; the text says 1), 红豆 04 (hidden) costs 1', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_PRICE', 'SERVER_CHESS_PRICE')) {
|
||||
for (const owner of owners) {
|
||||
const s = setup();
|
||||
s.ps.shop.slots[0] = { kind: 'chess', id: owner, basePrice: s.m.gd.chessPrice(owner), frozen: false, sold: false };
|
||||
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), g.bb.price, `${gid} ${owner}`);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
const s = setup();
|
||||
s.ps.shop.slots[0] = { kind: 'chess', id: 'chess_char_3_13_a', basePrice: 3, frozen: false, sold: false };
|
||||
const funds = s.ps.funds;
|
||||
assert.deepEqual(s.m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
|
||||
assert.equal(s.ps.funds, funds - 2);
|
||||
assert.equal(GR('garrison_13_b').bb.price, 2);
|
||||
});
|
||||
|
||||
test('regression: 购买价格 replaces the base price but keeps discounts applied earlier in the dispatch (远见-style −1)', () => {
|
||||
const s = setup();
|
||||
s.m.dispatcher.registry.global('gar_test_disc', { onPrice(ctx) { ctx.modifyPrice(-1); } });
|
||||
try {
|
||||
s.ps.shop.slots[0] = { kind: 'chess', id: 'chess_char_3_13_a', basePrice: 3, frozen: false, sold: false };
|
||||
s.ps.shop.slots[1] = { kind: 'chess', id: 'chess_char_1_02_a', basePrice: 2, frozen: false, sold: false };
|
||||
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), 1, '至简 2 − 1');
|
||||
assert.equal(s.ps.priceOf(s.ps.shop.slots[1]), 1, 'other chess: 2 − 1');
|
||||
} finally {
|
||||
s.m.dispatcher.registry.unregister('global:gar_test_disc');
|
||||
}
|
||||
});
|
||||
|
||||
test('SERVER_GAIN_EQUIP (诗怀雅 133, 耶拉 92, 卡涅利安 / 蜜蜡 49, 缪尔赛思 76): bb.count copies of bbStr.chess', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_GAIN', 'SERVER_GAIN_EQUIP')) {
|
||||
for (const owner of owners) {
|
||||
const s = setup();
|
||||
s.acquire(owner);
|
||||
assert.equal(itemUnits(s.ps, g.bbStr.chess), g.bb.count, `${gid} ${owner}: ${handItems(s.ps)}`);
|
||||
assert.equal(handItems(s.ps).every((id) => id === g.bbStr.chess || id === DATA.items[g.bbStr.chess].goldenId), true);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
const s = setup();
|
||||
s.acquire('chess_char_4_24_b');
|
||||
assert.deepEqual(handItems(s.ps), ['chess_item_3_05_e_b'], '卡涅利安 精锐: two 迅捷作战粮 merge into the golden one');
|
||||
const t = setup();
|
||||
t.acquire('chess_char_3_03_a');
|
||||
assert.deepEqual(handItems(t.ps), ['chess_item_1_03_e_a'], '诗怀雅: 1 盟约之币');
|
||||
});
|
||||
|
||||
test('free refreshes: 普罗旺斯 113 获得时, 德克萨斯 120 / 巫恋 132 售出时, 缄默德克萨斯 114 进入休整期时 (board only)', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_GAIN', 'SERVER_GAIN_FREE_REFRESH_COUNT')) {
|
||||
for (const owner of owners) { const s = setup(); s.acquire(owner); assert.equal(s.ps.shop.freeRefreshes, g.bb.count, gid); }
|
||||
cover(gid);
|
||||
}
|
||||
for (const { gid, g, owners } of idsOf('SERVER_CHESS_SOLD', 'SERVER_GAIN_FREE_REFRESH_COUNT')) {
|
||||
for (const owner of owners) { const s = setup(); s.sell(give(s.m, s.ps, owner, 'hand')); assert.equal(s.ps.shop.freeRefreshes, g.bb.count, gid); }
|
||||
cover(gid);
|
||||
}
|
||||
for (const { gid, g, owners } of idsOf('SERVER_PREP_START', 'SERVER_GAIN_FREE_REFRESH_COUNT')) {
|
||||
for (const owner of owners) {
|
||||
const s = setup();
|
||||
give(s.m, s.ps, owner, 'hand');
|
||||
s.roundStart();
|
||||
assert.equal(s.ps.shop.freeRefreshes, 0, `${gid}: hand`);
|
||||
give(s.m, s.ps, owner, 'board', [10, 4]);
|
||||
s.roundStart();
|
||||
assert.equal(s.ps.shop.freeRefreshes, g.bb.count, gid);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
const u = setup();
|
||||
u.acquire('chess_char_1_07_b');
|
||||
u.sell(give(u.m, u.ps, 'chess_char_1_08_a', 'hand'));
|
||||
give(u.m, u.ps, 'chess_char_4_16_b', 'board', [10, 4]);
|
||||
u.roundStart();
|
||||
assert.equal(u.ps.shop.freeRefreshes, 2 + 1 + 2);
|
||||
});
|
||||
|
||||
test('funds: 能天使 26 / 新约能天使 32 获得时, 格雷伊 67 / 泥岩 21 售出时 → next round; 琳琅诗怀雅 98 (hand needs 炎 / 投资人)', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_GAIN', 'SERVER_ONCE_GOLD')) {
|
||||
for (const owner of owners) { const s = setup(); s.acquire(owner); assert.equal(s.ps.pendingFunds, g.bb.count, gid); }
|
||||
cover(gid);
|
||||
}
|
||||
for (const { gid, g, owners } of idsOf('SERVER_CHESS_SOLD', 'SERVER_ONCE_GOLD')) {
|
||||
for (const owner of owners) {
|
||||
const s = setup();
|
||||
const funds = s.ps.funds;
|
||||
s.sell(give(s.m, s.ps, owner, 'hand'));
|
||||
assert.equal(s.ps.pendingFunds, g.bb.count, gid);
|
||||
assert.equal(s.ps.funds, funds + 1, 'the sale itself still pays 1');
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
for (const { gid, g, owners } of idsOf('SERVER_PREP_FIN', 'SERVER_ONCE_GOLD_WITH_BOND_CONDITION')) {
|
||||
for (const owner of owners) {
|
||||
const s = setup();
|
||||
give(s.m, s.ps, owner, 'board', [10, 4]);
|
||||
s.prepEnd();
|
||||
assert.equal(s.ps.pendingFunds, g.bb.count, `${gid}: on the board always`);
|
||||
for (const b of ids(g.bbStr.bond)) {
|
||||
const t = setup();
|
||||
give(t.m, t.ps, owner, 'hand');
|
||||
t.prepEnd();
|
||||
assert.equal(t.ps.pendingFunds, 0, `${gid}: hand without 炎 / 投资人`);
|
||||
t.activate(b);
|
||||
t.prepEnd();
|
||||
assert.equal(t.ps.pendingFunds, g.bb.count, `${gid}: hand with ${b}`);
|
||||
}
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
const s = setup();
|
||||
s.acquire('chess_char_6_13_b');
|
||||
s.sell(give(s.m, s.ps, 'chess_char_4_18_b', 'hand'));
|
||||
assert.equal(s.ps.pendingFunds, 4 + 4);
|
||||
});
|
||||
|
||||
test('松果 116 on sale: a free special recruit of tier I (精锐: tier V)', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_CHESS_SOLD', 'SERVER_SELL_CHESS_GAIN_SPECIAL_GOODS')) {
|
||||
const tier = DATA.choices.pools[g.bbStr.max_pool].tier;
|
||||
for (const owner of owners) {
|
||||
const s = setup();
|
||||
s.sell(give(s.m, s.ps, owner, 'hand'));
|
||||
const offer = s.ps.offers[s.ps.offers.length - 1];
|
||||
assert.ok(offer && offer.slots.length >= 1, gid);
|
||||
for (const sl of offer.slots) { assert.equal(CH(sl.id).tier, tier, gid); assert.equal(sl.price, 0); }
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
assert.deepEqual([DATA.choices.pools[GR('garrison_116_a').bbStr.pool3].tier, DATA.choices.pools[GR('garrison_116_b').bbStr.pool3].tier], [1, 5]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// pools
|
||||
|
||||
test('pools: 凯瑟琳 127 odd rounds, 佩佩 94, 洛洛 91, 焰尾 149, 歌蕾蒂娅 139 row of 3 — bb.count each', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_PREP_START', 'SERVER_GAIN_RANDOM_EQUIP_CHESS_IN_POOL')) {
|
||||
for (const owner of owners) {
|
||||
const s = setup();
|
||||
give(s.m, s.ps, owner, 'board', [10, 4]);
|
||||
s.roundStart();
|
||||
assert.equal(handItems(s.ps).length, g.bb.count, `${gid}: round 1 is odd`);
|
||||
s.m.round = 2;
|
||||
s.roundStart();
|
||||
s.m.round = 1;
|
||||
assert.equal(handItems(s.ps).length, g.bb.count, `${gid}: round 2 nothing`);
|
||||
for (const id of handItems(s.ps)) assert.ok(DATA.items[id].tier <= s.ps.shop.level, id);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
for (const [ev, key] of [['SERVER_GAIN', 'SERVER_POOL_EQUIP'], ['SERVER_PREP_START', 'SERVER_POOL_EQUIP']]) {
|
||||
for (const { gid, g, owners } of idsOf(ev, key)) {
|
||||
const pool = DATA.choices.pools[g.bbStr.pool];
|
||||
const allowed = new Set([...(pool.items || []), ...(pool.weighted || []).map((x) => x[0])]);
|
||||
for (const owner of owners) {
|
||||
for (let seed = 1; seed <= 4; seed++) {
|
||||
const s = setup(seed);
|
||||
if (ev === 'SERVER_GAIN') s.acquire(owner);
|
||||
else { give(s.m, s.ps, owner, 'board', [10, 4]); s.roundStart(); }
|
||||
let units = 0;
|
||||
for (const id of allowed) units += itemUnits(s.ps, id);
|
||||
assert.equal(units, g.bb.count, `${gid} seed ${seed}: ${handItems(s.ps)}`);
|
||||
}
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
}
|
||||
for (const [ev, key] of [['SERVER_GAIN', 'SERVER_POOL_CHAR'], ['SERVER_PREP_START', 'SERVER_POOL_CHAR']]) {
|
||||
for (const { gid, g, owners } of idsOf(ev, key)) {
|
||||
const pool = DATA.choices.pools[g.bbStr.pool];
|
||||
const allowed = new Set([...(pool.items || []), ...(pool.weighted || []).map((x) => x[0])]);
|
||||
for (const owner of owners) {
|
||||
const s = setup();
|
||||
const got = () => handChess(s.ps).filter((id) => allowed.has(id) || allowed.has(id.replace(/_b$/, '_a')));
|
||||
if (ev === 'SERVER_GAIN') {
|
||||
s.acquire(owner);
|
||||
assert.equal(got().length, g.bb.count, `${gid}: ${handChess(s.ps)}`);
|
||||
} else {
|
||||
const row = plain((c) => !allowed.has(c.chessId)).slice(0, 2);
|
||||
give(s.m, s.ps, owner, 'board', [10, 4]);
|
||||
give(s.m, s.ps, row[0], 'board', [10, 6]);
|
||||
s.roundStart();
|
||||
assert.equal(got().length, 0, `${gid}: 2 in the row`);
|
||||
give(s.m, s.ps, row[1], 'board', [10, 8]);
|
||||
s.roundStart();
|
||||
assert.equal(got().length, g.bb.count, `${gid}: 3 in the row`);
|
||||
}
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('余 37: a chess of the bond with the most members (normal: 3 in the row; 精锐: always)', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_PREP_START', 'SERVER_MOST_BOND')) {
|
||||
for (const owner of owners) {
|
||||
const s = setup();
|
||||
give(s.m, s.ps, owner, 'board', [10, 4]);
|
||||
s.ps.bondCountBonus.egirShip = 9;
|
||||
s.ps.recompute();
|
||||
s.roundStart();
|
||||
const rowRule = g.bbStr.conditionkey === 'character_same_row';
|
||||
assert.equal(handChess(s.ps).length, rowRule ? 0 : 1, `${gid}: alone in the row`);
|
||||
if (rowRule) {
|
||||
const row = plain((c) => !c.bonds.includes('egirShip')).slice(0, 2);
|
||||
give(s.m, s.ps, row[0], 'board', [10, 6]);
|
||||
give(s.m, s.ps, row[1], 'board', [10, 8]);
|
||||
s.roundStart();
|
||||
}
|
||||
const got = handChess(s.ps);
|
||||
assert.equal(got.length, 1, gid);
|
||||
assert.ok(CH(got[0]).bonds.includes('egirShip'), `${gid} ${got[0]}`);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// re-triggers / copies
|
||||
|
||||
test('铃兰 60: front "获得时" traits (精锐: the two tiles in front), ×投资人; nothing from the hand', () => {
|
||||
for (const [key, tiles] of [['SERVER_TRIGGER_ANOTHER', 1], ['SERVER_TRIGGER_FRONT_COUNT', 2]]) {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_PREP_START', key)) {
|
||||
for (const owner of owners) {
|
||||
const s = setup();
|
||||
give(s.m, s.ps, owner, 'board', [10, 4]);
|
||||
give(s.m, s.ps, 'chess_char_1_07_a', 'board', [10, 5]); // 普罗旺斯: 获得时 +1 free refresh
|
||||
give(s.m, s.ps, 'chess_char_1_07_b', 'board', [10, 6]); // +2
|
||||
give(s.m, s.ps, 'chess_char_1_07_a', 'board', [10, 7]); // 3 tiles away: never
|
||||
s.roundStart();
|
||||
assert.equal(s.ps.shop.freeRefreshes, tiles === 1 ? 1 : 3, gid);
|
||||
const t = setup();
|
||||
give(t.m, t.ps, owner, 'hand');
|
||||
give(t.m, t.ps, 'chess_char_1_07_a', 'board', [10, 5]);
|
||||
t.roundStart();
|
||||
assert.equal(t.ps.shop.freeRefreshes, 0, `${gid}: character_target_inboard`);
|
||||
}
|
||||
assert.equal(g.bbStr.event, 'SERVER_GAIN');
|
||||
cover(gid);
|
||||
}
|
||||
}
|
||||
const t = setup();
|
||||
give(t.m, t.ps, 'chess_char_5_10_b', 'board', [10, 4]);
|
||||
give(t.m, t.ps, 'chess_char_1_07_a', 'board', [10, 5]);
|
||||
give(t.m, t.ps, 'chess_char_1_07_b', 'board', [10, 6]);
|
||||
t.activate('investShip');
|
||||
t.roundStart();
|
||||
assert.equal(t.ps.shop.freeRefreshes, (1 + 2) * 2, 'both tiles, ×2 under 投资人');
|
||||
});
|
||||
|
||||
test('regression: 瑰盐 82 (hidden) triggers the top-most (highest row: row 0 is the bottom), then right-most prep-end trait', () => {
|
||||
for (const { gid, owners } of idsOf('SERVER_CHESS_SOLD', 'SERVER_TRIGGER_ANOTHER')) {
|
||||
for (const owner of owners) {
|
||||
const u = setup();
|
||||
give(u.m, u.ps, 'chess_char_2_17_a', 'board', [11, 3]); // 折桠 坚守 +4 — upper row
|
||||
give(u.m, u.ps, 'chess_char_1_20_a', 'board', [10, 9]); // 雷蛇 不屈 +1 — lower row, further right
|
||||
give(u.m, u.ps, 'chess_char_1_06_a', 'board', [11, 2]); // 刺玫 upper row, left of 折桠
|
||||
u.activate('steadShip', 'indomShip', 'victoriaShip');
|
||||
u.sell(give(u.m, u.ps, owner, 'hand'));
|
||||
onlyGains(u, { steadShip: 4 }, gid);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
});
|
||||
|
||||
test('塞雷娅 99 / 白面鸮 86: the copier runs the front operator\'s prep-end / prep-start trait as its own (chains followed)', () => {
|
||||
for (const { gid, owners } of idsOf('SERVER_PREP_FIN', 'SERVER_FRONT_SAME_EFFECT_PREP_FIN')) {
|
||||
for (const owner of owners) {
|
||||
const v = setup();
|
||||
give(v.m, v.ps, owner, 'board', [10, 4]);
|
||||
give(v.m, v.ps, 'chess_char_2_17_b', 'board', [10, 5]); // 折桠 精锐 坚守 +8
|
||||
v.activate('steadShip');
|
||||
v.prepEnd();
|
||||
assert.equal(v.G('steadShip'), 16, `${gid}: 折桠 8 + copied 8`);
|
||||
const w = setup();
|
||||
give(w.m, w.ps, owner, 'board', [10, 4]);
|
||||
give(w.m, w.ps, 'chess_char_4_14_a', 'board', [10, 5]); // 莱恩哈特 "自身已激活的盟约 +5" → the copier's own bonds
|
||||
w.activate(...CH(owner).bonds, ...CH('chess_char_4_14_a').bonds);
|
||||
w.prepEnd();
|
||||
const want = {};
|
||||
for (const id of [owner, 'chess_char_4_14_a']) for (const b of CH(id).bonds) if (w.active(b)) want[b] = (want[b] || 0) + 5;
|
||||
onlyGains(w, want, `${gid}: self-relative effects use the copier`);
|
||||
const x = setup();
|
||||
give(x.m, x.ps, owner, 'board', [10, 4]);
|
||||
give(x.m, x.ps, 'chess_char_1_07_a', 'board', [10, 5]); // 获得时 trait: not a prep-end trait
|
||||
x.prepEnd();
|
||||
assert.equal(x.ps.shop.freeRefreshes, 0);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
for (const { gid, owners } of idsOf('SERVER_PREP_START', 'SERVER_FRONT_SAME_EFFECT_PREP_START')) {
|
||||
for (const owner of owners) {
|
||||
const w = setup();
|
||||
give(w.m, w.ps, owner, 'board', [10, 4]);
|
||||
give(w.m, w.ps, 'chess_char_4_16_b', 'board', [10, 5]); // 缄默德克萨斯 精锐 +2 free refreshes
|
||||
w.roundStart();
|
||||
assert.equal(w.ps.shop.freeRefreshes, 4, gid);
|
||||
}
|
||||
cover(gid);
|
||||
}
|
||||
const w = setup();
|
||||
give(w.m, w.ps, 'chess_char_4_21_a', 'board', [10, 3]); // 白面鸮 → 白面鸮 (hidden 5_16) → 缄默德克萨斯
|
||||
give(w.m, w.ps, 'chess_char_5_16_a', 'board', [10, 4]);
|
||||
give(w.m, w.ps, 'chess_char_4_16_a', 'board', [10, 5]);
|
||||
w.roundStart();
|
||||
assert.equal(w.ps.shop.freeRefreshes, 3);
|
||||
});
|
||||
|
||||
test('triggerGainEffects export (band 铃兰): re-runs 获得时 garrisons ×投资人 and returns the effects run', () => {
|
||||
const s = setup();
|
||||
const p = give(s.m, s.ps, 'chess_char_5_03_a', 'board', [10, 4]); // 烛煌 炎/维多利亚 +5
|
||||
s.activate('investShip');
|
||||
let n = -1;
|
||||
s.m.dispatcher.registry.global('gar_test', { onPrepEnd(ctx) { n = triggerGainEffects(ctx, ctx.piece(p.uid)); } });
|
||||
try { s.prepEnd(); } finally { s.m.dispatcher.registry.unregister('global:gar_test'); }
|
||||
assert.equal(n, 1);
|
||||
assert.deepEqual([s.G('yanShip'), s.G('victoriaShip')], [10, 10]);
|
||||
});
|
||||
|
||||
test('风丸 93: only 2 copies merge into the elite', () => {
|
||||
const s = setup();
|
||||
assert.equal(s.m.gd.mergeCount('chess_char_2_11_a'), 2);
|
||||
s.acquire('chess_char_2_11_a');
|
||||
s.acquire('chess_char_2_11_a');
|
||||
assert.ok([...s.ps.hand, ...s.ps.temp].some((p) => p && p.id === 'chess_char_2_11_b'));
|
||||
assert.equal(GR('garrison_93_a').eventType, 'IN_BATTLE');
|
||||
});
|
||||
|
||||
test('every SERVER_* effectKey of the data has a registered handler', () => {
|
||||
const keys = new Set(Object.values(DATA.garrisons).filter((g) => g.eventType !== 'IN_BATTLE').map((g) => g.effectKey));
|
||||
for (const k of keys) assert.ok(REG.has(`garrison:${k}`), k);
|
||||
});
|
||||
|
||||
test('coverage: every SERVER_* garrison id of a visible chess was exercised above', () => {
|
||||
const missing = [...VISIBLE_IDS].filter((g) => !COVER.has(g)).sort();
|
||||
assert.deepEqual(missing, []);
|
||||
assert.ok(VISIBLE_IDS.size >= 100, `${VISIBLE_IDS.size} ids`);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,703 @@
|
||||
// Tier 1 alternate skills & modules (DESIGN §16 operator loadouts; server/sim/content/kits/tier1.js `skills` maps).
|
||||
// Every selectable non-default skill of every visible tier-1 chess runs a real battle — normal (Lv4) and elite (Lv7) —
|
||||
// through the harness with its loadout (`skillIndex` / `moduleId` on the board entry, as a BattleSpec carries them),
|
||||
// and its signature effect is asserted with numbers from that skill's own blackboard (data/chess.json skills[]).
|
||||
// Non-default modules ('none' — every tier-1 elite has one module) are checked where the module changes behaviour.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
import { absoluteRangeKeys } from '../../server/sim/targeting.js';
|
||||
import { loadoutOptions } from '../../shared/protocol.js';
|
||||
import { kitCoverage } from '../../tools/kit-coverage.mjs';
|
||||
|
||||
const ds = getDefaultSource();
|
||||
const raw = (id) => ds.rawChess(id);
|
||||
/** SkillRecord `skillId` of chess `id` (its level's bb / duration / grid). */
|
||||
const rec = (id, skillId) => {
|
||||
const r = raw(id).skills.find((s) => s.skillId === skillId);
|
||||
assert.ok(r, `${id} has ${skillId}`);
|
||||
return r;
|
||||
};
|
||||
const tal = (id, i = 0) => raw(id).talents.filter((t) => t.index !== -1)[i].bb;
|
||||
const approx = (a, b, msg = '', rel = 1e-6) => assert.ok(Math.abs(a - b) <= rel * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
|
||||
/** |a − b| ≤ tol (timings: skill start/end hooks fire inside ticks — allow ~1.5 ticks). */
|
||||
const within = (a, b, tol = 0.05, msg = '') => assert.ok(Math.abs(a - b) <= tol, `${msg} ${a} ≈ ${b} ± ${tol}`);
|
||||
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
|
||||
const READY = { sp: 999 };
|
||||
const HOOKS = ['damaged', 'heal', 'hit', 'skillStart', 'skillEnd', 'statusApplied', 'ammoUsed', 'attack', 'death', 'deploy'];
|
||||
const run = (o) => makeBattle({ seed: 7, autoFinish: false, timeLimit: 400, hooks: HOOKS, captureNoisy: true, ...o });
|
||||
/** defs.chess: the chess without its 特质 (garrisons have their own tests) — isolates kit numbers. */
|
||||
const noGarrison = (...ids) => Object.fromEntries(ids.map((id) => [id, { ...raw(id), garrisonIds: [] }]));
|
||||
/** Board entry of chess `id` carrying skill `skillId` (+ extra fields: module, tile, carry state). */
|
||||
const entry = (id, skillId, o = {}) => ({ chessId: id, skillIndex: rec(id, skillId).index, ...o });
|
||||
/** The unit of chess `id`, asserted to run the selected skill with its hand-authored spec. */
|
||||
function sel(h, id, skillId) {
|
||||
const u = h.unit(id);
|
||||
assert.ok(u, `${id} on the board`);
|
||||
assert.equal(u.skill.id, skillId, `${id} carries ${skillId}`);
|
||||
assert.equal(u.kit.skillSource, 'skills', `${id} ${skillId}: hand-authored spec`);
|
||||
return u;
|
||||
}
|
||||
const dealt = (h, u, f = () => true) => h.hooksOf('damaged').filter((c) => c.source === u && f(c));
|
||||
const heals = (h, u, f = () => true) => h.hooksOf('heal').filter((c) => c.source === u && f(c));
|
||||
const attacks = (h, u, f = () => true) => h.hooksOf('attack').filter((c) => c.attacker === u && f(c));
|
||||
const statuses = (h, key, f = () => true) => h.hooksOf('statusApplied').filter((c) => c.status === key && f(c));
|
||||
const started = (h, u) => h.hooksOf('skillStart').filter((c) => c.unit === u);
|
||||
const ended = (h, u) => h.hooksOf('skillEnd').filter((c) => c.unit === u);
|
||||
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
|
||||
const pair = (n) => [`chess_char_1_${n}_a`, `chess_char_1_${n}_b`];
|
||||
|
||||
// =================================================================================================================
|
||||
// coverage
|
||||
|
||||
test('tier 1: every selectable skill of every visible chess has a hand-authored spec (normal + elite)', () => {
|
||||
const rep = kitCoverage({ tier: 1 });
|
||||
assert.equal(rep.summary.chess, 16);
|
||||
assert.equal(rep.summary.covered, rep.summary.skills, rep.chess.flatMap((r) => r.skills.filter((s) => !s.covered).map((s) => `${r.name} S${s.index + 1}`)).join(', '));
|
||||
assert.equal(rep.summary.chessFullyCovered, rep.summary.chess);
|
||||
});
|
||||
|
||||
test('tier 1: every chess × every legal skill × module (default / none) fights 30 s with its authored spec, no content errors', () => {
|
||||
const bases = ds.chessIds().filter((id) => /^chess_char_1_\d+_a$/.test(id) && raw(id).visible);
|
||||
assert.equal(bases.length, 16);
|
||||
for (const base of bases) {
|
||||
const gold = base.replace(/_a$/, '_b');
|
||||
const opt = loadoutOptions(raw(base), raw(gold));
|
||||
for (const skillIndex of opt.skills) {
|
||||
for (const [id, moduleId] of [[base, undefined], [gold, undefined], [gold, 'none']]) {
|
||||
const h = makeBattle({
|
||||
stageId: 'act2autochess_m04', seed: 5,
|
||||
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 20000, atk: 300, bat: 2, speed: 0.6 }) } },
|
||||
units: [{ chessId: id, row: 9, col: 7, skillIndex, ...(moduleId ? { moduleId } : {}) }, { chessId: 'chess_char_1_02_a', row: 9, col: 4 }, { chessId: 'chess_char_2_14_a', row: 10, col: 5 }],
|
||||
enemies: [{ key: 'enemy_dummy', count: 4, interval: 2 }, { key: 'enemy_dummy', route: 1, count: 2, interval: 3 }],
|
||||
timeLimit: 60,
|
||||
});
|
||||
h.run(30);
|
||||
const u = h.unit(id);
|
||||
const tag = `${id} S${skillIndex + 1} ${moduleId ?? 'default module'}`;
|
||||
assert.equal(u.def.loadout.skillIndex, skillIndex, tag);
|
||||
assert.ok(['skills', undefined].includes(u.kit.skillSource), `${tag}: ${u.kit.skillSource}`);
|
||||
if (moduleId === 'none') assert.equal(u.def.raw.module.active, false, tag);
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, `${tag}: ${JSON.stringify(h.b.errors[0])}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// =================================================================================================================
|
||||
// alternate skills (normal Lv4 + elite Lv7)
|
||||
|
||||
test('1_01 隐现 S1 “不惹麻烦”: attack@trigger_time rounds of attack@atk_scale × ATK physical, then it ends', () => {
|
||||
for (const id of pair('01')) {
|
||||
const s = rec(id, 'skchr_inside_1'), bb = s.bb;
|
||||
const h = run({
|
||||
defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) },
|
||||
units: [entry(id, s.skillId, { row: 10, col: 4, carryState: READY })],
|
||||
enemies: [{ key: 'e', pos: [10, 6] }],
|
||||
});
|
||||
const u = sel(h, id, s.skillId);
|
||||
assert.equal(u.skill.kind, 'ammo');
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1 && !u.skill.active, 15));
|
||||
h.run(0.5); // the last round is still in flight
|
||||
assert.equal(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, bb['attack@trigger_time'], `${id} rounds`);
|
||||
const shots = dealt(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill);
|
||||
assert.equal(shots.length, bb['attack@trigger_time']);
|
||||
for (const c of shots) { assert.equal(c.type, 'phys'); approx(c.amount, u.s.atk * bb['attack@atk_scale'], `${id} shot`); }
|
||||
assert.equal(u.s.taunt, 0, 'no taunt change (S2 only)');
|
||||
done(h);
|
||||
// 火力支援 (弹药类技能): after `duration` s on the field the S1 magazine is +self_ammo too
|
||||
const t = tal(id);
|
||||
const mags = [];
|
||||
const h2 = run({
|
||||
defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) },
|
||||
units: [entry(id, s.skillId, { row: 10, col: 4 })],
|
||||
enemies: [{ key: 'e', pos: [10, 6] }],
|
||||
setup: (b) => b.on('skillStart', (c) => mags.push({ t: b.time, ammo: c.skill.ammoLeft }), { priority: -500 }),
|
||||
});
|
||||
const u2 = sel(h2, id, s.skillId);
|
||||
assert.ok(h2.runUntil(() => mags.some((m) => m.t >= t.duration), 120));
|
||||
assert.ok(mags.filter((m) => m.t < t.duration).every((m) => m.ammo === bb['attack@trigger_time']));
|
||||
assert.equal(mags.find((m) => m.t >= t.duration).ammo, bb['attack@trigger_time'] + t.self_ammo, `${id} +self_ammo`);
|
||||
assert.equal(u2.skill.id, s.skillId);
|
||||
done(h2);
|
||||
}
|
||||
});
|
||||
|
||||
test('1_02 角峰 S1 体能强化: TAKE_DAMAGE (TANK S1) — HP +max_hp, +hp_recovery_per_sec HP per second', () => {
|
||||
for (const id of pair('02')) {
|
||||
const s = rec(id, 'skchr_yak_1'), bb = s.bb;
|
||||
assert.equal(s.trigger.rule, 'TAKE_DAMAGE');
|
||||
const h = run({
|
||||
defs: { enemies: { e: dummy('e', { atk: 400, bat: 1.5 }) }, chess: noGarrison(id) },
|
||||
units: [entry(id, s.skillId, { row: 9, col: 5, carryState: READY })],
|
||||
enemies: [{ key: 'e', pos: [9, 5] }],
|
||||
});
|
||||
const u = sel(h, id, s.skillId);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 10));
|
||||
assert.equal(started(h, u)[0].reason, 'TAKE_DAMAGE');
|
||||
approx(u.s.maxHp, u.base.maxHp * (1 + bb.max_hp), `${id} HP`);
|
||||
approx(u.s.def, u.base.def, 'DEF unchanged (抗寒体质 only)');
|
||||
approx(u.s.hpRegen, u.base.hpRecoveryPerSec + bb.hp_recovery_per_sec, 'regen');
|
||||
u.hp = u.s.maxHp * 0.5;
|
||||
const t0 = h.b.time;
|
||||
h.run(3);
|
||||
const regen = heals(h, u, (c) => c.target === u && c.opts?.regen && c.t > t0).reduce((n, c) => n + c.amount, 0);
|
||||
assert.ok(Math.abs(regen - 3 * bb.hp_recovery_per_sec) <= 2, `${id} regenerated ${regen}`);
|
||||
h.runUntil(() => !u.skill.active, 40);
|
||||
within(ended(h, u)[0].t - started(h, u)[0].t, s.duration, 0.05, 'duration');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('1_03 惊蛰 S1 攻击力强化·γ型: ATK +atk and the chain keeps its falloff (unlike 初雷)', () => {
|
||||
for (const id of pair('03')) {
|
||||
const s = rec(id, 'skcom_atk_up[3]'), t = tal(id);
|
||||
const h = run({
|
||||
defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) },
|
||||
units: [entry(id, s.skillId, { row: 10, col: 4, carryState: READY })],
|
||||
enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 6] }, { key: 'e', pos: [11, 6] }],
|
||||
});
|
||||
const u = sel(h, id, s.skillId);
|
||||
h.runUntil(() => dealt(h, u).length >= 3, 10);
|
||||
assert.ok(u.skill.active);
|
||||
approx(u.s.atk, u.base.atk * (1 + s.bb.atk), `${id} ATK`);
|
||||
const f = u.profile.chain.falloff;
|
||||
assert.ok(f > 0);
|
||||
const a = dealt(h, u).slice(0, 3).map((c) => c.amount);
|
||||
approx(a[0], u.s.atk * t.atk_scale, 'primary');
|
||||
approx(a[1], u.s.atk * t.atk_scale * (1 - f), 'jump 1 loses damage');
|
||||
approx(a[2], u.s.atk * t.atk_scale * (1 - f) ** 2, 'jump 2');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('1_04 深巡 S1 侵袭破坏应对: TAKE_DAMAGE — ATK/DEF +x, normal single-target attacks in the normal range', () => {
|
||||
for (const id of pair('04')) {
|
||||
const s = rec(id, 'skchr_udflow_1');
|
||||
assert.equal(s.trigger.rule, 'TAKE_DAMAGE');
|
||||
const h = run({
|
||||
defs: { enemies: { e: dummy('e'), a: dummy('a', { atk: 300 }) }, chess: noGarrison(id) },
|
||||
units: [entry(id, s.skillId, { row: 9, col: 4, carryState: READY })],
|
||||
enemies: [{ key: 'a', pos: [9, 4] }, { key: 'e', pos: [9, 5] }, { key: 'e', pos: [9, 6] }],
|
||||
});
|
||||
const u = sel(h, id, s.skillId);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 10));
|
||||
assert.equal(started(h, u)[0].reason, 'TAKE_DAMAGE');
|
||||
approx(u.s.atk, u.base.atk * (1 + s.bb.atk), 'ATK');
|
||||
approx(u.s.def, u.base.def * (1 + s.bb.def), 'DEF');
|
||||
assert.deepEqual([...u.rangeKeys].sort(), [...u.baseRangeKeys].sort(), 'range unchanged');
|
||||
const t0 = h.b.time;
|
||||
h.run(4);
|
||||
const atks = attacks(h, u, (c) => c.t > t0);
|
||||
assert.ok(atks.length > 0 && atks.every((c) => c.targets.length === 1), 'no piercing darts');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('1_06 刺玫 S1 战术咏唱·γ型: ASPD +attack_speed; the trait heal goes to the most injured ally, nobody is taunted', () => {
|
||||
for (const id of pair('06')) {
|
||||
const s = rec(id, 'skcom_magic_rage[3]');
|
||||
const ally = chessRec({ id: 't_ally', profession: 'WARRIOR', rangeGrid: [[0, 0]], skill: null, stats: { maxHp: 5000 } });
|
||||
const h = run({
|
||||
defs: { enemies: { e: dummy('e') }, chess: { ...noGarrison(id), t_ally: ally } },
|
||||
units: [entry(id, s.skillId, { row: 10, col: 4, carryState: READY }), { chessId: 't_ally', row: 10, col: 5 }],
|
||||
enemies: [{ key: 'e', pos: [10, 7] }],
|
||||
});
|
||||
const u = sel(h, id, s.skillId), a = h.unit('t_ally');
|
||||
h.step();
|
||||
a.hp = a.s.maxHp * 0.3;
|
||||
assert.ok(h.runUntil(() => heals(h, u).length >= 2, 10));
|
||||
assert.ok(u.skill.active);
|
||||
approx(u.s.aspd, u.base.aspd + s.bb.attack_speed, `${id} ASPD`);
|
||||
assert.ok(heals(h, u).every((c) => c.target === a), 'most injured ally healed');
|
||||
assert.equal(a.findBuff('vendla:taunt'), null, 'no taunt (荆藤庇荫 only)');
|
||||
assert.equal(dealt(h, u, (c) => c.dmg.tags?.includes('counter')).length, 0);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('1_07 普罗旺斯 S2 杀戮嗅觉: ATK +atk; normal attacks skip enemies above 80 % HP; holds fire with only those', () => {
|
||||
for (const id of pair('07')) {
|
||||
const s = rec(id, 'skchr_prove_2'), t = tal(id);
|
||||
// (a) a full-HP enemy in front and a wounded one behind it: every skill attack goes to the wounded one
|
||||
const h = run({
|
||||
defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) },
|
||||
units: [entry(id, s.skillId, { row: 10, col: 4, carryState: READY })],
|
||||
enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 6] }],
|
||||
});
|
||||
const u = sel(h, id, s.skillId);
|
||||
h.step();
|
||||
const [full, hurt] = h.enemies().sort((x, y) => x.x - y.x);
|
||||
hurt.hp = hurt.s.maxHp * 0.5;
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||
const atk = u.s.atk;
|
||||
approx(atk, u.base.atk * (1 + s.bb.atk), `${id} ATK`);
|
||||
h.runUntil(() => !u.skill.active, 40);
|
||||
const [st, en] = [started(h, u)[0].t, ended(h, u)[0].t];
|
||||
within(en - st, s.duration, 0.05, 'duration');
|
||||
const during = dealt(h, u, (c) => c.dmg.isAttack && c.t >= st && c.t < en);
|
||||
assert.ok(during.length >= 5);
|
||||
assert.ok(during.every((c) => c.target === hurt), 'only the ≤ 80 % enemy is targeted');
|
||||
// 狼眼 is S1: the damage is ATK (×狩猎箭头 on procs), never raised by the target's missing HP
|
||||
for (const c of during) assert.ok([1, t.atk_scale].some((k) => Math.abs(c.amount - atk * k) < 1e-6 * c.amount + 1e-6), `${c.amount}`);
|
||||
done(h);
|
||||
// (b) only a full-HP enemy: the skill starts (enemy in range) but she holds her fire until it ends
|
||||
const h2 = run({
|
||||
defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) },
|
||||
units: [entry(id, s.skillId, { row: 10, col: 4, carryState: READY })],
|
||||
enemies: [{ key: 'e', pos: [10, 6] }],
|
||||
});
|
||||
const u2 = sel(h2, id, s.skillId);
|
||||
assert.ok(h2.runUntil(() => u2.skill.active, 5));
|
||||
const t0 = h2.b.time;
|
||||
h2.runUntil(() => !u2.skill.active, 40);
|
||||
const t1 = ended(h2, u2)[0].t;
|
||||
assert.equal(attacks(h2, u2, (c) => c.t > t0 && c.t < t1).length, 0, 'no attack at a > 80 % HP enemy');
|
||||
h2.run(3);
|
||||
assert.ok(attacks(h2, u2, (c) => c.t >= t1).length > 0, 'attacks again after the skill');
|
||||
done(h2);
|
||||
}
|
||||
});
|
||||
|
||||
test('1_08 德克萨斯 S1 冲锋号令·γ型 (AUTO): +cost DP as soon as SP is full — no enemy needed, no damage', () => {
|
||||
for (const id of pair('08')) {
|
||||
const s = rec(id, 'skcom_charge_cost[3]'), t = tal(id);
|
||||
const h = run({ flags: { dpPerSec: 0 }, defs: { chess: noGarrison(id) }, units: [entry(id, s.skillId, { row: 9, col: 5 })] });
|
||||
const u = sel(h, id, s.skillId);
|
||||
h.step();
|
||||
const p = h.b.getPlayer('p1');
|
||||
const dp0 = p.dp;
|
||||
approx(dp0, h.b.flags.dpInit + t.cost, 'initial DP (+战术快递)');
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 60));
|
||||
const st = started(h, u)[0];
|
||||
assert.equal(st.reason, 'SP_FULL');
|
||||
approx(st.t, (s.spCost - s.initSp) / u.s.spRecovery, 'fires when SP is full', 0.02);
|
||||
approx(p.dp, dp0 + s.bb.cost, `${id} +${s.bb.cost} DP`);
|
||||
assert.equal(dealt(h, u).length, 0);
|
||||
assert.equal(statuses(h, 'stun').length, 0, 'no 剑雨');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('1_09 跃跃 S1 强力击·β型: every (cost+1)-th attack hits at atk_scale × ATK', () => {
|
||||
for (const id of pair('09')) {
|
||||
const s = rec(id, 'skchr_caper_1'), t = tal(id);
|
||||
const h = run({
|
||||
defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) },
|
||||
units: [entry(id, s.skillId, { row: 10, col: 3, carryState: READY })],
|
||||
enemies: [{ key: 'e', pos: [10, 6] }],
|
||||
});
|
||||
const u = sel(h, id, s.skillId);
|
||||
assert.ok(h.runUntil(() => attacks(h, u).length >= s.spCost + 2, 30));
|
||||
h.run(0.5); // boomerangs in flight
|
||||
const flags = attacks(h, u).slice(0, s.spCost + 2).map((c) => !!c.isSkill);
|
||||
assert.deepEqual(flags, [true, ...Array(s.spCost).fill(false), true], `${id}: skill attack, ${s.spCost} normal ones, skill attack`);
|
||||
const hits = dealt(h, u, (c) => c.dmg.isAttack);
|
||||
const skillHits = hits.filter((c) => c.dmg.isSkill);
|
||||
assert.ok(skillHits.length >= 2);
|
||||
const ok = (c, k) => [1, t.atk_scale].some((p) => Math.abs(c.amount - u.s.atk * k * p) < 1e-6 * c.amount + 1e-6);
|
||||
for (const c of skillHits) assert.ok(ok(c, s.bb.atk_scale), `skill hit ${c.amount}`);
|
||||
for (const c of hits.filter((x) => !x.dmg.isSkill)) assert.ok(ok(c, 1), `normal hit ${c.amount}`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('1_10 古米 S2 食粮烹制: disarm s cooking (no attacks, DEF +def), then heals in the skill grid (ATK +atk, interval +130 %)', () => {
|
||||
for (const id of pair('10')) {
|
||||
const s = rec(id, 'skchr_sunbr_2'), bb = s.bb, tb = raw(id).trait.bb;
|
||||
const ally = chessRec({ id: 't_ally', profession: 'WARRIOR', rangeGrid: [[0, 0]], skill: null, stats: { maxHp: 20000 } });
|
||||
const h = run({
|
||||
defs: { enemies: { e: dummy('e') }, chess: { ...noGarrison(id), t_ally: ally } },
|
||||
units: [entry(id, s.skillId, { row: 9, col: 5, carryState: READY }), { chessId: 't_ally', row: 10, col: 6 }],
|
||||
enemies: [{ key: 'e', pos: [9, 5] }],
|
||||
});
|
||||
const u = sel(h, id, s.skillId), a = h.unit('t_ally');
|
||||
h.step();
|
||||
a.hp = a.s.maxHp * 0.3;
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||
const st = started(h, u)[0].t;
|
||||
assert.equal(started(h, u)[0].reason, 'DEFAULT');
|
||||
approx(u.s.def, u.base.def * (1 + bb.def), `${id} cooking DEF`);
|
||||
approx(u.s.atk, u.base.atk, 'no ATK while cooking');
|
||||
h.runUntil(() => h.b.time >= st + bb.disarm - 0.2, 20);
|
||||
assert.equal(attacks(h, u, (c) => c.t >= st).length, 0, 'no attack while cooking');
|
||||
assert.ok(h.runUntil(() => heals(h, u).length > 0, 5));
|
||||
const first = heals(h, u)[0];
|
||||
assert.ok(first.t >= st + bb.disarm - 1e-6, 'heals once cooking is done');
|
||||
assert.equal(first.target, a);
|
||||
approx(u.s.atk, u.base.atk * (1 + bb.atk), 'served: ATK +atk');
|
||||
approx(u.s.def, u.base.def, 'served: DEF back');
|
||||
approx(u.s.interval, u.base.bat * (1 + bb.base_attack_time) * 100 / u.s.aspd, 'interval +130 %');
|
||||
const k = tb.hp_ratio != null ? tb.heal_scale : 1; // elite module GUA-X below 50 % HP
|
||||
approx(first.amount, u.s.atk * k, `${id} heal`);
|
||||
h.runUntil(() => !u.skill.active, 60);
|
||||
within(ended(h, u)[0].t - st, bb.disarm + s.duration, 0.05, 'real duration = disarm + duration');
|
||||
assert.equal(dealt(h, u, (c) => c.t > st && c.t < ended(h, u)[0].t).length, 0, 'no damage dealt during the skill');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('1_12 艾丝黛尔 S1 攻击力强化·β型: ATK +atk (time SP) and she can still be healed', () => {
|
||||
for (const id of pair('12')) {
|
||||
const s = rec(id, 'skcom_atk_up[2]');
|
||||
const h = run({ defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) }, units: [entry(id, s.skillId, { row: 9, col: 5, carryState: READY })], enemies: [{ key: 'e', pos: [9, 6] }] });
|
||||
const u = sel(h, id, s.skillId);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||
assert.equal(u.skill.spType, 'time');
|
||||
approx(u.s.atk, u.base.atk * (1 + s.bb.atk), `${id} ATK`);
|
||||
u.hp = u.s.maxHp * 0.5;
|
||||
assert.ok(h.b.heal(null, u, 100) > 0, 'a heal target (舍身突击 only forbids it)');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('1_13 波登可 S1 花香疗法: a heal skill — ATK +atk, attacks heal the most injured ally; fires with no enemy, never without injured', () => {
|
||||
for (const id of pair('13')) {
|
||||
const s = rec(id, 'skchr_podego_1');
|
||||
const ally = chessRec({ id: 't_ally', profession: 'WARRIOR', rangeGrid: [[0, 0]], skill: null, stats: { maxHp: 20000 } });
|
||||
const defs = { enemies: { e: dummy('e') }, chess: { ...noGarrison(id), t_ally: ally } };
|
||||
// (a) enemy and injured ally in range: heals only, for ATK
|
||||
const h = run({ defs, units: [entry(id, s.skillId, { row: 10, col: 4, carryState: READY }), { chessId: 't_ally', row: 10, col: 5 }], enemies: [{ key: 'e', pos: [10, 6] }] });
|
||||
const u = sel(h, id, s.skillId), a = h.unit('t_ally');
|
||||
h.step();
|
||||
a.hp = a.s.maxHp * 0.3;
|
||||
assert.ok(h.runUntil(() => heals(h, u).length >= 2, 10));
|
||||
assert.ok(u.skill.active);
|
||||
approx(u.s.atk, u.base.atk * (1 + s.bb.atk + tal(id).atk), `${id} ATK (+园丁 on herself, a 【辅助】)`);
|
||||
for (const c of heals(h, u)) { assert.equal(c.target, a); approx(c.amount, u.s.atk, 'heal = ATK'); }
|
||||
const st = started(h, u)[0].t;
|
||||
h.runUntil(() => !u.skill.active, 40);
|
||||
within(ended(h, u)[0].t - st, s.duration, 0.05, 'duration');
|
||||
assert.equal(attacks(h, u, (c) => c.t > st && c.t < ended(h, u)[0].t && c.targets.some((x) => x.side === 'enemy')).length, 0, 'no attack on enemies');
|
||||
done(h);
|
||||
// (b) no enemy on the field: an injured ally in range is enough
|
||||
const h2 = run({ defs, units: [entry(id, s.skillId, { row: 10, col: 4, carryState: READY }), { chessId: 't_ally', row: 10, col: 5 }] });
|
||||
const u2 = sel(h2, id, s.skillId);
|
||||
h2.step();
|
||||
h2.unit('t_ally').hp = 5000;
|
||||
assert.ok(h2.runUntil(() => heals(h2, u2).length > 0, 5), 'heals without an enemy');
|
||||
assert.equal(started(h2, u2)[0].reason, 'DEFAULT');
|
||||
done(h2);
|
||||
// (c) an enemy but nobody injured: the heal skill waits
|
||||
const h3 = run({ defs, units: [entry(id, s.skillId, { row: 10, col: 4, carryState: READY }), { chessId: 't_ally', row: 10, col: 5 }], enemies: [{ key: 'e', pos: [10, 6] }] });
|
||||
const u3 = sel(h3, id, s.skillId);
|
||||
h3.run(6);
|
||||
assert.equal(u3.skill.activations, 0);
|
||||
assert.ok(dealt(h3, u3, (c) => c.dmg.isAttack).length > 0, 'keeps attacking the enemy');
|
||||
done(h3);
|
||||
}
|
||||
});
|
||||
|
||||
test('1_14 格雷伊 S1 战术咏唱·β型: ASPD +attack_speed; the talent 停顿 keeps its length (no ×talent_scale)', () => {
|
||||
for (const id of pair('14')) {
|
||||
const s = rec(id, 'skcom_magic_rage[2]'), t = tal(id);
|
||||
const h = run({ defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) }, units: [entry(id, s.skillId, { row: 10, col: 4, carryState: READY })], enemies: [{ key: 'e', pos: [10, 6] }] });
|
||||
const u = sel(h, id, s.skillId);
|
||||
assert.ok(h.runUntil(() => u.skill.active && statuses(h, 'sluggish', (c) => c.source === u).length > 0, 10));
|
||||
approx(u.s.aspd, u.base.aspd + s.bb.attack_speed, `${id} ASPD`);
|
||||
for (const c of statuses(h, 'sluggish', (x) => x.source === u)) approx(c.duration, t.sluggish, '停顿');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('1_17 深靛 S1 灯塔守卫者: skill range, attack interval ×0.2 (−80 %), attack@atk_scale × ATK arts per hit, 4 s', () => {
|
||||
for (const id of pair('17')) {
|
||||
const s = rec(id, 'skchr_indigo_1'), bb = s.bb;
|
||||
const h = run({
|
||||
defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) },
|
||||
units: [entry(id, s.skillId, { row: 10, col: 3, carryState: READY })],
|
||||
enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 6] }],
|
||||
// 柔光缚目 binds are tested with S2: here bound enemies would stop being targets
|
||||
setup: (b) => b.on('beforeStatus', (c) => { if (c.status === 'bind') c.cancel = true; }),
|
||||
});
|
||||
const u = sel(h, id, s.skillId);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||
const st = h.b.time;
|
||||
assert.deepEqual([...u.rangeKeys].sort((x, y) => x - y), absoluteRangeKeys(s.rangeGrid, u.tileR, u.tileC, u.dir, 0).sort((x, y) => x - y), 'skill range');
|
||||
approx(u.s.interval, u.base.bat * (1 + bb.base_attack_time) * 100 / u.s.aspd, `${id} interval ×0.2`);
|
||||
h.runUntil(() => !u.skill.active, 10);
|
||||
within(h.b.time - st, s.duration, 0.05, 'duration');
|
||||
h.run(0.5); // bolts in flight
|
||||
const hits = dealt(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill);
|
||||
assert.ok(hits.length >= Math.floor(s.duration / (u.base.bat * (1 + bb.base_attack_time))) - 1, `${hits.length} hits in ${s.duration} s`);
|
||||
for (const c of hits) { assert.equal(c.type, 'arts'); approx(c.amount, u.s.atk * bb['attack@atk_scale'], 'hit'); }
|
||||
assert.deepEqual([...u.rangeKeys].sort(), [...u.baseRangeKeys].sort(), 'range restored');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('1_18 宴 S1 分神: no attacks, block 0 (releases), DEF +def, regenerates hp ratio × max HP per s (under 武者 no-heal)', () => {
|
||||
for (const id of pair('18')) {
|
||||
const s = rec(id, 'skchr_utage_1'), bb = s.bb;
|
||||
const h = run({
|
||||
defs: { enemies: { e: dummy('e', { atk: 1 }) }, chess: noGarrison(id) },
|
||||
units: [entry(id, s.skillId, { row: 9, col: 5, carryState: READY })],
|
||||
enemies: [{ key: 'e', pos: [9, 5] }],
|
||||
});
|
||||
const u = sel(h, id, s.skillId);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||
const st = h.b.time;
|
||||
const e = h.enemies()[0];
|
||||
assert.equal(u.s.blockCnt, 0);
|
||||
assert.equal(u.blocking.length, 0);
|
||||
assert.equal(e.blockedBy, null, 'blocked enemy released');
|
||||
approx(u.s.def, u.base.def * (1 + bb.def), `${id} DEF`);
|
||||
approx(u.s.hpRegen, u.s.maxHp * bb.hp_recovery_per_sec_by_max_hp_ratio, 'regen');
|
||||
u.hp = u.s.maxHp * 0.3;
|
||||
const hp0 = u.hp;
|
||||
h.run(2);
|
||||
assert.equal(e.blockedBy, null, 'blocks nothing while the skill runs');
|
||||
assert.ok(u.hp - hp0 >= 2 * u.s.maxHp * bb.hp_recovery_per_sec_by_max_hp_ratio - 50, `${id} regenerates (${u.hp - hp0})`);
|
||||
h.runUntil(() => !u.skill.active, 20);
|
||||
const en = ended(h, u)[0].t;
|
||||
within(en - started(h, u)[0].t, s.duration, 0.05, 'duration');
|
||||
assert.equal(attacks(h, u, (c) => c.t > st && c.t < en).length, 0, 'stops attacking');
|
||||
assert.ok(u.s.blockCnt >= 1, 'block restored');
|
||||
h.run(3);
|
||||
assert.ok(attacks(h, u, (c) => c.t >= en).length > 0, 'attacks again');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('1_19 野鬃 S1 骑枪刺击 (PASSIVE): ASPD +attack_speed for `duration` s after every deployment', () => {
|
||||
for (const id of pair('19')) {
|
||||
const s = rec(id, 'skchr_wildmn_1');
|
||||
const h = run({ defs: { chess: noGarrison(id) }, units: [entry(id, s.skillId, { row: 9, col: 5 })] });
|
||||
const u = sel(h, id, s.skillId);
|
||||
assert.equal(u.skill.kind, 'passive');
|
||||
h.step();
|
||||
approx(u.s.aspd, u.base.aspd + s.bb.attack_speed, `${id} ASPD after deployment`);
|
||||
h.run(s.duration - 0.5);
|
||||
approx(u.s.aspd, u.base.aspd + s.bb.attack_speed, 'still up');
|
||||
h.run(1);
|
||||
approx(u.s.aspd, u.base.aspd, `expired after ${s.duration} s`);
|
||||
h.b.kill(u, null);
|
||||
h.step();
|
||||
assert.ok(h.b.redeploy(u, { free: true }));
|
||||
h.step();
|
||||
approx(u.s.aspd, u.base.aspd + s.bb.attack_speed, 'again after the redeployment');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('1_20 雷蛇 S1 充能防御: TAKE_DAMAGE — DEF +def for bb.duration s; the next hit is blocked (no 战术防御 SP from it)', () => {
|
||||
for (const id of pair('20')) {
|
||||
const s = rec(id, 'skchr_liskam_1'), bb = s.bb, t = tal(id);
|
||||
const mate = chessRec({ id: 't_mate', profession: 'WARRIOR', rangeGrid: [[0, 0]], skill: { spType: 'INCREASE_WHEN_ATTACK', spCost: 200 } });
|
||||
const h = run({
|
||||
defs: { enemies: { a: dummy('a', { atk: 700, bat: 1 }) }, chess: { ...noGarrison(id), t_mate: mate } },
|
||||
units: [entry(id, s.skillId, { row: 9, col: 4, carryState: READY }), { chessId: 't_mate', row: 10, col: 4 }],
|
||||
enemies: [{ key: 'a', pos: [9, 4] }],
|
||||
});
|
||||
const u = sel(h, id, s.skillId), m = h.unit('t_mate');
|
||||
assert.ok(h.runUntil(() => u.skill.active, 10));
|
||||
const st = h.b.time;
|
||||
assert.equal(started(h, u)[0].reason, 'TAKE_DAMAGE');
|
||||
approx(u.s.def, u.base.def * (1 + bb.def), `${id} DEF`);
|
||||
const hp = u.hp;
|
||||
const sp = m.skill.sp;
|
||||
assert.ok(h.runUntil(() => h.hooksOf('hit').some((c) => c.target === u && c.t > st), 5));
|
||||
h.step();
|
||||
const blocked = h.hooksOf('hit').filter((c) => c.target === u && c.t > st);
|
||||
assert.equal(blocked.length, 1);
|
||||
assert.equal(blocked[0].dmg.cancel, true, 'the next hit is blocked');
|
||||
assert.equal(u.hp, hp, 'no HP lost');
|
||||
assert.equal(m.skill.sp, sp, 'a blocked hit gives no 战术防御 SP');
|
||||
h.runUntil(() => h.hooksOf('hit').filter((c) => c.target === u && c.t > st).length >= 2, 5);
|
||||
h.step();
|
||||
assert.equal(h.hooksOf('hit').filter((c) => c.target === u && c.t > st)[1].dmg.cancel, false, 'only one hit blocked');
|
||||
assert.ok(u.hp < hp);
|
||||
approx(m.skill.sp, sp + t.sp, 'the following hit feeds the talent again');
|
||||
h.runUntil(() => !u.skill.active, 20);
|
||||
within(h.b.time - st, bb.duration, 0.05, 'duration');
|
||||
approx(u.s.def, u.base.def, 'DEF back');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
// =================================================================================================================
|
||||
// modules: every tier-1 elite has one module; 'none' is the non-default choice
|
||||
|
||||
test('modules: 无模组 stats = statsBase; the default module adds its attr', () => {
|
||||
for (const base of ds.chessIds().filter((id) => /^chess_char_1_\d+_a$/.test(id) && raw(id).visible)) {
|
||||
const id = base.replace(/_a$/, '_b');
|
||||
const r = raw(id);
|
||||
assert.equal(r.modules.length, 1, id);
|
||||
const h = run({ units: [{ chessId: id, row: 9, col: 4, uid: 1 }, { chessId: id, row: 10, col: 4, uid: 2, moduleId: 'none' }] });
|
||||
const [dm, nm] = [h.unit(1), h.unit(2)];
|
||||
for (const k of ['maxHp', 'atk', 'def', 'res', 'blockCnt', 'cost', 'respawnTime']) {
|
||||
approx(nm.base[k], r.statsBase[k], `${id} none ${k}`);
|
||||
approx(dm.base[k], r.statsBase[k] + (r.modules[0].attr[k] ?? 0), `${id} default ${k}`);
|
||||
}
|
||||
assert.equal(nm.def.raw.module.active, false);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('modules: profile traits follow the module (隐现 air ×1.1, 惊蛰 chain, 刺玫 heal ratio, 深靛 store, 野鬃 DP, 角峰 block)', () => {
|
||||
const cases = [
|
||||
['01', (u) => u.profile.flyScale, 1.1, 1],
|
||||
['03', (u) => [u.profile.chain.count, +u.profile.chain.falloff.toFixed(6), u.profile.chain.sluggish].join('/'), [4, 0.1, 0.8].join('/'), [4, 0.15, 0.5].join('/')],
|
||||
['06', (u) => u.profile.healRatio, 0.6, 0.5],
|
||||
['17', (u) => u.profile.storeMax, 4, 3],
|
||||
['19', (u) => u.profile.dpOnKill, 2, 1],
|
||||
['02', (u) => u.s.blockCnt, 4, 3],
|
||||
];
|
||||
for (const [n, get, withMod, without] of cases) {
|
||||
const id = `chess_char_1_${n}_b`;
|
||||
const h = run({ units: [{ chessId: id, row: 9, col: 4, uid: 1 }, { chessId: id, row: 10, col: 4, uid: 2, moduleId: 'none' }] });
|
||||
h.step();
|
||||
assert.deepEqual([get(h.unit(1)), get(h.unit(2))], [withMod, without], id);
|
||||
done(h);
|
||||
}
|
||||
// 隐现: the air bonus is real damage
|
||||
const id = 'chess_char_1_01_b';
|
||||
const h = run({
|
||||
defs: { enemies: { f: dummy('f', { motion: 'FLY' }) }, chess: noGarrison(id) },
|
||||
units: [{ chessId: id, row: 9, col: 4, uid: 1 }, { chessId: id, row: 11, col: 4, uid: 2, moduleId: 'none' }],
|
||||
enemies: [{ key: 'f', pos: [10, 6], route: 2 }],
|
||||
});
|
||||
h.runUntil(() => dealt(h, h.unit(1)).length && dealt(h, h.unit(2)).length, 10);
|
||||
approx(dealt(h, h.unit(1))[0].amount, h.unit(1).s.atk * 1.1, 'MAR-X vs air');
|
||||
approx(dealt(h, h.unit(2))[0].amount, h.unit(2).s.atk, 'no module');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('modules none: 深巡 / 雷蛇 lose the SPT-X reveal (kept with the default module, also when S1 is carried)', () => {
|
||||
for (const n of ['04', '20']) {
|
||||
const id = `chess_char_1_${n}_b`;
|
||||
const s1 = raw(id).skills.find((x) => !x.isDefault).skillId;
|
||||
const h = run({
|
||||
defs: { enemies: { e: dummy('e') } },
|
||||
units: [{ chessId: id, row: 9, col: 4, uid: 1 }, entry(id, s1, { row: 11, col: 4, uid: 2 }), { chessId: id, row: 12, col: 4, uid: 3, moduleId: 'none' }],
|
||||
enemies: [{ key: 'e', pos: [9, 6] }, { key: 'e', pos: [11, 6] }, { key: 'e', pos: [12, 6] }],
|
||||
});
|
||||
h.step();
|
||||
for (const e of h.enemies()) h.b.applyStatus(e, 'stealth', { duration: 100 });
|
||||
h.run(0.5);
|
||||
const at = (r) => h.enemies().find((e) => Math.round(e.y) === r);
|
||||
assert.ok(at(9).s.flags.reveal, `${id} default`);
|
||||
assert.ok(at(11).s.flags.reveal, `${id} S1 + module`);
|
||||
assert.ok(!at(12).s.flags.reveal, `${id} no module: no reveal`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('modules none: 跃跃 no ×1.1 on adjacent enemies; 艾丝黛尔 no −20 % physical above 50 %; 宴 no 庇护 below 50 %', () => {
|
||||
// 跃跃: adjacent target, no skill
|
||||
{
|
||||
const id = 'chess_char_1_09_b', t = tal(id);
|
||||
for (const [moduleId, k] of [[undefined, 1.1], ['none', 1]]) {
|
||||
const h = run({ defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4, ...(moduleId ? { moduleId } : {}) }], enemies: [{ key: 'e', pos: [10, 5] }] });
|
||||
const u = h.unit(id);
|
||||
h.runUntil(() => dealt(h, u).length >= 4, 20);
|
||||
for (const c of dealt(h, u).filter((x) => !x.dmg.isSkill)) assert.ok([1, t.atk_scale].some((p) => Math.abs(c.amount - u.s.atk * k * p) < 1e-6 * c.amount), `跃跃 ${moduleId ?? 'LPS-X'} ${c.amount}`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// 艾丝黛尔 above 50 % HP / 宴 below 50 % HP: a physical enemy hit of 1000
|
||||
for (const [id, hpRatio, red] of [
|
||||
['chess_char_1_12_b', 0.9, raw('chess_char_1_12_b').trait.bb.damage_resistance],
|
||||
['chess_char_1_18_b', 0.3, raw('chess_char_1_18_b').modules[0].talentChanges[0].bb.damage_resistance],
|
||||
]) {
|
||||
for (const [moduleId, k] of [[undefined, 1 - red], ['none', 1]]) {
|
||||
const h = run({ defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 9, col: 5, ...(moduleId ? { moduleId } : {}) }], enemies: [{ key: 'e', pos: [9, 7] }] });
|
||||
const u = h.unit(id);
|
||||
h.step();
|
||||
u.hp = u.s.maxHp * hpRatio;
|
||||
const got = h.b.dealDamage(h.enemies()[0], u, { amount: 1000, type: 'phys', isAttack: true, canDodge: false });
|
||||
approx(got, (1000 - u.s.def) * k, `${id} ${moduleId ?? 'module'}`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('modules none: 古米 heals without the GUA-X bonus (S1 and S2); 波登可 without +sp_recovery_per_sec SP/s', () => {
|
||||
const id = 'chess_char_1_10_b';
|
||||
for (const skillId of ['skchr_sunbr_1', 'skchr_sunbr_2']) {
|
||||
const s = rec(id, skillId);
|
||||
const ally = chessRec({ id: 't_ally', profession: 'WARRIOR', rangeGrid: [[0, 0]], skill: null, stats: { maxHp: 20000 } });
|
||||
const h = run({
|
||||
defs: { enemies: { e: dummy('e', { atk: 1 }) }, chess: { ...noGarrison(id), t_ally: ally } },
|
||||
units: [entry(id, skillId, { row: 9, col: 5, carryState: READY, moduleId: 'none' }), { chessId: 't_ally', row: 10, col: 5 }],
|
||||
enemies: [{ key: 'e', pos: [9, 5] }],
|
||||
});
|
||||
const u = h.unit(id), a = h.unit('t_ally');
|
||||
h.step();
|
||||
a.hp = a.s.maxHp * 0.3;
|
||||
assert.ok(h.runUntil(() => heals(h, u).length > 0, 20), skillId);
|
||||
approx(heals(h, u)[0].amount, u.s.atk * (s.bb.heal_scale ?? 1), `${skillId}: no ×1.15 without the module`);
|
||||
done(h);
|
||||
}
|
||||
const pid = 'chess_char_1_13_b', bonus = raw(pid).modules[0].talentChanges.find((x) => x.talentIndex === -1).bb.sp_recovery_per_sec;
|
||||
const sp = {};
|
||||
for (const moduleId of [undefined, 'none']) {
|
||||
const h = run({ defs: { enemies: { e: dummy('e') }, chess: noGarrison(pid) }, units: [{ chessId: pid, row: 10, col: 4, ...(moduleId ? { moduleId } : {}) }], enemies: [{ key: 'e', pos: [10, 6] }] });
|
||||
const u = h.unit(pid);
|
||||
h.step();
|
||||
const s0 = u.skill.sp;
|
||||
h.run(5);
|
||||
sp[moduleId ?? 'DEC-X'] = u.skill.sp - s0;
|
||||
done(h);
|
||||
}
|
||||
approx(sp['DEC-X'] - sp.none, 5 * bonus, '波登可 module SP bonus', 1e-2);
|
||||
});
|
||||
|
||||
// =================================================================================================================
|
||||
// review fixes (default skills / talents met by the alternate loadouts) — regression tests
|
||||
|
||||
test('1_07 普罗旺斯 狼眼 is continuous (PRTS: ×(1 + lost / 20 % × atk_scale_up)), not in 20 % steps', () => {
|
||||
for (const id of pair('07')) {
|
||||
const bb = raw(id).skill.bb;
|
||||
assert.equal(raw(id).skill.skillId, 'skchr_prove_1');
|
||||
const h = run({ defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'e', pos: [10, 6] }] });
|
||||
h.step();
|
||||
const u = h.unit(id), e = h.enemies()[0];
|
||||
for (const ratio of [0.9, 0.55, 0.3, 0.05]) {
|
||||
e.hp = e.s.maxHp * ratio;
|
||||
// an attack instance (splash ⇒ no 狩猎箭头 roll)
|
||||
const got = h.b.dealDamage(u, e, { amount: 1000, type: 'true', canDodge: false, isAttack: true, isSplash: true });
|
||||
approx(got, 1000 * (1 + ((1 - ratio) / bb.hp_ratio_drop) * bb.atk_scale_up), `${id} at ${ratio * 100} % HP`);
|
||||
}
|
||||
e.hp = e.s.maxHp;
|
||||
approx(h.b.dealDamage(u, e, { amount: 1000, type: 'true', canDodge: false, isAttack: true, isSplash: true }), 1000, 'full HP: no bonus');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('1_20 雷蛇 反击电弧: attack interval +70 % of the base (PRTS 增大(+70%)), normal and elite', () => {
|
||||
for (const id of pair('20')) {
|
||||
const bb = raw(id).skill.bb;
|
||||
assert.equal(raw(id).skill.skillId, 'skchr_liskam_2');
|
||||
const h = run({ defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 9, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [9, 5] }] });
|
||||
const u = h.unit(id);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||
approx(u.s.interval, u.base.bat * (1 + bb.base_attack_time) * 100 / u.s.aspd, `${id} interval ×${1 + bb.base_attack_time}`);
|
||||
h.runUntil(() => !u.skill.active, 30);
|
||||
approx(u.s.interval, u.base.bat * 100 / u.s.aspd, 'restored');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('刺玫 土壤基肥改良 (+heal_scale healing received) never boosts HP regeneration (角峰 S1 / 宴 S1 regen)', () => {
|
||||
const vendla = 'chess_char_1_06_a', yak = 'chess_char_1_02_a', s = rec(yak, 'skchr_yak_1');
|
||||
const t = tal(vendla);
|
||||
const h = run({
|
||||
defs: { enemies: { e: dummy('e', { atk: 1, bat: 5 }) }, chess: noGarrison(vendla, yak) },
|
||||
units: [{ chessId: vendla, row: 10, col: 3 }, entry(yak, s.skillId, { row: 9, col: 5, carryState: READY })],
|
||||
enemies: [{ key: 'e', pos: [9, 5] }],
|
||||
});
|
||||
const v = h.unit(vendla), y = h.unit(yak);
|
||||
assert.ok(h.runUntil(() => y.skill.active, 10), '体能强化 running');
|
||||
y.hp = y.s.maxHp * 0.3;
|
||||
approx(h.b.heal(null, y, 100), 100 * t.heal_scale, '治疗: ×heal_scale (the highest max-HP ally in range)');
|
||||
assert.equal(h.b.heal(y, y, 100, { self: true, silent: true, regen: true }), 100, '生命回复: unchanged');
|
||||
const t0 = h.b.time;
|
||||
h.run(3);
|
||||
const regen = heals(h, y, (c) => c.target === y && c.opts?.regen && c.t > t0).reduce((n, c) => n + c.amount, 0);
|
||||
assert.ok(Math.abs(regen - 3 * (y.s.hpRegen)) <= 2, `regen ${regen} ≈ ${3 * y.s.hpRegen} (no ×${t.heal_scale})`);
|
||||
assert.ok(v.alive && v.deployed);
|
||||
done(h);
|
||||
});
|
||||
@@ -0,0 +1,746 @@
|
||||
// Operator loadouts (DESIGN §16) for the tier-2 kits (server/sim/content/kits/tier2.js): every selectable NON-default
|
||||
// skill of every visible tier-2 chess is hand-authored (tools/kit-coverage.mjs) and shows its signature effect for the
|
||||
// normal (Lv4) and the elite (Lv7) chess — numbers from the selected skill's blackboard (data/chess.json skills[]) —
|
||||
// and the elite's module choice ('none' instead of the default module) changes what the kit / profile does.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
import { KITS, skillSpecSource } from '../../server/sim/content/index.js';
|
||||
import { kitCoverage } from '../../tools/kit-coverage.mjs';
|
||||
|
||||
const ds = getDefaultSource();
|
||||
const raw = (id) => ds.rawChess(id);
|
||||
/** The selectable non-default SkillRecord of a chess (tier 2 chess are E1: one alternative). */
|
||||
const alt = (id) => raw(id).skills.find((s) => !s.isDefault);
|
||||
const bbAlt = (id) => alt(id).bb;
|
||||
/** Named talent blackboard / trait blackboard of the loadout-resolved record. */
|
||||
const tal = (id, lo = null, i = 0) => ds.getChess(id, lo).raw.talents.filter((t) => t.index !== -1)[i]?.bb ?? {};
|
||||
const tbOf = (id, lo = null) => ds.getChess(id, lo).raw.trait?.bb ?? {};
|
||||
const both = (base) => [`${base}_a`, `${base}_b`];
|
||||
const NONE = { moduleId: 'none' };
|
||||
|
||||
const approx = (a, b, msg = '', rel = 1e-6) => assert.ok(Math.abs(a - b) <= rel * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
|
||||
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
|
||||
const READY = { sp: 999 }; // carryState: skill fully charged at deployment
|
||||
const IDLE = { sp: 0 }; // carryState: no initial SP
|
||||
const HOOKS = ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied', 'attack', 'death', 'deploy'];
|
||||
|
||||
function run(o) {
|
||||
return makeBattle({ seed: 7, autoFinish: false, timeLimit: 400, hooks: HOOKS, captureNoisy: true, ...o });
|
||||
}
|
||||
/** Unit entry with the alternate skill selected (plus loadout / carry extras). */
|
||||
const U = (id, row, col, o = {}) => ({ chessId: id, row, col, skillIndex: alt(id).index, ...o });
|
||||
const dealt = (h, u, f = () => true) => h.hooksOf('damaged').filter((c) => c.source === u && f(c));
|
||||
const statuses = (h, key, f = () => true) => h.hooksOf('statusApplied').filter((c) => c.status === key && f(c));
|
||||
const heals = (h, u, f = () => true) => h.hooksOf('heal').filter((c) => c.source === u && f(c));
|
||||
const tagged = (tag) => (c) => (c.dmg?.tags || c.opts?.tags || []).includes(tag);
|
||||
const started = (h, u) => h.hooksOf('skillStart').filter((c) => c.unit === u);
|
||||
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
|
||||
/** The selected skill is the alternative one, authored by the kit (not the generic fallback). */
|
||||
function usesAlt(u, id) {
|
||||
assert.equal(u.skill.id, alt(id).skillId, `${id} runs ${alt(id).name}`);
|
||||
assert.equal(u.kit.skillSource, 'skills', `${id}: hand-authored spec`);
|
||||
}
|
||||
/** defs.chess override: the chess without its 特质 (garrison buffs would blur the ATK numbers). */
|
||||
const noGarrison = (id) => ({ [id]: { ...raw(id), garrisonIds: [] } });
|
||||
/** Plain operator without skill / talents (heal targets, lock candidates). */
|
||||
const plain = (id, o = {}) => chessRec({ id, skill: null, ...o });
|
||||
|
||||
// =================================================================================================================
|
||||
// coverage
|
||||
|
||||
test('kit coverage: every selectable skill of every visible tier-2 chess is hand-authored (normal + elite)', () => {
|
||||
const rep = kitCoverage({ tier: 2 });
|
||||
assert.equal(rep.summary.chess, 17);
|
||||
assert.equal(rep.summary.covered, rep.summary.skills, JSON.stringify(rep.chess.filter((r) => r.skills.some((s) => !s.covered)).map((r) => r.name)));
|
||||
for (const r of rep.chess) {
|
||||
for (const s of r.skills.filter((x) => !x.isDefault)) {
|
||||
assert.deepEqual([s.normal, s.elite], ['skills', 'skills'], `${r.name} S${s.index + 1} ${s.name}`);
|
||||
// the def of every legal loadout (module choices included) resolves to the authored spec
|
||||
for (const id of [r.chessId, raw(r.chessId).goldenId].filter(Boolean)) {
|
||||
for (const moduleId of [null, 'none']) {
|
||||
const def = ds.getChess(id, { skillIndex: s.index, moduleId });
|
||||
assert.equal(def.skill.id, s.skillId);
|
||||
assert.equal(skillSpecSource(def, KITS), 'skills', `${id} ${s.skillId} module ${moduleId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// =================================================================================================================
|
||||
// alternate skills (normal Lv4 + elite Lv7)
|
||||
|
||||
test('2_01 送葬人 S1 铳口收束: ATK +atk; the trait multiplier hits every enemy in range (not only the front row); no double hits', () => {
|
||||
for (const id of both('chess_char_2_01')) {
|
||||
const bb = bbAlt(id), fs = tbOf(id).atk_scale;
|
||||
const setup = (carry) => run({
|
||||
defs: { enemies: { e: dummy('e') } },
|
||||
units: [U(id, 10, 4, { carryState: carry })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [11, 6] }],
|
||||
});
|
||||
// control: without the skill the off-row enemy takes ×1
|
||||
const c = setup(IDLE);
|
||||
c.step();
|
||||
const cu = c.unit(id), off0 = c.enemies()[1];
|
||||
c.run(1);
|
||||
assert.ok(!cu.skill.active);
|
||||
approx(dealt(c, cu, (x) => x.target === off0)[0].amount, cu.s.atk, `${id} off-row ×1 without the skill`);
|
||||
done(c);
|
||||
|
||||
const h = setup(READY);
|
||||
const u = h.unit(id);
|
||||
usesAlt(u, id);
|
||||
h.run(3);
|
||||
assert.ok(u.skill.active);
|
||||
approx(u.s.atk, u.base.atk * (1 + bb.atk), 'ATK');
|
||||
approx(u.s.interval, u.base.bat * 100 / u.s.aspd, 'attack interval unchanged (最终旅程 only)');
|
||||
const [front, off] = h.enemies();
|
||||
const hits = dealt(h, u, (x) => x.dmg.isAttack);
|
||||
const atks = h.hooksOf('attack').filter((x) => x.attacker === u).length;
|
||||
assert.equal(hits.length, 2 * atks, 'one hit per enemy and attack');
|
||||
for (const x of hits.filter((y) => y.target === off)) approx(x.amount, u.s.atk * fs, `${id} off-row ×${fs}`);
|
||||
for (const x of hits.filter((y) => y.target === front)) approx(x.amount, u.s.atk * fs, `${id} front ×${fs}`);
|
||||
assert.ok(hits.some((y) => y.target === off));
|
||||
assert.equal(u.skill.duration, alt(id).duration);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('2_02 赫默 S1 治疗强化·γ型: ATK +atk heals for its duration, no drone', () => {
|
||||
for (const id of both('chess_char_2_02')) {
|
||||
const bb = bbAlt(id);
|
||||
const h = run({ defs: { chess: { t_a: plain('t_a') } }, units: [U(id, 10, 4, { carryState: READY }), { chessId: 't_a', row: 9, col: 6 }] });
|
||||
const u = h.unit(id), a = h.unit('t_a');
|
||||
usesAlt(u, id);
|
||||
h.step();
|
||||
a.hp = a.s.maxHp * 0.3;
|
||||
h.run(1);
|
||||
assert.ok(u.skill.active);
|
||||
approx(u.s.atk, u.base.atk * (1 + bb.atk));
|
||||
const hh = heals(h, u, (c) => c.target === a);
|
||||
assert.ok(hh.length > 0);
|
||||
approx(hh[0].amount, u.s.atk * (tbOf(id).heal_scale ?? 1), 'heal (elite module: ×heal_scale on ground units)');
|
||||
assert.ok(h.b.allyUnits.every((x) => x.kind !== 'token'), 'no medical drone');
|
||||
const t0 = started(h, u)[0].t;
|
||||
h.runUntil(() => !u.skill.active, 40);
|
||||
approx(h.b.time - t0, alt(id).duration, 'duration', 0.02);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('2_04 小满 S1 竹笛飞声: two charges; each next attack deals atk_scale × ATK arts to one extra enemy; no sleep', () => {
|
||||
for (const id of both('chess_char_2_04')) {
|
||||
const bb = bbAlt(id);
|
||||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 6] }, { key: 'e', pos: [11, 5] }] });
|
||||
const u = h.unit(id);
|
||||
usesAlt(u, id);
|
||||
assert.equal(u.skill.maxCharges, bb.ct);
|
||||
h.run(u.s.interval * 2.5);
|
||||
const atks = h.hooksOf('attack').filter((c) => c.attacker === u);
|
||||
assert.ok(atks.length >= 3);
|
||||
assert.deepEqual(atks.slice(0, 3).map((c) => [c.isSkill, c.targets.length]), [[true, 2], [true, 2], [false, 1]], 'two charged attacks on 2 targets, then normal');
|
||||
for (const c of dealt(h, u, (x) => x.dmg.isAttack && x.t <= atks[1].t + 1e-9)) {
|
||||
assert.equal(c.type, 'arts');
|
||||
approx(c.amount, u.s.atk * bb.atk_scale, `${id} ×${bb.atk_scale}`);
|
||||
}
|
||||
for (const c of dealt(h, u, (x) => x.dmg.isAttack && x.t >= atks[2].t - 1e-9)) approx(c.amount, u.s.atk, 'normal attack ×1');
|
||||
assert.equal(statuses(h, 'sleep').length, 0, 'no 乡音沉沉 sleep');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('2_05 哈洛德 S1 治疗强化·γ型: ATK +atk; the 重症优先 target order is S2-only (lowest HP first)', () => {
|
||||
for (const id of both('chess_char_2_05')) {
|
||||
const bb = bbAlt(id);
|
||||
const h = run({ defs: { chess: { ...noGarrison(id), t_low: plain('t_low'), t_el: plain('t_el') } }, units: [U(id, 10, 4, { carryState: READY }), { chessId: 't_low', row: 10, col: 5 }, { chessId: 't_el', row: 11, col: 5 }] });
|
||||
const u = h.unit(id), low = h.unit('t_low'), el = h.unit('t_el');
|
||||
usesAlt(u, id);
|
||||
h.step();
|
||||
low.hp = low.s.maxHp * 0.3;
|
||||
el.elem.neural = 900;
|
||||
h.step();
|
||||
assert.ok(u.skill.active);
|
||||
approx(u.s.atk, u.base.atk * (1 + bb.atk));
|
||||
const first = h.hooksOf('attack').find((c) => c.attacker === u);
|
||||
assert.equal(first.targets[0], low, 'lowest HP ratio first, not the element-damaged ally');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('2_06 莎草 S2 临考发挥: locks the highest-max-HP ally as the only main target; ATK +atk, shorter interval, +1 chain jump', () => {
|
||||
for (const id of both('chess_char_2_06')) {
|
||||
const bb = bbAlt(id);
|
||||
const defs = { chess: { t_big: plain('t_big', { stats: { maxHp: 9000 } }), t_1: plain('t_1'), t_2: plain('t_2'), t_3: plain('t_3') } };
|
||||
const h = run({ defs, units: [U(id, 10, 4, { carryState: READY }), { chessId: 't_big', row: 10, col: 5 }, { chessId: 't_1', row: 9, col: 5 }, { chessId: 't_2', row: 11, col: 5 }, { chessId: 't_3', row: 10, col: 6 }] });
|
||||
const u = h.unit(id), big = h.unit('t_big');
|
||||
usesAlt(u, id);
|
||||
const count0 = u.profile.heal.count;
|
||||
h.step();
|
||||
for (const k of ['t_1', 't_2', 't_3']) { const a = h.unit(k); a.hp = a.s.maxHp * 0.2; }
|
||||
h.runUntil(() => u.skill.active, 5);
|
||||
assert.equal(u.mem.papyrsLock, big, 'locked: highest max HP (at full HP)');
|
||||
approx(u.s.atk, u.base.atk * (1 + bb.atk));
|
||||
approx(u.s.interval, (u.base.bat + bb.base_attack_time) * 100 / u.s.aspd, 'base attack time −1.1 s');
|
||||
assert.equal(u.profile.heal.count, count0 + bb['attack@chain.extra_value']);
|
||||
h.run(u.s.interval * 2.2);
|
||||
const atks = h.hooksOf('attack').filter((c) => c.attacker === u);
|
||||
assert.ok(atks.length >= 2);
|
||||
for (const c of atks) assert.equal(c.targets[0], big, 'the locked ally is the only main target');
|
||||
const firstHeals = heals(h, u, (c) => Math.abs(c.t - atks[0].t) < 1e-9 && !(c.opts?.tags || []).length);
|
||||
assert.equal(firstHeals.length, count0 + bb['attack@chain.extra_value'], 'main target + jumps (one more)');
|
||||
// the lock leaves the field ⇒ the skill stops at once; the chain count is restored
|
||||
h.b.retreat(big, { reason: 'retreat', permanent: true });
|
||||
h.step(2);
|
||||
assert.ok(!u.skill.active);
|
||||
assert.equal(h.hooksOf('skillEnd').find((c) => c.unit === u).reason, 'target');
|
||||
assert.equal(u.profile.heal.count, count0);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('2_06 莎草 S2 临考发挥: "无法可选单位时无法开启技能" — no lockable ally ⇒ never activates (no skillStart / 萨尔贡 gains, SP kept)', () => {
|
||||
for (const id of both('chess_char_2_06')) {
|
||||
// alone and injured, then with a 禁疗 ally in range: the DEFAULT heal trigger holds, but nothing can be locked
|
||||
const h = run({ defs: { chess: { t_a: plain('t_a') } }, units: [U(id, 10, 4, { carryState: READY }), { chessId: 't_a', row: 10, col: 5 }] });
|
||||
const u = h.unit(id), a = h.unit('t_a');
|
||||
h.step();
|
||||
h.b.retreat(a, { reason: 'retreat', permanent: true });
|
||||
const hurt = () => { if (u.hp > u.s.maxHp * 0.6) u.hp = u.s.maxHp * 0.3; };
|
||||
for (let i = 0; i < 30; i++) { hurt(); h.run(0.2); }
|
||||
assert.ok(heals(h, u, (c) => c.target === u).length >= 3, `${id} heals herself`);
|
||||
assert.equal(started(h, u).length, 0, 'never started (a phantom start would fire 特质 / 萨尔贡 skillStart gains)');
|
||||
assert.equal(h.hooksOf('skillEnd').filter((c) => c.unit === u).length, 0);
|
||||
assert.equal(u.skill.activations, 0);
|
||||
assert.equal(h.eventsOf('skill').filter((e) => e[1] === u.id).length, 0, 'no skill cast on the client');
|
||||
assert.ok(!u.skill.active && u.skill.ready && u.skill.charges === 1, 'charge kept');
|
||||
for (const c of heals(h, u, (x) => x.target === u)) approx(c.amount, u.s.atk, 'no boosted heal (ATK +atk never applied)');
|
||||
done(h);
|
||||
|
||||
const h2 = run({ defs: { chess: { t_a: plain('t_a') } }, units: [U(id, 10, 4, { carryState: READY }), { chessId: 't_a', row: 10, col: 5 }] });
|
||||
const u2 = h2.unit(id), a2 = h2.unit('t_a');
|
||||
h2.step();
|
||||
h2.b.applyStatus(a2, 'noHeal', { duration: 2, source: u2 });
|
||||
assert.ok(a2.s.flags.noHeal);
|
||||
u2.hp = u2.s.maxHp * 0.3;
|
||||
h2.run(1.5);
|
||||
assert.equal(started(h2, u2).length, 0, `${id}: a 禁疗 ally is never locked`);
|
||||
assert.ok(heals(h2, u2, (c) => c.target === u2).length > 0);
|
||||
u2.hp = u2.s.maxHp * 0.3;
|
||||
assert.ok(h2.runUntil(() => u2.skill.active, 5), 'starts once the ally can be locked');
|
||||
assert.equal(u2.mem.papyrsLock, a2);
|
||||
assert.equal(started(h2, u2).length, 1);
|
||||
done(h2);
|
||||
}
|
||||
});
|
||||
|
||||
test('2_07 幽灵鲨 S1 攻击力强化·γ型: ATK +atk; no undying and no self-stun (肉斩骨断 only)', () => {
|
||||
for (const id of both('chess_char_2_07')) {
|
||||
const bb = bbAlt(id);
|
||||
const h = run({ defs: { enemies: { e: dummy('e', { atk: 4000, bat: 0.5 }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [9, 5] }] });
|
||||
const u = h.unit(id);
|
||||
usesAlt(u, id);
|
||||
h.step();
|
||||
assert.ok(u.skill.active);
|
||||
approx(u.s.atk, u.base.atk * (1 + bb.atk));
|
||||
assert.ok(h.runUntil(() => h.hooksOf('death').some((c) => c.unit === u), 10), 'lethal hits kill her during the skill');
|
||||
assert.equal(statuses(h, 'stun', (c) => c.target === u).length, 0);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('2_08 泡泡 S1 防御力强化·β型: set off by a hit (TAKE_DAMAGE); DEF +def; keeps attacking; no counter, no taunt', () => {
|
||||
for (const id of both('chess_char_2_08')) {
|
||||
const bb = bbAlt(id), tb = tbOf(id);
|
||||
const h = run({ defs: { enemies: { e: dummy('e', { atk: 300, bat: 1 }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [9, 5] }] });
|
||||
const u = h.unit(id);
|
||||
usesAlt(u, id);
|
||||
h.runUntil(() => u.skill.active, 3);
|
||||
assert.equal(started(h, u)[0].reason, 'TAKE_DAMAGE');
|
||||
approx(u.s.def, u.base.def * (1 + bb.def + (tb.def ?? 0)), 'DEF (+ elite module while blocking)');
|
||||
assert.equal(u.s.taunt, 0);
|
||||
const t0 = started(h, u)[0].t;
|
||||
h.run(4);
|
||||
assert.ok(h.hooksOf('attack').filter((c) => c.attacker === u && c.t > t0).length > 0, 'still attacks');
|
||||
assert.equal(dealt(h, u, tagged('counter')).length, 0, 'no “挨打” counter');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('2_09 休谟斯 S1 固废切割: next attack atk_scale × ATK on every enemy it reaches + `value` HP once; every spCost+1 attacks', () => {
|
||||
for (const id of both('chess_char_2_09')) {
|
||||
const bb = bbAlt(id);
|
||||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [9, 6] }, { key: 'e', pos: [10, 6] }] });
|
||||
const u = h.unit(id);
|
||||
usesAlt(u, id);
|
||||
h.step();
|
||||
const first = h.hooksOf('attack').find((c) => c.attacker === u);
|
||||
assert.ok(first && first.isSkill && first.targets.length === 2);
|
||||
const hits = dealt(h, u, (c) => c.t === first.t && c.dmg.isAttack);
|
||||
assert.equal(hits.length, 2);
|
||||
for (const c of hits) approx(c.amount, u.s.atk * bb.atk_scale, `${id} ×${bb.atk_scale}`);
|
||||
const sh = heals(h, u, (c) => c.target === u && tagged('skill')(c));
|
||||
assert.equal(sh.length, 1, 'one extra heal per cast');
|
||||
assert.equal(sh[0].amount, bb.value);
|
||||
assert.ok(!u.findBuff('humus:peak'), 'no 精力充沛 (S2)');
|
||||
h.runUntil(() => started(h, u).length >= 2, 30);
|
||||
const atks = h.hooksOf('attack').filter((c) => c.attacker === u);
|
||||
const i2 = atks.findIndex((c, i) => i > 0 && c.isSkill);
|
||||
assert.equal(i2, alt(id).spCost + 1, 'the next cast after spCost normal attacks');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('2_10 洛洛 S1 战术咏唱·γ型: ASPD +attack_speed; no overload, no stun, no target lock (自负此轭 only)', () => {
|
||||
for (const id of both('chess_char_2_10')) {
|
||||
const bb = bbAlt(id);
|
||||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 6] }] });
|
||||
const u = h.unit(id);
|
||||
usesAlt(u, id);
|
||||
h.step();
|
||||
assert.ok(u.skill.active);
|
||||
approx(u.s.aspd - u.base.aspd, bb.attack_speed);
|
||||
h.run(alt(id).duration / 2 + 1);
|
||||
assert.ok(!u.findBuff('rockr:overload'));
|
||||
assert.equal(u.mem.rockLock ?? null, null);
|
||||
h.runUntil(() => !u.skill.active, 40);
|
||||
h.run(1);
|
||||
assert.equal(statuses(h, 'stun', (c) => c.target === u).length, 0);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('2_11 风丸 S1 纸艺·迅击: next attack atk_scale × ATK, loses hp_ratio × max HP; the loss can set off the <替身>', () => {
|
||||
for (const id of both('chess_char_2_11')) {
|
||||
const bb = bbAlt(id);
|
||||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [9, 6] }] });
|
||||
const u = h.unit(id);
|
||||
usesAlt(u, id);
|
||||
h.step();
|
||||
const first = h.hooksOf('attack').find((c) => c.attacker === u);
|
||||
assert.ok(first && first.isSkill);
|
||||
approx(dealt(h, u, (c) => c.dmg.isAttack)[0].amount, u.s.atk * bb.atk_scale, `${id} ×${bb.atk_scale}`);
|
||||
const loss = h.hooksOf('damaged').filter((c) => c.target === u && tagged('hpLoss')(c));
|
||||
assert.equal(loss.length, 1);
|
||||
approx(loss[0].amount, u.s.maxHp * bb.hp_ratio, 'max-HP based loss');
|
||||
assert.ok(h.b.allyUnits.every((x) => x.kind !== 'token'), 'no <纸偶> summon');
|
||||
// a cast at low HP is fatal ⇒ the dollkeeper substitution
|
||||
u.hp = u.s.maxHp * bb.hp_ratio * 0.5;
|
||||
assert.ok(h.runUntil(() => started(h, u).length >= 2, 15));
|
||||
h.step();
|
||||
assert.ok(u.alive && u.findBuff('trait:substitute'), 'switched to the <替身>');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('2_12 砾 S1 影袭: at deployment DEF +def decaying to 0 over `duration` s, updated once per second; no barrier', () => {
|
||||
for (const id of both('chess_char_2_12')) {
|
||||
const bb = bbAlt(id), aura = tal(id).def ?? 0; // elite 小个子支援 (cost ≤ 10) includes herself
|
||||
const h = run({ units: [U(id, 9, 5)] });
|
||||
const u = h.unit(id);
|
||||
usesAlt(u, id);
|
||||
h.run(0.5);
|
||||
approx(u.s.def, u.base.def * (1 + aura + bb.def), `${id} full bonus`);
|
||||
assert.equal(u.s.shield, 0, 'no 鼠群 barrier');
|
||||
h.run(1);
|
||||
approx(u.s.def, u.base.def * (1 + aura + bb.def * (bb.duration - 1) / bb.duration), 'one step down after 1 s');
|
||||
h.run(0.4);
|
||||
approx(u.s.def, u.base.def * (1 + aura + bb.def * (bb.duration - 1) / bb.duration), 'steps once per second');
|
||||
h.run(bb.duration);
|
||||
approx(u.s.def, u.base.def * (1 + aura), 'gone');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('2_13 蒂比 S1 专业喷绘技巧: DEFAULT trigger, takes off (skill range, ATK +atk, blocks flyers), single shots; hits never set it off', () => {
|
||||
for (const id of both('chess_char_2_13')) {
|
||||
const bb = bbAlt(id);
|
||||
const h = run({ defs: { enemies: { e: dummy('e'), f: dummy('f', { motion: 'FLY' }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [9, 6] }] });
|
||||
const u = h.unit(id);
|
||||
usesAlt(u, id);
|
||||
h.step();
|
||||
const range0 = u.baseRangeKeys.length;
|
||||
h.runUntil(() => u.skill.active, 3);
|
||||
assert.equal(started(h, u)[0].reason, 'DEFAULT');
|
||||
assert.equal(u.ground, false, 'airborne');
|
||||
approx(u.s.atk, u.base.atk * (1 + bb.atk));
|
||||
assert.ok(u.rangeKeys.length > range0, 'skill range');
|
||||
const fl = h.spawn('f', { pos: [9, 5] });
|
||||
h.step();
|
||||
assert.equal(fl.blockedBy, u, 'blocks flyers while airborne');
|
||||
h.run(3);
|
||||
const atk = h.hooksOf('attack').filter((c) => c.attacker === u);
|
||||
const hits = dealt(h, u, (c) => c.dmg.isAttack);
|
||||
assert.equal(hits.length, atk.reduce((n, c) => n + c.targets.length, 0), 'one shot per target (no 3 连射)');
|
||||
h.runUntil(() => !u.skill.active, 40);
|
||||
h.step();
|
||||
assert.equal(u.ground, true, 'landed');
|
||||
done(h);
|
||||
// an enemy attack from outside her range never sets S1 off (S2's 受到攻击后触发 is not hers)
|
||||
const h2 = run({ defs: { enemies: { r: dummy('r', { atk: 300, bat: 1, range: 3.2 }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'r', pos: [9, 8] }] });
|
||||
const u2 = h2.unit(id);
|
||||
h2.run(4);
|
||||
assert.ok(h2.hooksOf('damaged').some((c) => c.target === u2), 'she is being shot');
|
||||
assert.equal(started(h2, u2).length, 0, 'not triggered by hits');
|
||||
done(h2);
|
||||
}
|
||||
});
|
||||
|
||||
test('2_14 调香师 S1 治疗强化·β型: ATK +atk (ASPD unchanged); heals 3 allies (elite module: 4)', () => {
|
||||
for (const id of both('chess_char_2_14')) {
|
||||
const bb = bbAlt(id), ids = ['t_1', 't_2', 't_3', 't_4', 't_5'];
|
||||
const defs = { chess: Object.fromEntries(ids.map((k) => [k, plain(k)])) };
|
||||
const h = run({ defs, units: [U(id, 10, 4, { carryState: READY }), ...ids.map((c, i) => ({ chessId: c, row: [9, 10, 11, 9, 11][i], col: [4, 5, 5, 5, 4][i] }))] });
|
||||
const u = h.unit(id);
|
||||
usesAlt(u, id);
|
||||
h.step();
|
||||
for (const k of ids) { const a = h.unit(k); a.hp = a.s.maxHp * 0.4; }
|
||||
h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 5);
|
||||
assert.ok(u.skill.active);
|
||||
approx(u.s.atk, u.base.atk * (1 + bb.atk));
|
||||
approx(u.s.aspd, u.base.aspd, 'no 精调 ASPD −50');
|
||||
const n = h.hooksOf('attack').find((c) => c.attacker === u).targets.length;
|
||||
assert.equal(n, id.endsWith('_b') ? 4 : 3);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('2_16 拉普兰德 S1 日晷: toggle (持续时间无限) ATK +atk; blocks enemy physical damage with `prob`; physical normal attacks', () => {
|
||||
for (const id of both('chess_char_2_16')) {
|
||||
const bb = bbAlt(id);
|
||||
for (const roll of [true, false]) {
|
||||
const seen = [];
|
||||
const h = run({
|
||||
defs: { enemies: { e: dummy('e', { atk: 300, bat: 0.5 }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [9, 5] }],
|
||||
setup: (b) => { b.rng.chance = (p) => { seen.push(p); return roll; }; },
|
||||
});
|
||||
const u = h.unit(id);
|
||||
usesAlt(u, id);
|
||||
h.runUntil(() => u.skill.active, 3);
|
||||
assert.equal(u.skill.kind, 'toggle');
|
||||
approx(u.s.atk, u.base.atk * (1 + bb.atk));
|
||||
const taken0 = u.stats.taken;
|
||||
h.run(60);
|
||||
assert.ok(u.skill.active, 'still active after 60 s');
|
||||
assert.ok(seen.includes(bb.prob), 'rolls prob');
|
||||
if (roll) assert.equal(u.stats.taken, taken0, 'every blocked roll negates the physical hit');
|
||||
else assert.ok(u.stats.taken > taken0, 'a failed roll lands');
|
||||
assert.ok(dealt(h, u, (c) => c.dmg.isAttack).every((c) => c.type === 'phys'), 'no 狼魂 arts conversion');
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('2_17 折桠 S1 绝境抵抗: TAKE_DAMAGE; DEF +def and 抵抗 (control statuses halved) for its duration; 简易包扎 at the end', () => {
|
||||
for (const id of both('chess_char_2_17')) {
|
||||
const bb = bbAlt(id), t = tal(id);
|
||||
const h = run({ defs: { enemies: { e: dummy('e', { atk: 300, bat: 1 }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [9, 5] }] });
|
||||
const u = h.unit(id);
|
||||
usesAlt(u, id);
|
||||
h.runUntil(() => u.skill.active, 3);
|
||||
assert.equal(started(h, u)[0].reason, 'TAKE_DAMAGE');
|
||||
approx(u.s.def, u.base.def * (1 + bb.def));
|
||||
approx(h.b.resistOf(u), -bb.one_minus_status_resistance, '抵抗');
|
||||
h.b.applyStatus(u, 'stun', { duration: 4, source: h.enemies()[0] });
|
||||
approx(statuses(h, 'stun', (c) => c.target === u)[0].duration, 4 * (1 + bb.one_minus_status_resistance), 'stun halved');
|
||||
assert.equal(statuses(h, 'tremble').length, 0, 'no 生存决心 tremble');
|
||||
u.hp = u.s.maxHp * 0.3;
|
||||
h.runUntil(() => !u.skill.active, 20);
|
||||
const end = heals(h, u, (c) => c.target === u && tagged('talent')(c));
|
||||
assert.equal(end.length, 1);
|
||||
approx(end[0].amount, u.s.maxHp * t.hp_ratio);
|
||||
h.run(0.2);
|
||||
assert.equal(h.b.resistOf(u), 0, '抵抗 ends with the skill');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('2_18 灰毫 S2 专注轰击: blocks nothing, ranged splash bombs only, shorter interval, ATK +atk', () => {
|
||||
for (const id of both('chess_char_2_18')) {
|
||||
const bb = bbAlt(id);
|
||||
const enemies = { w: enemyRec({ key: 'w', hp: 1e7, speed: 2 }) };
|
||||
// control: the default skill blocks the walker
|
||||
const c = run({ defs: { enemies }, units: [{ chessId: id, row: 9, col: 5 }], enemies: [{ key: 'w', route: 0 }] });
|
||||
assert.ok(c.runUntil(() => c.enemies()[0]?.blockedBy === c.unit(id), 10), 'default: blocked');
|
||||
done(c);
|
||||
|
||||
const h = run({ defs: { enemies }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'w', route: 0 }] });
|
||||
const u = h.unit(id);
|
||||
usesAlt(u, id);
|
||||
h.runUntil(() => u.skill.active, 6);
|
||||
assert.ok(u.s.flags.noBlock);
|
||||
approx(u.s.atk, u.base.atk * (1 + u.findBuff('talent:ashlok').mods.atkPct + bb.atk), 'ATK (+ 炮术研习)');
|
||||
approx(u.s.interval, (u.base.bat + bb.base_attack_time) * 100 / u.s.aspd, 'base attack time shortened');
|
||||
const w = h.enemies()[0];
|
||||
assert.ok(h.runUntil(() => w.x < 4.5, 8), 'walks past her');
|
||||
assert.ok(u.skill.active);
|
||||
assert.ok(!h.hooksOf('damaged').some((x) => x.target === u), 'never blocked ⇒ never hit');
|
||||
const shots = h.eventsOf('atk').filter((e) => e[1] === u.id);
|
||||
assert.ok(shots.length > 0 && shots.every((e) => e[3] !== 'none'), 'ranged shots only');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('2_19 锡人 S1 “老科利”: alchemy unit — 8 s of weaken −atk and atk_scale × ATK arts/s on ground enemies within 1 tile', () => {
|
||||
for (const id of both('chess_char_2_19')) {
|
||||
const bb = bbAlt(id), wither = tal(id, null, 1)['skill@damage_scale'] ?? 1;
|
||||
const h = run({
|
||||
defs: { enemies: { g: dummy('g', { atk: 100 }), f: dummy('f', { atk: 100, motion: 'FLY' }) } },
|
||||
units: [U(id, 10, 4, { carryState: READY })],
|
||||
enemies: [{ key: 'g', pos: [10, 6] }, { key: 'f', pos: [10, 6] }, { key: 'g', pos: [11, 6] }, { key: 'g', pos: [10, 8] }],
|
||||
});
|
||||
const u = h.unit(id);
|
||||
usesAlt(u, id);
|
||||
h.step();
|
||||
assert.equal(started(h, u).length, 1);
|
||||
const atk0 = u.s.atk;
|
||||
const [g, f, g2, far] = h.enemies();
|
||||
h.run(0.5);
|
||||
approx(g.s.atk, 100 * (1 + bb.atk), 'weakened');
|
||||
approx(g2.s.atk, 100 * (1 + bb.atk), 'neighbour inside the unit radius');
|
||||
approx(f.s.atk, 100, 'flyers unaffected');
|
||||
approx(far.s.atk, 100, 'outside');
|
||||
h.run(bb.projectile_delay_time);
|
||||
const zone = dealt(h, u, (c) => c.target === g && (c.dmg.tags || []).includes('zone'));
|
||||
assert.equal(zone.length, bb.projectile_delay_time, 'one pulse per second');
|
||||
for (const c of zone) { assert.equal(c.type, 'arts'); approx(c.amount, atk0 * bb.atk_scale * wither, `${id} ×${bb.atk_scale}${wither > 1 ? ' ×凋敝魂灵' : ''}`); }
|
||||
assert.equal(dealt(h, u, (c) => (c.target === f || c.target === far) && (c.dmg.tags || []).includes('zone')).length, 0);
|
||||
approx(g.s.atk, 100, 'weaken over');
|
||||
assert.equal(u.mem.tinZones, 0);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
// =================================================================================================================
|
||||
// module choice: 'none' instead of the elite's default module (the kit reads the loadout-resolved record)
|
||||
|
||||
/** Elite (default module) vs the same elite without module: [measure(default), measure(none)]. */
|
||||
function withAndWithout(fn) { return [fn(null), fn('none')]; }
|
||||
const mod = (moduleId) => (moduleId ? { moduleId } : {});
|
||||
|
||||
test('modules: removing the elite module changes the trait / talent behaviour the kit and profile implement', async (t) => {
|
||||
await t.test('2_01 送葬人 RPR-X: front-row ×1.6 → ×1.5', () => {
|
||||
const id = 'chess_char_2_01_b';
|
||||
const [a, b] = withAndWithout((m) => {
|
||||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [10, 5] }] });
|
||||
h.run(1);
|
||||
const u = h.unit(id);
|
||||
const d = dealt(h, u)[0].amount / u.s.atk;
|
||||
done(h);
|
||||
return d;
|
||||
});
|
||||
approx(a, tbOf(id).atk_scale);
|
||||
approx(b, tbOf(id, NONE).atk_scale);
|
||||
assert.ok(a > b);
|
||||
});
|
||||
await t.test('2_02 赫默 PHY-Y: ground heals ×1.15 → ×1', () => {
|
||||
const id = 'chess_char_2_02_b';
|
||||
const [a, b] = withAndWithout((m) => {
|
||||
const h = run({ defs: { chess: { t_a: plain('t_a') } }, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE, ...mod(m) }, { chessId: 't_a', row: 9, col: 6 }] });
|
||||
h.step();
|
||||
const x = h.unit('t_a'), u = h.unit(id);
|
||||
x.hp = x.s.maxHp * 0.3;
|
||||
h.runUntil(() => heals(h, u).length > 0, 10);
|
||||
const r = heals(h, u)[0].amount / u.s.atk;
|
||||
done(h);
|
||||
return r;
|
||||
});
|
||||
approx(a, 1.15);
|
||||
approx(b, 1);
|
||||
});
|
||||
await t.test('2_04 小满 DEC-X: +0.2 SP/s with an enemy in range → none', () => {
|
||||
const id = 'chess_char_2_04_b';
|
||||
const [a, b] = withAndWithout((m) => {
|
||||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [10, 6] }] });
|
||||
h.run(2);
|
||||
const sp = h.unit(id).skill.sp;
|
||||
done(h);
|
||||
return sp;
|
||||
});
|
||||
approx(a, 2 * (1 + 0.2), 'module', 1e-3);
|
||||
approx(b, 2, 'none', 1e-3);
|
||||
});
|
||||
await t.test('2_05 哈洛德 WDM-X: element recovery 0.6 → 0.5 × ATK', () => {
|
||||
const id = 'chess_char_2_05_b';
|
||||
const [a, b] = withAndWithout((m) => {
|
||||
const h = run({ defs: { chess: { t_el: plain('t_el') } }, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE, ...mod(m) }, { chessId: 't_el', row: 10, col: 5 }] });
|
||||
h.step();
|
||||
const x = h.unit('t_el'), u = h.unit(id);
|
||||
x.elem.neural = 900;
|
||||
h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 5);
|
||||
const r = (900 - x.elem.neural) / u.s.atk;
|
||||
done(h);
|
||||
return r;
|
||||
});
|
||||
approx(a, tbOf(id).ep_heal_ratio);
|
||||
approx(b, tbOf(id, NONE).ep_heal_ratio);
|
||||
assert.ok(a > b);
|
||||
});
|
||||
await t.test('2_06 莎草 XAH-X: chain heal falloff −15 % → −25 % per jump', () => {
|
||||
const id = 'chess_char_2_06_b';
|
||||
const [a, b] = withAndWithout((m) => {
|
||||
const h = run({ defs: { chess: { t_1: plain('t_1'), t_2: plain('t_2') } }, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE, ...mod(m) }, { chessId: 't_1', row: 10, col: 5 }, { chessId: 't_2', row: 10, col: 6 }] });
|
||||
h.step();
|
||||
const u = h.unit(id), x1 = h.unit('t_1'), x2 = h.unit('t_2');
|
||||
x1.hp = x1.s.maxHp * 0.2; x2.hp = x2.s.maxHp * 0.3;
|
||||
h.runUntil(() => heals(h, u).length >= 2, 5);
|
||||
const [m0, j1] = heals(h, u).filter((c) => !(c.opts?.tags || []).length);
|
||||
done(h);
|
||||
return j1.amount / m0.amount;
|
||||
});
|
||||
approx(a, 0.85);
|
||||
approx(b, 0.75);
|
||||
});
|
||||
await t.test('2_07 幽灵鲨 CEN-X: vs blocked ×1.1 → ×1', () => {
|
||||
const id = 'chess_char_2_07_b';
|
||||
const [a, b] = withAndWithout((m) => {
|
||||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 9, col: 5, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [9, 5] }] });
|
||||
h.run(1);
|
||||
const u = h.unit(id);
|
||||
const r = dealt(h, u)[0].amount / u.s.atk;
|
||||
done(h);
|
||||
return r;
|
||||
});
|
||||
approx(a, 1.1);
|
||||
approx(b, 1);
|
||||
});
|
||||
await t.test('2_08 泡泡 PRO-X: DEF +20 % while blocking → none', () => {
|
||||
const id = 'chess_char_2_08_b';
|
||||
const [a, b] = withAndWithout((m) => {
|
||||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 9, col: 5, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [9, 5] }] });
|
||||
h.run(0.3);
|
||||
const u = h.unit(id);
|
||||
const r = u.s.def / u.base.def;
|
||||
done(h);
|
||||
return r;
|
||||
});
|
||||
approx(a, 1.2);
|
||||
approx(b, 1);
|
||||
});
|
||||
await t.test('2_09 休谟斯 REA-X: trait self heal 60 → 50 per enemy hit', () => {
|
||||
const id = 'chess_char_2_09_b';
|
||||
const [a, b] = withAndWithout((m) => {
|
||||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 9, col: 5, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [9, 5] }] });
|
||||
h.step();
|
||||
const u = h.unit(id);
|
||||
u.hp = u.s.maxHp * 0.5;
|
||||
h.runUntil(() => heals(h, u, (c) => c.target === u).length > 0, 3);
|
||||
const r = heals(h, u, (c) => c.target === u)[0].amount;
|
||||
done(h);
|
||||
return r;
|
||||
});
|
||||
assert.equal(a, tbOf(id).value);
|
||||
assert.equal(b, tbOf(id, NONE).value);
|
||||
assert.ok(a > b);
|
||||
});
|
||||
await t.test('2_10 洛洛 FUN-X: drone first hit 0.35 → 0.2 × ATK', () => {
|
||||
const id = 'chess_char_2_10_b';
|
||||
const [a, b] = withAndWithout((m) => {
|
||||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [10, 6] }] });
|
||||
h.runUntil(() => dealt(h, h.unit(id)).length > 0, 5);
|
||||
const u = h.unit(id);
|
||||
const r = dealt(h, u)[0].amount / u.s.atk;
|
||||
done(h);
|
||||
return r;
|
||||
});
|
||||
approx(a, tbOf(id).init_atk_scale);
|
||||
approx(b, tbOf(id, NONE).init_atk_scale);
|
||||
assert.ok(a > b);
|
||||
});
|
||||
await t.test('2_11 风丸 PUM-X: substituted ATK +15 % → +0 %', () => {
|
||||
const id = 'chess_char_2_11_b';
|
||||
const [a, b] = withAndWithout((m) => {
|
||||
const h = run({ defs: { enemies: { e: dummy('e', { atk: 8000, bat: 1 }) } }, units: [{ chessId: id, row: 9, col: 5, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [9, 5] }] });
|
||||
const u = h.unit(id);
|
||||
h.runUntil(() => u.findBuff('trait:substitute'), 20);
|
||||
h.step();
|
||||
const tokAtk = h.b.data.getToken('token_10022_kazema_shadow', id).stats.atk;
|
||||
const r = u.s.atk / tokAtk;
|
||||
done(h);
|
||||
return r;
|
||||
});
|
||||
approx(a, 1 + tbOf(id).atk);
|
||||
approx(b, 1);
|
||||
});
|
||||
await t.test('2_14 调香师 RIN-Y: heals 4 → 3 allies', () => {
|
||||
const id = 'chess_char_2_14_b', ids = ['t_1', 't_2', 't_3', 't_4', 't_5'];
|
||||
const defs = { chess: Object.fromEntries(ids.map((k) => [k, plain(k)])) };
|
||||
const [a, b] = withAndWithout((m) => {
|
||||
const h = run({ defs, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE, ...mod(m) }, ...ids.map((c, i) => ({ chessId: c, row: [9, 10, 11, 9, 11][i], col: [4, 5, 5, 5, 4][i] }))] });
|
||||
h.step();
|
||||
for (const k of ids) { const x = h.unit(k); x.hp = x.s.maxHp * 0.4; }
|
||||
const u = h.unit(id);
|
||||
h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 5);
|
||||
const n = h.hooksOf('attack').find((c) => c.attacker === u).targets.length;
|
||||
done(h);
|
||||
return n;
|
||||
});
|
||||
assert.deepEqual([a, b], [4, 3]);
|
||||
});
|
||||
await t.test('2_16 拉普兰德 LOR-X: +10 % ATK arts on hit → none', () => {
|
||||
const id = 'chess_char_2_16_b';
|
||||
const [a, b] = withAndWithout((m) => {
|
||||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 9, col: 4, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [9, 6] }] });
|
||||
h.run(1);
|
||||
const n = dealt(h, h.unit(id), (c) => (c.dmg.tags || []).includes('module')).length;
|
||||
done(h);
|
||||
return n;
|
||||
});
|
||||
assert.ok(a > 0);
|
||||
assert.equal(b, 0);
|
||||
});
|
||||
await t.test('2_17 折桠 UNY-X: damage from blocked attackers ×0.85 → ×1', () => {
|
||||
const id = 'chess_char_2_17_b';
|
||||
const [a, b] = withAndWithout((m) => {
|
||||
const h = run({ defs: { enemies: { e: dummy('e', { atk: 1500, bat: 1 }) } }, units: [{ chessId: id, row: 9, col: 5, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [9, 5] }] });
|
||||
const u = h.unit(id);
|
||||
h.runUntil(() => h.hooksOf('damaged').some((c) => c.target === u), 3);
|
||||
const r = h.hooksOf('damaged').find((c) => c.target === u).amount / (1500 - u.s.def);
|
||||
done(h);
|
||||
return r;
|
||||
});
|
||||
approx(a, 0.85);
|
||||
approx(b, 1);
|
||||
});
|
||||
await t.test('2_18 灰毫 FOR-X: vs blocked ×1.1 → ×1', () => {
|
||||
const id = 'chess_char_2_18_b';
|
||||
const [a, b] = withAndWithout((m) => {
|
||||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 5, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [10, 5] }] });
|
||||
h.run(4);
|
||||
const u = h.unit(id);
|
||||
const r = dealt(h, u)[0].amount / u.s.atk;
|
||||
done(h);
|
||||
return r;
|
||||
});
|
||||
approx(a, 1.1);
|
||||
approx(b, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// =================================================================================================================
|
||||
// smoke: every loadout of every tier-2 chess fights without content errors
|
||||
|
||||
test('every tier-2 loadout (skill × module, normal & elite) fights 30 s with its authored kit and no content errors', () => {
|
||||
const bases = Object.values(ds.raw.chess).filter((c) => c.tier === 2 && c.visible && !c.isGolden);
|
||||
for (const base of bases) {
|
||||
for (const id of [base.chessId, base.goldenId].filter(Boolean)) {
|
||||
const r = raw(id);
|
||||
const mods = Array.isArray(r.modules) && r.modules.length ? [null, 'none'] : [null];
|
||||
for (const s of r.skills) {
|
||||
for (const moduleId of mods) {
|
||||
const h = makeBattle({
|
||||
seed: 11, timeLimit: 30, autoFinish: false,
|
||||
units: [{ chessId: id, row: 10, col: 5, skillIndex: s.index, ...(moduleId ? { moduleId } : {}), carryState: READY }, { chessId: 'chess_char_1_02_a', row: 9, col: 5 }],
|
||||
enemies: [{ key: 'enemy_1007_slime', route: 0, count: 6, interval: 2 }, { key: 'enemy_1007_slime', route: 1, count: 4, interval: 3 }],
|
||||
});
|
||||
const u = h.unit(id);
|
||||
assert.equal(u.skill.id, s.skillId);
|
||||
assert.equal(u.kit.skillSource ?? 'kit', s.isDefault ? 'kit' : 'skills', `${id} ${s.skillId}`);
|
||||
h.run(30);
|
||||
assert.equal(h.b.errors.length, 0, `${id} S${s.index + 1} module ${moduleId}: ${JSON.stringify(h.b.errors[0])}`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,911 @@
|
||||
// Tier-3 operator loadouts (DESIGN §16, server/sim/content/kits/tier3.js): every selectable NON-default skill of every
|
||||
// visible tier-3 chess is hand-authored (`skills[skillId]`) and proves its signature effect for the normal (Lv4) and the
|
||||
// elite (Lv7) chess with its own blackboard; non-default modules that change behaviour are exercised too.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
import { skillSpecSource } from '../../server/sim/content/index.js';
|
||||
import { effectiveProfile } from '../../server/sim/ai.js';
|
||||
import { kitCoverage } from '../../tools/kit-coverage.mjs';
|
||||
import KITS from '../../server/sim/content/kits/tier3.js';
|
||||
|
||||
const ds = getDefaultSource();
|
||||
/** Skill index of `skillId` on chess `id`. */
|
||||
const IDX = (id, skillId) => ds.rawChess(id).skills.find((s) => s.skillId === skillId).index;
|
||||
/** Def of chess `id` with skill `skillId` selected (and module `moduleId`). */
|
||||
const LD = (id, skillId, moduleId) => ds.getChess(id, { skillIndex: IDX(id, skillId), ...(moduleId ? { moduleId } : {}) });
|
||||
const SB = (id, skillId) => LD(id, skillId).skill.bb;
|
||||
const TB = (id, i) => ds.getChess(id).talents[i].bb;
|
||||
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
|
||||
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, def: 0, res: 0, ...o });
|
||||
const fill = (u) => u.skill.gainSp(u.skill.spCost * u.skill.maxCharges + 1, 'test');
|
||||
const tagged = (h, tag, src = null) => h.hooksOf('damaged').filter((c) => c.dmg?.tags?.includes(tag) && (!src || c.source === src));
|
||||
const done = (h) => { assert.deepEqual(h.b.errors.map((e) => `${e.label} ${e.message}`), []); checkInvariants(h.b); };
|
||||
/** Chess record without its 特质 (garrisons re-type damage; they have their own tests). */
|
||||
const noGarrison = (...ids) => Object.fromEntries(ids.map((id) => [id, { ...ds.rawChess(id), garrisonIds: [] }]));
|
||||
const BOTH = (base) => [base, base.replace(/_a$/, '_b')];
|
||||
/** A unit entry with a loadout. */
|
||||
const U = (chessId, skillId, row, col, extra = {}) => ({ chessId, row, col, skillIndex: IDX(chessId, skillId), ...extra });
|
||||
/** Damage instances of the unit's normal/skill attacks. */
|
||||
const atkHits = (h, u, skill = null) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack && (skill == null || !!c.dmg.isSkill === skill));
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
test('coverage: every selectable skill of every visible tier-3 chess is hand-authored (normal + 精锐)', () => {
|
||||
const rep = kitCoverage({ tier: 3 });
|
||||
assert.equal(rep.summary.chess, 19);
|
||||
assert.equal(rep.summary.covered, rep.summary.skills, rep.chess.filter((r) => r.skills.some((s) => !s.covered)).map((r) => r.name).join(' '));
|
||||
for (const r of rep.chess) for (const s of r.skills) if (!s.isDefault) assert.deepEqual([s.normal, s.elite], ['skills', 'skills'], `${r.name} S${s.index + 1}`);
|
||||
});
|
||||
|
||||
test('skills map: each alternate spec is built from its OWN record whichever skill is selected', () => {
|
||||
const rows = kitCoverage({ tier: 3 }).chess;
|
||||
for (const r of rows) for (const id of BOTH(r.chessId)) {
|
||||
const d0 = ds.getChess(id);
|
||||
const kit = KITS[r.chessId](d0.skill.bb, d0.raw, d0);
|
||||
const alts = r.skills.filter((s) => !s.isDefault).map((s) => s.skillId);
|
||||
assert.deepEqual(Object.keys(kit.skills ?? {}).sort(), alts.slice().sort(), `${id} skills keys`);
|
||||
for (const sid of alts) {
|
||||
const dS = LD(id, sid);
|
||||
assert.equal(skillSpecSource(dS, KITS), 'skills', `${id} ${sid}`);
|
||||
const own = KITS[r.chessId](dS.skill.bb, dS.raw, dS).skills[sid];
|
||||
// same numbers from the default-selected kit and from the kit built for that skill
|
||||
assert.equal(JSON.stringify(kit.skills[sid], (k, v) => (typeof v === 'function' ? 'fn' : v)), JSON.stringify(own, (k, v) => (typeof v === 'function' ? 'fn' : v)), `${id} ${sid}`);
|
||||
}
|
||||
}
|
||||
const a = LD('chess_char_3_01_a', 'skchr_angel_1'), b = LD('chess_char_3_01_b', 'skchr_angel_1');
|
||||
const d0 = ds.getChess('chess_char_3_01_b');
|
||||
approx(KITS.chess_char_3_01_a(d0.skill.bb, d0.raw, d0).skills.skchr_angel_1.attack.atkScale, b.skill.bb.atk_scale, 1e-9, 'elite Lv7 number');
|
||||
assert.notEqual(a.skill.bb.atk_scale, b.skill.bb.atk_scale);
|
||||
});
|
||||
|
||||
test('every alternate skill × module of the visible tier-3 chess fights and casts in a real battle without content errors', () => {
|
||||
const rows = kitCoverage({ tier: 3 }).chess;
|
||||
for (const r of rows) {
|
||||
const raw = ds.rawChess(r.chessId.replace(/_a$/, '_b'));
|
||||
const mods = [null, 'none', ...(raw.modules || []).map((m) => m.uniEquipId)];
|
||||
for (const s of r.skills) for (const id of BOTH(r.chessId)) for (const moduleId of id.endsWith('_b') ? mods : [null]) {
|
||||
if (s.isDefault && moduleId == null) continue; // default kit: kits_t3 smoke test
|
||||
const melee = ds.getChess(id).position === 'MELEE';
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_w: enemyRec({ key: 'enemy_w', hp: 2e4, speed: 1, def: 100, res: 10, atk: 200, bat: 1.5 }), enemy_fl: enemyRec({ key: 'enemy_fl', hp: 5000, speed: 1, motion: 'FLY', atk: 50 }) } },
|
||||
timeLimit: 25, seed: 11, autoFinish: false,
|
||||
units: [{ chessId: id, row: melee ? 9 : 10, col: 6, skillIndex: s.index, ...(moduleId ? { moduleId } : {}) }, { chessId: 'chess_char_3_16_a', row: 9, col: 7 }],
|
||||
enemies: [{ key: 'enemy_w', route: 0, count: 4, interval: 3 }, { key: 'enemy_fl', route: 2, time: 2 }],
|
||||
});
|
||||
const u = h.unit(id);
|
||||
const tag = `${id} S${s.index + 1} ${moduleId ?? ''}`;
|
||||
assert.equal(u.skill.id, s.skillId, tag);
|
||||
for (let t = 0; t < 20; t++) { h.run(1); if (t % 5 === 1 && u.alive && u.skill.kind !== 'passive') u.skill.gainSp(u.skill.spCost, 'test'); }
|
||||
if (u.skill.kind === 'passive') assert.ok(u.skill.active, `${tag} passive on`);
|
||||
else assert.ok(u.skill.activations >= 1, `${tag} cast`);
|
||||
assert.ok(u.stats.dmg > 0 || u.stats.heal > 0, `${tag} did something`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
test('3_01 能天使 S1 冲锋模式: next attack = times shots × atk_scale; S2 扫射模式: 4 shots × attack@atk_scale every attack', () => {
|
||||
for (const id of BOTH('chess_char_3_01_a')) {
|
||||
const b1 = SB(id, 'skchr_angel_1');
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 60, hooks: ['damaged', 'attack'], captureNoisy: true,
|
||||
units: [U(id, 'skchr_angel_1', 10, 4)], enemies: [{ key: 'enemy_d', pos: [10, 6] }] });
|
||||
const u = h.unit(id);
|
||||
assert.equal(u.skill.kind, 'instant');
|
||||
assert.equal(u.skill.spType, 'attack');
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 30), 'cast after spCost attacks');
|
||||
h.run(1);
|
||||
const sk = atkHits(h, u, true);
|
||||
assert.equal(sk.length, b1.times, 'one 3-shot burst');
|
||||
for (const c of sk) approx(c.amount, u.s.atk * b1.atk_scale);
|
||||
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill).length, 1);
|
||||
assert.ok(atkHits(h, u, false).every((c) => Math.abs(c.amount - u.s.atk) < 1e-6), 'normal attacks one plain shot');
|
||||
done(h);
|
||||
|
||||
const b2 = SB(id, 'skchr_angel_2');
|
||||
const g = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 60, hooks: ['damaged', 'attack'], captureNoisy: true,
|
||||
units: [U(id, 'skchr_angel_2', 10, 4)], enemies: [{ key: 'enemy_d', pos: [10, 6] }] });
|
||||
const v = g.unit(id);
|
||||
g.step();
|
||||
fill(v);
|
||||
assert.ok(g.runUntil(() => v.skill.active, 5));
|
||||
assert.equal(effectiveProfile(v).hits, b2['attack@times']);
|
||||
approx(v.skill.timeLeft, LD(id, 'skchr_angel_2').skill.duration, 0.05);
|
||||
g.runUntil(() => !v.skill.active, 20);
|
||||
g.run(1);
|
||||
const atks = g.hooksOf('attack').filter((c) => c.attacker === v && c.isSkill).length;
|
||||
const hits = atkHits(g, v, true);
|
||||
assert.ok(atks >= 5);
|
||||
assert.equal(hits.length, atks * b2['attack@times']);
|
||||
for (const c of hits) approx(c.amount, v.s.atk * b2['attack@atk_scale']);
|
||||
done(g);
|
||||
}
|
||||
});
|
||||
|
||||
test('3_01 能天使 精锐 module MAR-Y: ASPD + only while a ground enemy is in range (no fly bonus); MAR-X keeps the fly ×', () => {
|
||||
const id = 'chess_char_3_01_b';
|
||||
const tb = ds.getChess(id, { moduleId: 'uniequip_003_angel' }).traitBb;
|
||||
const h = makeBattle({ defs: { enemies: { enemy_g: dummy('enemy_g'), enemy_f: dummy('enemy_f', { motion: 'FLY' }) } }, timeLimit: 30, hooks: ['damaged'], captureNoisy: true,
|
||||
units: [{ chessId: id, row: 10, col: 4, moduleId: 'uniequip_003_angel' }], enemies: [{ key: 'enemy_f', pos: [10, 6] }, { key: 'enemy_g', pos: [10, 7], time: 3 }] });
|
||||
const u = h.unit(id);
|
||||
h.run(2);
|
||||
const base = u.s.aspd;
|
||||
assert.equal(u.findBuff('trait:angel_ground'), null, 'only a flyer in range');
|
||||
const fl = atkHits(h, u, false).find((c) => c.target.defId === 'enemy_f');
|
||||
approx(fl.amount, u.s.atk, 1e-6, 'MAR-Y: no fly bonus');
|
||||
h.run(2);
|
||||
assert.ok(u.findBuff('trait:angel_ground'));
|
||||
approx(u.s.aspd, base + tb.attack_speed);
|
||||
done(h);
|
||||
const x = makeBattle({ defs: { enemies: { enemy_f: dummy('enemy_f', { motion: 'FLY' }) } }, timeLimit: 30, hooks: ['damaged'], captureNoisy: true,
|
||||
units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'enemy_f', pos: [10, 6] }] });
|
||||
const w = x.unit(id);
|
||||
x.run(2);
|
||||
approx(atkHits(x, w, false)[0].amount, w.s.atk * ds.getChess(id).traitBb.atk_scale, 1e-6, 'default MAR-X fly ×');
|
||||
assert.equal(w.findBuff('trait:angel_ground'), null);
|
||||
done(x);
|
||||
});
|
||||
|
||||
test('3_02 断崖 S1 多导向散射弹丸: next attack hits max_target enemies (arts, lord ranged ×) and makes them 停顿', () => {
|
||||
for (const id of BOTH('chess_char_3_02_a')) {
|
||||
const b = SB(id, 'skchr_ayer_1');
|
||||
const rs = ds.getChess(id).traitBb.atk_scale ?? 0.8;
|
||||
const h = makeBattle({ defs: { enemies: { enemy_e1: dummy('enemy_e1'), enemy_e2: dummy('enemy_e2'), enemy_e3: dummy('enemy_e3'), enemy_e4: dummy('enemy_e4') } }, timeLimit: 60, hooks: ['damaged', 'attack'], captureNoisy: true,
|
||||
units: [U(id, 'skchr_ayer_1', 10, 4)], enemies: [{ key: 'enemy_e1', pos: [10, 5] }, { key: 'enemy_e2', pos: [9, 5] }, { key: 'enemy_e3', pos: [11, 5] }, { key: 'enemy_e4', pos: [10, 6] }] });
|
||||
const u = h.unit(id);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 40));
|
||||
h.run(0.5);
|
||||
const atk = h.hooksOf('attack').find((c) => c.attacker === u && c.isSkill);
|
||||
assert.equal(atk.targets.length, b.max_target);
|
||||
const sk = atkHits(h, u, true);
|
||||
assert.equal(sk.length, b.max_target);
|
||||
for (const c of sk) {
|
||||
assert.equal(c.type, 'arts');
|
||||
approx(c.amount, u.s.atk * b.atk_scale * (Math.round(c.target.y) === 10 && Math.round(c.target.x) === 5 ? 1 : rs));
|
||||
}
|
||||
assert.ok(atk.targets.every((t) => t.findBuff('sluggish')), '停顿 on every target');
|
||||
assert.ok(!h.b.enemies.filter((e) => !atk.targets.includes(e)).some((e) => e.findBuff('sluggish')), 'only on them');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('3_03 诗怀雅 S1 指挥调度: the talent covers the skill range (x-1 / x-2) at ×talent_scale while it runs', () => {
|
||||
for (const id of BOTH('chess_char_3_03_a')) {
|
||||
const b = SB(id, 'skchr_swire_1'), t0 = TB(id, 0);
|
||||
const elite = id.endsWith('_b');
|
||||
// (10,6) = 2 tiles ahead (x-1 and x-2); (11,6) = [1,2] only in the elite's x-2
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 60,
|
||||
units: [U(id, 'skchr_swire_1', 10, 4), { chessId: 'chess_char_3_16_a', row: 10, col: 6 }, { chessId: 'chess_char_3_02_a', row: 11, col: 6 }, { chessId: 'chess_char_3_18_a', row: 11, col: 4 }],
|
||||
enemies: [{ key: 'enemy_d', pos: [10, 5] }] });
|
||||
const u = h.unit(id), far = h.unit('chess_char_3_16_a'), diag = h.unit('chess_char_3_02_a'), near = h.unit('chess_char_3_18_a');
|
||||
h.run(0.5);
|
||||
approx(near.s.atk, near.base.atk * (1 + t0.atk), 1e-6, '3×3 as usual');
|
||||
approx(far.s.atk, far.base.atk, 1e-6, 'outside the talent range');
|
||||
fill(u);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||
h.step();
|
||||
approx(u.s.atk, u.base.atk * (1 + t0.atk * b.talent_scale), 1e-6, 'no ATK of its own');
|
||||
approx(near.s.atk, near.base.atk * (1 + t0.atk * b.talent_scale));
|
||||
approx(far.s.atk, far.base.atk * (1 + t0.atk * b.talent_scale), 1e-6, 'range enlarged');
|
||||
approx(diag.s.atk, diag.base.atk * (elite ? 1 + t0.atk * b.talent_scale : 1), 1e-6, elite ? '大幅度扩大' : 'x-1 only');
|
||||
h.runUntil(() => !u.skill.active, 40);
|
||||
h.run(0.5);
|
||||
approx(far.s.atk, far.base.atk);
|
||||
approx(near.s.atk, near.base.atk * (1 + t0.atk));
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('3_04 琳琅诗怀雅 S1 仗义疏财: 2 coins; an attack spends one to heal the most injured ally (< 70 %) around her; no bombs', () => {
|
||||
for (const id of BOTH('chess_char_3_04_a')) {
|
||||
const b = SB(id, 'skchr_swire2_1'), t0 = TB(id, 0);
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 60, hooks: ['heal', 'deploy'], captureNoisy: true, flags: { dpInit: 60, dpMax: 99 },
|
||||
units: [U(id, 'skchr_swire2_1', 9, 5), { chessId: 'chess_char_3_16_a', row: 10, col: 5 }, { chessId: 'chess_char_3_05_a', row: 9, col: 3 }],
|
||||
enemies: [{ key: 'enemy_d', pos: [9, 6] }] });
|
||||
const u = h.unit(id), ally = h.unit('chess_char_3_16_a'), far = h.unit('chess_char_3_05_a');
|
||||
h.step();
|
||||
assert.equal(u.skill.kind, 'passive');
|
||||
assert.equal(u.mem.coins, t0.sp, '开启技能 coin at deployment');
|
||||
far.hp = far.s.maxHp * 0.3; // two tiles away: not "周围八格"
|
||||
ally.hp = ally.s.maxHp * 0.75; // not below 70 %: no heal, the coin is kept
|
||||
h.run(2.5);
|
||||
assert.equal(h.hooksOf('heal').filter((c) => c.source === u).length, 0);
|
||||
assert.equal(u.mem.coins, t0.sp);
|
||||
ally.hp = ally.s.maxHp * 0.5;
|
||||
assert.ok(h.runUntil(() => h.hooksOf('heal').some((c) => c.source === u), 5));
|
||||
const heal = h.hooksOf('heal').find((c) => c.source === u);
|
||||
assert.equal(heal.target, ally);
|
||||
approx(heal.amount, u.s.atk * b['attack@heal_scale']);
|
||||
h.run(30);
|
||||
assert.ok(u.mem.coins <= b.sp, 'cap 2');
|
||||
assert.ok(!h.hooksOf('deploy').some((c) => c.unit.defId === 'token_10031_swire2_gdtrap'), 'no 香槟炸弹 with S1');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('3_04 琳琅诗怀雅 S3 千金一掷: 二连击, kills pay a coin, a full purse is cashed out on the front range (hits + push)', () => {
|
||||
for (const id of BOTH('chess_char_3_04_a')) {
|
||||
const b = SB(id, 'skchr_swire2_3'), t0 = TB(id, 0);
|
||||
const h = makeBattle({ defs: { enemies: { enemy_heavy: dummy('enemy_heavy', { mass: 9 }), enemy_weak: dummy('enemy_weak', { hp: 1 }) } }, timeLimit: 90, hooks: ['damaged', 'attack', 'deploy'], captureNoisy: true, flags: { dpInit: 60, dpMax: 99 },
|
||||
units: [U(id, 'skchr_swire2_3', 9, 5)], enemies: [{ key: 'enemy_heavy', pos: [9, 6] }] });
|
||||
const u = h.unit(id);
|
||||
assert.equal(u.skill.kind, 'toggle');
|
||||
h.step();
|
||||
assert.equal(u.mem.coins ?? 0, 0, 'no coin before the cast');
|
||||
fill(u);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||
assert.equal(u.mem.coins, t0.sp, '开启技能 coin');
|
||||
h.run(2);
|
||||
const atk = h.hooksOf('attack').find((c) => c.attacker === u && c.isSkill);
|
||||
assert.equal(atkHits(h, u, true).filter((c) => c.dmg.attackId === atkHits(h, u, true)[0].dmg.attackId).length, 2, '二连击');
|
||||
assert.ok(atk);
|
||||
const c0 = u.mem.coins;
|
||||
h.spawn('enemy_weak', { pos: [9, 5] });
|
||||
assert.ok(h.runUntil(() => u.mem.coins === c0 + 1, 3), 'a kill pays a coin');
|
||||
assert.ok(u.skill.active, 'still on');
|
||||
u.mem.coins = b.sp; // full purse (10)
|
||||
h.step();
|
||||
assert.equal(u.skill.active, false, 'closed once full');
|
||||
const cash = tagged(h, 'swire2Cash', u);
|
||||
assert.equal(cash.length, b.sp, 'one hit per coin');
|
||||
for (const c of cash) approx(c.amount, u.s.atk * b.atk_scale);
|
||||
assert.equal(u.mem.coins, 0);
|
||||
assert.equal(u.skill.activations, 1);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 2, 10), 'recast once SP is full again');
|
||||
assert.equal(u.mem.coins, t0.sp);
|
||||
assert.ok(!h.hooksOf('deploy').some((c) => c.unit.defId === 'token_10031_swire2_gdtrap'), 'no bombs');
|
||||
done(h);
|
||||
}
|
||||
// the push: a light enemy is shoved forward (and out of the range: the remaining coins are spent on nobody)
|
||||
const id = 'chess_char_3_04_a';
|
||||
const g = makeBattle({ defs: { enemies: { enemy_light: dummy('enemy_light') } }, timeLimit: 30, hooks: ['damaged'], captureNoisy: true, flags: { dpInit: 60, dpMax: 99 },
|
||||
units: [U(id, 'skchr_swire2_3', 9, 5)], enemies: [{ key: 'enemy_light', pos: [9, 6] }] });
|
||||
const v = g.unit(id);
|
||||
g.step();
|
||||
fill(v);
|
||||
assert.ok(g.runUntil(() => v.skill.active, 5));
|
||||
const e = g.enemy('enemy_light');
|
||||
const x0 = e.x;
|
||||
v.mem.coins = SB(id, 'skchr_swire2_3').sp;
|
||||
g.step();
|
||||
assert.ok(e.x > x0 + 0.3, 'pushed forward');
|
||||
assert.ok(tagged(g, 'swire2Cash', v).length >= 1);
|
||||
done(g);
|
||||
});
|
||||
|
||||
test('3_04 琳琅诗怀雅 精锐 module MER-Y: ATK +4 % per trait payment (≤ 5 stacks) on top of 大买家; pays 3 DP', () => {
|
||||
const id = 'chess_char_3_04_b', mod = 'uniequip_003_swire2';
|
||||
const d = ds.getChess(id, { moduleId: mod });
|
||||
const mt = d.raw.talents.find((t) => t.hidden && t.fromModule).bb;
|
||||
const t0 = TB(id, 0);
|
||||
const h = makeBattle({ timeLimit: 60, flags: { dpInit: 90, dpMax: 99, dpPerSec: 0 },
|
||||
units: [{ chessId: id, row: 9, col: 5, moduleId: mod }] });
|
||||
const u = h.unit(id);
|
||||
const p = h.b.getPlayer('p1');
|
||||
h.step();
|
||||
const dp0 = p.dp;
|
||||
h.run(3.05);
|
||||
approx(dp0 - p.dp, Math.abs(d.traitBb.cost), 1e-6, 'MER-Y drain');
|
||||
assert.equal(u.findBuff('trait:swire2_module')?.stacks, 1);
|
||||
h.run(30);
|
||||
assert.equal(u.findBuff('trait:swire2_module')?.stacks, mt.max_stack_cnt);
|
||||
const buyer = u.findBuff('talent:swire2_buyer')?.stacks ?? 0;
|
||||
approx(u.s.atk, u.base.atk * (1 + mt.atk * mt.max_stack_cnt + t0.atk * buyer));
|
||||
done(h);
|
||||
// the default module MER-X has no such stack
|
||||
const g = makeBattle({ timeLimit: 20, flags: { dpInit: 90, dpMax: 99 }, units: [{ chessId: id, row: 9, col: 5 }] });
|
||||
g.run(10);
|
||||
assert.equal(g.unit(id).findBuff('trait:swire2_module'), null);
|
||||
done(g);
|
||||
});
|
||||
|
||||
test('3_05 斯卡蒂 S1 迅捷打击·γ型 ATK/ASPD; S2 跃浪击 ATK + for `duration` s after deployment; 精锐 DRE-X ×vs blocked', () => {
|
||||
for (const id of BOTH('chess_char_3_05_a')) {
|
||||
const b1 = SB(id, 'skcom_quickattack[3]');
|
||||
const own = TB(id, 0).atk; // 深海掠食者: she is an Abyssal Hunter herself
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 60,
|
||||
units: [U(id, 'skcom_quickattack[3]', 10, 4)], enemies: [{ key: 'enemy_d', pos: [10, 5] }] });
|
||||
const u = h.unit(id);
|
||||
h.step();
|
||||
fill(u);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||
h.step();
|
||||
approx(u.s.aspd, u.base.aspd + b1.attack_speed);
|
||||
approx(u.s.atk, u.base.atk * (1 + b1.atk + own));
|
||||
h.runUntil(() => !u.skill.active, 40);
|
||||
h.step();
|
||||
approx(u.s.aspd, u.base.aspd);
|
||||
done(h);
|
||||
|
||||
const b2 = SB(id, 'skchr_skadi_2');
|
||||
const g = makeBattle({ timeLimit: 60, units: [U(id, 'skchr_skadi_2', 10, 4)] });
|
||||
const v = g.unit(id);
|
||||
g.step();
|
||||
assert.equal(v.skill.kind, 'passive');
|
||||
approx(v.s.atk, v.base.atk * (1 + b2.atk + own));
|
||||
g.run(b2.duration - 0.5);
|
||||
approx(v.s.atk, v.base.atk * (1 + b2.atk + own));
|
||||
g.run(1);
|
||||
approx(v.s.atk, v.base.atk * (1 + own), 1e-6, 'gone after its duration');
|
||||
// redeployed ⇒ again
|
||||
g.b.dealDamage(null, v, { type: 'true', amount: 1e7 });
|
||||
if (!v.alive) {
|
||||
g.b.redeploy(v, { free: true });
|
||||
g.step();
|
||||
approx(v.s.atk, v.base.atk * (1 + b2.atk + own), 1e-6, 'every deployment');
|
||||
}
|
||||
done(g);
|
||||
}
|
||||
// DRE-X: ×atk_scale on blocked enemies; no DRE-Y revive
|
||||
const id = 'chess_char_3_05_b', mod = 'uniequip_003_skadi';
|
||||
const tb = ds.getChess(id, { moduleId: mod }).traitBb;
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: noGarrison(id) }, timeLimit: 30, hooks: ['damaged'], captureNoisy: true,
|
||||
units: [{ chessId: id, row: 10, col: 4, moduleId: mod }], enemies: [{ key: 'enemy_d', pos: [10, 4] }] });
|
||||
const u = h.unit(id);
|
||||
h.run(3);
|
||||
assert.equal(h.enemy('enemy_d').blockedBy, u);
|
||||
approx(atkHits(h, u, false)[0].amount, u.s.atk * tb.atk_scale, 1e-6, 'DRE-X ×');
|
||||
h.b.dealDamage(null, u, { type: 'true', amount: 1e7 });
|
||||
assert.equal(u.alive, false, 'no DRE-Y revive with DRE-X');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('3_06 菲莱 S1 灵河护佑 (TAKE_DAMAGE): HP +, clears her element gauges, 损伤屏障 absorbs element damage; no S2 counters', () => {
|
||||
for (const id of BOTH('chess_char_3_06_a')) {
|
||||
const b = SB(id, 'skchr_philae_1'), t0 = TB(id, 0);
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d', { atk: 50 }) } }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
|
||||
units: [U(id, 'skchr_philae_1', 10, 4)], enemies: [{ key: 'enemy_d', pos: [10, 4] }] });
|
||||
const u = h.unit(id);
|
||||
assert.equal(u.skill.rule, 'TAKE_DAMAGE');
|
||||
h.step();
|
||||
const e = h.enemy('enemy_d');
|
||||
u.elem.burn = 300;
|
||||
fill(u);
|
||||
assert.equal(u.skill.active, false, 'waits for a hit');
|
||||
h.b.dealDamage(e, u, { type: 'phys', amount: 10 });
|
||||
assert.equal(u.skill.active, true);
|
||||
approx(u.s.maxHp, u.base.maxHp * (1 + b.max_hp));
|
||||
assert.equal(u.elem.burn, 0, 'element cleared');
|
||||
approx(u.mem.philaeBarrier, b.shield_value);
|
||||
const mul = 1 - t0.damage_resistance; // 神河谕使
|
||||
const a1 = (b.shield_value / mul) * 0.5;
|
||||
h.b.dealDamage(e, u, { type: 'element', element: 'burn', amount: a1 });
|
||||
assert.equal(u.elem.burn, 0, 'absorbed');
|
||||
approx(u.mem.philaeBarrier, b.shield_value * 0.5);
|
||||
h.b.dealDamage(e, u, { type: 'element', element: 'burn', amount: a1 * 2 });
|
||||
approx(u.elem.burn, b.shield_value * 0.5, 1e-6, 'the rest goes through');
|
||||
assert.equal(u.mem.philaeBarrier, 0);
|
||||
h.run(3);
|
||||
assert.equal(tagged(h, 'counter', u).length, 0, 'no 冥河诅咒 counter');
|
||||
assert.ok(atkHits(h, u).length > 0, 'keeps attacking');
|
||||
h.runUntil(() => !u.skill.active, 20);
|
||||
h.b.dealDamage(e, u, { type: 'element', element: 'burn', amount: 100 });
|
||||
approx(u.elem.burn, b.shield_value * 0.5 + 100 * mul, 1e-6, 'no barrier after the skill');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('3_08 薄绿 S1 风语 (阵法术师 技能1: "初始攻击范围内出现敌人后自动释放"): wider range, attacks at attack@atk_scale; guard/taunt rules kept', () => {
|
||||
for (const id of BOTH('chess_char_3_08_a')) {
|
||||
const b = SB(id, 'skchr_mint_1'), t0 = TB(id, 0);
|
||||
assert.equal(LD(id, 'skchr_mint_1').skill.trigger?.rule, 'SEARCH', 'data: the phalanx S1 row');
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d'), enemy_in: dummy('enemy_in') } }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
|
||||
units: [U(id, 'skchr_mint_1', 10, 4)], enemies: [{ key: 'enemy_d', pos: [11, 6] }] }); // [1,2]: x-2 only
|
||||
const u = h.unit(id);
|
||||
h.run(1);
|
||||
assert.equal(atkHits(h, u).length, 0, 'phalanx: no attack while the skill is off');
|
||||
fill(u);
|
||||
h.run(2);
|
||||
assert.equal(u.skill.activations, 0, 'an enemy outside her INITIAL range does not open it (not a whole-field search)');
|
||||
h.spawn('enemy_in', { pos: [10, 6] }); // [0,2]: initial range
|
||||
assert.ok(h.runUntil(() => u.skill.active, 1), 'an enemy inside her initial range opens it at once (she never attacks before)');
|
||||
assert.ok(u.findBuff('talent:mint_taunt'));
|
||||
approx(u.s.taunt, (u.base.tauntLevel ?? 0) + t0.taunt_level);
|
||||
h.run(3);
|
||||
const hits = atkHits(h, u, true).filter((c) => c.target.defId === 'enemy_d');
|
||||
assert.ok(hits.length >= 1, 'reaches [1,2] with the S1 range');
|
||||
assert.equal(hits[0].type, 'arts');
|
||||
approx(hits[0].amount, u.s.atk * b['attack@atk_scale']);
|
||||
h.runUntil(() => !u.skill.active, 30);
|
||||
assert.equal(u.findBuff('talent:mint_taunt'), null);
|
||||
assert.equal(tagged(h, 'burst', u).length, 0, 'no 聚能涡旋 end burst');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('3_09 海霓 S1 迷惑性洋流图: next attack ×atk_scale on max_target enemies at once', () => {
|
||||
for (const id of BOTH('chess_char_3_09_a')) {
|
||||
const b = SB(id, 'skchr_haini_1');
|
||||
const keys = ['enemy_e1', 'enemy_e2', 'enemy_e3', 'enemy_e4', 'enemy_e5'];
|
||||
const h = makeBattle({ defs: { enemies: Object.fromEntries(keys.map((k) => [k, dummy(k)])) }, timeLimit: 60, hooks: ['damaged', 'attack'], captureNoisy: true,
|
||||
units: [U(id, 'skchr_haini_1', 10, 4)], enemies: keys.map((k, i) => ({ key: k, pos: [9 + (i % 3), 5 + Math.floor(i / 3)] })) });
|
||||
const u = h.unit(id);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 60));
|
||||
h.run(1);
|
||||
const atk = h.hooksOf('attack').find((c) => c.attacker === u && c.isSkill);
|
||||
assert.equal(atk.targets.length, b.max_target);
|
||||
const sk = atkHits(h, u, true);
|
||||
assert.equal(new Set(sk.map((c) => c.target)).size, b.max_target);
|
||||
// (a normal hit once 测绘器材's fragile aura is up — it is applied right after the first tick)
|
||||
const norm = atkHits(h, u, false).find((c) => c.t > 0.5 && sk.some((x) => x.target === c.target));
|
||||
const same = sk.find((x) => x.target === norm.target);
|
||||
approx(same.amount / norm.amount, b.atk_scale, 1e-6, 'skill hit / normal hit');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('3_10 松果 S1 RMA长钉 (charges): an immediate extra shot ×atk_scale ignoring def_penetrate_fixed DEF, all in range', () => {
|
||||
for (const id of BOTH('chess_char_3_10_a')) {
|
||||
const b = SB(id, 'skchr_pinecn_1');
|
||||
const d = LD(id, 'skchr_pinecn_1');
|
||||
const front = ds.getChess(id).traitBb.atk_scale ?? 1.5;
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d', { def: 300 }), enemy_s: dummy('enemy_s', { def: 300 }) }, chess: noGarrison(id) }, timeLimit: 60, hooks: ['damaged', 'attack'], captureNoisy: true,
|
||||
units: [U(id, 'skchr_pinecn_1', 10, 4)], enemies: [{ key: 'enemy_d', pos: [10, 5] }, { key: 'enemy_s', pos: [9, 6] }] });
|
||||
const u = h.unit(id);
|
||||
assert.equal(u.skill.maxCharges, d.skill.maxCharges);
|
||||
h.run(1);
|
||||
const n0 = h.hooksOf('attack').length;
|
||||
fill(u);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 5));
|
||||
const at = h.hooksOf('attack').slice(n0).filter((c) => c.attacker === u);
|
||||
assert.ok(at.some((c) => c.isSkill) && at.some((c) => !c.isSkill), 'skill shot + the normal attack');
|
||||
assert.equal(new Set(at.map((c) => c.t)).size, 1, 'at the same moment');
|
||||
h.run(0.5);
|
||||
const sk = atkHits(h, u, true);
|
||||
const main = sk.find((c) => c.target.defId === 'enemy_d');
|
||||
approx(main.amount, u.s.atk * b.atk_scale * front - (300 - b.def_penetrate_fixed), 1e-6, 'front row × and DEF ignore');
|
||||
assert.ok(sk.some((c) => c.target.defId === 'enemy_s'), 'every enemy in range');
|
||||
const plain = atkHits(h, u, false).find((c) => c.target.defId === 'enemy_d');
|
||||
approx(plain.amount, u.s.atk * front - 300, 1e-6, 'the ignore is for the skill shot only');
|
||||
assert.ok(h.runUntil(() => u.skill.activations >= d.skill.maxCharges, 8), 'every stored charge is used, one per attack');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('3_11 雪猎 S1 强力击·β型: next shot ×atk_scale (× trait), 裂云兽 on that enemy; no empty-magazine cast', () => {
|
||||
for (const id of BOTH('chess_char_3_11_a')) {
|
||||
const b = SB(id, 'skchr_snhunt_1'), t0 = TB(id, 0);
|
||||
const ammoScale = ds.getChess(id).traitBb.atk_scale ?? 1.2;
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: noGarrison(id) }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
|
||||
units: [U(id, 'skchr_snhunt_1', 10, 4)], enemies: [{ key: 'enemy_d', pos: [10, 6] }] });
|
||||
const u = h.unit(id);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 30));
|
||||
h.run(1);
|
||||
const sk = atkHits(h, u, true);
|
||||
assert.equal(sk.length, 1);
|
||||
approx(sk[0].amount, u.s.atk * b.atk_scale * ammoScale);
|
||||
const beast = tagged(h, 'cloudbeast', u);
|
||||
assert.equal(beast.length, 1);
|
||||
approx(beast[0].amount, u.s.atk * t0.atk_scale);
|
||||
// empty magazine + SP full: S1 is a normal shot, it waits for a round
|
||||
const acts = u.skill.activations;
|
||||
u.trait.ammo = 0;
|
||||
fill(u);
|
||||
h.run(0.5);
|
||||
assert.equal(u.skill.activations, acts);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('3_12 瑕光 S1 光芒涌动 (TAKE_DAMAGE, charges): next attack ×atk_scale + heals the most injured ally of the 3×3', () => {
|
||||
for (const id of BOTH('chess_char_3_12_a')) {
|
||||
const b = SB(id, 'skchr_blemsh_1');
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 60, hooks: ['damaged', 'heal'], captureNoisy: true,
|
||||
units: [U(id, 'skchr_blemsh_1', 10, 4), { chessId: 'chess_char_3_16_a', row: 11, col: 4 }, { chessId: 'chess_char_3_05_a', row: 12, col: 4 }],
|
||||
enemies: [{ key: 'enemy_d', pos: [10, 5] }] });
|
||||
const u = h.unit(id), ally = h.unit('chess_char_3_16_a'), far = h.unit('chess_char_3_05_a');
|
||||
assert.equal(u.skill.rule, 'TAKE_DAMAGE');
|
||||
h.step();
|
||||
ally.hp = ally.s.maxHp * 0.6;
|
||||
far.hp = far.s.maxHp * 0.3; // outside the 3×3
|
||||
fill(u);
|
||||
h.b.dealDamage(h.enemy('enemy_d'), u, { type: 'phys', amount: 1 });
|
||||
assert.ok(u.skill.active && u.skill.pending, 'cast when hit; next attack armed');
|
||||
h.runUntil(() => !u.skill.active, 5);
|
||||
const sk = atkHits(h, u, true);
|
||||
assert.equal(sk.length, 1);
|
||||
approx(sk[0].amount, u.s.atk * b.atk_scale);
|
||||
const heals = h.hooksOf('heal').filter((c) => c.source === u);
|
||||
assert.equal(heals[0].target, ally);
|
||||
approx(heals[0].amount, u.s.atk * b.heal_scale);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('3_12 瑕光 S2 慑敌辉光: ATK +, ground enemies on her tile sleep for the skill, allies of the skill range healed each second', () => {
|
||||
for (const id of BOTH('chess_char_3_12_a')) {
|
||||
const b = SB(id, 'skchr_blemsh_2'), t1 = TB(id, 1);
|
||||
const dur = LD(id, 'skchr_blemsh_2').skill.duration;
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d'), enemy_n: dummy('enemy_n') } }, timeLimit: 60, hooks: ['damaged', 'heal'], captureNoisy: true,
|
||||
units: [U(id, 'skchr_blemsh_2', 10, 4), { chessId: 'chess_char_3_16_a', row: 10, col: 6 }],
|
||||
enemies: [{ key: 'enemy_d', pos: [10, 4] }, { key: 'enemy_n', pos: [10, 5] }] });
|
||||
const u = h.unit(id), ally = h.unit('chess_char_3_16_a');
|
||||
h.run(1);
|
||||
const e = h.enemy('enemy_d'), n = h.enemy('enemy_n');
|
||||
assert.equal(e.blockedBy, u);
|
||||
ally.hp = ally.s.maxHp * 0.3;
|
||||
fill(u);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 3));
|
||||
h.step();
|
||||
approx(u.s.atk, u.base.atk * (1 + b.atk));
|
||||
assert.ok(e.s.flags.sleep, 'her tile: asleep');
|
||||
approx(e.findBuff('sleep').timeLeft, dur - h.b.dt, 0.05, 'for the skill duration');
|
||||
assert.ok(!n.s.flags.sleep, 'next tile: awake');
|
||||
const n0 = h.hooksOf('damaged').length;
|
||||
h.run(2.2);
|
||||
const onSleeper = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.target === e && c.dmg?.isAttack);
|
||||
assert.ok(onSleeper.length >= 1, '仁慈: she hits the sleeper');
|
||||
approx(onSleeper[0].amount, u.s.atk * t1.atk_scale, 1e-6, '×仁慈');
|
||||
const regen = h.hooksOf('heal').filter((c) => c.source === u && c.target === ally);
|
||||
assert.equal(regen.length, 2, 'once per second');
|
||||
approx(regen[0].amount, u.s.atk * b['attack@atk_to_hp_recovery_ratio']);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('3_12 瑕光 精锐 module GUA-X: heals on allies under 50 % HP ×1.15 (not above); no GUA-Y damage cut', () => {
|
||||
const id = 'chess_char_3_12_b', mod = 'uniequip_003_blemsh';
|
||||
const tb = ds.getChess(id, { moduleId: mod }).traitBb;
|
||||
const b = SB(id, 'skchr_blemsh_1');
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 60, hooks: ['heal', 'damaged'], captureNoisy: true,
|
||||
units: [U(id, 'skchr_blemsh_1', 10, 4, { moduleId: mod }), { chessId: 'chess_char_3_16_a', row: 11, col: 4 }], enemies: [{ key: 'enemy_d', pos: [10, 5] }] });
|
||||
const u = h.unit(id), ally = h.unit('chess_char_3_16_a');
|
||||
h.step();
|
||||
assert.equal(u.findBuff('trait:blemsh_guard'), null);
|
||||
ally.hp = ally.s.maxHp * 0.4;
|
||||
fill(u);
|
||||
h.b.dealDamage(h.enemy('enemy_d'), u, { type: 'phys', amount: 1 });
|
||||
h.runUntil(() => !u.skill.pending, 5);
|
||||
let heal = h.hooksOf('heal').filter((c) => c.source === u).at(-1);
|
||||
approx(heal.amount, u.s.atk * b.heal_scale * tb.heal_scale, 1e-6, 'under 50 %');
|
||||
ally.hp = ally.s.maxHp * 0.6;
|
||||
fill(u);
|
||||
h.b.dealDamage(h.enemy('enemy_d'), u, { type: 'phys', amount: 1 });
|
||||
h.runUntil(() => !u.skill.pending, 5);
|
||||
heal = h.hooksOf('heal').filter((c) => c.source === u).at(-1);
|
||||
approx(heal.amount, u.s.atk * b.heal_scale, 1e-6, 'above 50 %');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('3_13 至简 S1 / 3_16 蛇屠箱 S1 / 3_20 耶拉 S1: timed stat skills from their own blackboards (蛇屠箱 on TAKE_DAMAGE, keeps attacking)', () => {
|
||||
for (const [base, sid, check] of [
|
||||
['chess_char_3_13_a', 'skcom_quickattack[3]', (u, b) => { approx(u.s.aspd, u.base.aspd + b.attack_speed); assert.ok(u.s.atk >= u.base.atk * (1 + b.atk) - 1e-6); }],
|
||||
// (精锐 PRO-X: + trait DEF while blocking — the enemy stands on her tile)
|
||||
['chess_char_3_16_a', 'skcom_def_up[2]', (u, b) => approx(u.s.def, u.base.def * (1 + b.def + TB(u.defId, 0).def + (u.blocking.length ? ds.getChess(u.defId).traitBb.def ?? 0 : 0)))],
|
||||
['chess_char_3_20_a', 'skcom_atk_up[3]', (u, b) => assert.ok(u.s.atk > u.base.atk * (1 + b.atk) - 1e-6)],
|
||||
]) {
|
||||
for (const id of BOTH(base)) {
|
||||
const b = SB(id, sid);
|
||||
const d = LD(id, sid);
|
||||
const melee = ds.getChess(id).position === 'MELEE';
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d', { atk: 10 }) } }, timeLimit: 80, hooks: ['damaged'], captureNoisy: true,
|
||||
units: [U(id, sid, 10, 4)], enemies: [{ key: 'enemy_d', pos: [10, melee ? 4 : 5] }] });
|
||||
const u = h.unit(id);
|
||||
h.run(1);
|
||||
const atk0 = u.s.atk;
|
||||
fill(u);
|
||||
if (d.skill.trigger.rule === 'TAKE_DAMAGE') {
|
||||
h.run(0.5);
|
||||
assert.equal(u.skill.activations, 0, `${id}: waits for a hit`);
|
||||
h.b.dealDamage(h.enemy('enemy_d'), u, { type: 'phys', amount: 1 });
|
||||
}
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5), id);
|
||||
h.step();
|
||||
check(u, b);
|
||||
const n0 = atkHits(h, u).length;
|
||||
h.run(3);
|
||||
assert.ok(atkHits(h, u).length > n0, `${id} attacks during the skill`);
|
||||
approx(u.skill.timeLeft + 3 + h.b.dt, d.skill.duration, 0.2);
|
||||
h.runUntil(() => !u.skill.active, d.skill.duration + 1);
|
||||
h.step();
|
||||
approx(u.s.atk, atk0, 1e-6, `${id} back`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('3_14 初雪 S1 传音回响: 2 targets, enemies in range ASPD + attack_speed while it runs', () => {
|
||||
for (const id of BOTH('chess_char_3_14_a')) {
|
||||
const b = SB(id, 'skchr_slbell_1');
|
||||
const h = makeBattle({ defs: { enemies: { enemy_e1: dummy('enemy_e1'), enemy_e2: dummy('enemy_e2'), enemy_e3: dummy('enemy_e3') } }, timeLimit: 60, hooks: ['attack'], captureNoisy: true,
|
||||
units: [U(id, 'skchr_slbell_1', 10, 4)], enemies: [{ key: 'enemy_e1', pos: [10, 5] }, { key: 'enemy_e2', pos: [10, 6] }, { key: 'enemy_e3', pos: [9, 5] }] });
|
||||
const u = h.unit(id);
|
||||
h.run(0.5);
|
||||
fill(u);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||
h.step();
|
||||
for (const e of h.enemies()) approx(e.s.aspd, e.base.aspd + b.attack_speed);
|
||||
h.run(2);
|
||||
const sa = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill);
|
||||
assert.ok(sa.length >= 1 && sa.every((c) => c.targets.length === b['attack@max_target']));
|
||||
h.runUntil(() => !u.skill.active, 30);
|
||||
h.step();
|
||||
for (const e of h.enemies()) approx(e.s.aspd, e.base.aspd, 1e-6, 'restored');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('3_17 流星 S1 碎甲击: next attack ×atk_scale and the target DEF − for `duration` s', () => {
|
||||
for (const id of BOTH('chess_char_3_17_a')) {
|
||||
const b = SB(id, 'skchr_shotst_1');
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d', { def: 200 }) }, chess: noGarrison(id) }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
|
||||
units: [U(id, 'skchr_shotst_1', 10, 4)], enemies: [{ key: 'enemy_d', pos: [10, 6] }] });
|
||||
const u = h.unit(id);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 30));
|
||||
h.run(0.3);
|
||||
const e = h.enemy('enemy_d');
|
||||
const sk = atkHits(h, u, true);
|
||||
assert.equal(sk.length, 1);
|
||||
approx(sk[0].amount, u.s.atk * b.atk_scale - 200);
|
||||
const shred = e.findBuff('skill:shotst_shred');
|
||||
assert.ok(shred);
|
||||
approx(e.s.def, 200 * (1 + b.def));
|
||||
h.run(b.duration + 0.5);
|
||||
if (!e.findBuff('skill:shotst_shred')) approx(e.s.def, 200, 1e-6, 'expired');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('3_18 忍冬 S1 小施惩戒: next attack + extra arts and +DP; S2 坠刃拷问: +DP, ≤6 in its range, 停顿, then stun if already 停顿', () => {
|
||||
for (const id of BOTH('chess_char_3_18_a')) {
|
||||
const b1 = SB(id, 'skchr_vulpis_1');
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true, flags: { dpPerSec: 0 },
|
||||
units: [U(id, 'skchr_vulpis_1', 10, 4)], enemies: [{ key: 'enemy_d', pos: [10, 4] }] });
|
||||
const u = h.unit(id), p = h.b.getPlayer('p1');
|
||||
h.run(0.5);
|
||||
const dp0 = p.dp;
|
||||
fill(u);
|
||||
assert.equal(u.skill.charges, LD(id, 'skchr_vulpis_1').skill.maxCharges);
|
||||
assert.ok(h.runUntil(() => tagged(h, 'vulpisPunish', u).length >= 1, 5));
|
||||
const x = tagged(h, 'vulpisPunish', u)[0];
|
||||
assert.equal(x.type, 'arts');
|
||||
approx(x.amount, u.s.atk * b1.extra_damage_ratio);
|
||||
approx(p.dp, dp0 + b1.cost * tagged(h, 'vulpisPunish', u).length);
|
||||
done(h);
|
||||
|
||||
const b2 = SB(id, 'skchr_vulpis_2');
|
||||
const d2 = LD(id, 'skchr_vulpis_2');
|
||||
const keys = ['enemy_e1', 'enemy_e2', 'enemy_e3', 'enemy_e4', 'enemy_e5', 'enemy_e6', 'enemy_e7'];
|
||||
// [0,3] (10,7) is inside 3-12 but not in her melee range: the cast needs no enemy next to her
|
||||
const g = makeBattle({ defs: { enemies: Object.fromEntries(keys.map((k) => [k, dummy(k)])) }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true, flags: { dpPerSec: 0 },
|
||||
units: [U(id, 'skchr_vulpis_2', 10, 4)], enemies: [{ key: 'enemy_e1', pos: [10, 7] }] });
|
||||
const v = g.unit(id), gp = g.b.getPlayer('p1');
|
||||
g.run(0.5);
|
||||
assert.equal(atkHits(g, v).length, 0, 'nothing in her own range');
|
||||
const gdp = gp.dp;
|
||||
v.skill.gainSp(v.skill.spCost - v.skill.sp + 0.01, 'test'); // one charge
|
||||
assert.ok(g.runUntil(() => v.skill.activations === 1, 1), 'CUSTOM_RANGE on the skill range');
|
||||
approx(gp.dp, gdp + b2.cost);
|
||||
const e1 = g.enemy('enemy_e1');
|
||||
const hit = tagged(g, 'vulpisTorture', v);
|
||||
assert.equal(hit.length, 1);
|
||||
approx(hit[0].amount, v.s.atk * b2.atk_scale);
|
||||
assert.ok(e1.findBuff('sluggish'));
|
||||
assert.ok(!e1.s.flags.stun, 'not 停顿 before: no stun');
|
||||
for (const [i, k] of keys.slice(1).entries()) g.spawn(k, { pos: [[9, 5], [11, 5], [10, 5], [10, 6], [11, 4], [9, 4]][i] });
|
||||
v.skill.gainSp(v.skill.spCost + 0.01, 'test');
|
||||
assert.ok(g.runUntil(() => v.skill.activations === 2, 1));
|
||||
const second = tagged(g, 'vulpisTorture', v).slice(1);
|
||||
assert.equal(second.length, b2.max_target, '≤ max_target enemies');
|
||||
if (second.some((c) => c.target === e1)) assert.ok(e1.s.flags.stun, 'already 停顿 ⇒ stunned');
|
||||
assert.equal(d2.skill.maxCharges, 2);
|
||||
done(g);
|
||||
}
|
||||
});
|
||||
|
||||
test('3_19 伺夜 S1 领袖的呼唤 (ALWAYS): +DP and one more “狼影” (≤ max)', () => {
|
||||
for (const id of BOTH('chess_char_3_19_a')) {
|
||||
const b = SB(id, 'skchr_vigil_1');
|
||||
const h = makeBattle({ defs: { chess: noGarrison(id) }, timeLimit: 60, flags: { dpPerSec: 0 }, units: [U(id, 'skchr_vigil_1', 10, 3)] });
|
||||
const u = h.unit(id), p = h.b.getPlayer('p1');
|
||||
assert.equal(u.skill.rule, 'SP_FULL');
|
||||
h.step();
|
||||
const w = u.trait.reinforcement;
|
||||
assert.equal(w.mem.wolves, 2);
|
||||
const dp0 = p.dp;
|
||||
fill(u);
|
||||
h.step();
|
||||
assert.equal(u.skill.activations, 1, 'no enemy needed');
|
||||
approx(p.dp, dp0 + b.cost);
|
||||
assert.equal(w.mem.wolves, 3);
|
||||
assert.equal(w.s.blockCnt, 3);
|
||||
fill(u);
|
||||
h.step();
|
||||
assert.equal(w.mem.wolves, 3, 'capped');
|
||||
approx(p.dp, dp0 + 2 * b.cost);
|
||||
done(h);
|
||||
}
|
||||
// a 狼群 piece placed in the prep phase (content/tokens.js pack) gets the shadow too
|
||||
const id = 'chess_char_3_19_a';
|
||||
const g = makeBattle({ defs: { chess: noGarrison(id) }, timeLimit: 30, flags: { dpPerSec: 0 },
|
||||
units: [U(id, 'skchr_vigil_1', 12, 3, { uid: 1 }), { kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 11, col: 5, uid: 2 }] });
|
||||
const u = g.unit(1), piece = g.unit(2);
|
||||
g.step();
|
||||
assert.equal(u.trait.reinforcement, piece);
|
||||
const n0 = piece.mem.shadows, blk = piece.s.blockCnt;
|
||||
fill(u);
|
||||
g.step();
|
||||
assert.equal(piece.mem.shadows, n0 + 1);
|
||||
assert.equal(piece.s.blockCnt, blk + 1);
|
||||
done(g);
|
||||
});
|
||||
|
||||
test('3_19 伺夜 S2 领袖的馈赠: +DP, the pack recovers HP, its next attack ×atk_scale, a kill by it pays +DP', () => {
|
||||
for (const id of BOTH('chess_char_3_19_a')) {
|
||||
const b = SB(id, 'skchr_vigil_2');
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: noGarrison(id) }, timeLimit: 60, hooks: ['damaged', 'attack'], captureNoisy: true, flags: { dpPerSec: 0 },
|
||||
units: [U(id, 'skchr_vigil_2', 10, 3)], enemies: [{ key: 'enemy_d', pos: [9, 3], time: 0.5 }] });
|
||||
const u = h.unit(id), p = h.b.getPlayer('p1');
|
||||
h.run(2);
|
||||
const w = u.trait.reinforcement, e = h.enemy('enemy_d');
|
||||
assert.equal(e.blockedBy, w);
|
||||
w.hp = w.s.maxHp * 0.5;
|
||||
const dp0 = p.dp;
|
||||
fill(u);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 3));
|
||||
approx(p.dp, dp0 + b.cost);
|
||||
approx(w.hp, w.s.maxHp * (0.5 + b['vigil_wolf_s_2.hp_ratio']));
|
||||
h.b.addBuff(u, { key: 'test:disarm', flags: { disarm: true } });
|
||||
const n0 = h.hooksOf('attack').length;
|
||||
assert.ok(h.runUntil(() => h.hooksOf('attack').slice(n0).filter((c) => c.attacker === w).length >= 2, 6));
|
||||
const bites = h.hooksOf('attack').slice(n0).filter((c) => c.attacker === w);
|
||||
const dmgOf = (a) => h.hooksOf('damaged').filter((c) => c.source === w && c.dmg?.isAttack && c.dmg.attackId === a);
|
||||
const ids = [...new Set(h.hooksOf('damaged').filter((c) => c.source === w && c.dmg?.isAttack).map((c) => c.dmg.attackId))].filter((a) => a > 0).slice(-bites.length);
|
||||
const first = dmgOf(ids[0]), second = dmgOf(ids[1]);
|
||||
assert.equal(first.length, w.mem.wolves);
|
||||
for (const c of first) approx(c.amount, w.s.atk * b['vigil_wolf_s_2.atk_scale'], 1e-6, 'empowered bite');
|
||||
for (const c of second) approx(c.amount, w.s.atk, 1e-6, 'one attack only');
|
||||
done(h);
|
||||
|
||||
// kill by the empowered attack ⇒ +cost DP
|
||||
const g = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: noGarrison(id) }, timeLimit: 60, flags: { dpPerSec: 0 },
|
||||
units: [U(id, 'skchr_vigil_2', 10, 3)], enemies: [{ key: 'enemy_d', pos: [9, 3], time: 0.5 }] });
|
||||
const v = g.unit(id), gp = g.b.getPlayer('p1');
|
||||
g.run(2);
|
||||
g.b.addBuff(v, { key: 'test:disarm', flags: { disarm: true } }); // (his own shots must not take the kill)
|
||||
g.run(1);
|
||||
fill(v);
|
||||
assert.ok(v.skill.activate('test'));
|
||||
const gdp = gp.dp;
|
||||
const ge = g.enemy('enemy_d');
|
||||
ge.hp = 1;
|
||||
assert.ok(g.runUntil(() => !ge.alive, 5));
|
||||
approx(gp.dp, gdp + b['vigil_wolf_s_2.cost'], 1e-6, 'kill bonus');
|
||||
done(g);
|
||||
}
|
||||
});
|
||||
|
||||
test('3_19 伺夜 精锐 module TAC-Y: ×165 % on pack-blocked enemies; "援军阻挡的敌人更容易受到我方的攻击" = those enemies taunt +1 (not the pack); TAC-X guard not applied', () => {
|
||||
const id = 'chess_char_3_19_b', mod = 'uniequip_003_vigil';
|
||||
const tb = ds.getChess(id, { moduleId: mod }).traitBb;
|
||||
const h = makeBattle({ defs: { enemies: { enemy_h: dummy('enemy_h', { atk: 400, bat: 1 }) }, chess: noGarrison(id) }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true, flags: { dpPerSec: 0 },
|
||||
units: [{ chessId: id, row: 10, col: 3, moduleId: mod }], enemies: [{ key: 'enemy_h', pos: [9, 3], time: 0.5 }] });
|
||||
const u = h.unit(id);
|
||||
h.run(3);
|
||||
const w = u.trait.reinforcement, e = h.enemy('enemy_h');
|
||||
assert.equal(e.blockedBy, w);
|
||||
approx(w.s.taunt, w.base.tauntLevel ?? 0, 1e-9, 'the pack itself keeps its taunt level');
|
||||
assert.equal(e.s.taunt, 1, 'the enemy it blocks: taunt_level +1');
|
||||
approx(atkHits(h, u, false)[0].amount, u.s.atk * tb.atk_scale, 1e-6, 'trait ×1.65');
|
||||
const taken = h.hooksOf('damaged').find((c) => c.target === w && c.source === e);
|
||||
approx(taken.amount, 400 - w.s.def, 1e-6, 'no TAC-X damage cut');
|
||||
// released (the pack is withdrawn) ⇒ the mark goes away
|
||||
h.b.retreat(w, { reason: 'expired', permanent: true });
|
||||
h.run(0.5);
|
||||
assert.equal(e.s.taunt || 0, 0, 'no mark once no longer blocked by the pack');
|
||||
done(h);
|
||||
|
||||
// operators pick the pack-blocked enemy over one nearer to the goal (a prep-placed 狼群 piece run by tokens.js);
|
||||
// the default TAC-X marks nothing
|
||||
for (const m of [mod, null]) {
|
||||
const g = makeBattle({ defs: { enemies: { enemy_h: dummy('enemy_h'), enemy_o: dummy('enemy_o') }, chess: noGarrison(id) }, timeLimit: 60, hooks: ['attack'], captureNoisy: true, flags: { dpPerSec: 0 },
|
||||
units: [{ chessId: id, row: 10, col: 4, uid: 1, ...(m ? { moduleId: m } : {}) }, { kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 9, col: 6, uid: 2 }],
|
||||
enemies: [{ key: 'enemy_h', pos: [9, 6], time: 0.5 }, { key: 'enemy_o', pos: [9, 5], time: 0.5 }] });
|
||||
const v = g.unit(1), piece = g.unit(2);
|
||||
g.run(3);
|
||||
const eh = g.enemy('enemy_h'), eo = g.enemy('enemy_o');
|
||||
assert.equal(v.trait.reinforcement, piece);
|
||||
assert.equal(eh.blockedBy, piece);
|
||||
assert.ok(g.b.remainingDistance(eo) < g.b.remainingDistance(eh), 'the other enemy is nearer to the goal');
|
||||
const shots = g.hooksOf('attack').filter((c) => c.attacker === v);
|
||||
assert.ok(shots.length >= 2);
|
||||
const want = m ? eh : eo;
|
||||
assert.ok(shots.every((c) => c.targets[0] === want), `${m ?? 'TAC-X'}: 伺夜 shoots ${want.defId}`);
|
||||
done(g);
|
||||
}
|
||||
});
|
||||
|
||||
test('3_21 空弦 S1 箭矢·散逸: next attack ×atk_scale + up to 3 other enemies around × atk_scale_2', () => {
|
||||
for (const id of BOTH('chess_char_3_21_a')) {
|
||||
const b = SB(id, 'skchr_archet_1');
|
||||
const keys = ['enemy_e1', 'enemy_e2', 'enemy_e3', 'enemy_e4', 'enemy_e5'];
|
||||
const h = makeBattle({ defs: { enemies: Object.fromEntries(keys.map((k) => [k, dummy(k)])), chess: noGarrison(id) }, timeLimit: 60, hooks: ['damaged', 'attack'], captureNoisy: true,
|
||||
units: [U(id, 'skchr_archet_1', 10, 3)],
|
||||
enemies: [{ key: 'enemy_e1', pos: [10, 6] }, { key: 'enemy_e2', pos: [9, 6] }, { key: 'enemy_e3', pos: [11, 6] }, { key: 'enemy_e4', pos: [10, 7] }, { key: 'enemy_e5', pos: [9, 7] }] });
|
||||
const u = h.unit(id);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 30));
|
||||
h.run(1);
|
||||
const main = atkHits(h, u, true);
|
||||
assert.equal(main.length, 1);
|
||||
approx(main[0].amount, u.s.atk * b.atk_scale);
|
||||
const sc = tagged(h, 'archetScatter', u);
|
||||
assert.equal(sc.length, b.max_target - 1);
|
||||
assert.ok(!sc.some((c) => c.target === main[0].target), 'other enemies');
|
||||
for (const c of sc) {
|
||||
approx(c.amount, u.s.atk * b.atk_scale_2);
|
||||
assert.ok(Math.hypot(c.target.x - main[0].target.x, c.target.y - main[0].target.y) <= 1.5 + 1e-9);
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('3_21 空弦 S2 箭矢·追猎 (charges): times hits on the target, then bounces with one hit fewer each time', () => {
|
||||
for (const id of BOTH('chess_char_3_21_a')) {
|
||||
const b = SB(id, 'skchr_archet_2');
|
||||
const d = LD(id, 'skchr_archet_2');
|
||||
const h = makeBattle({ defs: { enemies: { enemy_e1: dummy('enemy_e1'), enemy_e2: dummy('enemy_e2'), enemy_e3: dummy('enemy_e3') }, chess: noGarrison(id) }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
|
||||
units: [U(id, 'skchr_archet_2', 10, 3)], enemies: [{ key: 'enemy_e1', pos: [10, 6] }, { key: 'enemy_e2', pos: [10, 7] }, { key: 'enemy_e3', pos: [10, 8] }] });
|
||||
const u = h.unit(id);
|
||||
assert.equal(u.skill.maxCharges, d.skill.maxCharges);
|
||||
h.run(0.5);
|
||||
u.skill.gainSp(u.skill.spCost - u.skill.sp + 0.01, 'test');
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 3));
|
||||
h.run(2);
|
||||
const hits = tagged(h, 'archetPursuit', u);
|
||||
const per = (k) => hits.filter((c) => c.target.defId === k).length;
|
||||
const first = hits[0].target.defId;
|
||||
assert.equal(per(first), b.times);
|
||||
const counts = ['enemy_e1', 'enemy_e2', 'enemy_e3'].map(per).sort((x, y) => y - x);
|
||||
assert.deepEqual(counts, [b.times, b.times - 1, b.times - 2], '5, 4, 3');
|
||||
for (const c of hits) approx(c.amount, u.s.atk * b.atk_scale);
|
||||
done(h);
|
||||
}
|
||||
// a lone target takes all the hits of the first volley only (no one to bounce to)
|
||||
const id = 'chess_char_3_21_a';
|
||||
const g = makeBattle({ defs: { enemies: { enemy_e1: dummy('enemy_e1') }, chess: noGarrison(id) }, timeLimit: 30, hooks: ['damaged'], captureNoisy: true,
|
||||
units: [U(id, 'skchr_archet_2', 10, 3)], enemies: [{ key: 'enemy_e1', pos: [10, 6] }] });
|
||||
const v = g.unit(id);
|
||||
g.run(0.5);
|
||||
v.skill.gainSp(v.skill.spCost - v.skill.sp + 0.01, 'test');
|
||||
assert.ok(g.runUntil(() => v.skill.activations === 1, 3));
|
||||
g.run(2);
|
||||
assert.equal(tagged(g, 'archetPursuit', v).length, SB(id, 'skchr_archet_2').times);
|
||||
done(g);
|
||||
});
|
||||
|
||||
test('3_21 空弦 精锐 modules: MAR-X fly × and no ground ASPD; ISW-A (集成战略 only) no range bonus', () => {
|
||||
const id = 'chess_char_3_21_b';
|
||||
const x = ds.getChess(id, { moduleId: 'uniequip_003_archet' });
|
||||
const h = makeBattle({ defs: { enemies: { enemy_f: dummy('enemy_f', { motion: 'FLY' }), enemy_g: dummy('enemy_g') }, chess: noGarrison(id) }, timeLimit: 30, hooks: ['damaged'], captureNoisy: true,
|
||||
units: [{ chessId: id, row: 10, col: 4, moduleId: 'uniequip_003_archet' }], enemies: [{ key: 'enemy_f', pos: [10, 6] }, { key: 'enemy_g', pos: [10, 7] }] });
|
||||
const u = h.unit(id);
|
||||
h.run(3);
|
||||
const fl = atkHits(h, u, false).find((c) => c.target.defId === 'enemy_f');
|
||||
approx(fl.amount, u.s.atk * x.traitBb.atk_scale, 1e-6, 'MAR-X fly ×');
|
||||
assert.equal(u.findBuff('trait:archet_ground'), null, 'MAR-Y ground ASPD not installed');
|
||||
done(h);
|
||||
const g = makeBattle({ defs: { chess: noGarrison(id) }, timeLimit: 10, units: [{ chessId: id, row: 10, col: 4, moduleId: 'uniequip_004_archet' }] });
|
||||
const v = g.unit(id);
|
||||
g.step();
|
||||
assert.equal(v.s.rangeExtend, 0);
|
||||
assert.equal(v.rangeKeys.length, ds.getChess(id).rangeGrid.length);
|
||||
done(g);
|
||||
});
|
||||
|
||||
test('忍冬 精锐 module SOL-Y: stats only (the initial deployment is free), no SOL-X block buff', () => {
|
||||
const id = 'chess_char_3_18_b', mod = 'uniequip_003_vulpis';
|
||||
const d = ds.getChess(id, { moduleId: mod });
|
||||
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 20, flags: { dpPerSec: 0 },
|
||||
units: [{ chessId: id, row: 10, col: 4, moduleId: mod }], enemies: [{ key: 'enemy_d', pos: [10, 4] }] });
|
||||
const u = h.unit(id);
|
||||
const dp0 = h.b.getPlayer('p1').dp;
|
||||
h.run(2);
|
||||
assert.equal(u.base.atk, d.stats.atk);
|
||||
assert.equal(h.enemy('enemy_d').blockedBy, u);
|
||||
assert.equal(u.findBuff('trait:vulpis_block'), null);
|
||||
assert.ok(h.b.getPlayer('p1').dp >= dp0, 'no DP spent or refunded');
|
||||
done(h);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,208 @@
|
||||
// test/content/summon_loadout_conflicts.test.js — summoner kits read their summon's stats / blackboards for the OWNER's
|
||||
// loadout (DESIGN §16, docs/SIM.md §6 `battle.tokenDef(tokenId, ownerUnit)`), also when two players of one shared field
|
||||
// (联防) run the same summoner with different skills / modules. The per-battle data view resolves an id-only
|
||||
// `battle.data.getToken(id, chessId)` with the FIRST player's loadout of that chess (`loadoutConflicts`), so a kit
|
||||
// reading it gave the second player the first one's variant. The real tokens.json variants of these summons differ
|
||||
// between loadouts only in fields the kits do not read, so each test patches the field its kit reads (a copy of the
|
||||
// real record) and runs both seat orders.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { DataSource, getDefaultSource, hasGeneratedData } from '../../server/sim/simdata.js';
|
||||
|
||||
const skip = !hasGeneratedData() && 'no generated data (run node tools/build-data.mjs)';
|
||||
const base = getDefaultSource();
|
||||
|
||||
const DEF = {}, NONE = { moduleId: 'none' };
|
||||
const ORDERS = (x, y) => [[x, y], [y, x]];
|
||||
const mk = (playerId, seat, colOffset, units) => ({ playerId, seat, side: 'L', colOffset, units, bonds: {}, playerEffects: [] });
|
||||
const dummy = (key) => enemyRec({ key, hp: 1e7, speed: 0, def: 0, res: 0, atk: 0 });
|
||||
const approx = (a, b, msg = '') => assert.ok(Math.abs(a - b) <= 1e-6 * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
|
||||
|
||||
/** `{ [tokenId]: copy of the real record }` with `patch(variant)` applied to chess `owner`'s variant. */
|
||||
function patchedToken(tokenId, owner, patch) {
|
||||
const rec = structuredClone(base.rawToken(tokenId));
|
||||
patch(rec.variants[owner]);
|
||||
return { [tokenId]: rec };
|
||||
}
|
||||
/** The token def the patched data gives a summon of `chessId` with loadout `lo` (the expectation). */
|
||||
const want = (tokens, tokenId, chessId, lo) => new DataSource({ chess: {}, enemies: {}, tokens, stages: {}, waves: {} }, base).getToken(tokenId, chessId, lo);
|
||||
|
||||
/**
|
||||
* A 联防 field: p1 (left half) and p2 (right half, +8 columns) each field `chessId` (uid 1) with loadouts lo[0] / lo[1]
|
||||
* (plus `extra` units). Returns the harness and both summoners after the first tick.
|
||||
*/
|
||||
function duel({ chessId, lo, tokens, row = 10, col = 4, extra = [], enemies = {}, spawns = [] }) {
|
||||
const units = (l) => [{ uid: 1, chessId, row, col, dir: 'RIGHT', ...l }, ...extra.map((e, i) => ({ uid: i + 2, dir: 'RIGHT', ...e }))];
|
||||
const h = makeBattle({
|
||||
kind: 'unite', seed: 7, timeLimit: 120, defs: { tokens, enemies }, enemies: spawns, hooks: ['damaged', 'deploy'], captureNoisy: true,
|
||||
players: [mk('p1', 0, 0, units(lo[0])), mk('p2', 1, 8, units(lo[1]))],
|
||||
});
|
||||
h.step();
|
||||
const own = (pid) => h.b.allyUnits.find((u) => u.kind === 'op' && u.player.playerId === pid && u.uid === 1);
|
||||
const ops = [own('p1'), own('p2')];
|
||||
assert.ok(ops.every((u) => u && u.alive && u.deployed), 'both summoners deployed');
|
||||
assert.ok(h.b.data.loadoutConflicts.includes(chessId), 'an id-only lookup would give p2 p1\'s loadout');
|
||||
return { h, ops };
|
||||
}
|
||||
const summons = (h, owner, tokenId) => h.b.allyUnits.filter((u) => u.kind === 'token' && u.ownerUnit === owner && u.defId === tokenId);
|
||||
const done = (h) => { assert.deepEqual(h.b.errors.map((e) => `${e.label} ${e.message}`), []); checkInvariants(h.b); };
|
||||
|
||||
/** Cast each summoner's skill once; returns the summon each one placed. */
|
||||
function castAndCollect(h, ops, tokenId) {
|
||||
for (const u of ops) assert.ok(u.skill.activate('test', { free: true }), `${u.player.playerId} cast`);
|
||||
h.step();
|
||||
return ops.map((u) => {
|
||||
const s = summons(h, u, tokenId);
|
||||
assert.equal(s.length, 1, `${u.player.playerId} summoned one`);
|
||||
return s[0];
|
||||
});
|
||||
}
|
||||
|
||||
/** Lifetime check: at `short` + 0.5 s only the summon of the owner whose loadout gives `short` is gone. */
|
||||
function assertLifetimes(h, sums, lives) {
|
||||
const short = Math.min(...lives), long = Math.max(...lives);
|
||||
assert.ok(long - short > 1, 'the loadouts give different lifetimes');
|
||||
h.run(short + 0.5);
|
||||
sums.forEach((s, i) => assert.equal(s.alive, lives[i] > short + 0.5, `summon ${i + 1} (${lives[i]} s) at ${short + 0.5} s`));
|
||||
h.run(long - short);
|
||||
sums.forEach((s, i) => assert.equal(s.alive, false, `summon ${i + 1} expired`));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
test('赫默: the medical drone lives its owner\'s withdraw duration (module variant), whichever seat plays which module', { skip }, () => {
|
||||
const HEMO = 'chess_char_2_02_b', DRONE = 'token_10000_silent_healrb';
|
||||
// LONGER than the default: the drone's own token kit (tokens.js) also withdraws it after its variant's time, so only a
|
||||
// spawn duration shorter than the owner's variant shows the kit reading another loadout
|
||||
const tokens = patchedToken(DRONE, HEMO, (v) => { v.byModule.none.skill = { ...v.skill, duration: 16 }; });
|
||||
// the withdraw skill (自我销毁) is not part of the normalised token def: the kit reads the owner's raw variant
|
||||
const life = (lo) => (lo === NONE ? 16 : base.rawToken(DRONE).variants[HEMO].skill.duration);
|
||||
assert.equal(life(DEF), 10);
|
||||
for (const lo of ORDERS(DEF, NONE)) {
|
||||
const { h, ops } = duel({ chessId: HEMO, lo, tokens });
|
||||
const drones = castAndCollect(h, ops, DRONE);
|
||||
assertLifetimes(h, drones, lo.map(life));
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('风丸: the 纸偶 substitute takes the ATK / DEF of its owner\'s module variant of the shadow token', { skip }, () => {
|
||||
const KAZ = 'chess_char_2_11_b', SHADOW = 'token_10022_kazema_shadow';
|
||||
const tokens = patchedToken(SHADOW, KAZ, (v) => { v.byModule.none.stats = { ...v.byModule.none.stats, atk: 1234, def: 567 }; });
|
||||
assert.notEqual(want(tokens, SHADOW, KAZ, DEF).stats.atk, want(tokens, SHADOW, KAZ, NONE).stats.atk);
|
||||
for (const lo of ORDERS(DEF, NONE)) {
|
||||
const { h, ops } = duel({ chessId: KAZ, lo, tokens });
|
||||
for (const u of ops) h.b.dealDamage(null, u, { type: 'true', amount: 1e7 });
|
||||
h.step();
|
||||
ops.forEach((u, i) => {
|
||||
const ts = want(tokens, SHADOW, KAZ, lo[i]).stats;
|
||||
assert.ok(u.alive && u.findBuff('trait:substitute'), `${u.player.playerId} substituted`);
|
||||
const doll = u.findBuff('kazema:doll');
|
||||
assert.ok(doll, `${u.player.playerId} 纸偶 stats`);
|
||||
assert.equal(u.base.atk + doll.mods.atkFlat, ts.atk, `${u.player.playerId} ATK of its own variant`);
|
||||
assert.equal(u.base.def + doll.mods.defFlat, ts.def, `${u.player.playerId} DEF of its own variant`);
|
||||
});
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('琳琅诗怀雅: a 香槟炸弹 arms after its owner\'s module variant of duration_switch', { skip }, () => {
|
||||
const SWIRE = 'chess_char_3_04_b', TRAP = 'token_10031_swire2_gdtrap', M3 = { moduleId: 'uniequip_003_swire2' };
|
||||
// module 003 (patched): armed at once ⇒ its bombs always hit twice; the default arms after 3 s ⇒ a fresh bomb hits once
|
||||
const tokens = patchedToken(TRAP, SWIRE, (v) => { v.byModule[M3.moduleId].skill = { ...v.skill, bb: { ...v.skill.bb, duration_switch: 0 } }; });
|
||||
assert.equal(want(tokens, TRAP, SWIRE, DEF).skill.bb.duration_switch, 3);
|
||||
assert.equal(want(tokens, TRAP, SWIRE, M3).skill.bb.duration_switch, 0);
|
||||
for (const lo of ORDERS(DEF, M3)) {
|
||||
const { h, ops } = duel({
|
||||
chessId: SWIRE, lo, tokens, row: 9, col: 5, enemies: { enemy_d: dummy('enemy_d') },
|
||||
spawns: [{ key: 'enemy_d', pos: [9, 5] }, { key: 'enemy_d', pos: [9, 13] }],
|
||||
});
|
||||
for (const u of ops) { u.mem.coins = 5; u.player.dp = 99; }
|
||||
// the moment a bomb stands, an enemy steps on it (the bomb is < 1 tick old)
|
||||
const bombs = [null, null];
|
||||
for (let i = 0; i < 30 * 10 && bombs.some((x) => !x); i++) {
|
||||
h.step();
|
||||
ops.forEach((u, k) => {
|
||||
if (bombs[k]) return;
|
||||
const t = summons(h, u, TRAP).find((x) => x.alive);
|
||||
if (t) { bombs[k] = t; h.spawn('enemy_d', { def: dummy('enemy_d'), pos: [t.tileR, t.tileC] }); }
|
||||
});
|
||||
}
|
||||
assert.ok(bombs.every(Boolean), 'both placed a bomb');
|
||||
h.run(0.5);
|
||||
ops.forEach((u, k) => {
|
||||
assert.equal(bombs[k].alive, false, `${u.player.playerId} bomb went off`);
|
||||
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags?.includes('trap'));
|
||||
assert.equal(hits.length, lo[k] === M3 ? 2 : 1, `${u.player.playerId} (${lo[k] === M3 ? 'module 003' : 'default'}) hits`);
|
||||
});
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('巫恋: the 诅咒娃娃 lasts its owner\'s module variant of the token skill duration', { skip }, () => {
|
||||
const VOD = 'chess_char_3_15_b', DOLL = 'token_10006_vodfox_doll';
|
||||
const tokens = patchedToken(DOLL, VOD, (v) => { v.byModule.none.skill = { ...v.skill, duration: 5 }; });
|
||||
const life = (lo) => want(tokens, DOLL, VOD, lo).skill.duration;
|
||||
assert.deepEqual([life(DEF), life(NONE)], [15, 5]);
|
||||
for (const lo of ORDERS(DEF, NONE)) {
|
||||
const { h, ops } = duel({ chessId: VOD, lo, tokens });
|
||||
const dolls = castAndCollect(h, ops, DOLL);
|
||||
assertLifetimes(h, dolls, lo.map(life));
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('蜜蜡: the 守卫尖碑 stands its owner\'s module variant of the token talent duration', { skip }, () => {
|
||||
const BEE = 'chess_char_4_05_b', OBELISK = 'token_10011_beewax_oblisk';
|
||||
const tokens = patchedToken(OBELISK, BEE, (v) => { v.byModule.none.talents[0].bb.duration = 6; });
|
||||
const life = (lo) => want(tokens, OBELISK, BEE, lo).talents[0].bb.duration;
|
||||
assert.deepEqual([life(DEF), life(NONE)], [20, 6]);
|
||||
for (const lo of ORDERS(DEF, NONE)) {
|
||||
const { h, ops } = duel({ chessId: BEE, lo, tokens });
|
||||
const obs = castAndCollect(h, ops, OBELISK);
|
||||
assertLifetimes(h, obs, lo.map(life));
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('凯瑟琳: device shield cap and deploy limit follow the owner\'s module variant of the device token', { skip }, () => {
|
||||
const CATHY = 'chess_char_4_11_b', CATSLD = 'token_10041_cathy_catsld';
|
||||
const tokens = patchedToken(CATSLD, CATHY, (v) => {
|
||||
v.byModule.none.talents[0].bb.max_shield_ratio = 0.5;
|
||||
v.byModule.none.stats = { ...v.byModule.none.stats, deployLimit: 1 };
|
||||
});
|
||||
const cap = (lo) => want(tokens, CATSLD, CATHY, lo).talents[0].bb.max_shield_ratio;
|
||||
const limit = (lo) => (lo === NONE ? 1 : base.rawToken(CATSLD).variants[CATHY].stats.deployLimit);
|
||||
assert.deepEqual([cap(DEF), cap(NONE), limit(DEF)], [0.2, 0.5, 2]);
|
||||
for (const lo of ORDERS(DEF, NONE)) {
|
||||
// one more operator each, so the default limit (2) and the patched one (1) differ in devices placed
|
||||
const { h, ops } = duel({ chessId: CATHY, lo, tokens, extra: [{ chessId: 'chess_char_1_02_a', row: 11, col: 4 }] });
|
||||
h.run(0.5);
|
||||
ops.forEach((u, i) => {
|
||||
const devs = summons(h, u, CATSLD).filter((d) => d.alive);
|
||||
assert.equal(devs.length, limit(lo[i]), `${u.player.playerId} devices (deploy limit)`);
|
||||
for (const d of devs) {
|
||||
const t = h.b.unitAt(d.mem.tr, d.mem.tc);
|
||||
assert.ok(t && t.kind === 'op' && t.ownerId === u.ownerId, `${u.player.playerId} device points at its operator`);
|
||||
approx(t.findBuff('cathy:shield')?.shield ?? 0, u.s.maxHp * cap(lo[i]), `${u.player.playerId} shield`);
|
||||
}
|
||||
});
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('凛御银灰: the 风雪之眼 cost the kit splits the waiting area with follows the owner\'s skill variant', { skip }, () => {
|
||||
// each skill names its own eye (skill overrideTokenKey: S2 eagle2, S3 eagle3); the S3 owner's variant of eagle3 is
|
||||
// patched, so a lookup with the S2 (default) owner's loadout — p1's in the first order — gives the unpatched cost
|
||||
const SVASH = 'chess_char_5_14_b', S3 = { skillIndex: 2 };
|
||||
const EYE = (lo) => (lo === S3 ? 'token_10057_svash2_eagle3' : 'token_10057_svash2_eagle2');
|
||||
const tokens = patchedToken(EYE(S3), SVASH, (v) => { v.bySkill[2].stats = { ...v.stats, cost: 9 }; });
|
||||
const cost = (lo) => want(tokens, EYE(lo), SVASH, lo).stats.cost;
|
||||
assert.deepEqual([cost(DEF), cost(S3), want(tokens, EYE(S3), SVASH, DEF).stats.cost], [14, 9, 19]);
|
||||
for (const lo of ORDERS(DEF, S3)) {
|
||||
const { h, ops } = duel({ chessId: SVASH, lo, tokens });
|
||||
ops.forEach((u, i) => assert.equal(u.mem.eyeCost, cost(lo[i]), `${u.player.playerId} eye cost`));
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,339 @@
|
||||
// Summon regressions from user playtest #2 (docs/DESIGN.md §14/§15; content/tokens.js, kits tier3 琳琅诗怀雅 / tier6
|
||||
// 缪尔赛思, public/js/audio.js):
|
||||
// #4 琳琅诗怀雅's 香槟炸弹 is a summon, not an operator: using it up is no knock-out (no operator-knocked-down sound,
|
||||
// no death count, no "干员被击倒" effect); clients hear its explosion.
|
||||
// #5 缪尔赛思's 流形: never attacks before it copied an operator, the copy skill waits (ready) while nobody can be
|
||||
// copied, one respawn 25 s after being killed — never a quick second one (stale timers, her redeploy).
|
||||
// audit: official per-class death/deploy sounds for every summon kind (operator / summon / map character / device).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { hasGeneratedData, getDefaultSource } from '../../server/sim/simdata.js';
|
||||
import { TOKEN_IDS, pickCopyTarget } from '../../server/sim/content/tokens.js';
|
||||
import { AudioManager, deathSfxUrl, deploySfxUrl, unitSoundClass } from '../../public/js/audio.js';
|
||||
import { showsDeathFx } from '../../public/js/render/app.js';
|
||||
|
||||
const REAL = { skip: !hasGeneratedData() };
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const manifest = JSON.parse(readFileSync(path.join(ROOT, 'data', 'assets.json'), 'utf8'));
|
||||
const SFX = manifest.audio.sfx;
|
||||
const TOKEN_DEAD = SFX.battle.charDie.replace(/b_char_dead\.mp3$/, 'b_char_tokendead.mp3');
|
||||
const ds = getDefaultSource();
|
||||
const dummy = (o = {}) => enemyRec({ key: o.key ?? 'enemy_dummy', hp: 1e7, speed: 0, def: 0, res: 0, ...o });
|
||||
const walker = (o = {}) => enemyRec({ key: o.key ?? 'enemy_walker', hp: 1e6, speed: 1, def: 0, atk: 0, ...o });
|
||||
const guard = (o = {}) => chessRec({ id: 'test_guard', profession: 'WARRIOR', skill: null, stats: { atk: 600, def: 300, maxHp: 5000, blockCnt: 2, bat: 1 }, rangeGrid: [[0, 0], [0, 1]], ...o });
|
||||
const MF = TOKEN_IDS.manifold;
|
||||
const manifolds = (h) => h.b.allyUnits.filter((u) => u.defId === MF && !u.mem.mlyssClone && !u.mem.isClone);
|
||||
const standing = (h) => manifolds(h).filter((u) => u.alive && u.deployed);
|
||||
const noErrors = (h) => { assert.deepEqual(h.b.errors.map((e) => `${e.label} ${e.message}`), []); checkInvariants(h.b); };
|
||||
|
||||
/** Records every attack (and damage) by a 流形 made while it has no copy (tier6 `mem.mlyss` / tokens.js `mem.copy`). */
|
||||
function watchUncopied(b, out) {
|
||||
const bare = (u) => u && u.defId === MF && !u.mem.mlyss && !u.mem.copy;
|
||||
b.on('attack', (c) => { if (bare(c.attacker)) out.push({ t: b.time, id: c.attacker.id, what: 'attack' }); }, { priority: 1000 });
|
||||
b.on('damaged', (c) => { if (bare(c.source)) out.push({ t: b.time, id: c.source.id, what: 'damage' }); }, { priority: 1000 });
|
||||
}
|
||||
const attacksBy = (b, list) => { b.on('attack', (c) => { if (c.attacker.defId === MF) list.push({ t: b.time, u: c.attacker }); }); };
|
||||
|
||||
/** An AudioManager whose sounds are recorded instead of played. */
|
||||
function recordingAudio() {
|
||||
const a = new AudioManager({ win: null, getManifest: () => manifest });
|
||||
a.ctx = {}; // "unlocked"
|
||||
const played = [];
|
||||
a._play = (url) => { played.push(url); };
|
||||
return { a, played };
|
||||
}
|
||||
|
||||
// =================================================================================================================
|
||||
// #4 香槟炸弹
|
||||
|
||||
test('#4 香槟炸弹 (琳琅诗怀雅): used up by its blast = no knock-out — no death count, no operator-knocked-down sound, its explosion is heard', REAL, () => {
|
||||
const id = 'chess_char_3_04_a';
|
||||
const knocked = [];
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_w: walker({ key: 'enemy_w' }) } }, timeLimit: 60, hooks: ['death', 'deploy'],
|
||||
units: [{ chessId: id, row: 9, col: 5 }], enemies: [{ key: 'enemy_w', route: 0 }],
|
||||
setup: (b) => b.on('death', (c) => { if (c.reason === 'killed' && c.unit.side === 'ally') knocked.push(c.unit.defId); }),
|
||||
});
|
||||
assert.ok(h.runUntil(() => h.hooksOf('death').some((c) => c.unit.defId === TOKEN_IDS.champagne), 30), 'a bomb went off');
|
||||
h.run(0.1);
|
||||
const bombDeaths = h.hooksOf('death').filter((c) => c.unit.defId === TOKEN_IDS.champagne);
|
||||
for (const c of bombDeaths) {
|
||||
assert.equal(c.unit.kind, 'token');
|
||||
assert.notEqual(c.reason, 'killed', 'a used-up bomb is not knocked out');
|
||||
}
|
||||
assert.deepEqual(knocked, [], 'no ally knocked out');
|
||||
assert.equal(h.result().perPlayer.p1.deaths, 0, 'result: no operator deaths');
|
||||
// the client stream: the blast fx names the bomb as used up right before its `die` event
|
||||
const ev = h.events;
|
||||
const bombIds = new Set(bombDeaths.map((c) => c.unit.id));
|
||||
for (const bid of bombIds) {
|
||||
const iDie = ev.findIndex((e) => e[0] === 'die' && e[1] === bid);
|
||||
const iBoom = ev.findIndex((e) => e[0] === 'fx' && e[4] && e[4].id === bid && e[4].consumed === true);
|
||||
assert.ok(iBoom >= 0 && iBoom < iDie, 'consumed blast fx before the die event');
|
||||
}
|
||||
// …and the audio manager fed with it plays the bomb's impact sound, never the operator knock-down sound
|
||||
const { a, played } = recordingAudio();
|
||||
a.setFieldUnits([]);
|
||||
a.handleBattleEvents(ev);
|
||||
assert.ok(!played.includes(SFX.battle.charDie), 'no 干员被击倒 sound');
|
||||
assert.ok(!played.includes(TOKEN_DEAD), 'no token death sound either (it exploded)');
|
||||
assert.ok(played.includes(SFX.units[TOKEN_IDS.champagne].hit), 'its explosion (official ON_ABILITY_HIT) is heard');
|
||||
assert.ok(played.includes(SFX.units[TOKEN_IDS.champagne].born), 'placed with its own sound');
|
||||
// …and the renderer shows no knock-down particles for it (its blast is its end — integration)
|
||||
for (const bid of bombIds) {
|
||||
const info = ev.find((e) => e[0] === 'spawn' && e[1]?.id === bid)?.[1] || { kind: 'token' };
|
||||
assert.equal(showsDeathFx(info, true), false, 'used-up bomb: no death particles');
|
||||
assert.equal(showsDeathFx(info, false), true, 'a summon destroyed otherwise still gets them');
|
||||
}
|
||||
assert.equal(showsDeathFx({ kind: 'device' }), false, 'stage devices: none');
|
||||
noErrors(h);
|
||||
});
|
||||
|
||||
test('#4 audio classes (official ON_UNIT_DEAD/BORN defaults): only operators play the knock-down / operator deploy sounds', () => {
|
||||
const op = { side: 'ally', kind: 'op', defId: 'chess_char_1_01_a', def: 'char_x' };
|
||||
const tok = { side: 'ally', kind: 'token', defId: TOKEN_IDS.wolfPack, def: TOKEN_IDS.wolfPack };
|
||||
const own = { side: 'ally', kind: 'token', defId: MF, def: MF };
|
||||
const bomb = { side: 'ally', kind: 'token', defId: TOKEN_IDS.champagne, def: TOKEN_IDS.champagne };
|
||||
const mapChar = { side: 'ally', kind: 'token', defId: 'char_605_cmedic', def: 'char_605_cmedic' };
|
||||
const crate = { side: 'ally', kind: 'device', defId: 'trap_1105_accrate', def: 'trap_1105_accrate' };
|
||||
const enemy = { side: 'enemy', kind: 'enemy', defId: 'enemy_x', def: 'enemy_x' };
|
||||
const boss = { ...enemy, boss: true };
|
||||
assert.deepEqual([op, tok, bomb, mapChar, crate, enemy].map(unitSoundClass), ['char', 'token', 'token', 'char', 'device', 'enemy']);
|
||||
// deaths
|
||||
assert.equal(deathSfxUrl(manifest, op), SFX.battle.charDie);
|
||||
assert.equal(deathSfxUrl(manifest, tok), TOKEN_DEAD, 'summon ⇒ b_char_tokendead');
|
||||
assert.equal(deathSfxUrl(manifest, own), SFX.units[MF].die, 'own ON_UNIT_DEAD first (流形)');
|
||||
assert.equal(deathSfxUrl(manifest, bomb), TOKEN_DEAD);
|
||||
assert.equal(deathSfxUrl(manifest, bomb, { consumed: true }), null, 'used up ⇒ silent');
|
||||
assert.equal(deathSfxUrl(manifest, mapChar), SFX.battle.charDie, 'band map characters are operators');
|
||||
assert.equal(deathSfxUrl(manifest, crate), TOKEN_DEAD, 'act crate trap_1105 ⇒ b_char_tokendead');
|
||||
assert.equal(deathSfxUrl(manifest, enemy), SFX.battle.enemyDie);
|
||||
assert.equal(deathSfxUrl(manifest, boss), SFX.battle.enemyDieHeavy);
|
||||
assert.equal(deathSfxUrl(manifest, op, { reason: 'retreat' }), null, 'a retreat is no knock-down');
|
||||
assert.equal(deathSfxUrl(manifest, op, { reason: 'killed' }), SFX.battle.charDie);
|
||||
assert.equal(deathSfxUrl(null, op), null);
|
||||
// deployments
|
||||
assert.equal(deploySfxUrl(manifest, op), SFX.battle.deploy);
|
||||
assert.equal(deploySfxUrl(manifest, tok), SFX.units[TOKEN_IDS.wolfPack].born);
|
||||
assert.equal(deploySfxUrl(manifest, { ...tok, def: 'token_nope', defId: 'token_nope' }), SFX.battle.tokenDeploy, 'summon ⇒ b_char_tokenset');
|
||||
assert.equal(deploySfxUrl(manifest, crate), null, 'stage devices are placed silently');
|
||||
assert.equal(deploySfxUrl(manifest, enemy), null);
|
||||
// through the event handler
|
||||
const { a, played } = recordingAudio();
|
||||
a.setFieldUnits([{ id: 1, ...op, spine: 'char_x' }, { id: 2, side: 'ally', kind: 'token', defId: 'token_nope', spine: 'token_nope' }, { id: 3, ...crate, spine: crate.def }]);
|
||||
a.handleBattleEvents([['die', 2], ['die', 3], ['deploy', 3]]);
|
||||
assert.deepEqual(played, [TOKEN_DEAD, TOKEN_DEAD], 'summon + crate: token death sound, no operator one');
|
||||
a.handleBattleEvents([['die', 1]]);
|
||||
assert.deepEqual(played.slice(2), [SFX.battle.charDie]);
|
||||
});
|
||||
|
||||
test('#4 die events carry their reason: a retreat plays no knock-down sound, a knock-out does (integration)', REAL, () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { test_guard: guard() }, enemies: { enemy_dummy: dummy() } }, timeLimit: 60, autoFinish: false,
|
||||
units: [{ chessId: 'test_guard', row: 10, col: 5, uid: 1 }, { chessId: 'test_guard', row: 11, col: 5, uid: 2 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [9, 8] }],
|
||||
});
|
||||
h.step();
|
||||
const [g1, g2] = h.b.allyUnits.filter((u) => u.defId === 'test_guard');
|
||||
h.b.retreat(g1);
|
||||
h.b.kill(g2);
|
||||
h.step();
|
||||
const dies = h.eventsOf('die');
|
||||
assert.deepEqual(dies.find((e) => e[1] === g1.id), ['die', g1.id, 'retreat']);
|
||||
assert.deepEqual(dies.find((e) => e[1] === g2.id), ['die', g2.id, 'killed']);
|
||||
const { a, played } = recordingAudio();
|
||||
const op = { side: 'ally', kind: 'op', defId: 'test_guard', def: 'test_guard' };
|
||||
a.setFieldUnits([{ id: g1.id, ...op, spine: 'char_x' }, { id: g2.id, ...op, spine: 'char_y' }]);
|
||||
a.handleBattleEvents(dies.filter((e) => e[1] === g1.id));
|
||||
assert.deepEqual(played, [], 'a retreat is no knock-down');
|
||||
a.handleBattleEvents(dies.filter((e) => e[1] === g2.id));
|
||||
assert.deepEqual(played, [SFX.battle.charDie], 'a knock-out plays b_char_dead');
|
||||
noErrors(h);
|
||||
});
|
||||
|
||||
// =================================================================================================================
|
||||
// #5 流形
|
||||
|
||||
test('#5 流形: never attacks before copying; with nobody to copy its copy skill waits ready, then copies at once', REAL, () => {
|
||||
const bad = [], atk = [];
|
||||
const h = makeBattle({
|
||||
defs: { chess: { test_guard: guard() }, enemies: { enemy_dummy: dummy() } }, timeLimit: 120, autoFinish: false,
|
||||
units: [{ chessId: 'chess_char_6_11_a', row: 12, col: 2, uid: 1 }, { chessId: 'test_guard', row: 12, col: 9, uid: 2 }, { kind: 'token', tokenId: MF, row: 9, col: 6, uid: 3, ownerUid: 1 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }], // standing on the 流形's own tile (its uncopied range)
|
||||
setup: (b) => { watchUncopied(b, bad); attacksBy(b, atk); },
|
||||
});
|
||||
h.step();
|
||||
const m = standing(h)[0], g = h.unit('test_guard');
|
||||
assert.ok(m);
|
||||
assert.equal(m.profile.noAttack, true, 'uncopied: no attack');
|
||||
h.b.dealDamage(null, g, { amount: 1e9, type: 'true' }); // the only operator it could copy is knocked out
|
||||
assert.equal(g.alive, false);
|
||||
h.run(12);
|
||||
assert.equal(pickCopyTarget(h.b, m), null);
|
||||
assert.equal(m.skill.active, false, 'copy skill not spent on nobody');
|
||||
assert.ok(m.skill.ready, 'it stays ready');
|
||||
assert.equal(m.mem.mlyss ?? null, null);
|
||||
assert.deepEqual(bad, [], 'no attack / damage without a copy');
|
||||
assert.equal(atk.length, 0);
|
||||
// the operator comes back: the 流形 copies it at once and only then attacks
|
||||
assert.ok(h.b.redeploy(g, { free: true }));
|
||||
h.step(2);
|
||||
assert.ok(m.mem.mlyss, 'copied as soon as someone can be copied');
|
||||
assert.equal(m.mem.mlyss.from, g.id);
|
||||
assert.equal(m.profile.noAttack, false);
|
||||
assert.ok(h.runUntil(() => atk.length > 0, 5), 'attacks once copied');
|
||||
assert.deepEqual(bad, []);
|
||||
noErrors(h);
|
||||
});
|
||||
|
||||
test('#5 流形: a respawned 流形 comes back uncopied after 25 s and does not attack until its copy (5 s later)', REAL, () => {
|
||||
const bad = [];
|
||||
const h = makeBattle({
|
||||
defs: { chess: { test_guard: guard() }, enemies: { enemy_dummy: dummy() } }, timeLimit: 120, autoFinish: false,
|
||||
units: [{ chessId: 'chess_char_6_11_a', row: 12, col: 2, uid: 1 }, { chessId: 'test_guard', row: 12, col: 6, uid: 2 }, { kind: 'token', tokenId: MF, row: 9, col: 6, uid: 3, ownerUid: 1 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }],
|
||||
setup: (b) => watchUncopied(b, bad),
|
||||
});
|
||||
h.step();
|
||||
const first = standing(h)[0];
|
||||
assert.ok(h.runUntil(() => first.mem.mlyss, 7), 'first copy after 95 → 100 SP');
|
||||
h.b.dealDamage(null, first, { amount: 1e9, type: 'true' });
|
||||
const respawn = ds.getToken(MF, 'chess_char_6_11_a').talents.find((t) => t.bb.scale != null).bb.interval;
|
||||
const tKill = h.b.time;
|
||||
assert.ok(h.runUntil(() => standing(h).length === 1, respawn + 2));
|
||||
const again = standing(h)[0];
|
||||
assert.ok(Math.abs(h.b.time - tKill - respawn) < 0.1, `back after ${respawn} s (${h.b.time - tKill})`);
|
||||
assert.deepEqual([again.tileR, again.tileC], [9, 6], 'on its tile');
|
||||
assert.equal(again.mem.mlyss ?? null, null, 'uncopied');
|
||||
assert.equal(again.profile.noAttack, true);
|
||||
const t0 = h.b.time;
|
||||
assert.ok(h.runUntil(() => again.mem.mlyss, 6));
|
||||
assert.ok(h.b.time - t0 > 4.5, 'copies when its SP fills (95 → 100)');
|
||||
assert.deepEqual(bad, [], 'the enemy on its tile was never attacked by an uncopied 流形');
|
||||
noErrors(h);
|
||||
});
|
||||
|
||||
test('#5 流形: no quick second revive — her knock-out cancels the pending respawn, her redeploy brings exactly one back', REAL, () => {
|
||||
const deploys = [];
|
||||
const h = makeBattle({
|
||||
defs: { chess: { test_guard: guard() }, enemies: { enemy_dummy: dummy() } }, timeLimit: 200, autoFinish: false, hooks: ['death'],
|
||||
units: [{ chessId: 'chess_char_6_11_a', row: 12, col: 2, uid: 1 }, { chessId: 'test_guard', row: 12, col: 7, uid: 2 }, { kind: 'token', tokenId: MF, row: 11, col: 5, uid: 3, ownerUid: 1 }],
|
||||
setup: (b) => b.on('deploy', (c) => { if (c.unit.defId === MF && !c.unit.mem.mlyssClone) deploys.push({ t: b.time, id: c.unit.id, tile: [c.unit.tileR, c.unit.tileC] }); }),
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('chess_char_6_11_a');
|
||||
h.run(1);
|
||||
h.b.dealDamage(null, standing(h)[0], { amount: 1e9, type: 'true' }); // 流形 killed: respawn pending (25 s)
|
||||
h.run(2);
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); // then she is knocked out
|
||||
assert.equal(u.alive, false);
|
||||
h.run(40); // past the old respawn time: she is still down
|
||||
assert.equal(standing(h).length, 0, 'no 流形 while she is off the field');
|
||||
assert.ok(h.b.redeploy(u, { free: true })); // her redeploy summons it as her 援军
|
||||
h.step();
|
||||
assert.equal(standing(h).length, 1);
|
||||
assert.deepEqual([standing(h)[0].tileR, standing(h)[0].tileC], [11, 5], 'back on the tactical point the player chose (its board tile, inside her range)');
|
||||
const n = deploys.length;
|
||||
// kill it right away: the next one needs the full 25 s (no stale timer firing within a second)
|
||||
h.b.dealDamage(null, standing(h)[0], { amount: 1e9, type: 'true' });
|
||||
h.run(20);
|
||||
assert.equal(standing(h).length, 0, 'not back within 20 s');
|
||||
assert.equal(deploys.length, n);
|
||||
h.run(6);
|
||||
assert.equal(standing(h).length, 1, 'back after 25 s');
|
||||
h.run(60);
|
||||
assert.equal(deploys.length, n + 1, 'exactly one respawn');
|
||||
assert.equal(h.hooksOf('death').filter((c) => c.unit.defId === MF && c.reason === 'expired').length, 0, 'never replaced by a second 流形');
|
||||
noErrors(h);
|
||||
});
|
||||
|
||||
test('#5 流形 (unmanaged token kit): redeploys uncopied (data stats, own tile range), then copies again', REAL, () => {
|
||||
const genericNoSkill = () => ({ skill: null, talents: [], generic: true });
|
||||
const bad = [];
|
||||
const h = makeBattle({
|
||||
defs: { chess: { test_guard: guard() }, enemies: { enemy_dummy: dummy() } }, timeLimit: 120, autoFinish: false,
|
||||
kits: { chess_char_6_11_a: genericNoSkill },
|
||||
units: [{ chessId: 'chess_char_6_11_a', row: 12, col: 2, uid: 1 }, { chessId: 'test_guard', row: 12, col: 6, uid: 2 }, { kind: 'token', tokenId: MF, row: 9, col: 6, uid: 3, ownerUid: 1 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }],
|
||||
setup: (b) => watchUncopied(b, bad),
|
||||
});
|
||||
h.step();
|
||||
const m = standing(h)[0];
|
||||
const tdef = ds.getToken(MF, 'chess_char_6_11_a');
|
||||
assert.ok(h.runUntil(() => m.mem.copy, 7));
|
||||
assert.notEqual(m.base.atk, tdef.stats.atk);
|
||||
h.b.dealDamage(null, m, { amount: 1e9, type: 'true' });
|
||||
const respawn = tdef.talents.find((t) => t.bb.scale != null).bb.interval;
|
||||
assert.ok(h.runUntil(() => m.alive, respawn + 2), 'same piece redeployed');
|
||||
assert.equal(m.mem.copy, null);
|
||||
assert.equal(m.base.atk, tdef.stats.atk, 'uncopied data ATK');
|
||||
assert.equal(m.base.blockCnt, tdef.stats.blockCnt);
|
||||
assert.deepEqual(m.rangeGrid, tdef.rangeGrid, 'own tile range again');
|
||||
assert.equal(m.profile.noAttack, true);
|
||||
assert.ok(h.runUntil(() => m.mem.copy, 6), 'copies again');
|
||||
assert.deepEqual(bad, [], 'no attack before either copy');
|
||||
noErrors(h);
|
||||
});
|
||||
|
||||
// =================================================================================================================
|
||||
// adversarial review (workstream "summons")
|
||||
|
||||
const fastSniper = (o = {}) => chessRec({
|
||||
id: 'test_sniper', profession: 'SNIPER', position: 'RANGED', skill: null, stats: { atk: 300, def: 50, maxHp: 3000, blockCnt: 1, bat: 0.3 },
|
||||
rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3], [1, 0], [1, 1], [1, 2], [-1, 0], [-1, 1], [-1, 2]], ...o,
|
||||
});
|
||||
|
||||
test('#5 流形: a split clone alive when 缪尔赛思 redeploys is not taken for her 流形 — a real one comes back and stays', REAL, () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { test_sniper: fastSniper() }, enemies: { enemy_dummy: dummy({ atk: 0 }) } }, timeLimit: 300, autoFinish: false,
|
||||
units: [{ chessId: 'chess_char_6_11_a', row: 12, col: 2, uid: 1 }, { chessId: 'test_sniper', row: 12, col: 6, uid: 2 }, { kind: 'token', tokenId: MF, row: 11, col: 5, uid: 3, ownerUid: 1 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }],
|
||||
});
|
||||
h.step();
|
||||
const clones = () => h.b.allyUnits.filter((u) => u.defId === MF && u.alive && u.deployed && (u.mem.mlyssClone || u.mem.isClone));
|
||||
assert.ok(h.runUntil(() => clones().length > 0, 60), 'the ranged copy split');
|
||||
const m = h.unit('chess_char_6_11_a');
|
||||
h.b.dealDamage(null, standing(h)[0], { amount: 1e9, type: 'true' });
|
||||
h.b.dealDamage(null, m, { amount: 1e9, type: 'true' });
|
||||
h.step();
|
||||
assert.equal(standing(h).length, 0);
|
||||
assert.equal(clones().length, 1, 'the clone outlives them (25 s)');
|
||||
assert.ok(h.b.redeploy(m, { free: true }));
|
||||
h.step(2);
|
||||
assert.equal(standing(h).length, 1, 'her redeploy brings a real 流形 although a clone stands');
|
||||
assert.equal(m.trait.reinforcement, standing(h)[0], 'her 援军 is the 流形, not the clone');
|
||||
assert.deepEqual([standing(h)[0].tileR, standing(h)[0].tileC], [11, 5], 'on the tactical point the player chose');
|
||||
h.run(60); // the clone expired long ago
|
||||
assert.equal(standing(h).length, 1, 'still has her 流形');
|
||||
noErrors(h);
|
||||
});
|
||||
|
||||
test('#5 流形 (unmanaged token kit): no respawn while 缪尔赛思 is down, her redeploy brings exactly one back', REAL, () => {
|
||||
const genericNoSkill = () => ({ skill: null, talents: [], generic: true });
|
||||
const deploys = [];
|
||||
const h = makeBattle({
|
||||
defs: { chess: { test_guard: guard() } }, timeLimit: 200, autoFinish: false,
|
||||
kits: { chess_char_6_11_a: genericNoSkill },
|
||||
units: [{ chessId: 'chess_char_6_11_a', row: 12, col: 2, uid: 1 }, { chessId: 'test_guard', row: 12, col: 6, uid: 2 }, { kind: 'token', tokenId: MF, row: 11, col: 4, uid: 3, ownerUid: 1 }],
|
||||
setup: (b) => b.on('deploy', (c) => { if (c.unit.defId === MF && !c.unit.mem.isClone) deploys.push(b.time); }),
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('chess_char_6_11_a');
|
||||
const n0 = deploys.length;
|
||||
h.b.dealDamage(null, standing(h)[0], { amount: 1e9, type: 'true' });
|
||||
h.run(2);
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
h.run(40); // past the 25 s respawn
|
||||
assert.equal(standing(h).length, 0, 'not back while she is down');
|
||||
assert.equal(deploys.length, n0);
|
||||
assert.ok(h.b.redeploy(u, { free: true }));
|
||||
h.step();
|
||||
assert.equal(standing(h).length, 1, 'back with her');
|
||||
h.run(60);
|
||||
assert.equal(deploys.length, n0 + 1, 'exactly once');
|
||||
noErrors(h);
|
||||
});
|
||||
Reference in new issue
Block a user