Files
Dune/tests/guild-transport-karama-engine.test.ts

412 lines
21 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
import { territory } from '../game/board';
import { passNativeGuildTransportPermission } from './fixture-guild-ambassador-income-arrival';
const player = (g: Game, id: string) => g.players.find(seat => seat.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function fixture(shipper: 'g' | 'e' | 'f' = 'g', advanced = false) {
const g = createGame('GUILDTRANSPORT', newPlayer('g', 'Guild', 'guild'), advanced);
g.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('f', 'Fremen', 'fremen'),
newPlayer('a', 'Atreides', 'atreides'));
g.status = 'playing';
g.phase = 5;
g.turn = 2;
g.storm = 18;
g.order = ['g', 'e', 'f', 'a'];
g.movementRemaining = [...g.order];
g.active = shipper;
g.deck = baseDeck();
for (const seat of g.players) {
seat.spice = 20;
seat.reserves = 20;
seat.forces = {};
seat.hand = [];
}
if (shipper !== 'g') {
player(g, shipper).ally = 'g';
player(g, 'g').ally = shipper;
}
const karama = g.deck.splice(g.deck.findIndex(card => card.effect === 'karama'), 1)[0];
player(g, 'a').hand.push(karama);
return { g, karama };
}
function stationed(g: Game, id: string, sources: Record<string, number>) {
const seat = player(g, id);
seat.forces = { ...sources };
seat.reserves -= Object.values(sources).reduce((sum, n) => sum + n, 0);
}
function declare(g: Game, id: string, sources: Record<string, number> | 'reserves',
destination: string, sector = 0, allyPayment = 0, eliteForces?: Record<string, number>): Game {
const selection = sources === 'reserves'
? { from: 'reserves', amount: 3 }
: { forces: sources, ...(eliteForces ? { eliteForces } : {}) };
const permission = applyAction(g, id, {
type: 'guildShip', ...selection, territory: destination, sector, allyPayment,
});
return passNativeGuildTransportPermission(permission).game;
}
function canceled(g: Game, card: string): Game {
assert.equal(g.response?.kind, 'guildRate');
return applyAction(reload(g), 'a', { type: 'card', mode: 'cancel', card });
}
function rejectUnchanged(g: Game, actor: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before);
}
function physical(g: Game, id: string) {
const seat = player(g, id);
return { spice: seat.spice, reserves: seat.reserves, forces: seat.forces,
elites: seat.elites, shipped: seat.shipped };
}
void test('multi-sector Guild cross-shipment waits unpaid, then Karama charges full for the exact selected forces', () => {
const { g: initial, karama } = fixture();
stationed(initial, 'g', { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 });
const before = physical(initial, 'g');
const offered = declare(initial, 'g', { 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, 'carthag', 11);
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(offered.pendingGuildTransport?.cost, 2);
assert.equal(offered.pendingGuildTransport?.amount, 3);
assert.deepEqual(physical(offered, 'g'), before);
assert.equal(viewGame(offered, 'a').responseControls?.cancelCards.includes(karama.id), true);
assert.equal(viewGame(offered, 'a').players.find(seat => seat.id === 'g')?.spice, undefined);
assert.deepEqual(normalizeAutomaticGame(reload(offered)), reload(offered));
const done = canceled(offered, karama.id);
assert.equal(done.pendingGuildTransport, null);
assert.equal(done.response, null);
assert.equal(player(done, 'g').spice, 17);
assert.equal(player(done, 'g').reserves, 15);
assert.equal(player(done, 'g').forces['pasty_mesa:5'], 1);
assert.equal(player(done, 'g').forces['pasty_mesa:6'], 1);
assert.equal(player(done, 'g').forces['carthag:11'], 3);
assert.equal(player(done, 'g').shipped, true);
assert.equal(done.discard.filter(card => card.id === karama.id).length, 1);
rejectUnchanged(done, 'a', { type: 'card', mode: 'cancel', card: karama.id });
});
void test('Guild return to abstract reserves is still a cancelable shipment and restores its exact force group', () => {
const { g: initial, karama } = fixture();
stationed(initial, 'g', { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 });
const offered = declare(initial, 'g', { 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, 'reserves');
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(offered.pendingGuildTransport?.cost, 2);
assert.equal(player(offered, 'g').reserves, 15);
assert.equal(player(offered, 'g').spice, 20);
const done = canceled(offered, karama.id);
assert.equal(player(done, 'g').spice, 17);
assert.equal(player(done, 'g').reserves, 18);
assert.equal(player(done, 'g').forces['pasty_mesa:5'], 1);
assert.equal(player(done, 'g').forces['pasty_mesa:6'], 1);
assert.equal(player(done, 'g').shipped, true);
});
void test('passing a Guild transport rate response retains the half tariff and its Guild income', () => {
const { g: initial } = fixture('e');
stationed(initial, 'e', { 'imperial_basin:10': 2, 'imperial_basin:11': 3 });
const offered = declare(initial, 'e', { 'imperial_basin:10': 1, 'imperial_basin:11': 2 }, 'red_chasm', 7);
assert.equal(offered.pendingGuildTransport?.cost, 3);
assert.equal(player(offered, 'e').spice, 20);
assert.equal(player(offered, 'g').spice, 20);
const pendingIncome = applyAction(reload(offered), 'a', { type: 'passResponse' });
assert.equal(pendingIncome.pendingGuildTransport, null);
assert.equal(pendingIncome.response?.kind, 'guildIncome');
assert.equal(player(pendingIncome, 'e').spice, 17);
assert.equal(player(pendingIncome, 'g').spice, 20);
const done = applyAction(reload(pendingIncome), 'a', { type: 'passResponse' });
assert.equal(player(done, 'g').spice, 23);
assert.equal(player(done, 'e').forces['red_chasm:7'], 3);
assert.equal(player(done, 'e').shipped, true);
});
void test('the reciprocal Guild ally retains elite selections and original aid split at canceled full desert price', () => {
const { g: initial, karama } = fixture('e');
stationed(initial, 'e', { 'imperial_basin:10': 2, 'imperial_basin:11': 3 });
player(initial, 'e').elites = { reserves: 3, tanks: 0, forces: { 'imperial_basin:10': 1, 'imperial_basin:11': 1 }, revived: 0 };
player(initial, 'e').spice = 5;
const funded = applyAction(initial, 'g', { type: 'pledgeAid', amount: 2 });
const offered = declare(funded, 'e', { 'imperial_basin:10': 1, 'imperial_basin:11': 2 },
'red_chasm', 7, 1, { 'imperial_basin:10': 1, 'imperial_basin:11': 1 });
assert.equal(offered.pendingGuildTransport?.cost, 3);
assert.equal(offered.pendingGuildTransport?.allyPayment, 1);
assert.equal(player(offered, 'e').spice, 5);
assert.equal(player(offered, 'e').elites?.forces['imperial_basin:10'], 1);
const done = canceled(offered, karama.id);
assert.equal(player(done, 'e').spice, 0);
assert.equal(done.aid.g.amount, 1);
assert.equal(player(done, 'e').forces['imperial_basin:10'], 1);
assert.equal(player(done, 'e').forces['imperial_basin:11'], 1);
assert.equal(player(done, 'e').forces['red_chasm:7'], 3);
assert.equal(player(done, 'e').elites?.forces['red_chasm:7'], 2);
assert.equal(player(done, 'e').elites?.forces['imperial_basin:10'] ?? 0, 0);
assert.equal(player(done, 'e').elites?.forces['imperial_basin:11'] ?? 0, 0);
assert.equal(player(done, 'g').spice, 23);
assert.equal(done.guildRateBlocked, undefined);
});
void test('an unfunded full-price return withdraws intact; its replacement pays full using newly approved aid', () => {
const { g: initial, karama } = fixture();
stationed(initial, 'g', { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 });
player(initial, 'g').spice = 2;
player(initial, 'g').ally = 'e';
player(initial, 'e').ally = 'g';
const funded = applyAction(initial, 'e', { type: 'pledgeAid', amount: 2 });
const offered = declare(funded, 'g', { 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, 'reserves');
assert.equal(offered.pendingGuildTransport?.allyPayment, 0);
const untouched = physical(offered, 'g');
const withdrawn = canceled(offered, karama.id);
assert.deepEqual(physical(withdrawn, 'g'), untouched);
assert.equal(withdrawn.aid.e.amount, 2);
assert.equal(withdrawn.pendingGuildTransport, null);
assert.equal(withdrawn.guildRateBlocked?.player, 'g');
assert.equal(viewGame(withdrawn, 'g').guildRateCanceled, true);
assert.equal(viewGame(withdrawn, 'e').guildRateCanceled, false);
assert.equal(withdrawn.discard.filter(card => card.id === karama.id).length, 1);
rejectUnchanged(withdrawn, 'g', {
type: 'guildShip', forces: { 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, territory: 'reserves', allyPayment: 0,
});
const replacement = declare(reload(withdrawn), 'g',
{ 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, 'reserves', 0, 1);
assert.notEqual(replacement.response?.kind, 'guildRate');
assert.equal(player(replacement, 'g').spice, 1);
assert.equal(player(replacement, 'g').reserves, 18);
assert.equal(replacement.aid.e.amount, 1);
assert.equal(player(replacement, 'g').shipped, true);
});
void test('an allied Fremen southern-reserve cross uses the cancelable Guild rate, not off-planet reserves', () => {
const { g: initial, karama } = fixture('f');
const offered = declare(initial, 'f', 'reserves', 'carthag', 11);
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(offered.pendingGuildTransport?.cost, 2);
assert.equal(player(offered, 'f').reserves, 20);
assert.equal(player(offered, 'f').spice, 20);
const done = canceled(offered, karama.id);
assert.equal(player(done, 'f').reserves, 17);
assert.equal(player(done, 'f').spice, 17);
assert.equal(player(done, 'f').forces['carthag:11'], 3);
assert.equal(player(done, 'g').spice, 23);
assert.equal(player(done, 'f').shipped, true);
});
void test('a withdrawn Fremen southern-reserve declaration preserves its native forces and requires full-price replacement', () => {
const { g: initial, karama } = fixture('f');
player(initial, 'f').spice = 2;
const offered = declare(initial, 'f', 'reserves', 'carthag', 11);
const withdrawn = canceled(offered, karama.id);
assert.equal(withdrawn.pendingGuildTransport, null);
assert.equal(player(withdrawn, 'f').reserves, 20);
assert.equal(player(withdrawn, 'f').forces['carthag:11'] ?? 0, 0);
assert.equal(player(withdrawn, 'f').spice, 2);
assert.equal(player(withdrawn, 'f').shipped, false);
assert.equal(viewGame(withdrawn, 'f').guildRateCanceled, true);
rejectUnchanged(withdrawn, 'f', {
type: 'guildShip', from: 'reserves', amount: 3, territory: 'carthag', sector: 11,
});
const replacement = applyAction(reload(withdrawn), 'f', {
type: 'guildShip', from: 'reserves', amount: 2, territory: 'carthag', sector: 11,
});
assert.notEqual(replacement.response?.kind, 'guildRate');
assert.equal(player(replacement, 'f').spice, 0);
assert.equal(player(replacement, 'f').reserves, 18);
assert.equal(player(replacement, 'f').forces['carthag:11'], 2);
});
void test('Advanced cross-transport retains the Guild rate response separately from the reserve shipment stop', () => {
const { g: initial, karama } = fixture('e', true);
stationed(initial, 'e', { 'imperial_basin:10': 3 });
const offered = declare(initial, 'e', { 'imperial_basin:10': 3 }, 'arrakeen', 10);
assert.equal(offered.decision, null);
assert.equal(offered.response?.kind, 'guildRate');
assert.equal(player(offered, 'e').forces['arrakeen:10'] ?? 0, 0);
const done = canceled(offered, karama.id);
assert.equal(player(done, 'e').spice, 17);
assert.equal(player(done, 'e').forces['arrakeen:10'], 3);
assert.equal(player(done, 'e').shipped, true);
});
void test('independently purchased Karama shipping remains half-price and bank-paid, not Guild-cancelable', () => {
const { g: initial, karama } = fixture('e');
player(initial, 'a').hand.splice(player(initial, 'a').hand.indexOf(karama), 1);
player(initial, 'e').hand.push(karama);
stationed(initial, 'e', { 'imperial_basin:10': 3 });
const purchased = applyAction(initial, 'e', { type: 'card', mode: 'shipment', card: karama.id });
assert.equal(purchased.karamaShipping?.player, 'e');
const done = declare(purchased, 'e', { 'imperial_basin:10': 3 }, 'arrakeen', 10);
assert.notEqual(done.response?.kind, 'guildRate');
assert.equal(player(done, 'e').spice, 18);
assert.equal(player(done, 'g').spice, 20);
assert.equal(player(done, 'e').forces['arrakeen:10'], 3);
assert.equal(done.karamaShipping, null);
});
void test('BG Worthless conversion preserves the signed cross-shipment while its cancellation waits', () => {
const { g: initial, karama } = fixture('g', true);
initial.players.push(newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
initial.order.push('b');
initial.movementRemaining!.push('b');
const bg = player(initial, 'b');
bg.spice = 20;
bg.reserves = 20;
bg.forces = {};
const index = initial.deck.findIndex(card => card.kind === 'worthless');
const worthless = initial.deck.splice(index, 1)[0];
bg.hand.push(worthless);
stationed(initial, 'g', { 'pasty_mesa:5': 3 });
let g = declare(initial, 'g', { 'pasty_mesa:5': 3 }, 'carthag', 11);
const signature = g.pendingGuildTransport!.signature;
g = applyAction(reload(g), 'b', { type: 'card', mode: 'cancel', card: worthless.id });
assert.equal(g.response?.kind, 'worthlessKarama');
assert.equal(g.pendingKarama?.use.kind === 'cancel'
? g.pendingKarama.use.response.kind : null, 'guildRate');
assert.equal(g.pendingGuildTransport?.signature, signature);
assert.equal(viewGame(reload(g), 'a').response?.kind, 'worthlessKarama');
for (let turn = 0; g.response && turn < 12; turn++) {
const responder = g.players.find(seat => !g.response!.passed.includes(seat.id))!;
g = applyAction(reload(g), responder.id, { type: 'passResponse' });
}
assert.equal(g.pendingGuildTransport, null);
assert.equal(g.response, null);
assert.equal(player(g, 'g').spice, 17);
assert.equal(player(g, 'g').forces['carthag:11'], 3);
assert.equal(g.discard.filter(card => card.id === worthless.id).length, 1);
assert.equal(player(g, 'a').hand.some(card => card.id === karama.id), true);
});
void test('a restored pending cross rejects stale or tampered physical and response data without mutation', () => {
const { g: initial, karama } = fixture();
stationed(initial, 'g', { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 });
const offered = reload(declare(initial, 'g',
{ 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, 'carthag', 11));
const cancel: Action = { type: 'card', mode: 'cancel', card: karama.id };
rejectUnchanged(offered, 'g', { type: 'guildShip', from: 'pasty_mesa:5', amount: 1, territory: 'reserves' });
for (const corrupt of [
(g: Game) => { g.response!.guildRateEvent = 'stale'; },
(g: Game) => { g.pendingGuildTransport!.signature = 'stale'; },
(g: Game) => { g.pendingGuildTransport!.turn = g.turn - 1; },
(g: Game) => { g.pendingGuildTransport!.group[0][1] = 2; },
(g: Game) => { g.pendingGuildTransport!.cost = 1; },
]) {
const stale = reload(offered);
corrupt(stale);
const before = structuredClone(stale);
assert.throws(() => viewGame(stale, 'a'));
assert.throws(() => normalizeAutomaticGame(stale));
assert.deepEqual(stale, before);
rejectUnchanged(stale, 'a', cancel);
}
});
void test('a depleted saved source cannot spend or arrive from the obsolete force group', () => {
const { g: initial } = fixture();
stationed(initial, 'g', { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 });
const offered = reload(declare(initial, 'g',
{ 'pasty_mesa:5': 1, 'pasty_mesa:6': 2 }, 'carthag', 11));
player(offered, 'g').forces['pasty_mesa:5'] = 0;
const spice = player(offered, 'g').spice;
const remaining = structuredClone(player(offered, 'g').forces);
const released = applyAction(offered, 'a', { type: 'passResponse' });
assert.equal(released.pendingGuildTransport, null);
assert.equal(player(released, 'g').spice, spice);
assert.deepEqual(player(released, 'g').forces, remaining);
assert.equal(player(released, 'g').forces['carthag:11'] ?? 0, 0);
assert.equal(player(released, 'g').shipped, false);
});
void test('all AI profiles pass pending transport and a blocked ally still selects a legal full-price shipment', () => {
for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const { g: initial } = fixture();
player(initial, 'a').bot = level;
stationed(initial, 'g', { 'pasty_mesa:5': 3 });
const offered = declare(initial, 'g', { 'pasty_mesa:5': 3 }, 'carthag', 11);
const reply = botActions(viewGame(offered, 'a'))[0];
assert.ok(reply, `${level} must respond to a legal Guild transport`);
const completed = applyAction(reload(offered), 'a', reply);
assert.equal(completed.pendingGuildTransport, null);
assert.equal(player(completed, 'g').forces['carthag:11'], 3);
assert.equal(player(completed, 'g').spice, 18);
}
const { g: initial, karama } = fixture('e');
stationed(initial, 'e', { 'imperial_basin:10': 4 });
player(initial, 'e').spice = 2;
const withdrawn = canceled(declare(initial, 'e',
{ 'imperial_basin:10': 4 }, 'arrakeen', 10), karama.id);
player(withdrawn, 'e').bot = 'Easy';
const candidate = botActions(viewGame(withdrawn, 'e')).find(action =>
action.type === 'ship' || action.type === 'guildShip');
assert.ok(candidate, 'A canceled discount leaves the allied bot a legal full-price shipment.');
const done = applyAction(reload(withdrawn), 'e', candidate);
assert.equal(player(done, 'e').shipped, true);
assert.ok(player(done, 'e').spice < 2);
});
void test('controlled southern-reserve permission denial preserves typed Fremen counters and their separate free reinforcement', () => {
const { g: initial, karama } = fixture('f', true);
player(initial, 'f').elites = { reserves: 3, tanks: 0, forces: {}, revived: 0 };
const offered = applyAction(reload(initial), 'f', { type: 'guildShip', from: 'reserves',
territory: 'carthag', sector: 11, amount: 3, elite: 1, allyPayment: 0 });
assert.equal(offered.response?.kind, 'guildTransportPermission');
const denied = applyAction(offered, 'a', { type: 'card', mode: 'cancel', card: karama.id });
assert.deepEqual(physical(denied, 'f'), physical(initial, 'f'));
assert.equal(viewGame(denied, 'f').guildTransportCanceled, true);
assert.equal(viewGame(denied, 'f').guildRateCanceled, false);
const sector = territory('the_great_flat').sectors.find(value => value !== denied.storm)!;
const done = applyAction(denied, 'f', { type: 'ship', territory: 'the_great_flat',
sector, amount: 3, elite: 1, allyPayment: 0 });
assert.equal(player(done, 'f').spice, player(initial, 'f').spice);
assert.equal(player(done, 'g').spice, player(initial, 'g').spice);
assert.equal(player(done, 'f').reserves, 17);
assert.equal(player(done, 'f').elites?.reserves, 2);
assert.equal(player(done, 'f').forces[`the_great_flat:${sector}`], 3);
assert.equal(player(done, 'f').elites?.forces[`the_great_flat:${sector}`], 1);
assert.equal(player(done, 'f').shipped, true);
});
for (const counterConversion of [false, true]) {
void test(`controlled BG conversion ${counterConversion ? 'countered' : 'allowed'} keeps the original native permission source and physical group`, () => {
const { g: initial, karama } = fixture('g', true);
stationed(initial, 'g', { 'pasty_mesa:5': 5 });
initial.players.push(newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
initial.order.push('b'); initial.movementRemaining!.push('b');
const index = initial.deck.findIndex(card => card.kind === 'worthless');
assert.ok(index >= 0);
const worthless = initial.deck.splice(index, 1)[0];
player(initial, 'b').hand.push(worthless);
let g = applyAction(reload(initial), 'g', { type: 'guildShip', from: 'pasty_mesa:5',
territory: 'carthag', sector: 11, amount: 3, elite: 0, allyPayment: 0 });
assert.equal(g.response?.kind, 'guildTransportPermission');
g = applyAction(g, 'b', { type: 'card', mode: 'cancel', card: worthless.id });
assert.equal(g.response?.kind, 'worthlessKarama');
assert.deepEqual(physical(g, 'g'), physical(initial, 'g'));
g = applyAction(g, 'a', counterConversion
? { type: 'card', mode: 'cancel', card: karama.id } : { type: 'passResponse' });
if (counterConversion) {
assert.equal(player(g, 'g').spice, 18);
assert.equal(player(g, 'g').forces['pasty_mesa:5'], 2);
assert.equal(player(g, 'g').forces['carthag:11'], 3);
assert.equal(player(g, 'g').shipped, true);
assert.ok(!viewGame(g, 'g').guildTransportCanceled);
} else {
assert.deepEqual(physical(g, 'g'), physical(initial, 'g'));
assert.equal(viewGame(g, 'g').guildTransportCanceled, true);
assert.ok(player(g, 'a').hand.some(card => card.id === karama.id));
}
assert.equal(g.discard.filter(card => card.id === worthless.id).length, 1);
assert.equal(g.discard.filter(card => card.id === karama.id).length, counterConversion ? 1 : 0);
});
}