Files
ark-stronghold-protocol/test/content/choices.test.js
T
sganggs 620f88c0d8 Playtest #6 follow-up (v2.5.1): leader HP review — 直接乘算, one debuff instance, 999 layer cap, 限伤; elite to the board; 假想敌:胄 kit
- co-op leaders died within seconds: leader HP is the official data (40 values pinned); bond / strategy / item /
  机变 / 特质 "+X%" bonuses now add up (PRTS 直接乘算) instead of compounding; a same-named enemy debuff keeps one
  (strongest) instance per target (奥术 across a pair field); leader parts pass damage 1:1; fixed co-op pool (the user's call)
- official limits read from the client: each bond stops at 999 layers (MAX_GARRISON_STACK); a leader hit of 300000+ in
  boss / hidden battles is cancelled (MAX_BATTLE_DAMAGE, 限伤) — shared/constants.js switches, research 11
- a merge that consumes a deployed copy puts the elite on that tile and facing (user-confirmed); gold target highlight,
  promotion effect
- 假想敌:胄 per PRTS: one shell, shells within range, any drone death costs 2 % max HP, 剑/锤 sorties and 瘫痪
- DESIGN §20.10–§20.14; 2842 unit tests, all 28 browser suites green; Chrome = Node on boss fields
2026-10-02 04:19:28 +08:00

817 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 机变 (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));
// the listed bounty cards are offered by this draft even when the real one would not (战术特训 comes from 法术教鞭
// only — choices.json `draft`, user playtest #6 item 4): these tests drive the payouts through the draft
if (bounty) out.choices.cards.bounty = ch.cards.bounty.filter((c) => bounty.includes(c.effectId)).map((c) => ({ ...c, draft: true }));
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');
// 56 normal EQUIP items − the 5 effect-only ones (4 special 维式重锤, 突变细胞: never sold, user playtest #4)
assert.equal(ids.items.length, 51, '51 shop items');
for (const id of ['chess_item_2_03_e_a', 'chess_item_3_09_e_a', 'chess_item_3_10_e_a', 'chess_item_4_09_e_a', 'chess_item_5_08_e_a']) assert.ok(!ids.items.includes(id), `${id} is never a supply card`);
assert.ok(ids.items.includes('chess_item_1_01_e_a'), 'the plain 维式重锤 is');
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 (纸偶) — but not one that holds 禁疗 (沙之碑, PRTS 备注 "持有禁疗"; tokens.json abnormal)
const tok = h.b.spawnToken(u1, 'token_10022_kazema_shadow', 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');
const ob = h.b.spawnToken(u1, 'token_10011_beewax_oblisk', 12, 4, { stats: { maxHp: 1000 } });
assert.ok(ob, '沙之碑 placed');
const ob0 = ob.hp;
hit(ob, 200);
assert.equal(ob.hp, ob0 - 200, '沙之碑 (禁疗) not 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);
// 无瑕 "攻击力和防御力+30%": 直接乘算 — the additive percentage bucket (constants.js DIRECT_BONUS_STACKING)
assert.deepEqual(battlePlanOf(refOf('allybuff_select_18')).flawless, { atkPct: 0.3, defPct: 0.3 });
assert.deepEqual(battlePlanOf(refOf('allybuff_select_14')).opMods, { redeployMul: 0.5 });
assert.deepEqual(battlePlanOf(refOf('enemydebuff_select_8')).enemy, [{ rank: 'BOSS', mods: { defFlat: -200 } }]);
for (const id of TEAM_PREP) assert.equal(battlePlanOf(refOf(id)), null, `${id}: prep only`);
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();
});