Files
Dune/tests/juice-of-sapho-engine-review.test.ts
2026-09-08 07:37:52 +10:00

149 lines
4.2 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
const card = 'richese-juice-of-sapho';
function fixture() {
const g = createGame(
'SAPHOREVIEW',
newPlayer('r', 'Richese', 'richese'),
false,
['choam'],
);
g.players.push(
newPlayer('a', 'Atreides', 'atreides'),
newPlayer('e', 'Emperor', 'emperor'),
);
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
active: 'a',
order: ['a', 'r', 'e'],
movementRemaining: ['a', 'r', 'e'],
deck: baseDeck(),
richeseCache: richeseCards(),
});
for (const p of g.players) {
p.hand = [];
p.forces = {};
p.reserves = 20;
p.spice = 20;
}
const index = g.richeseCache!.findIndex((c) => c.id === card);
g.players[0].hand.push(g.richeseCache!.splice(index, 1)[0]);
return g;
}
function hold(g: Game, owner: string, effect: string) {
const index = g.deck.findIndex((c) => c.effect === effect);
assert.ok(index >= 0, effect);
const c = g.deck.splice(index, 1)[0];
g.players.find((p) => p.id === owner)!.hand.push(c);
return c.id;
}
function play(g: Game): Action {
return {
type: 'card',
card,
mode: 'last',
scope: 'movement',
event: `movement:${g.turn}`,
};
}
function blocked(g: Game) {
assert.deepEqual(viewGame(g, 'r').saphoOptions, []);
const before = JSON.stringify(g);
assert.throws(() => applyAction(g, 'r', play(g)));
assert.equal(JSON.stringify(g), before);
}
void test('an actual reserved Richese gift suppresses Sapho and cannot discard its pending physical card', () => {
let g = fixture();
g.players[0].ally = 'a';
g.players[1].ally = 'r';
hold(g, 'e', 'karama');
g = applyAction(g, 'r', { type: 'richeseGift', card });
assert.equal(g.response?.kind, 'richeseGift');
assert.equal(g.pendingRicheseGift!.intent.cardId, card);
blocked(g);
g = applyAction(JSON.parse(JSON.stringify(g)), 'e', { type: 'passResponse' });
assert.equal(g.pendingRicheseGift, null);
assert.ok(g.players[1].hand.some((c) => c.id === card));
assert.equal(
g.discard.some((c) => c.id === card),
false,
);
assert.deepEqual(viewGame(g, 'r').saphoOptions, []);
});
void test('actual Truthtrance priority and answer overlays block Sapho without altering future movement opportunity', () => {
let g = fixture();
const truth = hold(g, 'e', 'truthtrance');
g = applyAction(g, 'e', { type: 'card', card: truth });
blocked(g);
while (g.truthtrance?.stage === 'priority') {
const actor = g.players.find(
(p) => !g.truthtrance!.passed.includes(p.id),
)!.id;
g = applyAction(g, actor, { type: 'truthPass' });
}
blocked(g);
g = applyAction(g, 'e', {
type: 'truthAsk',
question: {
kind: 'fact',
target: 'r',
fact: { kind: 'spice', compare: 'gte', value: 0 },
},
});
blocked(g);
g = applyAction(g, 'r', { type: 'truthAnswer', answer: 'yes' });
assert.ok(viewGame(g, 'r').saphoOptions.some((o) => o.mode === 'last'));
g = applyAction(g, 'r', play(g));
assert.deepEqual(g.movementRemaining, ['a', 'e', 'r']);
});
void test('a real Karama response to shipment retains the suspended declaration when Sapho is rejected', () => {
let g = fixture();
g.advanced = true;
g.players[2].faction = 'guild';
hold(g, 'e', 'karama');
g = applyAction(g, 'a', {
type: 'ship',
territory: 'polar_sink',
sector: 0,
amount: 1,
});
assert.ok(g.decision || g.response || g.pendingShipment);
blocked(g);
});
void test('unsupported scopes and malformed requests are rejected atomically despite a valid physical holder', () => {
const g = fixture();
for (const extra of [
{ scope: 'normalAuction' },
{ scope: 'silent' },
{ scope: 'battle' },
{ mode: 'aggressor' },
{ mode: 'next' },
{ event: 'movement:1' },
{ event: undefined },
{ scope: undefined },
{ mode: undefined },
]) {
const before = JSON.stringify(g);
assert.throws(() => applyAction(g, 'r', { ...play(g), ...extra }));
assert.equal(JSON.stringify(g), before);
}
});