501 lines
19 KiB
TypeScript
501 lines
19 KiB
TypeScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {
|
||
applyAction,
|
||
createGame,
|
||
newPlayer,
|
||
viewGame,
|
||
handLimit,
|
||
type Game,
|
||
} from '../game/engine';
|
||
import { baseDeck, treacheryDeck } from '../game/cards';
|
||
import { createTechTokens } from '../game/tech-tokens';
|
||
import { richeseCards } from '../game/richese-cards';
|
||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||
import { botActions } from '../game/bots';
|
||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||
|
||
function fixture(advanced = false, ix = false) {
|
||
const g = createGame(
|
||
'CHOAM222',
|
||
newPlayer('c', 'CHOAM', 'choam'),
|
||
advanced,
|
||
ix ? ['choam', 'ix'] : ['choam'],
|
||
);
|
||
g.players.push(
|
||
newPlayer('e', 'Emperor', 'emperor'),
|
||
newPlayer('b', 'Bene Gesserit', 'beneGesserit'),
|
||
);
|
||
g.status = 'playing';
|
||
g.phase = 1;
|
||
g.nexus = true;
|
||
g.order = ['c', 'e', 'b'];
|
||
g.deck = ix ? treacheryDeck(['ix']) : baseDeck();
|
||
g.players.forEach((p) => {
|
||
p.spice = 0;
|
||
p.traitors = p.leaders.length ? [p.leaders[0].id] : [];
|
||
p.traitorChoices = [];
|
||
});
|
||
return g;
|
||
}
|
||
function ready(state: Game) {
|
||
let g = state;
|
||
const players = g.phase === 2 && g.charityOrder ? [...g.charityOrder.remaining] : g.players.map(p => p.id);
|
||
for (const player of players) g = applyAction(g, player, { type: 'ready' });
|
||
while (g.decision?.kind === 'choamMarket')
|
||
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
|
||
return g;
|
||
}
|
||
function charityTurn(state: Game, player: string) {
|
||
let g = state;
|
||
while (g.phase === 2 && g.charityOrder && g.charityOrder.remaining[0] !== player) {
|
||
const current = g.charityOrder.remaining[0];
|
||
assert.ok(current, 'the requested Charity opportunity must still be pending');
|
||
g = applyAction(g, current, { type: 'ready' });
|
||
}
|
||
return g;
|
||
}
|
||
function allow(state: Game) {
|
||
let g = state;
|
||
while (g.response) {
|
||
const p = g.players.find(
|
||
(p) =>
|
||
!viewGame(g, p.id).responseControls?.hasPassed &&
|
||
!!viewGame(g, p.id).responseControls?.cancelCards.length,
|
||
)!;
|
||
g = applyAction(g, p.id, { type: 'passResponse' });
|
||
}
|
||
return g;
|
||
}
|
||
function hold(g: Game, id: string, effect: string) {
|
||
const i = g.deck.findIndex((c) => c.effect === effect);
|
||
assert.ok(i >= 0);
|
||
const card = g.deck.splice(i, 1)[0];
|
||
g.players.find((p) => p.id === id)!.hand.push(card);
|
||
return card;
|
||
}
|
||
function contest(g: Game, count = 1, id = 'e') {
|
||
for (let n = 0; n < count; n++) {
|
||
const index = g.deck.findIndex((card) => card.effect === 'karama');
|
||
assert.ok(index >= 0);
|
||
g.players.find((p) => p.id === id)!.hand.push(g.deck.splice(index, 1)[0]);
|
||
}
|
||
return g;
|
||
}
|
||
function cancel(g: Game) {
|
||
const card = g.players
|
||
.find((p) => p.id === 'e')!
|
||
.hand.find((card) => card.effect === 'karama');
|
||
assert.ok(card, 'canceller must hold Karama before the declaration');
|
||
return applyAction(g, 'e', { type: 'card', card: card.id, mode: 'cancel' });
|
||
}
|
||
void test('clean printed Karama cancellation offers Semuta before CHOAM Charity is canceled', () => {
|
||
let g = fixture();
|
||
g.players[1] = newPlayer('e', 'Richese', 'richese');
|
||
g.richeseCache = richeseCards();
|
||
g.semutaPreview = true;
|
||
const semuta = g.richeseCache.findIndex(c => c.id === SEMUTA_DRUG_ID);
|
||
assert.ok(semuta >= 0);
|
||
g.players[2].hand.push(g.richeseCache.splice(semuta, 1)[0]);
|
||
contest(g);
|
||
g = ready(g);
|
||
assert.equal(g.response?.kind, 'choamCharity');
|
||
assert.equal(g.players[0].spice, 0);
|
||
g = cancel(g);
|
||
const event = g.pendingTreacheryDiscard!.batch.event;
|
||
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||
assert.equal(g.response, null);
|
||
assert.equal(g.choamCharity, undefined);
|
||
assert.equal(viewGame(g, 'b').semutaReaction?.canCommit, true);
|
||
assert.equal(viewGame(g, 'c').semutaReaction?.canCommit, false);
|
||
for (const change of [
|
||
(saved: Game) => {
|
||
const continuation = saved.pendingTreacheryDiscard!.continuation;
|
||
assert.equal(continuation.kind, 'karamaCharityDiscard');
|
||
if (continuation.kind === 'karamaCharityDiscard')
|
||
continuation.stateSignature = 'wrong';
|
||
},
|
||
(saved: Game) => { saved.players[1].spice = 1; },
|
||
(saved: Game) => { saved.pendingTreacheryDiscard!.batch.entries[0].card.name = 'Forgery'; },
|
||
]) {
|
||
const corrupted: Game = JSON.parse(JSON.stringify(g));
|
||
change(corrupted);
|
||
const snapshot = JSON.stringify(corrupted);
|
||
assert.throws(() => applyAction(corrupted, 'b', { type: 'semutaCommit', event }));
|
||
assert.equal(JSON.stringify(corrupted), snapshot);
|
||
}
|
||
let declined: Game = JSON.parse(JSON.stringify(g));
|
||
for (const id of ['c', 'e', 'b'])
|
||
declined = applyAction(declined, id, { type: 'semutaPass', event });
|
||
assert.equal(declined.pendingTreacheryDiscard, null);
|
||
assert.deepEqual(declined.choamCharity, { turn: 1, canceled: true });
|
||
assert.equal(declined.players[0].spice, 0);
|
||
const claimed = applyAction(JSON.parse(JSON.stringify(g)), 'b', { type: 'semutaCommit', event });
|
||
assert.equal(claimed.pendingTreacheryDiscard, null);
|
||
assert.deepEqual(claimed.choamCharity, declined.choamCharity);
|
||
assert.equal(claimed.players[2].hand.some(c => c.effect === 'karama'), true);
|
||
assert.equal(claimed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
|
||
assert.equal(claimed.players[0].spice, 0);
|
||
});
|
||
|
||
void test('prior-turn CHOAM Charity receipt does not block a fresh Karama Semuta response', () => {
|
||
let g = fixture();
|
||
g.players[1] = newPlayer('e', 'Richese', 'richese');
|
||
g.richeseCache = richeseCards();
|
||
g.semutaPreview = true;
|
||
g.turn = 2;
|
||
g.choamCharity = { turn: 1, canceled: false };
|
||
const semuta = g.richeseCache.findIndex(c => c.id === SEMUTA_DRUG_ID);
|
||
g.players[2].hand.push(g.richeseCache.splice(semuta, 1)[0]);
|
||
contest(g);
|
||
g = ready(g);
|
||
assert.equal(g.response?.kind, 'choamCharity');
|
||
const pending = cancel(g);
|
||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||
assert.deepEqual(pending.choamCharity, { turn: 1, canceled: false });
|
||
let resumed = pending;
|
||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||
for (const id of ['c', 'e', 'b'])
|
||
resumed = applyAction(resumed, id, { type: 'semutaPass', event });
|
||
assert.deepEqual(resumed.choamCharity, { turn: 2, canceled: true });
|
||
assert.equal(resumed.players[0].spice, 0);
|
||
});
|
||
|
||
void test('clean BG charity Karama cancellation offers Semuta without awarding extra charity', () => {
|
||
let g = fixture(true);
|
||
g.players[1] = newPlayer('e', 'Richese', 'richese');
|
||
g.richeseCache = richeseCards();
|
||
g.semutaPreview = true;
|
||
const semuta = g.richeseCache.findIndex(c => c.id === SEMUTA_DRUG_ID);
|
||
assert.ok(semuta >= 0);
|
||
g.players[0].hand.push(g.richeseCache.splice(semuta, 1)[0]);
|
||
g = allow(ready(g));
|
||
contest(g);
|
||
g.players[2].spice = 12;
|
||
g = charityTurn(g, 'b');
|
||
g = applyAction(g, 'b', { type: 'charity' });
|
||
assert.equal(g.response?.kind, 'bgCharity');
|
||
assert.equal(viewGame(g, 'b').players[2].charityClaimed, true);
|
||
const beforeSpice = g.players.map(p => p.spice);
|
||
g = cancel(g);
|
||
const event = g.pendingTreacheryDiscard!.batch.event;
|
||
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||
assert.equal(g.response, null);
|
||
assert.equal(viewGame(g, 'c').semutaReaction?.canCommit, true);
|
||
assert.equal(viewGame(g, 'b').semutaReaction?.canCommit, false);
|
||
let declined: Game = JSON.parse(JSON.stringify(g));
|
||
for (const id of ['c', 'e', 'b'])
|
||
declined = applyAction(declined, id, { type: 'semutaPass', event });
|
||
assert.equal(declined.pendingTreacheryDiscard, null);
|
||
assert.deepEqual(declined.players.map(p => p.spice), beforeSpice);
|
||
const claimed = applyAction(JSON.parse(JSON.stringify(g)), 'c', { type: 'semutaCommit', event });
|
||
assert.equal(claimed.pendingTreacheryDiscard, null);
|
||
assert.deepEqual(claimed.players.map(p => p.spice), beforeSpice);
|
||
assert.equal(claimed.players[0].hand.filter(c => c.effect === 'karama').length, 1);
|
||
assert.equal(claimed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
|
||
assert.equal(viewGame(claimed, 'b').players[2].charityClaimed, true);
|
||
});
|
||
|
||
void test('CHOAM receives two spice per faction, including itself, only after its opening response', () => {
|
||
const initial = contest(fixture());
|
||
let g = ready(initial);
|
||
assert.equal(g.phase, 2);
|
||
assert.equal(g.response?.kind, 'choamCharity');
|
||
assert.equal(g.players[0].spice, 0);
|
||
assert.throws(
|
||
() => applyAction(g, 'e', { type: 'charity' }),
|
||
/response|power/i,
|
||
);
|
||
assert.throws(
|
||
() => applyAction(g, 'e', { type: 'ready' }),
|
||
/response|power/i,
|
||
);
|
||
g = allow(g);
|
||
assert.equal(g.players[0].spice, 6);
|
||
assert.deepEqual(g.choamCharity, { turn: 1, canceled: false });
|
||
assert.equal(initial.players[0].spice, 0);
|
||
});
|
||
void test('CHOAM pays ordinary charity by actual need and advanced Bene Gesserit a full two spice', () => {
|
||
for (const advanced of [false, true])
|
||
for (const starting of [0, 1]) {
|
||
let g = allow(ready(fixture(advanced)));
|
||
g.players[1].spice = starting;
|
||
g.players[2].spice = starting;
|
||
g = charityTurn(g, 'e');
|
||
g = applyAction(g, 'e', { type: 'charity' });
|
||
assert.equal(g.players[1].spice, 2);
|
||
assert.equal(g.players[0].spice, 6 - (2 - starting));
|
||
g = charityTurn(g, 'b');
|
||
g = applyAction(g, 'b', { type: 'charity' });
|
||
assert.equal(g.players[2].spice, advanced ? starting + 2 : 2);
|
||
assert.equal(
|
||
g.players.reduce((n, p) => n + p.spice, 0),
|
||
6 + 2 * starting,
|
||
);
|
||
assert.throws(
|
||
() => applyAction(g, 'b', { type: 'charity' }),
|
||
/once per turn|fewer than/,
|
||
);
|
||
}
|
||
});
|
||
void test('wealthy Bene Gesserit payment waits for its response; cancellation never charges CHOAM', () => {
|
||
for (const canceled of [false, true]) {
|
||
let g = allow(ready(fixture(true)));
|
||
contest(g);
|
||
g.players[2].spice = 12;
|
||
g = charityTurn(g, 'b');
|
||
g = applyAction(g, 'b', { type: 'charity' });
|
||
assert.equal(g.response?.kind, 'bgCharity');
|
||
assert.equal(g.players[0].spice, 6);
|
||
g = canceled ? cancel(g) : allow(g);
|
||
assert.equal(g.players[0].spice, canceled ? 6 : 4);
|
||
assert.equal(g.players[2].spice, canceled ? 12 : 14);
|
||
assert.equal(viewGame(g, 'b').players[2].charityClaimed, true);
|
||
}
|
||
});
|
||
void test('wealthy BG response preserves cancellation before provisional shortfall funding', () => {
|
||
for (const canceled of [false, true]) {
|
||
let g = allow(ready(fixture(true)));
|
||
contest(g);
|
||
g = applyAction(g, 'c', { type: 'bribe', target: 'e', amount: 6 });
|
||
g.players[2].spice = 12;
|
||
g = charityTurn(g, 'b');
|
||
g = applyAction(g, 'b', { type: 'charity' });
|
||
assert.equal(g.response?.kind, 'bgCharity');
|
||
assert.equal(g.players[0].spice, 0);
|
||
assert.equal(g.players[2].spice, 12);
|
||
g = canceled ? cancel(g) : allow(g);
|
||
assert.equal(g.players[0].spice, 0);
|
||
assert.equal(g.players[2].spice, canceled ? 12 : 14);
|
||
assert.equal(viewGame(g, 'b').players[2].charityClaimed, true);
|
||
assert.equal(g.players[1].bribes, 6);
|
||
}
|
||
});
|
||
void test('Karama cancels CHOAM income, restores bank payments and permits CHOAM ordinary charity', () => {
|
||
let g = cancel(ready(contest(fixture(true))));
|
||
assert.equal(g.players[0].spice, 0);
|
||
assert.deepEqual(g.choamCharity, { turn: 1, canceled: true });
|
||
for (const id of g.order) {
|
||
g = charityTurn(g, id);
|
||
g = applyAction(g, id, { type: 'charity' });
|
||
}
|
||
assert.deepEqual(
|
||
g.players.map((p) => p.spice),
|
||
[2, 2, 2],
|
||
);
|
||
assert.equal(viewGame(g, 'e').charity.payer, null);
|
||
assert.equal(viewGame(g, 'c').charity.incomeCanceled, true);
|
||
});
|
||
void test('income cancellation expires on the next turn and the next opening pays exactly once', () => {
|
||
let g = cancel(ready(contest(fixture())));
|
||
g.turn++;
|
||
g.phase = 1;
|
||
g.nexus = true;
|
||
g = allow(ready(g));
|
||
assert.equal(g.players[0].spice, 6);
|
||
assert.equal(g.choamCharity?.canceled, false);
|
||
g = ready(g);
|
||
assert.equal(g.phase, 3);
|
||
assert.equal(g.players[0].spice, 6);
|
||
});
|
||
void test('Ix phase opening resolves before CHOAM income and survives JSON persistence', () => {
|
||
let g = ready(contest(fixture(false, true)));
|
||
assert.equal(g.phaseOpening?.initialize, true);
|
||
assert.equal(g.response, null);
|
||
const amal = hold(g, 'c', 'amal');
|
||
g.players[0].spice = 9;
|
||
g = applyAction(g, 'c', { type: 'card', card: amal.id });
|
||
assert.equal(g.players[0].spice, 4);
|
||
g = JSON.parse(JSON.stringify(g)) as Game;
|
||
g = ready(g);
|
||
assert.equal(g.response?.kind, 'choamCharity');
|
||
g = allow(g);
|
||
assert.equal(g.players[0].spice, 10);
|
||
});
|
||
void test('a canceled Bene Gesserit Worthless conversion restores the pending CHOAM income response', () => {
|
||
let g = contest(fixture(true), 2);
|
||
const worthless = g.deck.find((c) => c.kind === 'worthless')!;
|
||
g.deck = g.deck.filter((c) => c.id !== worthless.id);
|
||
g.players[2].hand.push(worthless);
|
||
g.players[2].hand.push(
|
||
g.deck.splice(
|
||
g.deck.findIndex((card) => card.kind === 'worthless'),
|
||
1,
|
||
)[0],
|
||
);
|
||
g = ready(g);
|
||
g = applyAction(g, 'e', { type: 'passResponse' });
|
||
g = applyAction(g, 'b', { type: 'card', card: worthless.id, mode: 'cancel' });
|
||
assert.equal(g.response?.kind, 'worthlessKarama');
|
||
g = cancel(g);
|
||
assert.equal(g.response?.kind, 'choamCharity');
|
||
assert.deepEqual(g.response?.passed, ['e']);
|
||
assert.deepEqual(viewGame(g, 'c').response?.passed, []);
|
||
assert.deepEqual(viewGame(g, 'b').response?.passed, []);
|
||
assert.deepEqual(viewGame(g, 'e').response?.passed, ['e']);
|
||
assert.deepEqual(viewGame(g, 'c').responseControls?.cancelCards, []);
|
||
assert.deepEqual(
|
||
viewGame(g, 'b').responseControls?.cancelCards,
|
||
g.players[2].hand.map((card) => card.id),
|
||
);
|
||
g = allow(g);
|
||
assert.equal(g.players[0].spice, 6);
|
||
});
|
||
void test('charity projections expose the payer and own entitlement without leaking opponent wealth', () => {
|
||
const g = allow(ready(fixture()));
|
||
const v = viewGame(g, 'e');
|
||
assert.deepEqual(v.charity, {
|
||
ordinary: 2,
|
||
homeworld: 0,
|
||
total: 2,
|
||
multiplier: 1,
|
||
amount: 2,
|
||
payer: 'c',
|
||
incomePending: false,
|
||
incomeCanceled: false,
|
||
incomeHomeworld: 0,
|
||
orderBlocked: null,
|
||
truthBlocked: null,
|
||
});
|
||
assert.equal(v.players[0].spice, undefined);
|
||
assert.equal(v.players[0].handLimit, 5);
|
||
assert.equal(v.players[0].charityClaimed, undefined);
|
||
});
|
||
void test('ordinary funded claims still accrue and settle Spice Production once', () => {
|
||
let g = fixture(true);
|
||
g.techTokens = createTechTokens();
|
||
g.techTokens.production.owner = 'e';
|
||
g = allow(ready(g));
|
||
g = charityTurn(g, 'e');
|
||
assert.equal(g.techTokens!.production.spice, 0);
|
||
g = applyAction(g, 'e', { type: 'charity' });
|
||
assert.equal(g.techTokens!.production.spice, 1);
|
||
g = charityTurn(g, 'b');
|
||
g = applyAction(g, 'b', { type: 'charity' });
|
||
assert.equal(g.techTokens!.production.spice, 1);
|
||
g = ready(g);
|
||
assert.equal(g.players[1].spice, 3);
|
||
assert.equal(g.techTokens!.production.spice, 0);
|
||
});
|
||
void test('provisional shortfall funding completes the full claim after actual CHOAM bribes', () => {
|
||
for (const remaining of [0, 1, 2]) {
|
||
let g = allow(ready(fixture()));
|
||
g = applyAction(g, 'c', { type: 'bribe', target: 'e', amount: 6 - remaining });
|
||
const before = structuredClone(g);
|
||
const claimed = applyAction(g, 'b', { type: 'charity' });
|
||
assert.equal(claimed.players[0].spice, 0);
|
||
assert.equal(claimed.players[2].spice, 2);
|
||
assert.equal(claimed.players[1].bribes, 6 - remaining);
|
||
assert.equal(viewGame(claimed, 'b').players[2].charityClaimed, true);
|
||
assert.throws(() => applyAction(claimed, 'b', { type: 'charity' }), /once per turn|fewer than/);
|
||
assert.deepEqual(g, before);
|
||
}
|
||
});
|
||
void test('CHOAM self-claim uses the bank after its original income is legally given away', () => {
|
||
for (const remaining of [0, 1]) {
|
||
let g = allow(ready(fixture()));
|
||
g = applyAction(g, 'c', { type: 'bribe', target: 'e', amount: 6 - remaining });
|
||
assert.equal(viewGame(g, 'c').charity.payer, null);
|
||
const claimed = applyAction(g, 'c', { type: 'charity' });
|
||
assert.equal(claimed.players[0].spice, 2);
|
||
assert.equal(claimed.players[1].bribes, 6 - remaining);
|
||
assert.equal(viewGame(claimed, 'c').players[0].charityClaimed, true);
|
||
assert.equal(viewGame(claimed, 'b').charity.payer, 'c');
|
||
}
|
||
});
|
||
void test('CHOAM can buy its fifth card, but a full five-card hand cannot take an auction with Karama', () => {
|
||
let g = allow(ready(fixture()));
|
||
const karama = hold(g, 'c', 'karama');
|
||
g.players[0].hand.push(...g.deck.splice(0, 3));
|
||
assert.equal(handLimit(g.players[0]), 5);
|
||
g = ready(g);
|
||
assert.ok(g.auction);
|
||
g = applyAction(g, 'c', { type: 'bid', amount: 1 });
|
||
g = applyAction(g, 'e', { type: 'passBid' });
|
||
g = applyAction(g, 'b', { type: 'passBid' });
|
||
assert.equal(g.decision?.kind, 'auctionPayment');
|
||
g = allow(applyAction(g, 'c', { type: 'decision', karama: false }));
|
||
assert.equal(g.players[0].hand.length, 5);
|
||
assert.equal(g.players[0].spice, 5);
|
||
assert.notEqual(g.auction!.active, 'c');
|
||
assert.throws(
|
||
() =>
|
||
applyAction(g, 'c', { type: 'card', card: karama.id, mode: 'purchase' }),
|
||
/hand|bid|full|available/i,
|
||
);
|
||
assert.equal(handLimit(newPlayer('h', 'Harkonnen', 'harkonnen')), 8);
|
||
assert.equal(handLimit(newPlayer('e2', 'Emperor', 'emperor')), 4);
|
||
});
|
||
void test('all AI levels proceed after automatic CHOAM income and claim funded charity through private projections', () => {
|
||
for (const difficulty of DIFFICULTIES) {
|
||
let g = ready(fixture(true));
|
||
g.players.forEach((p) => {
|
||
p.bot = difficulty;
|
||
});
|
||
while (g.response) {
|
||
const p = g.players.find(
|
||
(p) =>
|
||
!viewGame(g, p.id).responseControls?.hasPassed &&
|
||
!!viewGame(g, p.id).responseControls?.cancelCards.length,
|
||
)!;
|
||
const actions = botActions(viewGame(g, p.id));
|
||
assert.ok(actions.length);
|
||
g = applyAction(g, p.id, actions[0]);
|
||
}
|
||
assert.equal(g.charityOrder?.remaining[0], 'c');
|
||
const choam = botActions(viewGame(g, g.charityOrder!.remaining[0]));
|
||
assert.equal(choam[0]?.type, 'ready');
|
||
g = applyAction(g, 'c', choam[0]);
|
||
for (const id of ['e', 'b']) {
|
||
assert.equal(g.charityOrder?.remaining[0], id);
|
||
const actions = botActions(viewGame(g, g.charityOrder!.remaining[0]));
|
||
assert.equal(actions[0]?.type, 'charity');
|
||
g = applyAction(g, id, actions[0]);
|
||
if (id === 'e') {
|
||
const finish = botActions(viewGame(g, g.charityOrder!.remaining[0]));
|
||
assert.equal(finish[0]?.type, 'ready');
|
||
g = applyAction(g, id, finish[0]);
|
||
}
|
||
}
|
||
assert.deepEqual(
|
||
g.players.map((p) => p.spice),
|
||
[2, 2, 2],
|
||
);
|
||
}
|
||
});
|
||
void test('Brutal AI can cancel enemy CHOAM income but preserves an ally’s income', () => {
|
||
for (const allied of [false, true]) {
|
||
const g = ready(contest(fixture()));
|
||
if (allied) {
|
||
g.players[1].ally = 'c';
|
||
g.players[0].ally = 'e';
|
||
}
|
||
g.players[1].bot = 'Brutal';
|
||
const action = botActions(viewGame(g, 'e'))[0];
|
||
assert.equal(action.type, allied ? 'passResponse' : 'card');
|
||
const result = applyAction(g, 'e', action);
|
||
if (!allied) assert.equal(result.choamCharity?.canceled, true);
|
||
}
|
||
});
|
||
|
||
void test('six-player CHOAM income pays twelve and can fund every other ordinary charity claim', () => {
|
||
let g = fixture();
|
||
g.players.push(
|
||
newPlayer('a', 'Atreides', 'atreides'),
|
||
newPlayer('h', 'Harkonnen', 'harkonnen'),
|
||
newPlayer('g', 'Guild', 'guild'),
|
||
);
|
||
g.players.forEach((p) => {
|
||
p.spice = 0;
|
||
});
|
||
g.order = g.players.map((p) => p.id);
|
||
g = allow(ready(g));
|
||
assert.equal(g.players[0].spice, 12);
|
||
for (const p of g.players.slice(1))
|
||
g = applyAction(g, p.id, { type: 'charity' });
|
||
assert.deepEqual(
|
||
g.players.map((p) => p.spice),
|
||
[2, 2, 2, 2, 2, 2],
|
||
);
|
||
});
|