Files
Dune/tests/pending-shipment-integrity.test.ts

401 lines
14 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
function fixture() {
let g = createGame('SHIPINTENT', newPlayer('e', 'Emperor', 'emperor'), true);
g.players.push(
newPlayer('g', 'Guild', 'guild'),
newPlayer('a', 'Ally', 'atreides'),
newPlayer('b', 'Bene Gesserit', 'beneGesserit'),
);
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 3,
active: 'e',
order: ['e', 'g', 'a', 'b'],
movementRemaining: ['e', 'g', 'a', 'b'],
storm: 18,
deck: baseDeck(),
});
for (const p of g.players) {
p.spice = 20;
p.forces = {};
p.reserves = 20;
p.hand = [];
}
g.players[0].elites = { reserves: 5, tanks: 0, revived: 0, forces: {} };
g.players[0].ally = 'a';
g.players[2].ally = 'e';
const i = g.deck.findIndex((c) => c.effect === 'karama');
g.players[1].hand.push(g.deck.splice(i, 1)[0]);
g = applyAction(g, 'a', { type: 'pledgeAid', amount: 3 });
return g;
}
const declaration = {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 4,
elite: 2,
allyPayment: 2,
};
const declare = (g = fixture()) => applyAction(g, 'e', declaration);
const permit = (g: Game, id = 'g') =>
applyAction(g, id, { type: 'decision', allow: true });
const stop = (g: Game) =>
applyAction(g, 'g', {
type: 'card',
mode: 'special',
card: g.players[1].hand.find((c) => c.effect === 'karama')!.id,
});
const pendingRecord = (g: Game) =>
g.pendingShipment as unknown as Record<string, unknown>;
const decisionRecord = (g: Game) =>
g.decision as unknown as Record<string, unknown>;
function corrupted(label: string, change: (g: Game) => void, allowActor = 'g') {
const g = declare();
change(g);
const snapshot = structuredClone(g);
assert.throws(() => permit(g, allowActor), `${label}: allowance must reject`);
assert.deepEqual(g, snapshot, `${label}: allowance is atomic`);
assert.throws(
() => stop(g),
`${label}: stop must reject before spending a card`,
);
assert.deepEqual(g, snapshot, `${label}: rejected stop is atomic`);
}
function inventory(g: Game) {
for (const p of g.players) {
assert.equal(
p.reserves +
p.tanks +
Object.values(p.forces).reduce((sum, n) => sum + n, 0),
20,
);
if (p.elites)
assert.equal(
p.elites.reserves +
p.elites.tanks +
Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
5,
);
}
assert.deepEqual(
[...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
.map((c) => c.id)
.sort(),
baseDeck()
.map((c) => c.id)
.sort(),
);
}
function finishFollowups(state: Game) {
let g = state;
while (g.response) {
const actor = g.players.find((p) => !g.response!.passed.includes(p.id))!;
g = applyAction(g, actor.id, { type: 'passResponse' });
}
if (g.decision?.kind === 'advisor')
g = applyAction(g, g.decision.player, { type: 'decision', accept: false });
return g;
}
void test('new ordinary physical declarations persist their turn without exposing the private intent', () => {
const initial = fixture(),
g = declare(initial);
assert.equal(pendingRecord(g).turn, g.turn);
assert.equal(g.pendingShipment!.amount, 4);
assert.equal(g.pendingShipment!.elite, 2);
assert.equal(g.pendingShipment!.cost, 4);
assert.equal(g.pendingShipment!.allyPayment, 2);
assert.deepEqual(g.players, initial.players);
assert.deepEqual(g.aid, initial.aid);
for (const p of g.players) {
const view = viewGame(g, p.id);
assert.equal('pendingShipment' in view, false);
assert.equal('allyPayment' in view.decision!, false);
assert.equal('elite' in view.decision!, false);
}
});
void test('saved physical shipments reject stale timing and spent shipment opportunities atomically', () => {
corrupted('wrong phase', (g) => {
g.phase = 4;
});
corrupted('inactive shipper', (g) => {
g.active = 'a';
});
corrupted('spent shipment', (g) => {
g.players[0].shipped = true;
});
corrupted('finished game', (g) => {
g.status = 'finished';
});
for (const turn of [0, 2, 4, 1.5, '3', null])
corrupted(`invalid declaration turn ${String(turn)}`, (g) => {
pendingRecord(g).turn = turn;
});
corrupted('unknown shipper', (g) => {
pendingRecord(g).player = 'missing';
});
});
void test('saved physical force and elite allocations must remain valid against current typed reserves', () => {
for (const amount of [0, -1, 1.5, 21, '4', NaN, Infinity])
corrupted(`amount ${String(amount)}`, (g) => {
pendingRecord(g).amount = amount;
decisionRecord(g).amount = amount;
});
for (const elite of [-1, 1.5, 5, '2', NaN])
corrupted(`elite ${String(elite)}`, (g) => {
pendingRecord(g).elite = elite;
});
corrupted('insufficient current reserves', (g) => {
g.players[0].reserves = 3;
g.players[0].forces = { 'red_chasm:7': 17 };
g.players[0].elites!.reserves = 1;
g.players[0].elites!.forces = { 'red_chasm:7': 4 };
});
corrupted('insufficient selected elites', (g) => {
g.players[0].reserves = 16;
g.players[0].forces = { 'red_chasm:7': 4 };
g.players[0].elites!.reserves = 1;
g.players[0].elites!.forces = { 'red_chasm:7': 4 };
});
corrupted('nonelite minimum no longer possible', (g) => {
g.players[0].reserves = 5;
g.players[0].forces = { 'red_chasm:7': 15 };
});
});
void test('saved shipment price and contribution are revalidated without repricing or unaffordable recovery', () => {
for (const cost of [0, -1, 2, 5, 4.5, '4', NaN])
corrupted(`cost ${String(cost)}`, (g) => {
pendingRecord(g).cost = cost;
});
for (const share of [-1, 1.5, 4, 5, '2', NaN])
corrupted(`share ${String(share)}`, (g) => {
pendingRecord(g).allyPayment = share;
});
corrupted('own funds spent', (g) => {
g.players[0].spice = 1;
});
corrupted('pledge reduced', (g) => {
g.aid.a.amount = 1;
g.players[2].spice += 2;
});
corrupted('pledge recipient changed', (g) => {
g.aid.a.recipient = 'g';
});
corrupted('alliance no longer supplies escrow', (g) => {
g.players[0].ally = null;
g.players[2].ally = null;
});
corrupted('donor alliance is no longer reciprocal', (g) => {
g.players[2].ally = null;
});
corrupted('donor now allied to another seat', (g) => {
g.players[2].ally = 'b';
});
for (const amount of [3.5, Infinity, NaN, '3'])
corrupted(`malformed current escrow ${String(amount)}`, (g) => {
(g.aid.a as unknown as Record<string, unknown>).amount = amount;
});
corrupted('new rate benefit changes price', (g) => {
g.karamaShipping = { player: 'e', owner: 'g' };
});
corrupted('new Guild alliance changes price', (g) => {
g.players[0].ally = 'g';
g.players[1].ally = 'e';
g.players[2].ally = null;
g.aid.g = { recipient: 'e', amount: 3 };
});
});
void test('saved destination and advisor stance must still permit the declared physical arrival', () => {
corrupted('storm reached destination', (g) => {
g.storm = 10;
});
for (const sector of [7, -1, 1.5, '10'])
corrupted(`sector ${String(sector)}`, (g) => {
pendingRecord(g).sector = sector;
decisionRecord(g).sector = sector;
});
corrupted('unknown destination', (g) => {
pendingRecord(g).territory = 'missing';
decisionRecord(g).territory = 'missing';
});
corrupted('stronghold filled', (g) => {
g.players[1].forces = { 'arrakeen:10': 1 };
g.players[1].reserves = 19;
g.players[3].forces = { 'arrakeen:10': 1 };
g.players[3].reserves = 19;
});
for (const advisors of [true, undefined, 'false', 0])
corrupted(`false advisor flag ${String(advisors)}`, (g) => {
pendingRecord(g).advisors = advisors;
});
});
void test('saved ordinary shipment commits after an ally enters the destination', () => {
const pending = declare();
pending.players[2].forces = { 'arrakeen:10': 1 };
pending.players[2].reserves = 19;
const restored = JSON.parse(JSON.stringify(pending)) as Game;
const snapshot = structuredClone(restored);
const allowed = permit(restored);
assert.deepEqual(restored, snapshot);
assert.equal(allowed.pendingShipment, null);
assert.equal(allowed.players[0].reserves, 16);
assert.equal(allowed.players[0].forces['arrakeen:10'], 4);
assert.equal(allowed.players[0].elites!.reserves, 3);
assert.equal(allowed.players[0].elites!.forces['arrakeen:10'], 2);
assert.equal(allowed.players[0].spice, 18);
assert.equal(allowed.players[2].spice, 17);
assert.equal(allowed.aid.a.amount, 1);
assert.equal(allowed.players[0].shipped, true);
assert.equal(allowed.players[2].forces['arrakeen:10'], 1);
assert.equal(allowed.players[2].reserves, 19);
assert.throws(() => permit(allowed));
const complete = finishFollowups(allowed);
assert.equal(complete.players[1].spice, 24);
inventory(complete);
const ended = applyAction(complete, 'e', { type: 'endMovement' });
assert.equal(ended.players[0].forces['arrakeen:10'] ?? 0, 0);
assert.equal(ended.players[0].tanks, 4);
assert.equal(ended.players[0].elites!.forces['arrakeen:10'] ?? 0, 0);
assert.equal(ended.players[0].elites!.tanks, 2);
assert.equal(ended.players[2].forces['arrakeen:10'], 1);
assert.equal(ended.players[2].tanks, 0);
inventory(ended);
});
void test('public Guild permission must bind to the saved shipper, destination, amount and actual Guild seat', () => {
for (const [key, value] of [
['shipper', 'a'],
['territory', 'carthag'],
['sector', 11],
['amount', 3],
] as const)
corrupted(`public ${key} mismatch`, (g) => {
decisionRecord(g)[key] = value;
});
corrupted(
'wrong permission owner',
(g) => {
decisionRecord(g).player = 'a';
},
'a',
);
corrupted('Guild power already used', (g) => {
g.players[1].specialKaramaUsed = true;
});
});
void test('valid current and legacy JSON continuations spend physical forces, elites and escrow exactly once', () => {
for (const legacy of [false, true]) {
const pending = declare();
if (legacy) delete pendingRecord(pending).turn;
const restored = JSON.parse(JSON.stringify(pending)) as Game;
for (const p of pending.players)
assert.deepEqual(viewGame(restored, p.id), viewGame(pending, p.id));
const allowed = permit(restored);
assert.equal(allowed.pendingShipment, null);
assert.equal(allowed.players[0].reserves, 16);
assert.equal(allowed.players[0].forces['arrakeen:10'], 4);
assert.equal(allowed.players[0].elites!.reserves, 3);
assert.equal(allowed.players[0].elites!.forces['arrakeen:10'], 2);
assert.equal(allowed.players[0].spice, 18);
assert.equal(allowed.players[2].spice, 17);
assert.equal(allowed.aid.a.amount, 1);
assert.equal(allowed.players[0].shipped, true);
assert.throws(() => permit(allowed));
const complete = finishFollowups(JSON.parse(JSON.stringify(allowed)));
assert.equal(complete.players[1].spice, 24);
assert.equal(complete.decision, null);
inventory(complete);
assert.throws(() => applyAction(complete, 'e', declaration));
}
});
void test('valid stopped physical shipments preserve the established no-payment outcome after JSON restore', () => {
for (const legacy of [false, true]) {
const pending = declare();
if (legacy) delete pendingRecord(pending).turn;
const done = stop(JSON.parse(JSON.stringify(pending)));
assert.equal(done.players[0].reserves, 20);
assert.equal(done.players[0].elites!.reserves, 5);
assert.equal(done.players[0].spice, 20);
assert.equal(done.players[0].shipped, true);
assert.deepEqual(done.aid, pending.aid);
assert.equal(done.players[1].specialKaramaUsed, true);
assert.equal(done.pendingShipment, null);
assert.equal(done.decision, null);
assert.equal(done.discard.filter((c) => c.effect === 'karama').length, 1);
assert.throws(() => stop(done));
inventory(done);
}
});
void test('a real advisor shipment retains its stance and rejects a saved stance made obsolete before commitment', () => {
const initial = fixture();
initial.players[0].faction = 'beneGesserit';
initial.players[0].elites = undefined;
initial.players[3].faction = 'emperor';
initial.players[3].elites = { reserves: 5, tanks: 0, revived: 0, forces: {} };
initial.players[0].forces = { 'arrakeen:10': 1 };
initial.players[0].reserves = 19;
initial.players[0].advisors = { arrakeen: {} };
initial.players[1].forces = { 'arrakeen:10': 1 };
initial.players[1].reserves = 19;
const pending = applyAction(initial, 'e', { ...declaration, elite: 0 });
assert.equal(pending.pendingShipment!.advisors, true);
const allowed = finishFollowups(permit(JSON.parse(JSON.stringify(pending))));
assert.deepEqual(allowed.players[0].advisors, { arrakeen: {} });
assert.equal(allowed.players[0].forces['arrakeen:10'], 5);
inventory(allowed);
const stale = structuredClone(pending);
stale.players[1].forces = {};
stale.players[1].reserves = 20;
const snapshot = structuredClone(stale);
assert.throws(() => permit(stale));
assert.throws(() => stop(stale));
assert.deepEqual(stale, snapshot);
});
void test('a current shipment-rate card survives JSON continuation but a removed benefit invalidates its saved price', () => {
let initial = fixture();
const index = initial.deck.findIndex((c) => c.effect === 'karama');
const card = initial.deck.splice(index, 1)[0];
initial.players[0].hand.push(card);
initial = applyAction(initial, 'e', {
type: 'card',
mode: 'shipment',
card: card.id,
});
const pending = applyAction(initial, 'e', { ...declaration, allyPayment: 1 });
assert.equal(pending.pendingShipment!.cost, 2);
const allowed = finishFollowups(permit(JSON.parse(JSON.stringify(pending))));
assert.equal(allowed.players[0].spice, 19);
assert.equal(allowed.aid.a.amount, 2);
assert.equal(allowed.players[1].spice, 20);
assert.equal(allowed.karamaShipping, null);
assert.equal(allowed.discard.filter((c) => c.id === card.id).length, 1);
inventory(allowed);
const stale = structuredClone(pending);
stale.karamaShipping = null;
const snapshot = structuredClone(stale);
assert.throws(() => permit(stale));
assert.throws(() => stop(stale));
assert.deepEqual(stale, snapshot);
});