Connect bounded Reinforcements Battle Plan

This commit is contained in:
Quintine committed 2026-09-28 13:07:27 +10:00
1 parent 763749f25e
commit 23ce406872
19 files changed
+745 -35

No files matched your search

+2 -2
View File
@@ -77,12 +77,12 @@ void test('named card inspections still lock their exact physical card, includin
}
});
void test('the table keeps a no-category slot selectable and offers Harass without unfinished Reinforcements', () => {
void test('the table offers the held no-category Harass card in its physical battle slot', () => {
const view = fixture();
const html = renderToStaticMarkup(createElement(GameTable, { game: view, send: async () => {}, onExit() {}, busy: false }));
const select = html.match(/<select[^>]*id="battle-weapon"[^>]*>[\s\S]*?<\/select>/)?.[0];
assert.ok(select);
assert.doesNotMatch(select, /disabled|ecaz-reinforcements|ecaz-recruits|value="treachery-/);
assert.doesNotMatch(select, /disabled|ecaz-recruits|value="treachery-/);
assert.match(select, /value="ecaz-harass-withdraw"/);
assert.match(html, /Harass and Withdraw battle guidance/);
});
+2 -10
View File
@@ -662,15 +662,7 @@ void test('Basic and Advanced Ixian half-dial withdrawal preserves all seven phy
}
});
void test('Reinforcements and public Richese/optional-module contexts remain guarded', () => {
let g = harassWithdrawGame();
takeHarassCard(g, 'a', 'ecaz-reinforcements');
reject(
g,
'a',
ownPlan(g, { defense: 'ecaz-reinforcements' }),
/Reinforcements.*still/,
);
void test('public Richese and optional-module contexts remain guarded', () => {
const richese = harassWithdrawGame({
factions: ['emperor', 'atreides', 'richese'],
});
@@ -679,7 +671,7 @@ void test('Reinforcements and public Richese/optional-module contexts remain gua
/Richese card family/,
);
reject(richese, 'a', ownPlan(richese), /Richese card family/);
g = harassWithdrawGame({ factions: ['emperor', 'atreides', 'choam'] });
const g = harassWithdrawGame({ factions: ['emperor', 'atreides', 'choam'] });
assert.equal(viewGame(g, 'a').battle!.harassWithdraw!.blocked, null);
const forged = structuredClone(g);
forged.nexusCards = { cards: null, phase: null };
+111
View File
@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame } from '../game/engine';
import type { Game, GameView } from '../game/engine';
import { botActions } from '../game/bots';
import { battlePlanCardOptions } from '../components/battle-preparation';
import { harassWithdrawGame, takeHarassCard } from './fixture-harass-withdraw';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
// GameTable loads framework aliases and CSS modules; install test hooks before importing it.
const { GameTable } = await import('../components/game-table');
aliases.deregister();
const cardId = 'ecaz-reinforcements';
function battle(advanced = false): Game {
const game = harassWithdrawGame({ advanced, factions: ['emperor', 'atreides', 'harkonnen'] });
takeHarassCard(game, 'a', cardId);
return game;
}
function selections(view: GameView) {
return (['weapon', 'defense'] as const).map(slot =>
battlePlanCardOptions(view, slot, 'emperor-0', '', '').some(card => card.id === cardId));
}
function selector(html: string, slot: 'weapon' | 'defense') {
const select = html.match(new RegExp(`<select[^>]*id="battle-${slot}"[^>]*>[\\s\\S]*?<\\/select>`))?.[0];
assert.ok(select, `Missing ${slot} selection`);
return select;
}
void test('a held Reinforcements card with an owner offer is selectable in either physical slot, but never appears in another player’s view', () => {
const game = battle();
const owner = viewGame(game, 'a');
assert.deepEqual(owner.battle?.reinforcements, { blocked: null, normal: 3, elite: 0 });
assert.deepEqual(selections(owner), [true, true]);
const html = renderToStaticMarkup(createElement(GameTable, { game: owner, send: async () => {}, onExit() {}, busy: false }));
for (const slot of ['weapon', 'defense'] as const)
assert.match(selector(html, slot), /value="ecaz-reinforcements"/);
const opponent = viewGame(game, 'd');
assert.equal(opponent.battle?.reinforcements, null);
assert.ok(!opponent.players.find(player => player.id === 'a')?.hand?.some(card => card.id === cardId));
assert.deepEqual(selections(opponent), [false, false]);
const opponentHtml = renderToStaticMarkup(createElement(GameTable, { game: opponent, send: async () => {}, onExit() {}, busy: false }));
assert.doesNotMatch(opponentHtml, /value="ecaz-reinforcements"/);
});
void test('a no-category inspection permits a held Reinforcements card but a second slot cannot reuse it', () => {
const view = viewGame(battle(), 'a');
view.battle!.ownCommitments = [{ source: 'native', beneficiary: 'd', target: 'a', field: 'weapon', value: null }];
assert.ok(battlePlanCardOptions(view, 'weapon', 'emperor-0', '', '').some(card => card.id === cardId));
assert.ok(!battlePlanCardOptions(view, 'weapon', 'emperor-0', '', cardId).some(card => card.id === cardId));
view.battle!.ownCommitments[0].value = 'ecaz-harass-withdraw';
assert.ok(!battlePlanCardOptions(view, 'weapon', 'emperor-0', '', '').some(card => card.id === cardId));
});
void test('blocked, absent, or unheld owner offers cannot make Reinforcements selectable', () => {
const view = viewGame(battle(), 'a');
assert.ok(view.battle?.reinforcements);
const offer = view.battle.reinforcements;
for (const blockedOffer of [{ ...offer, blocked: 'Unsupported battle combination.' }, null]) {
view.battle.reinforcements = blockedOffer;
assert.deepEqual(selections(view), [false, false]);
const html = renderToStaticMarkup(createElement(GameTable, { game: view, send: async () => {}, onExit() {}, busy: false }));
for (const slot of ['weapon', 'defense'] as const)
assert.doesNotMatch(selector(html, slot), /value="ecaz-reinforcements"/);
}
view.battle.reinforcements = offer;
view.players.find(player => player.id === 'a')!.hand = view.players.find(player => player.id === 'a')!.hand!.filter(card => card.id !== cardId);
assert.deepEqual(selections(view), [false, false]);
});
void test('bot candidates using Reinforcements in both slots seal as legal Basic and Advanced plans; absent or blocked offers retain legal alternatives', () => {
for (const advanced of [false, true]) {
const game = battle(advanced);
const view = viewGame(game, 'a');
view.players.find(player => player.id === 'a')!.bot = 'Medium';
const actions = botActions(view).filter(action => action.type === 'battlePlan');
for (const slot of ['weapon', 'defense'] as const) {
const other = slot === 'weapon' ? 'defense' : 'weapon';
const plan = actions.find(action => action[slot] === cardId && !action[other]);
assert.ok(plan, `Missing ${advanced ? 'Advanced' : 'Basic'} ${slot} Reinforcements candidate`);
const sealed = applyAction(game, 'a', plan);
assert.equal(sealed.battle?.plans.a?.[slot], cardId);
}
for (const offer of [null, { ...view.battle!.reinforcements!, blocked: 'Unsupported battle combination.' }]) {
view.battle!.reinforcements = offer;
const fallback = botActions(view);
const ordinaryPlan = fallback.find(action => action.type === 'battlePlan' && action.weapon !== cardId && action.defense !== cardId);
assert.ok(ordinaryPlan);
assert.ok(fallback.every(action => action.weapon !== cardId && action.defense !== cardId));
assert.ok(applyAction(game, 'a', ordinaryPlan).battle?.plans.a);
}
}
});
+131
View File
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { harassCustody, harassWithdrawGame, takeHarassCard } from './fixture-harass-withdraw';
const cardId = 'ecaz-reinforcements';
function fixture(advanced: boolean): Game {
const g = harassWithdrawGame({ advanced, factions: ['emperor', 'atreides', 'harkonnen'] });
takeHarassCard(g, 'a', cardId);
harassCustody(g);
return g;
}
function seal(g: Game, slot: 'weapon' | 'defense', own: Partial<Action> = {}, other: Partial<Action> = {}) {
g = applyAction(g, 'a', {
type: 'battlePlan', dial: 1, support: g.advanced ? 1 : 0,
leader: 'emperor-0', weapon: slot === 'weapon' ? cardId : null,
defense: slot === 'defense' ? cardId : null, ...own,
});
return applyAction(g, 'd', {
type: 'battlePlan', dial: 1, support: g.advanced ? 1 : 0,
leader: 'atreides-0', weapon: null, defense: null, ...other,
});
}
function resolve(g: Game, callA = false, callD = false) {
g = applyAction(g, 'a', { type: 'traitorCall', call: callA });
const serialized = JSON.parse(JSON.stringify(g)) as Game;
for (const p of g.players)
assert.deepEqual(viewGame(serialized, p.id), viewGame(g, p.id));
const before = JSON.stringify(g);
const done = applyAction(g, 'd', { type: 'traitorCall', call: callD });
assert.equal(JSON.stringify(g), before);
harassCustody(done);
return done;
}
function reject(g: Game, id: string, action: Action, reason: RegExp) {
const before = JSON.stringify(g);
assert.throws(() => applyAction(g, id, action), reason);
assert.equal(JSON.stringify(g), before);
}
void test('Reinforcements consumes exactly three own normal reserves in either card slot, Basic and Advanced', () => {
for (const advanced of [false, true]) for (const slot of ['weapon', 'defense'] as const) {
const start = fixture(advanced);
const owner = viewGame(start, 'a');
assert.deepEqual(owner.battle?.reinforcements, { blocked: null, normal: 3, elite: 0 });
assert.equal(viewGame(start, 'd').battle?.reinforcements, null);
assert.equal(viewGame(start, 't').battle?.reinforcements, null);
const privatePlan = applyAction(start, 'a', {
type: 'battlePlan', dial: 1, support: advanced ? 1 : 0, leader: 'emperor-0',
weapon: slot === 'weapon' ? cardId : null,
defense: slot === 'defense' ? cardId : null,
});
assert.deepEqual(viewGame(privatePlan, 'd').battle?.plans, {});
assert.deepEqual(viewGame(privatePlan, 't').battle?.plans, {});
const sealed = applyAction(privatePlan, 'd', {
type: 'battlePlan', dial: 1, support: advanced ? 1 : 0,
leader: 'atreides-0', weapon: null, defense: null,
});
assert.equal(viewGame(sealed, 'a').battle?.reinforcements?.blocked,
'Choose Reinforcements before sealing your Battle Plan.');
const done = resolve(sealed);
assert.equal(done.battle, null);
assert.equal(done.players[0].reserves, 12);
assert.equal(done.players[0].tanks >= 3, true);
assert.equal(done.players[0].battleLosses >= 3, true);
assert.equal(done.discard.filter(c => c.id === cardId).length, 1);
assert.equal(done.log.filter(e => e.text.includes('used Reinforcements: three reserve forces')).length, 1);
assert.deepEqual(viewGame(JSON.parse(JSON.stringify(done)) as Game, 'a'), viewGame(done, 'a'));
}
});
void test('opposing physical Harass plan resolves with Reinforcements instead of trapping both revealed plans', () => {
for (const advanced of [false, true]) {
const g = fixture(advanced);
takeHarassCard(g, 'd', 'ecaz-harass-withdraw');
const sealed = seal(g, 'weapon', {}, { defense: 'ecaz-harass-withdraw' });
const done = resolve(sealed);
assert.equal(done.battle, null);
assert.equal(done.players[0].reserves, 12);
assert.equal(done.discard.filter(c => c.id === cardId).length, 1);
assert.equal(done.discard.filter(c => c.id === 'ecaz-harass-withdraw').length, 1);
assert.equal(done.log.filter(e => e.text.includes('used Reinforcements: three reserve forces')).length, 1);
assert.equal(done.log.filter(e => e.text.includes('used Harass & Withdraw')).length, 1);
}
});
void test('Reinforcements normal-first cost preserves elite subpool and is paid on a traitor outcome', () => {
let g = fixture(true);
const p = g.players[0];
p.reserves = 3;
p.tanks = 12;
p.elites!.reserves = 2;
p.elites!.tanks = 3;
harassCustody(g);
assert.deepEqual(viewGame(g, 'a').battle?.reinforcements, { blocked: null, normal: 1, elite: 2 });
// Give Atreides the Emperor's selected leader as a real traitor, preserving the deck's identities.
const target = 'emperor-0';
const old = g.players[1].traitors[0];
const pools = [g.traitorReserve!, ...g.players.map(player => player.traitors)];
const source = pools.find(pool => pool.includes(target))!;
source[source.indexOf(target)] = old;
g.players[1].traitors[0] = target;
g = seal(g, 'defense');
const done = resolve(g, false, true);
assert.equal(done.lastBattleContext?.result, 'traitor');
assert.equal(done.players[0].reserves, 0);
assert.equal(done.players[0].elites!.reserves, 0);
assert.equal(done.players[0].elites!.tanks, 5);
assert.equal(done.discard.filter(c => c.id === cardId).length, 1);
assert.equal(done.log.filter(e => e.text.includes('used Reinforcements: three reserve forces (2 elite)')).length, 1);
});
void test('insufficient reserves, unsupported module, and a paired special reject without mutating the original game', () => {
const g = fixture(true);
const p = g.players[0];
p.reserves = 2;
p.tanks = 13;
p.elites!.reserves = 2;
p.elites!.tanks = 3;
harassCustody(g);
assert.match(viewGame(g, 'a').battle!.reinforcements!.blocked!, /three physical forces/);
reject(g, 'a', { type: 'battlePlan', dial: 1, leader: 'emperor-0', weapon: cardId }, /three own forces/);
p.reserves = 15;
p.tanks = 0;
p.elites!.reserves = 5;
p.elites!.tanks = 0;
reject(g, 'a', { type: 'battlePlan', dial: 1, leader: 'emperor-0', weapon: cardId,
defense: 'ecaz-harass-withdraw' }, /cannot share/);
g.sandtrout = {} as Game['sandtrout'];
reject(g, 'a', { type: 'battlePlan', dial: 1, leader: 'emperor-0', weapon: cardId }, /standalone Ecaz card variant/);
});
+88
View File
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync } from 'node:sqlite';
import { viewGame, type Action, type Game } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import { harassCustody, harassWithdrawGame, takeHarassCard } from './fixture-harass-withdraw';
const clock: RoomsClock = { now: () => 83001, sleep: async () => {} };
const rows = (sqlite: DatabaseSync) => ({
rooms: sqlite.prepare('SELECT * FROM rooms ORDER BY code').all(),
seats: sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(),
});
void test('a privately sealed Reinforcements plan and its one-time physical cost survive D1-style restart and competing final writes', async t => {
for (const advanced of [false, true]) {
const store = unitStore();
t.after(() => store.sqlite.close());
const made = await store.rooms.createRoom('Reinforcements recovery', 'emperor', advanced, []);
const code = made.view.code;
const tokens = [made.token,
(await store.rooms.joinRoom(code, 'Atreides', 'atreides')).token!,
(await store.rooms.joinRoom(code, 'Harkonnen', 'harkonnen')).token!];
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
const ids = auths.map(auth => auth.playerId) as [string, string, string];
let game = harassWithdrawGame({ advanced, ids, factions: ['emperor', 'atreides', 'harkonnen'] });
game.code = code;
game.version = (await store.rooms.readRoom(code)).version;
takeHarassCard(game, ids[0], 'ecaz-reinforcements');
harassCustody(game);
store.sqlite.prepare('UPDATE rooms SET state=?, version=? WHERE code=?')
.run(JSON.stringify(game), game.version, code);
store.writes.length = 0;
const act = async (index: number, action: Action) => {
const rooms = store.restart();
const before = await rooms.readRoom(code);
await rooms.act(code, await rooms.authenticate(code, tokens[index]), before.version, action, clock);
return rooms.readRoom(code);
};
game = await act(0, { type: 'battlePlan', dial: 1, support: advanced ? 1 : 0,
leader: 'emperor-0', defense: 'ecaz-reinforcements' });
const preReveal = rows(store.sqlite);
for (const [index, token] of tokens.entries()) {
const rooms = store.restart();
const seat = await rooms.readSeatView(code, await rooms.authenticate(code, token));
assert.deepEqual(seat, viewGame(game, ids[index]));
assert.deepEqual(seat, viewGame(JSON.parse(JSON.stringify(game)) as Game, ids[index]));
if (index !== 0) {
assert.deepEqual(seat.battle?.plans, {});
assert.equal(seat.battle?.reinforcements, null);
assert.equal(JSON.stringify(seat).includes('ecaz-reinforcements'), false);
}
}
assert.deepEqual(rows(store.sqlite), preReveal);
game = await act(1, { type: 'battlePlan', dial: 1, support: advanced ? 1 : 0,
leader: 'atreides-0' });
game = await act(0, { type: 'traitorCall', call: false });
const before = rows(store.sqlite);
const version = game.version;
const rooms = store.restart();
const auth = await rooms.authenticate(code, tokens[1]);
const startWrite = store.writes.length;
let entered = 0;
let release!: () => void;
const both = new Promise<void>(resolve => { release = resolve; });
store.hooks.beforeWrite = async () => {
if (++entered === 2) release();
await both;
};
const attempts = await Promise.allSettled([0, 1].map(() =>
rooms.act(code, auth, version, { type: 'traitorCall', call: false }, clock)));
store.hooks.beforeWrite = undefined;
assert.equal(attempts.filter(result => result.status === 'fulfilled').length, 1);
assert.deepEqual(store.writes.slice(startWrite).map(write => write.changes).sort((a, b) => a - b), [0, 1]);
const resolved = await store.restart().readRoom(code);
harassCustody(resolved);
assert.equal(resolved.version, version + 1);
assert.equal(resolved.battle, null);
assert.equal(resolved.players[0].reserves, 12);
assert.equal(resolved.discard.filter(card => card.id === 'ecaz-reinforcements').length, 1);
assert.equal(resolved.log.filter(entry => entry.text.includes('used Reinforcements: three reserve forces')).length, 1);
assert.notDeepEqual(rows(store.sqlite).rooms, before.rooms);
for (const [index, token] of tokens.entries()) {
const seat = await store.restart().readSeatView(code, await store.restart().authenticate(code, token));
assert.deepEqual(seat, viewGame(JSON.parse(JSON.stringify(resolved)) as Game, ids[index]));
}
}
});
+145
View File
@@ -0,0 +1,145 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { baseDeck, leaders, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { BattleResolutionQuoteError, quoteBattleResolution, type BattleResolutionInput } from '../game/battle-resolution-quote';
import { isReinforcements, quoteReinforcements, ReinforcementsError, REINFORCEMENTS_CARD } from '../game/reinforcements';
const physicalCards = [...baseDeck(), ...richeseCards(), ...ecazTreacheryCards()];
const reinforcement = ecazTreacheryCards().find(c => c.id === REINFORCEMENTS_CARD)!;
function fixture(slot: 'weapon' | 'defense' = 'weapon'): BattleResolutionInput {
const attackerLeader = leaders('emperor')[0];
const defenderLeader = leaders('guild')[0];
return {
advanced: true, turn: 2, territory: 'arrakeen',
attacker: {
id: 'a', faction: 'emperor', hand: [reinforcement], spice: 10,
leader: attackerLeader,
plan: { dial: 2, support: 2, leader: attackerLeader.id, weapon: null, defense: null, [slot]: reinforcement.id },
forces: { normal: 5, elite: 0, eliteStrength: 2, freeSupport: false },
reinforcementsReserves: { normal: 1, elite: 2 },
},
defender: {
id: 'd', faction: 'guild', hand: [], spice: 10,
leader: defenderLeader,
plan: { dial: 2, support: 2, leader: defenderLeader.id, weapon: null, defense: null },
forces: { normal: 5, elite: 0, eliteStrength: 2, freeSupport: false },
},
voters: [
{ id: 'a', beneficiary: 'a', called: false, traitors: [] },
{ id: 'd', beneficiary: 'd', called: false, traitors: [] },
],
participants: [{ id: 'a', faction: 'emperor' }, { id: 'd', faction: 'guild' }],
physicalCards, pendingAuditorPresent: false, pendingRetentionPresent: false,
};
}
function putCard(g: BattleResolutionInput, side: 'attacker' | 'defender', slot: 'weapon' | 'defense', kind: Card['kind'] | 'stoneBurner') {
const card = physicalCards.find(c => (c.kind === kind || c.effect === kind) && !g.attacker.hand.some(h => h.id === c.id) && !g.defender.hand.some(h => h.id === c.id))!;
assert.ok(card);
g[side].hand = [...g[side].hand, card];
g[side].plan[slot] = card.id;
}
void test('canonical identity and reserve cost boundaries preserve the physical counter types', () => {
assert.equal(isReinforcements(reinforcement), true);
assert.equal(isReinforcements({ ...reinforcement, name: 'Reinforcement' }), false);
assert.equal(isReinforcements({ ...reinforcement, effect: 'recruits' }), false);
assert.equal(isReinforcements({ ...reinforcement, extra: true } as Card), false);
assert.deepEqual(quoteReinforcements(3, 0), { normal: 3, elite: 0, bonus: 2 });
assert.deepEqual(quoteReinforcements(1, 2), { normal: 1, elite: 2, bonus: 2 });
assert.deepEqual(quoteReinforcements(0, 3), { normal: 0, elite: 3, bonus: 2 });
assert.deepEqual(quoteReinforcements(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER), { normal: 3, elite: 0, bonus: 2 });
for (const reserves of [[2, 0], [1, 1], [-1, 4], [1.5, 3], [NaN, 3], [Infinity, 3], [Number.MAX_SAFE_INTEGER + 1, 0]])
assert.throws(() => quoteReinforcements(reserves[0], reserves[1]), ReinforcementsError);
});
void test('either slot adds two score but not dial, support, payment or casualties; card always discards', () => {
for (const slot of ['weapon', 'defense'] as const) {
const g = fixture(slot);
const before = structuredClone(g);
const q = quoteBattleResolution(g);
assert.deepEqual(q.reinforcements, [{ player: 'a', card: REINFORCEMENTS_CARD, normal: 1, elite: 2 }]);
assert.equal(q.result, 'normal');
assert.equal(q.winner, 'a');
assert.equal(q.scores!.attacker, 2 + g.attacker.leader!.strength + 2);
assert.equal(q.scores!.defender, 2 + g.defender.leader!.strength);
assert.equal(q.casualties!.dial, 2);
assert.equal(q.casualties!.support, 2);
assert.deepEqual(q.casualties!.forces, g.attacker.forces);
assert.deepEqual(q.casualties!.options, [{ normal: 2, elite: 0, paidNormal: 2, paidElite: 0 }]);
assert.equal(q.payments.find(p => p.player === 'a')!.cost, 2);
assert.deepEqual(q.discarded, [{ player: 'a', card: REINFORCEMENTS_CARD }]);
assert.deepEqual(q.winnerCards, []);
assert.deepEqual(g, before);
}
const ordinary = fixture();
ordinary.attacker.hand = [];
ordinary.attacker.plan.weapon = null;
delete ordinary.attacker.reinforcementsReserves;
assert.equal(quoteBattleResolution(ordinary).reinforcements, undefined);
});
void test('traitor, mutual traitors and explosion all pay the reserve cost and discard independently of winner', () => {
for (const outcome of ['ownTraitor', 'opponentTraitor', 'mutualTraitors', 'explosion'] as const) {
const g = fixture();
if (outcome === 'explosion') {
putCard(g, 'attacker', 'defense', 'shield');
putCard(g, 'defender', 'weapon', 'lasgun');
} else {
if (outcome !== 'opponentTraitor') {
g.voters[0].called = true;
g.voters[0].traitors = [g.defender.plan.leader!];
}
if (outcome !== 'ownTraitor') {
g.voters[1].called = true;
g.voters[1].traitors = [g.attacker.plan.leader!];
}
}
const q = quoteBattleResolution(g);
assert.equal(q.result, outcome === 'ownTraitor' || outcome === 'opponentTraitor' ? 'traitor' : outcome);
assert.deepEqual(q.reinforcements, [{ player: 'a', card: REINFORCEMENTS_CARD, normal: 1, elite: 2 }]);
assert.equal(q.discarded.filter(c => c.card === REINFORCEMENTS_CARD).length, 1);
assert.equal(q.winnerCards.includes(REINFORCEMENTS_CARD), false);
assert.equal(q.scores, null);
assert.equal(q.casualties, null);
}
});
void test('the losing card cannot enter a Moritani Nexus retention window', () => {
const g = fixture();
g.nexusMoritani = true;
g.attacker.plan.dial = 0;
g.attacker.plan.support = 0;
g.defender.plan.dial = 4;
g.defender.plan.support = 4;
const q = quoteBattleResolution(g);
assert.equal(q.winner, 'd');
assert.deepEqual(q.reinforcements, [{ player: 'a', card: REINFORCEMENTS_CARD, normal: 1, elite: 2 }]);
assert.equal(q.retention, null);
assert.deepEqual(q.discarded, [{ player: 'a', card: REINFORCEMENTS_CARD }]);
});
void test('selected card requires own funded typed reserves and rejects malformed cards, pairs and unsupported combinations', () => {
const missing = fixture();
delete missing.attacker.reinforcementsReserves;
assert.throws(() => quoteBattleResolution(missing), BattleResolutionQuoteError);
for (const reserves of [{ normal: 2, elite: 0 }, { normal: -1, elite: 4 }, { normal: 1.5, elite: 2 }, { normal: 0, elite: NaN }]) {
const g = fixture();
g.attacker.reinforcementsReserves = reserves;
assert.throws(() => quoteBattleResolution(g), BattleResolutionQuoteError);
}
const surplus = fixture();
surplus.defender.reinforcementsReserves = { normal: 3, elite: 0 };
assert.throws(() => quoteBattleResolution(surplus), BattleResolutionQuoteError);
const fake = fixture();
fake.attacker.hand = [{ ...reinforcement, name: 'Other' }];
assert.throws(() => quoteBattleResolution(fake), BattleResolutionQuoteError);
const duplicate = fixture();
duplicate.attacker.plan.defense = reinforcement.id;
assert.throws(() => quoteBattleResolution(duplicate), BattleResolutionQuoteError);
const stone = fixture();
putCard(stone, 'defender', 'weapon', 'stoneBurner');
assert.throws(() => quoteBattleResolution(stone), BattleResolutionQuoteError);
});