// 机变 (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, tacticCard } 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; each card belongs to one kind of official draft — // `draftPool`, player feedback #2; a card without one fits every draft): these tests drive the payouts through the draft if (bounty) out.choices.cards.bounty = ch.cards.bounty.filter((c) => bounty.includes(c.effectId)).map(({ draftPool, ...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; } /** * Lay out the open 战术决策 as exactly the tactic cards `ids` (each its own index). The draft draws every card on its own, * with replacement (the official 补给 ×2), so a small test pool can repeat one card and miss another: the tests that * drive a card's effect through g.choice offer their cards this way, after checking what the generator drew. */ function offerTactic(m, ids) { m.sp.cards = ids.map((id, idx) => ({ ...tacticCard(DATA.choices.cards.tactic.find((c) => c.effectId === id)), idx, family: 'tactic' })); m.markPublic(); 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] }); 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 the official composition; 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 the official composition with duplicates allowed (generation, 30 drafts; the evidence is test/match/feedback1-secret-shop.test.js)', () => { const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, seed: 5, data: dataWith('mode_multi_hard', 11, 'shop'), registry: REG, fake: true }); const coin = DATA.choices.shopDraft.coin; 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); assert.equal(d.cards.filter((c) => DATA.items[c.id].tier === 6).length, 2, 'two tier-VI items'); assert.ok(d.cards.some((c) => c.id === coin), 'a 盟约之币'); 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, 3, 4, 5, 6], 'tiers III–VI and the tier-I 盟约之币'); assert.ok(dupes > 0, 'duplicates allowed'); }); test('机密商店 E2E (co-op): two identical cards are two cards — both can be taken, by index, and each picker gets the item', () => { // six 变形同构体: every slot draws the one item `coin` names const one = Object.values(DATA.items).find((i) => i.name === '变形同构体' && !i.isGolden).id; const data = dataWith('mode_multi_hard', 3, 'shop'); // This case isolates card-index ownership with sufficient stock; shared-stock.test.js covers exhaustion. data.config = { ...data.config, economy: { ...data.config.economy, itemPoolCopiesOverrides: { ...data.config.economy.itemPoolCopiesOverrides, [one]: 8 } } }; data.choices = { ...data.choices, shopDraft: { ...data.choices.shopDraft, rounds: [3], slots: Array.from({ length: 6 }, () => ({ coin: 1 })), coin: one } }; const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, bots: 2, seed: 43, data, registry: REG, fake: true }); const m = h.m; h.start(); const sp = toDraft(h, 3); assert.equal(sp.family, 'shop'); const pub = m.publicView().sp; assert.deepEqual(pub.cards.map((c) => c.id), Array(6).fill(one), 'six identical offers'); assert.deepEqual(pub.cards.map((c) => c.idx), [0, 1, 2, 3, 4, 5], 'each its own index'); const funds = { p_0: h.ps('p_0').funds, p_1: h.ps('p_1').funds }; const picked = {}; for (let guard = 0; guard < 1000 && m.phase === 'SP_DRAFT'; guard++) { const pid = m.spTurn(); if (pid !== 'p_0' && pid !== 'p_1') { h.sched.runNext(); continue; } // a taken card is refused even though an identical one is still free const taken = Object.keys(m.sp.taken).map(Number); if (taken.length) assert.deepEqual(m.handle(pid, { t: 'g.choice', idx: taken[0] }), { error: 'SOLD_OUT' }, 'a taken index stays taken'); const idx = m.sp.cards.find((c) => m.sp.taken[c.idx] == null).idx; assert.deepEqual(m.handle(pid, { t: 'g.choice', idx }), { ok: true }); picked[pid] = idx; } assert.equal(m.phase, 'PREP'); assert.notEqual(picked.p_0, picked.p_1, 'two different cards of the same item'); assert.equal(Object.keys(sp.taken).length, 4, 'every player took one of the identical cards'); for (const pid of ['p_0', 'p_1']) { const ps = h.ps(pid); assert.ok([...ps.hand, ...ps.temp].some((p) => p && (p.id === one || p.id === DATA.items[one].goldenId)), `${pid} holds the item`); assert.equal(ps.funds, funds[pid], 'free'); } matchInvariants(m); m.dispose(); }); test('战术决策 E2E (co-op): identical cards are separate cards — one pick applies its card exactly once, the twin stays pickable, picks stack', () => { // a pool of one card: every place draws 补给 (with replacement; the official match 7 R11 offered 补给 twice) const one = 'allybuff_select_4'; const data = dataWith('mode_multi_normal', 3, 'tactic', { tactic: [one] }); const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, bots: 2, seed: 44, data, registry: REG, fake: true }); const m = h.m; h.start(); const sp = toDraft(h, 3); assert.equal(sp.family, 'tactic'); const pub = m.publicView().sp; assert.deepEqual(pub.cards.map((c) => c.id), Array(6).fill(one), 'six 补给'); assert.deepEqual(pub.cards.map((c) => c.idx), [0, 1, 2, 3, 4, 5], 'each its own index'); assert.equal(new Set(sp.cards).size, 6, 'six card objects, not one shared'); const all = [...m.players.values()]; const free0 = new Map(all.map((ps) => [ps.playerId, ps.shop.freeRefreshes])); const freeNow = () => all.map((ps) => ps.shop.freeRefreshes - free0.get(ps.playerId)); const picked = {}; for (let guard = 0; guard < 1000 && m.phase === 'SP_DRAFT'; guard++) { const pid = m.spTurn(); if (pid !== 'p_0' && pid !== 'p_1') { h.sched.runNext(); continue; } const taken = Object.keys(m.sp.taken).map(Number); if (taken.length) assert.deepEqual(m.handle(pid, { t: 'g.choice', idx: taken[0] }), { error: 'SOLD_OUT' }, 'a taken index stays taken while a twin is free'); const before = freeNow(); const idx = m.sp.cards.find((c) => m.sp.taken[c.idx] == null).idx; assert.deepEqual(m.handle(pid, { t: 'g.choice', idx }), { ok: true }); picked[pid] = idx; assert.deepEqual(m.sp.taken[idx], pid, 'that index alone is taken'); assert.equal(Object.values(m.sp.taken).filter((x) => x === pid).length, 1, 'one card per pick'); // 补给 is a team card: +2 free refreshes for every alive player, once — not once per identical card assert.deepEqual(freeNow().map((x, i) => x - before[i]), all.map(() => 2), `${pid}: one 补给 applied once`); } assert.equal(m.phase, 'PREP'); assert.notEqual(picked.p_0, picked.p_1, 'two different cards of the same tactic'); assert.equal(Object.keys(sp.taken).length, 4, 'every player took one of the identical cards'); assert.deepEqual(freeNow(), all.map(() => 8), 'four picks of 补给 stack (+2 each)'); matchInvariants(m); m.dispose(); }); test('战术决策 E2E (solo): of three identical personal cards the one picked applies once; the other two stay untaken', () => { const one = 'allybuff_select_6'; // 升华: the next purchased operator becomes elite const data = dataWith('mode_single_normal', 9, 'tactic', { tactic: [one] }); const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', humans: 1, seed: 45, data, registry: REG, fake: true }); const m = h.m; h.start(); const sp = toDraft(h, 9); assert.deepEqual(sp.cards.map((c) => [c.idx, c.id]), [[0, one], [1, one], [2, one]]); assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 1 }), { ok: true }); assert.deepEqual(sp.taken, { 1: 'p_0' }); assert.deepEqual(sp.picks, { p_0: 1 }); const refs = h.ps('p_0').effects.filter((e) => e.key === BUILTIN_REFS.eliteChess); assert.equal(refs.length, 1, 'one 升华'); assert.equal(refs[0].counter, 1); for (let guard = 0; guard < 50 && m.phase === 'SP_DRAFT'; guard++) h.sched.runNext(); assert.equal(m.phase, 'PREP'); assert.equal(h.ps('p_0').effects.filter((e) => e.key === BUILTIN_REFS.eliteChess).length, 1, 'still one after the draft closed'); matchInvariants(m); m.dispose(); }); // ===================================================================================================================== // 战术决策 — 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.equal(m.publicView().sp.desc, '进行协同调整,做好迎战准备。', 'the official header'); assert.equal(sp.cards.length, 6); assert.ok(sp.cards.every((c) => TEAM_PREP.includes(c.id)), 'drawn from the pool (with replacement)'); offerTactic(m, TEAM_PREP); 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); offerTactic(m, ['allybuff_select_5', 'allybuff_select_6', 'allybuff_select_3']); 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); offerTactic(m, ['allybuff_select_13', 'allybuff_select_19']); 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); offerTactic(m, [id, 'allybuff_select_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'); offerTactic(m, ['map_m01_1', 'allybuff_select_3']); 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(); });