Connect bounded Reinforcements Battle Plan
This commit is contained in:
1 parent
763749f25e
commit
23ce406872
19 files changed
+745
-35
No files matched your search
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user