647 lines
23 KiB
TypeScript
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);
|
|
});
|