Files
Dune/tests/choam-combat.test.ts

516 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
RuleError,
type Game,
type Action,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
function fixture(allied = true) {
const g = createGame('CFORCES2', newPlayer('c', 'CHOAM', 'choam'), true, [
'choam',
]);
g.players.push(
newPlayer('g', 'Guild', 'guild'),
newPlayer('e', 'Emperor', 'emperor'),
);
g.status = 'playing';
g.phase = 6;
g.storm = 18;
g.active = 'g';
g.order = ['g', 'e', 'c'];
g.deck = baseDeck();
for (const p of g.players) {
p.spice = 10;
p.traitorChoices = [];
p.traitors = [];
p.hand = [];
p.forces = p.id === 'c' ? {} : { 'red_chasm:7': 8 };
p.reserves = p.id === 'c' ? 20 : 12;
for (const l of p.leaders) l.strength = 0;
}
if (allied) {
g.players[0].ally = 'g';
g.players[1].ally = 'c';
}
return g;
}
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const send = (g: Game, id: string, a: Action) => applyAction(g, id, a);
function contest(g: Game, id = 'e') {
hold(g, id, 'Karama');
return g;
}
function allow(state: Game) {
let g = state;
for (let n = 0; g.response && n < 30; n++)
g = send(
g,
g.players.find(
(p) =>
!viewGame(g, p.id).responseControls?.hasPassed &&
!!viewGame(g, p.id).responseControls?.cancelCards.length,
)!.id,
{
type: 'passResponse',
},
);
assert.equal(g.response, null);
return g;
}
function start(state = fixture(), amount = 0) {
let g = send(state, 'g', {
type: 'chooseBattle',
territory: 'red_chasm',
target: 'e',
});
if (g.decision?.kind === 'choamBattleFunding')
g = send(g, 'c', { type: 'decision', amount });
return allow(g);
}
function plan(
g: Game,
id: string,
support: number,
extra: Record<string, unknown> = {},
) {
return send(g, id, {
type: 'battlePlan',
dial: support,
support,
leader: player(g, id).leaders[0]?.id ?? null,
...extra,
});
}
function settle(state: Game, call: string[] = []) {
let g = state;
for (const id of ['g', 'e'])
if (g.battle)
g = send(g, id, { type: 'traitorCall', call: call.includes(id) });
for (
let n = 0;
g.decision &&
['battleLosses', 'battleCards'].includes(g.decision.kind) &&
n < 8;
n++
) {
const d = g.decision;
g = send(
g,
d.player,
d.kind === 'battleLosses'
? { type: 'decision', choice: 0 }
: { type: 'decision', discard: [] },
);
}
return g;
}
function hold(g: Game, id: string, name: string) {
const i = g.deck.findIndex((c) => c.name === name);
assert.ok(i >= 0);
const card = g.deck.splice(i, 1)[0];
player(g, id).hand.push(card);
return card.id;
}
void test('CHOAM explicitly funds its ally before battle preparation; unrelated battles have no funding decision', () => {
let g = send(contest(fixture()), 'g', {
type: 'chooseBattle',
territory: 'red_chasm',
target: 'e',
});
assert.equal(g.decision?.kind, 'choamBattleFunding');
assert.throws(
() => send(g, 'g', { type: 'decision', amount: 3 }),
/pending decision/,
);
assert.throws(() => plan(g, 'g', 3), /pending decision/);
g = send(g, 'c', { type: 'decision', amount: 4 });
assert.equal(player(g, 'c').spice, 6);
assert.equal(g.response?.kind, 'choamBattleAid');
assert.equal(start(fixture(false)).decision, null);
});
void test('only advanced CHOAM may pledge during Battle, with mutual alliance and atomic affordable amounts', () => {
const g = fixture();
const snapshot = structuredClone(g);
for (const amount of [-1, 11, 1.5, '2'])
assert.throws(() => send(g, 'c', { type: 'pledgeAid', amount }), RuleError);
assert.throws(
() => send(g, 'g', { type: 'pledgeAid', amount: 2 }),
RuleError,
);
assert.deepEqual(g, snapshot);
g.advanced = false;
assert.throws(
() => send(g, 'c', { type: 'pledgeAid', amount: 2 }),
RuleError,
);
g.advanced = true;
player(g, 'g').ally = null;
assert.throws(
() => send(g, 'c', { type: 'pledgeAid', amount: 2 }),
RuleError,
);
});
void test('a sealed plan combines real own spice and reserved CHOAM spice, then locks only its declared shares', () => {
const initial = contest(fixture());
player(initial, 'g').spice = 2;
let g = start(initial, 4);
g = plan(g, 'g', 5);
assert.equal(g.battle!.plans.g.allyPayment, 3);
assert.equal(player(g, 'g').spice, 2);
assert.equal(g.aid.c.amount, 4);
assert.throws(
() => send(g, 'c', { type: 'pledgeAid', amount: 2 }),
RuleError,
);
assert.throws(
() => send(g, 'g', { type: 'bribe', target: 'e', amount: 1 }),
RuleError,
);
g = send(g, 'c', { type: 'pledgeAid', amount: 3 });
assert.equal(player(g, 'c').spice, 7);
g = settle(plan(g, 'e', 3));
assert.equal(player(g, 'g').spice, 0);
assert.equal(g.aid.c.amount, 0);
assert.equal(player(g, 'e').spice, 7);
assert.equal(g.response?.kind, 'choamBattleIncome');
assert.equal(g.response.amount, 2);
assert.equal(player(g, 'c').spice, 7);
g = allow(g);
assert.equal(player(g, 'c').spice, 9);
});
void test('the ally can choose CHOAM to pay despite having its own spice; forged or unfunded shares fail', () => {
const g = start(fixture(), 4);
assert.throws(() => plan(g, 'g', 3, { allyPayment: 4 }), RuleError);
assert.throws(() => plan(g, 'e', 3, { allyPayment: 1 }), RuleError);
assert.throws(() => plan(g, 'g', 3, { allyPayment: -1 }), RuleError);
const result = settle(plan(plan(g, 'g', 3, { allyPayment: 3 }), 'e', 2));
assert.equal(player(result, 'g').spice, 10);
assert.equal(result.aid.c.amount, 1);
assert.equal(result.response, null);
assert.equal(player(result, 'c').spice, 7); // Six after funding, plus one from opponent; no self rebate.
});
void test('income rounds down the combined support and is collected only once after combat choices', () => {
let g = settle(plan(plan(start(fixture(false)), 'g', 3), 'e', 3));
assert.equal(g.response, null);
assert.equal(player(g, 'c').spice, 13); // Half of 3 + 3; uncontested income settles immediately.
assert.equal(player(g, 'g').spice, 7);
assert.equal(player(g, 'e').spice, 7);
g = allow(g);
assert.equal(player(g, 'c').spice, 13);
assert.equal(g.pendingChoamBattleIncome, null);
assert.equal(g.decision?.kind, 'choamMarket');
g = send(g, 'c', { type: 'decision', done: true });
assert.equal(player(g, 'c').spice, 13);
});
void test('two other combatants paying three and five yield four CHOAM income without changing either debit', () => {
let g = settle(plan(plan(start(fixture(false)), 'g', 3), 'e', 5));
assert.equal(player(g, 'c').spice, 14);
assert.equal(player(g, 'g').spice, 7);
assert.equal(player(g, 'e').spice, 5);
assert.equal(g.pendingChoamBattleIncome, null);
assert.equal(g.decision?.kind, 'choamMarket');
g = send(g, 'c', { type: 'decision', done: true });
assert.equal(player(g, 'c').spice, 14);
});
void test('Karama redirects just this battle income to the bank without refunding force payments', () => {
const initial = fixture(false);
const card = hold(initial, 'e', 'Karama');
let g = settle(plan(plan(start(initial), 'g', 4), 'e', 2));
g = send(g, 'e', { type: 'card', card, mode: 'cancel' });
assert.equal(player(g, 'c').spice, 10);
assert.equal(player(g, 'g').spice, 6);
assert.equal(player(g, 'e').spice, 8);
assert.equal(g.pendingChoamBattleIncome, null);
assert.equal(g.decision?.kind, 'choamMarket');
assert.equal(
g.discard.some((c) => c.id === card),
true,
);
});
void test('canceling CHOAM allied funding leaves escrow intact but makes the ally plan with its own wealth', () => {
const initial = fixture();
player(initial, 'g').spice = 0;
const card = hold(initial, 'e', 'Karama');
let g = send(initial, 'g', {
type: 'chooseBattle',
territory: 'red_chasm',
target: 'e',
});
g = send(g, 'c', { type: 'decision', amount: 4 });
g = send(g, 'e', { type: 'card', card, mode: 'cancel' });
assert.equal(g.battle?.choamAidBlocked, true);
assert.equal(g.aid.c.amount, 4);
assert.equal(viewGame(g, 'g').aid.available, 0);
assert.throws(() => plan(g, 'g', 1), RuleError);
g = send(g, 'c', { type: 'pledgeAid', amount: 5 });
assert.throws(() => plan(g, 'g', 1), RuleError);
g = settle(plan(plan(g, 'g', 0, { dial: 4 }), 'e', 2));
g = allow(g);
g = send(g, 'c', { type: 'decision', done: true });
assert.deepEqual(g.aid, {});
assert.equal(player(g, 'c').spice, 11);
});
void test('traitor victory preserves the winning side support and all CHOAM income is suppressed', () => {
const initial = fixture();
player(initial, 'g').spice = 0;
player(initial, 'g').traitors = [player(initial, 'e').leaders[0].id];
let g = start(initial, 4);
g = settle(plan(plan(g, 'g', 3), 'e', 3), ['g']);
assert.equal(player(g, 'g').spice, 0);
assert.equal(g.aid.c.amount, 4);
assert.equal(player(g, 'e').spice, 7);
assert.equal(player(g, 'c').spice, 6);
assert.equal(g.pendingChoamBattleIncome ?? null, null);
g = send(g, 'c', { type: 'decision', done: true });
assert.equal(player(g, 'c').spice, 10);
});
void test('mutual traitors still consume both support payments but give no CHOAM income', () => {
const initial = fixture(false);
player(initial, 'g').traitors = [player(initial, 'e').leaders[0].id];
player(initial, 'e').traitors = [player(initial, 'g').leaders[0].id];
const g = settle(plan(plan(start(initial), 'g', 3), 'e', 3), ['g', 'e']);
assert.equal(player(g, 'g').spice, 7);
assert.equal(player(g, 'e').spice, 7);
assert.equal(player(g, 'c').spice, 10);
assert.equal(g.response, null);
});
void test('CHOAM own support goes entirely to the bank; only its opponent contributes income', () => {
let g = fixture(false);
player(g, 'c').forces = { 'red_chasm:7': 8 };
player(g, 'c').reserves = 12;
player(g, 'g').forces = {};
g.active = 'c';
g.order = ['c', 'e', 'g'];
g = send(g, 'c', {
type: 'chooseBattle',
territory: 'red_chasm',
target: 'e',
});
g = allow(g);
g = plan(plan(g, 'c', 4), 'e', 3);
g = send(g, 'c', { type: 'traitorCall', call: false });
g = send(g, 'e', { type: 'traitorCall', call: false });
assert.equal(player(g, 'c').forces['red_chasm:7'], 4);
assert.equal(player(g, 'c').tanks, 4);
assert.equal(g.response, null);
g = allow(g);
assert.equal(player(g, 'c').spice, 7);
});
void test('no income response occurs for basic combat or payments too small to produce income', () => {
let g = fixture(false);
g.advanced = false;
g = settle(plan(plan(start(g), 'g', 3), 'e', 2));
assert.equal(g.response, null);
assert.equal(player(g, 'c').spice, 10);
g = settle(plan(plan(start(fixture(false)), 'g', 1), 'e', 1));
assert.equal(g.response, null);
});
void test('private projections and reconnects retain reserved funding without revealing an ally plan or spice', () => {
const initial = fixture();
player(initial, 'g').spice = 1;
const g = plan(start(initial, 4), 'g', 4);
const own = viewGame(g, 'g'),
donor = viewGame(g, 'c'),
observer = viewGame(g, 'e');
assert.equal(own.aid.available, 4);
assert.equal(donor.aid.pledged, 4);
assert.equal(observer.aid.available, 0);
assert.equal(donor.players.find((p) => p.id === 'g')!.spice, undefined);
assert.equal(donor.battle?.plans.g, undefined);
assert.equal(observer.battle?.plans.g, undefined);
assert.deepEqual(viewGame(JSON.parse(JSON.stringify(g)), 'g'), own);
const waiting = settle(plan(g, 'e', 2));
assert.deepEqual(allow(JSON.parse(JSON.stringify(waiting))), allow(waiting));
});
void test('all AI levels allocate allied support, use funded legal plans and finish income responses', () => {
for (const difficulty of DIFFICULTIES) {
let g = fixture();
for (const p of g.players) p.bot = difficulty;
player(g, 'g').spice = 0;
g = send(g, 'g', {
type: 'chooseBattle',
territory: 'red_chasm',
target: 'e',
});
const funding = botActions(viewGame(g, 'c'))[0];
assert.equal(funding.type, 'decision');
assert.ok(Number(funding.amount) > 0);
g = send(g, 'c', funding);
g = allow(g);
const actions = botActions(viewGame(g, 'g'));
assert.ok(actions.some((a) => a.type === 'battlePlan'));
const legal = actions.find((a) => {
try {
send(g, 'g', a);
return true;
} catch {
return false;
}
});
assert.ok(legal);
g = send(g, 'g', legal);
assert.ok((g.battle?.plans.g.support ?? 0) <= g.aid.c.amount);
g = settle(plan(g, 'e', 3));
if (g.response) {
const action = botActions(viewGame(g, 'c'))[0];
assert.equal(action.type, 'passResponse');
}
}
});
void test('a binding supported-dial promise is feasible through existing CHOAM funding and protects its own share', () => {
const initial = fixture();
player(initial, 'g').spice = 1;
let g = start(initial, 4);
g.battle!.truthPromises = [
{
player: 'g',
asker: 'e',
claim: { kind: 'support', compare: 'gte', value: 5 },
answer: true,
},
];
const completion = viewGame(g, 'g').battle!.compliantPlan;
assert.ok(completion);
assert.equal(completion.allyPayment, 4);
g = send(g, 'g', { type: 'battlePlan', ...completion });
assert.throws(
() => send(g, 'c', { type: 'pledgeAid', amount: 3 }),
RuleError,
);
});
void test('canceling allied aid affects one battle; the same unspent pledge can fund a later territory', () => {
const initial = fixture();
for (const id of ['g', 'e']) {
player(initial, id).forces['rock_outcroppings:13'] = 3;
player(initial, id).reserves -= 3;
}
const card = hold(initial, 'e', 'Karama');
let g = send(initial, 'g', {
type: 'chooseBattle',
territory: 'red_chasm',
target: 'e',
});
g = send(g, 'c', { type: 'decision', amount: 4 });
g = send(g, 'e', { type: 'card', card, mode: 'cancel' });
g = settle(plan(plan(g, 'g', 1), 'e', 1));
assert.equal(g.active, 'g');
assert.equal(g.aid.c.amount, 4);
g = send(g, 'g', {
type: 'chooseBattle',
territory: 'rock_outcroppings',
target: 'e',
});
g = send(g, 'c', { type: 'decision', amount: 4 });
g = allow(g);
assert.equal(g.battle?.choamAidBlocked, false);
assert.equal(viewGame(g, 'g').aid.available, 4);
const leader = player(g, 'g').leaders.find((l) => !l.usedAt)!.id;
g = plan(g, 'g', 3, { allyPayment: 3, leader });
assert.equal(g.battle!.plans.g.allyPayment, 3);
});
void test('lasgun–shield explosion still pays support and CHOAM income when no traitor is revealed', () => {
const initial = fixture(false);
const weapon = hold(initial, 'g', 'Lasgun');
const defense = hold(initial, 'e', 'Shield');
let g = start(initial);
g = plan(g, 'g', 4, { weapon });
g = plan(g, 'e', 2, { defense });
g = settle(g);
assert.equal(g.response, null);
assert.equal(player(g, 'c').spice, 13);
assert.equal(player(g, 'g').tanks, 8);
assert.equal(player(g, 'e').tanks, 8);
g = allow(g);
assert.equal(player(g, 'c').spice, 13);
});
void test('free Fremen support produces no income, while its paying opponent does', () => {
const initial = fixture(false);
const fremen = newPlayer('g', 'Fremen', 'fremen');
Object.assign(fremen, {
forces: { 'red_chasm:7': 8 },
reserves: 12,
spice: 0,
traitorChoices: [],
traitors: [],
});
for (const l of fremen.leaders) l.strength = 0;
initial.players[1] = fremen;
const g = settle(plan(plan(start(initial), 'g', 0, { dial: 5 }), 'e', 3));
assert.equal(g.response, null);
assert.equal(player(g, 'c').spice, 11);
assert.equal(player(g, 'g').spice, 0);
});
void test('Bene Gesserit Worthless Karama cancellation preserves the pending income through its counter-window', () => {
const initial = fixture(false);
const bg = newPlayer('b', 'Bene Gesserit', 'beneGesserit');
initial.players.push(bg);
initial.order.push('b');
const worthless = hold(initial, 'b', 'Baliset');
contest(initial);
let g = settle(plan(plan(start(initial), 'g', 4), 'e', 2));
g = send(g, 'b', { type: 'card', card: worthless, mode: 'cancel' });
assert.equal(g.response?.kind, 'worthlessKarama');
assert.equal(g.pendingChoamBattleIncome?.amount, 3);
g = allow(JSON.parse(JSON.stringify(g)));
assert.equal(player(g, 'c').spice, 10);
assert.equal(g.pendingChoamBattleIncome, null);
});
void test('full-plan prescience offer survives the CHOAM funding decision and cancellation sequence', () => {
const initial = fixture();
initial.players.push(newPlayer('a', 'Atreides', 'atreides'));
initial.order.push('a');
let g = send(initial, 'g', {
type: 'chooseBattle',
territory: 'red_chasm',
target: 'e',
});
assert.equal(g.decision?.kind, 'choamBattleFunding');
g = send(g, 'c', { type: 'decision', amount: 3 });
assert.equal(g.decision?.kind, 'fullPlanOffer');
g = allow(g);
assert.equal(g.decision?.kind, 'fullPlanOffer');
assert.equal(g.decision.player, 'a');
g = send(g, 'a', { type: 'decision', decline: true });
assert.equal(g.decision, null);
assert.equal(g.aid.c.amount, 3);
g = plan(g, 'g', 3, { allyPayment: 3 });
assert.equal(g.battle!.plans.g.allyPayment, 3);
});
void test('each AI level fulfills a five-spice support promise using its CHOAM pledge and no own spice', () => {
for (const difficulty of DIFFICULTIES) {
const initial = fixture();
player(initial, 'g').spice = 0;
player(initial, 'g').bot = difficulty;
let g = start(initial, 5);
g.battle!.truthPromises = [
{
player: 'g',
asker: 'e',
claim: { kind: 'support', compare: 'eq', value: 5 },
answer: true,
},
];
const actions = botActions(viewGame(g, 'g'));
let result: Game | undefined;
for (const action of actions) {
try {
result = send(g, 'g', action);
break;
} catch {
/* Candidate must pass the authoritative validator. */
}
}
assert.ok(result, difficulty);
g = result;
assert.equal(g.battle!.plans.g.support, 5);
assert.equal(g.battle!.plans.g.allyPayment, 5);
}
});