Files
Dune/tests/shipment-promises.test.ts
T
Quintine ae2d0371ce Bind Truthtrance shipments before respondent turns
Admit supported active or originally queued unused shipments during the current Shipment & Movement phase. Answering preserves the real active player and queue; private future reachability uses owned resources, joint whole promises and the actual respondent profile. Hide executable guidance until that owner is active. Keep native Guild WAIT/TAKE, frozen mixed facts, interception, costs and actual delivery.

Do not release a waiting promise while another declaration is unresolved or count its approved ally contribution as freely recoverable. Before response declaration, reject Emperor gifts or optional BG accompaniment that remove the last legal completion; affordable options remain legal. Minimal AI declines optional accompaniment while bound rather than attempting to spend required reserves. No strategy tuning or new profile/module/earlier-phase opening.

Validation and exact frozen-source evidence are recorded in the canonical Truthtrance shipment contract. Three real public Basic/Advanced programmes use original unopened starting deals, paid intervening turns and a later Emperor or native out-of-order Guild fulfillment. Four minimal policies answer while waiting and ship only on their actual turn. Controlled funding, temporary price, gift and advisor probes remain separate. Existing stale scope fixtures and two type declarations were repaired during feedback.

Earlier phases, broader modules/actions, full Truthtrance/modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
2026-10-24 12:07:05 +11:00

634 lines
22 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,
);
});
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 [
'expansion',
'phase',
'spent',
'inactive',
'territory',
]) {
const bad = structuredClone(g);
if (change === 'expansion') bad.expansions = ['ix'];
if (change === 'phase') bad.phase = 4;
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 = 4;
},
(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);
});