Files
Dune/tests/shipment-promises.test.ts

647 lines
23 KiB
TypeScript

import test from 'node:test';
import { shipmentPromiseModeSupported } from '../game/shipment-promises';
import assert from 'node:assert/strict';
void test('Advanced shipment scope excludes separately enabled optional modules and preserves existing Basic tech scope', () => {
const base = {
advanced: true,
expansions: [],
players: [{ faction: 'emperor' }, { faction: 'atreides' }],
};
assert.equal(shipmentPromiseModeSupported(base), true);
assert.equal(
shipmentPromiseModeSupported({
...base,
players: [...base.players, { faction: 'guild' }],
}),
true,
);
for (const option of [
'techTokens',
'strongholdCards',
'nexusCards',
'homeworlds',
])
assert.equal(
shipmentPromiseModeSupported({ ...base, [option]: {} }),
false,
option,
);
assert.equal(
shipmentPromiseModeSupported({ ...base, advanced: false, techTokens: {} }),
true,
);
});
void test('native faction decks admit early Basic and Advanced shipment scope while retaining module fences', () => {
const families = [
['ix', 'ixians'], ['ix', 'tleilaxu'], ['choam', 'choam'],
['choam', 'richese'], ['ecaz', 'ecaz'], ['ecaz', 'moritani'],
];
for (const advanced of [false, true]) for (const phase of [0, 2, 5]) for (const [family, faction] of families) {
const native = { advanced, phase, expansions: [family], players: [{ faction: 'atreides' }, { faction }] };
assert.equal(shipmentPromiseModeSupported(native), true, `${faction}/${advanced}/${phase}`);
assert.equal(shipmentPromiseModeSupported({ ...native, expansions: [family, family] }), false);
assert.equal(shipmentPromiseModeSupported({ ...native, ecazTreachery: true }), false);
if (phase < 5) assert.equal(shipmentPromiseModeSupported({ ...native, expansions: [] }), false);
}
});
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { TERRITORIES } from '../game/board';
import type { FactionId } from '../game/catalog';
const factions: FactionId[] = [
'atreides',
'beneGesserit',
'emperor',
'fremen',
'guild',
'harkonnen',
];
function fixture(faction: FactionId = 'emperor') {
const others = factions.filter((f) => f !== faction && f !== 'guild');
const g = createGame(
'SHIPTRUTH',
newPlayer('a', 'Asker', others[0]),
false,
[],
);
g.players.push(
newPlayer('p', 'Shipper', faction),
newPlayer('o', 'Observer', others[1]),
);
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
active: 'p',
order: ['p', 'a', 'o'],
movementRemaining: ['p', 'a', 'o'],
deck: baseDeck(),
discard: [],
});
for (const p of g.players) {
p.spice = 100;
p.hand = [];
p.forces = {};
p.reserves = 20;
p.traitorChoices = [];
}
return g;
}
function hold(g: Game, id: string, name: string) {
const index = g.deck.findIndex((c) => c.name === name);
assert.ok(index >= 0, name);
const card = g.deck.splice(index, 1)[0];
g.players.find((p) => p.id === id)!.hand.push(card);
return card.id;
}
function ask(state: Game, minimum = 6, territory = 'carthag') {
let g = structuredClone(state);
const card = hold(g, 'a', 'Truthtrance');
g = applyAction(g, 'a', { type: 'card', card });
while (g.truthtrance?.stage === 'priority') {
const player = g.players.find(
(p) => !g.truthtrance!.passed.includes(p.id),
)!;
g = applyAction(g, player.id, { type: 'truthPass' });
}
return applyAction(g, 'a', {
type: 'truthAsk',
question: { kind: 'shipment', target: 'p', territory, minimum },
});
}
function answer(g: Game, value: 'yes' | 'no') {
return applyAction(g, 'p', { type: 'truthAnswer', answer: value });
}
function bind(
g: Game,
value: 'yes' | 'no' = 'yes',
minimum = 6,
territory = 'carthag',
) {
return answer(ask(g, minimum, territory), value);
}
function ship(
g: Game,
amount: number,
territory = 'carthag',
sector = TERRITORIES.find((t) => t.id === territory)!.sectors[0],
) {
return applyAction(g, 'p', { type: 'ship', territory, sector, amount });
}
function reject(g: Game, id: string, action: Action, reason?: string) {
const before = structuredClone(g);
if (reason) assert.throws(() => applyAction(g, id, action), { message: reason });
else assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
void test('all six Basic factions answer and fulfill every minimum from one through twenty with physical reserve forces', () => {
for (const faction of factions)
for (let n = 1; n <= 20; n++) {
const initial = fixture(faction),
asked = ask(initial, n, 'the_great_flat');
assert.deepEqual(
viewGame(asked, 'p').truthShipmentAnswers,
['yes', 'no'],
`${faction}/${n}`,
);
reject(asked, 'a', { type: 'truthAnswer', answer: 'yes' });
reject(asked, 'p', { type: 'truthAnswer', answer: 'unknown' });
const promised = answer(reload(asked), 'yes'),
done = ship(promised, n, 'the_great_flat');
assert.equal(done.players[1].reserves, 20 - n);
assert.equal(done.players[1].forces['the_great_flat:15'], n);
assert.equal(done.shipmentPromises?.[0].fulfilled, true);
assert.equal(done.players[1].shipped, true);
assert.equal(
done.discard.filter((c) => c.effect === 'truthtrance').length,
1,
);
assert.deepEqual(normalizeAutomaticGame(reload(done)), reload(done));
}
});
void test('joint Yes and No predicates retain the full legal amount interval and any legal destination sector', () => {
const g = bind(bind(fixture(), 'yes', 6, 'wind_pass'), 'no', 8, 'wind_pass');
for (const amount of [6, 7])
for (const sector of [14, 15, 16, 17]) {
const done = ship(g, amount, 'wind_pass', sector);
assert.ok(done.shipmentPromises?.every((p) => p.fulfilled));
assert.equal(done.players[1].forces[`wind_pass:${sector}`], amount);
}
for (const amount of [1, 5, 8, 20])
reject(g, 'p', {
type: 'ship',
territory: 'wind_pass',
sector: 15,
amount,
});
const first = bind(fixture());
const implied = ask(first, 3, 'carthag');
assert.deepEqual(viewGame(implied, 'p').truthShipmentAnswers, ['yes']);
reject(implied, 'p', { type: 'truthAnswer', answer: 'no' });
const incompatible = ask(first, 1, 'arrakeen');
assert.deepEqual(viewGame(incompatible, 'p').truthShipmentAnswers, ['no']);
reject(incompatible, 'p', { type: 'truthAnswer', answer: 'yes' });
});
void test('No forbids only matching reserve shipments and permits fewer forces, another destination, movement first or no shipment', () => {
const initial = fixture();
initial.players[1].forces = { 'arrakeen:10': 1 };
initial.players[1].reserves = 19;
const g = bind(initial, 'no');
reject(g, 'p', { type: 'ship', territory: 'carthag', sector: 11, amount: 6 });
assert.equal(ship(g, 5).players[1].forces['carthag:11'], 5);
assert.equal(
ship(g, 6, 'wind_pass', 15).players[1].forces['wind_pass:15'],
6,
);
const moved = applyAction(g, 'p', {
type: 'move',
from: 'arrakeen:10',
territory: 'imperial_basin',
sector: 10,
amount: 1,
});
assert.equal(moved.shipmentPromises?.[0].fulfilled, true);
const skipped = applyAction(g, 'p', { type: 'endMovement' });
assert.equal(skipped.shipmentPromises?.[0].fulfilled, true);
});
void test('Yes cannot be evaded by moving first, ending movement or another Guild shipment, but fulfillment permits later movement', () => {
const initial = fixture('guild');
initial.players[1].forces = { 'arrakeen:10': 1 };
initial.players[1].reserves = 19;
const g = bind(initial);
reject(g, 'p', { type: 'endMovement' });
reject(g, 'p', {
type: 'move',
from: 'arrakeen:10',
territory: 'imperial_basin',
sector: 10,
amount: 1,
});
reject(g, 'p', {
type: 'guildShip',
from: 'arrakeen:10',
territory: 'carthag',
sector: 11,
amount: 1,
});
reject(g, 'p', { type: 'guildShip', from: 'arrakeen:10', amount: 1 });
let done = ship(g, 6);
if (done.decision?.kind === 'advisor')
done = applyAction(done, done.decision.player, {
type: 'decision',
accept: false,
});
assert.doesNotThrow(() =>
applyAction(done, 'p', {
type: 'move',
from: 'arrakeen:10',
territory: 'imperial_basin',
sector: 10,
amount: 1,
}),
);
assert.doesNotThrow(() => applyAction(done, 'p', { type: 'endMovement' }));
});
void test('allied occupancy permits either shipment answer while storm and two enemy factions still deny impossible Yes privately', () => {
for (const blocked of ['storm', 'ally', 'full']) {
const g = fixture();
if (blocked === 'storm') g.storm = 11;
else {
g.players[0].forces = { 'carthag:11': 1 };
g.players[0].reserves = 19;
if (blocked === 'ally') {
g.players[0].ally = 'p';
g.players[1].ally = 'a';
} else {
g.players[2].forces = { 'carthag:11': 1 };
g.players[2].reserves = 19;
}
}
const asked = ask(g);
assert.deepEqual(viewGame(asked, 'p').truthShipmentAnswers,
blocked === 'ally' ? ['yes', 'no'] : ['no']);
if (blocked === 'ally') {
const promised = answer(reload(asked), 'yes');
reject(promised, 'p', { type: 'endMovement' },
'Honor your Truthtrance shipment answer before using this shipment opportunity.');
let arrived = ship(promised, 6);
assert.equal(arrived.shipmentPromises?.[0].fulfilled, true);
assert.equal(arrived.players[1].forces['carthag:11'], 6);
assert.equal(arrived.players[1].reserves, 14);
assert.equal(arrived.players[1].spice, 94);
assert.equal(arrived.players[1].shipped, true);
assert.equal(arrived.players[1].moved, 0);
assert.equal(arrived.players[1].tanks, 0);
assert.deepEqual(arrived.players[0].forces, g.players[0].forces);
assert.deepEqual(viewGame(arrived, 'p').basicAlliedShipment, {
territory: 'Carthag', normal: 6, elite: 0,
});
if (arrived.decision?.kind === 'advisor')
arrived = applyAction(arrived, arrived.decision.player, { type: 'decision', accept: false });
const done = applyAction(reload(arrived), 'p', { type: 'endMovement' });
assert.deepEqual(done.players[1].forces, {});
assert.equal(done.players[1].tanks, 6);
assert.equal(done.players[1].reserves, 14);
assert.equal(done.players[1].spice, 94);
assert.deepEqual(done.players[0].forces, g.players[0].forces);
assert.deepEqual(done.deck, arrived.deck);
assert.deepEqual(done.discard, arrived.discard);
assert.deepEqual(done.players.map((p) => p.hand), arrived.players.map((p) => p.hand));
for (const p of done.players)
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, x) => n + x, 0), 20);
const refused = answer(reload(asked), 'no');
reject(refused, 'p', {
type: 'ship', territory: 'carthag', sector: 11, amount: 6,
}, 'Honor your Truthtrance shipment answer before using this shipment opportunity.');
const fewer = ship(refused, 5);
assert.equal(fewer.players[1].forces['carthag:11'], 5);
assert.equal(fewer.shipmentPromises?.[0].fulfilled, true);
const skipped = applyAction(refused, 'p', { type: 'endMovement' });
assert.equal(skipped.shipmentPromises?.[0].fulfilled, true);
assert.equal(skipped.players[1].reserves, 20);
assert.equal(skipped.players[1].tanks, 0);
} else {
reject(asked, 'p', { type: 'truthAnswer', answer: 'yes' },
'Choose an answer consistent with your available shipment and earlier promises.');
const refused = answer(reload(asked), 'no');
const skipped = applyAction(refused, 'p', { type: 'endMovement' });
assert.equal(skipped.shipmentPromises?.[0].fulfilled, true);
reject(refused, 'p', {
type: 'ship', territory: 'carthag', sector: 11, amount: 6,
}, 'Honor your Truthtrance shipment answer before using this shipment opportunity.');
reject(g, 'p', {
type: 'ship', territory: 'carthag', sector: 11, amount: 6,
}, blocked === 'storm' ? 'That sector is in storm.' :
'A stronghold cannot contain three occupying factions.');
}
for (const id of ['a', 'o']) {
assert.equal(viewGame(asked, id).truthShipmentAnswers, null);
assert.equal(viewGame(asked, id).shipmentCompletion, null);
}
}
const g = fixture();
g.storm = 15;
const promised = bind(g, 'yes', 1, 'wind_pass');
assert.doesNotThrow(() => ship(promised, 1, 'wind_pass', 14));
reject(promised, 'p', {
type: 'ship',
territory: 'wind_pass',
sector: 15,
amount: 1,
}, 'That sector is in storm.');
});
void test('Ghola, retained Karama and recoverable outgoing ally escrow combine into an executable completion', () => {
let g = fixture();
g.players[1].reserves = 1;
g.players[1].tanks = 5;
g.players[1].forces = { 'arrakeen:10': 14 };
g.players[1].spice = 3;
g.players[1].ally = 'o';
g.players[2].ally = 'p';
const ghola = hold(g, 'p', 'Tleilaxu Ghola'),
karama = hold(g, 'p', 'Karama');
g = applyAction(g, 'p', { type: 'pledgeAid', amount: 2 });
const asked = ask(g);
assert.deepEqual(viewGame(asked, 'p').truthShipmentAnswers, ['yes', 'no']);
let next = answer(asked, 'yes');
const actions: Action[] = [];
for (let i = 0; i < 5 && !next.players[1].shipped; i++) {
const completion = viewGame(next, 'p').shipmentCompletion;
assert.ok(completion?.actions.length);
actions.push(completion.actions[0]);
next = applyAction(next, 'p', completion.actions[0]);
}
assert.equal(next.players[1].shipped, true);
assert.equal(next.players[1].forces['carthag:11'], 6);
assert.equal(next.players[1].spice, 0);
assert.equal(next.players[1].tanks, 0);
assert.equal(next.aid.p.amount, 0);
assert.ok(actions.some((a) => a.type === 'pledgeAid'));
assert.ok(actions.some((a) => a.card === ghola));
assert.ok(actions.some((a) => a.card === karama));
const limited = fixture();
limited.players[1].reserves = 1;
limited.players[1].tanks = 10;
hold(limited, 'p', 'Tleilaxu Ghola');
assert.deepEqual(viewGame(ask(limited, 7), 'p').truthShipmentAnswers, ['no']);
});
void test('active Karama and incoming ally escrow count, while unpledged ally funds and incoming bribes do not', () => {
let g = fixture();
g.players[1].spice = 0;
g.players[1].ally = 'o';
g.players[2].ally = 'p';
const card = hold(g, 'a', 'Karama');
g = applyAction(g, 'a', {
type: 'card',
card,
mode: 'shipment',
target: 'p',
});
g = applyAction(g, 'o', { type: 'pledgeAid', amount: 3 });
const done = ship(bind(g), 6);
assert.equal(done.players[1].forces['carthag:11'], 6);
assert.equal(done.aid.o.amount, 0);
const poor = fixture();
poor.players[1].spice = 0;
poor.players[1].bribes = 100;
poor.players[1].ally = 'o';
poor.players[2].ally = 'p';
hold(poor, 'o', 'Karama');
assert.deepEqual(viewGame(ask(poor), 'p').truthShipmentAnswers, ['no']);
});
void test('Guild-allied Fremen southern-reserve transport can fulfill outside ordinary reinforcement radius and No rejects that same event', () => {
const g = fixture('fremen');
g.players[2].faction = 'guild';
g.players[1].ally = 'o';
g.players[2].ally = 'p';
g.players[1].spice = 3;
const positive = bind(g);
reject(positive, 'p', {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 6,
});
assert.equal(
viewGame(positive, 'p').shipmentCompletion?.actions.at(-1)?.type,
'guildShip',
);
const command: Action = {
type: 'guildShip',
from: 'reserves',
territory: 'carthag',
sector: 11,
amount: 6,
};
const done = applyAction(positive, 'p', command);
assert.equal(done.players[1].reserves, 14);
assert.equal(done.players[1].spice, 0);
assert.equal(done.shipmentPromises?.[0].fulfilled, true);
reject(bind(g, 'no'), 'p', command);
const alone = fixture('fremen');
assert.deepEqual(viewGame(ask(alone), 'p').truthShipmentAnswers, ['no']);
});
void test('voluntary bribes and spending the only required Ghola or Karama cannot destroy a feasible Yes', () => {
const plain = fixture();
plain.players[1].spice = 6;
const g = bind(plain);
const original = JSON.stringify(g);
assert.equal(viewGame(g, 'p').bribeOptions.targets.find(p => p.id === 'o')!.maximum, 0);
const extra = structuredClone(g);
extra.players[1].spice += 2;
assert.equal(viewGame(extra, 'p').bribeOptions.targets.find(p => p.id === 'o')!.maximum, 2);
assert.ok(applyAction(extra, 'p', { type: 'bribe', target: 'o', amount: 2 }));
reject(extra, 'p', { type: 'bribe', target: 'o', amount: 3 });
assert.equal(JSON.stringify(g), original);
reject(g, 'p', { type: 'bribe', target: 'o', amount: 1 });
const discounted = fixture();
discounted.players[1].spice = 3;
discounted.players[1].reserves = 20;
const karama = hold(discounted, 'p', 'Karama');
const promised = bind(discounted);
// Donating the only rate card to a different active recipient is rejected by timing,
// and a legitimate unrelated cancellation must not consume the promised resource.
const response = structuredClone(promised);
response.players[2].faction = 'guild';
response.response = {
kind: 'guildIncome',
owner: 'o',
amount: 1,
passed: [],
};
reject(response, 'p', { type: 'card', card: karama, mode: 'cancel' });
const revival = fixture();
revival.players[1].reserves = 1;
revival.players[1].tanks = 5;
revival.players[1].leaders[0].dead = true;
const ghola = hold(revival, 'p', 'Tleilaxu Ghola');
reject(bind(revival), 'p', {
type: 'card',
card: ghola,
leader: revival.players[1].leaders[0].id,
});
});
void test('opposing aid withdrawal releases a now-impossible answer once, while recoverable outgoing pledges preserve the obligation', () => {
let g = fixture();
g.players[1].spice = 0;
g.players[1].ally = 'o';
g.players[2].ally = 'p';
g = applyAction(g, 'o', { type: 'pledgeAid', amount: 6 });
g = bind(g);
const released = applyAction(g, 'o', { type: 'pledgeAid', amount: 0 });
assert.equal(released.shipmentPromises?.[0].released, true);
assert.doesNotThrow(() =>
applyAction(released, 'p', { type: 'endMovement' }),
);
assert.deepEqual(normalizeAutomaticGame(reload(released)), reload(released));
let recoverable = fixture();
recoverable.players[1].spice = 6;
recoverable.players[1].ally = 'o';
recoverable.players[2].ally = 'p';
recoverable = bind(recoverable);
recoverable = applyAction(recoverable, 'p', { type: 'pledgeAid', amount: 6 });
assert.notEqual(recoverable.shipmentPromises?.[0].released, true);
assert.deepEqual(viewGame(recoverable, 'p').shipmentCompletion?.actions[0], {
type: 'pledgeAid',
amount: 0,
});
reject(recoverable, 'p', { type: 'endMovement' });
});
void test('malformed questions and unsupported public scopes reject without discarding the queued Truthtrance; legacy saves remain playable', () => {
const initial = fixture(),
card = hold(initial, 'a', 'Truthtrance');
let g = applyAction(initial, 'a', { type: 'card', card });
while (g.truthtrance?.stage === 'priority')
g = applyAction(
g,
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
{ type: 'truthPass' },
);
for (const minimum of [
0,
-1,
21,
0.5,
Infinity,
NaN,
'6',
null,
Number.MAX_SAFE_INTEGER,
])
reject(g, 'a', {
type: 'truthAsk',
question: {
kind: 'shipment',
target: 'p',
territory: 'carthag',
minimum,
},
});
for (const change of [
'duplicateDeck',
'phase',
'spent',
'inactive',
'territory',
]) {
const bad = structuredClone(g);
if (change === 'duplicateDeck') bad.expansions = ['ix', 'ix'];
if (change === 'phase') bad.phase = 6;
if (change === 'spent') bad.players[1].shipped = true;
if (change === 'inactive') { bad.active = 'a'; bad.movementRemaining = ['a', 'o']; }
reject(bad, 'a', {
type: 'truthAsk',
question: {
kind: 'shipment',
target: 'p',
territory: change === 'territory' ? 'missing' : 'carthag',
minimum: 6,
},
});
}
const legacy = reload(fixture());
delete legacy.shipmentPromises;
assert.doesNotThrow(() => ship(legacy, 1));
assert.doesNotThrow(() => applyAction(legacy, 'p', { type: 'endMovement' }));
});
void test('private feasible answers and preparation witnesses do not change any opposing view', () => {
const affordable = fixture(),
poor = structuredClone(affordable);
poor.players[1].spice = 0;
const a = ask(affordable),
b = ask(poor);
assert.deepEqual(viewGame(a, 'p').truthShipmentAnswers, ['yes', 'no']);
assert.deepEqual(viewGame(b, 'p').truthShipmentAnswers, ['no']);
for (const id of ['a', 'o'])
assert.deepEqual(viewGame(a, id), viewGame(b, id));
const six = structuredClone(affordable);
six.players[1].spice = 6;
const high = bind(affordable),
low = bind(six);
assert.ok(viewGame(high, 'p').shipmentCompletion);
assert.ok(viewGame(low, 'p').shipmentCompletion);
for (const id of ['a', 'o']) {
assert.equal(viewGame(high, id).shipmentCompletion, null);
assert.deepEqual(viewGame(high, id), viewGame(low, id));
}
});
void test('saved malformed promise bindings reject before action or normalization while old and fulfilled JSON remain valid', () => {
const valid = bind(fixture());
const changes: ((g: Game) => void)[] = [
(g) => {
g.shipmentPromises![0].minimum = 0;
},
(g) => {
g.shipmentPromises![0].territory = 'missing';
},
(g) => {
g.shipmentPromises![0].player = 'missing';
},
(g) => {
g.shipmentPromises![0].asker = 'p';
},
(g) => {
g.shipmentPromises![0].turn = g.turn + 1;
},
(g) => {
Object.assign(g.shipmentPromises![0], { answer: 'yes' });
},
(g) => {
g.shipmentPromises![0].fulfilled = true;
g.shipmentPromises![0].released = true;
},
(g) => {
g.phase = 6;
},
(g) => {
g.active = 'a';
g.movementRemaining = ['a', 'o'];
},
(g) => {
g.players[1].shipped = true;
},
(g) => {
Object.assign(g, { shipmentPromises: {} });
},
];
for (const change of changes) {
const invalid = reload(valid);
change(invalid);
const before = structuredClone(invalid);
assert.throws(() => normalizeAutomaticGame(invalid), /shipment promise/);
reject(invalid, 'p', { type: 'endMovement' });
assert.deepEqual(invalid, before);
}
const old = reload(valid);
old.shipmentPromises![0].turn--;
assert.doesNotThrow(() => applyAction(old, 'p', { type: 'endMovement' }));
const fulfilled = reload(ship(valid, 6));
assert.equal(fulfilled.shipmentPromises![0].fulfilled, true);
assert.deepEqual(normalizeAutomaticGame(fulfilled), fulfilled);
});