- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed - #2 two-tap confirm on 机变 cards (like the shop) - #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles) - #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band - #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from - #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others) - #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats) - #8 official element gauges on units; burst cooldown (阻回) per PRTS - #9 knocked-down operators stay on their tile with a redeploy countdown ring - #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼) - #11 赫默's drone only from her skill; placeable summons limited to talent tokens - #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel - #13 the occasional giant bond icon (loading texture scaled ×46) fixed - docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
809 lines
47 KiB
JavaScript
809 lines
47 KiB
JavaScript
// 机变 (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');
|
||
// 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
|
||
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();
|
||
});
|