Files
Dune/tests/sandtrout.test.ts
2026-09-08 07:37:52 +10:00

283 lines
9.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { placeFixtureHand } from './fixture-hand';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createGame,
newPlayer,
joinGame,
applyAction,
viewGame,
type Game,
} from '../game/engine';
import { spiceDeck, baseDeck } from '../game/cards';
function fixture() {
let g = createGame('ADVTEST2', newPlayer('f', 'Fremen', 'fremen'));
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
g.players.forEach((p) => (p.ready = true));
g = applyAction(g, 'f', { type: 'start' });
for (const p of g.players)
if (p.traitorChoices.length)
g = applyAction(g, p.id, {
type: 'traitor',
leader: p.traitorChoices[0],
});
g = applyAction(g, 'f', {
type: 'fremenSetup',
placements: { sietch_tabr: 10 },
});
g.advanced = true;
g.players[0].elites = { reserves: 3, tanks: 0, forces: {}, revived: 0 };
return g;
}
function ready(state: Game) {
let g = state;
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
function allow(state: Game) {
let g = state;
while (g.response)
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
return g;
}
const land = spiceDeck().filter((c) => 'territory' in c);
const worms = spiceDeck().filter((c) => 'worm' in c);
function spiceState(advanced = false) {
const g = fixture();
g.advanced = advanced;
g.turn = 2;
g.phase = 1;
g.storm = 18;
g.players.forEach((p) => {
p.forces = {};
p.reserves = 20;
p.hand = [];
});
g.spiceDiscard = [[], []];
g.spice = {};
g.response = null;
g.decision = null;
g.ready = [];
return g;
}
const trout = { sandtrout: true as const };
void test('the Ix spice deck adds exactly one Sandtrout without changing the base inventory', () => {
assert.equal(spiceDeck().length, 21);
assert.equal(spiceDeck(true).length, 22);
assert.equal(spiceDeck(true).filter((c) => 'sandtrout' in c).length, 1);
});
void test('Sandtrout cancels alliances and offers, refunds outstanding ally credit and remains set aside', () => {
const before = spiceState();
before.players[0].ally = 'a';
before.players[1].ally = 'f';
before.allianceOffers = { f: 'a', a: 'f' };
before.aid = { f: { recipient: 'a', amount: 3 } };
before.spiceDeck = [trout, land[0], ...land.slice(1)];
const g = ready(before);
assert.ok(g.players.every((p) => p.ally === null));
assert.deepEqual(g.allianceOffers, {});
assert.deepEqual(g.aid, {});
assert.equal(g.players[0].spice, before.players[0].spice + 3);
assert.equal(g.sandtrout, true);
assert.equal(g.nexus, false);
assert.deepEqual(g.spiceDiscard[0], [land[0]]);
assert.equal(g.spiceWindow?.amount, land[0].amount);
assert.equal(viewGame(g, 'a').sandtrout, true);
assert.equal(before.players[0].ally, 'a');
});
void test('a suppressed worm neither destroys forces/spice nor opens Nexus and doubles only its immediate land replacement', () => {
const before = spiceState();
before.spiceDiscard[0] = [land[0]];
before.players[1].forces = { [`${land[0].territory}:${land[0].sector}`]: 4 };
before.players[1].reserves = 16;
before.spice[`${land[0].territory}:${land[0].sector}`] = 7;
before.spiceDeck = [trout, worms[0], land[1], ...land.slice(2)];
const g = ready(before);
assert.equal(g.sandtrout, false);
assert.equal(g.nexus, false);
assert.equal(g.decision, null);
assert.equal(g.spiceWindow?.amount, land[1].amount * 2);
assert.equal(g.players[1].tanks, 0);
assert.equal(g.spice[`${land[0].territory}:${land[0].sector}`], 7);
assert.deepEqual(g.spiceDiscard[0].slice(-3), [
trout,
{ worm: true, suppressed: true },
land[1],
]);
});
void test('Sandtrout persists across phase and turn boundaries, with JSON reconnect preserving the pending effect', () => {
const before = spiceState();
before.spiceDeck = [trout, land[0], worms[0], land[1], ...land.slice(2)];
let g = ready(ready(before));
assert.equal(g.phase, 2);
assert.equal(g.sandtrout, true);
g = JSON.parse(JSON.stringify(g));
g.phase = 1;
g.turn++;
g.ready = [];
g = ready(g);
assert.equal(g.sandtrout, false);
assert.equal(g.spiceWindow?.amount, land[1].amount * 2);
});
void test('a second worm is normal, devours the prior land, opens Nexus and cancels the double-spice bonus', () => {
const before = spiceState(true);
before.spiceDiscard[0] = [land[0]];
before.players[1].forces = { [`${land[0].territory}:${land[0].sector}`]: 4 };
before.players[1].reserves = 16;
before.spiceDeck = [trout, worms[0], worms[1], land[1], ...land.slice(2)];
const g = ready(before);
assert.equal(g.nexus, true);
assert.equal(g.players[1].tanks, 4);
assert.equal(g.spiceWindow?.amount, land[1].amount);
assert.equal(g.decision, null);
});
void test('Fremen extra placement starts only after a real worm, not after the suppressed worm', () => {
const before = spiceState(true);
before.spiceDiscard[0] = [land[0]];
before.spiceDeck = [
trout,
worms[0],
worms[1],
worms[2],
land[1],
...land.slice(2),
];
const g = ready(before);
assert.equal(g.decision?.kind, 'wormPlacement');
assert.deepEqual(g.spiceDeck[0], land[1]);
});
void test('double spice blow can carry pending Sandtrout from pile A to pile B without mixing discard targets', () => {
const before = spiceState(true);
before.spiceDiscard[1] = [land[2]];
before.spiceDeck = [trout, land[0], worms[0], land[1], ...land.slice(3)];
let g = ready(before);
assert.equal(g.sandtrout, true);
assert.equal(g.spiceWindow?.territory, land[0].territory);
g = ready(g);
assert.equal(g.spiceSequence?.pile, 1);
assert.equal(g.sandtrout, false);
assert.equal(g.spiceWindow?.amount, land[1].amount * 2);
assert.deepEqual(g.spiceDiscard[0], [land[0]]);
assert.deepEqual(g.spiceDiscard[1], [
land[2],
trout,
{ worm: true, suppressed: true },
land[1],
]);
});
void test('Harvester doubles the Sandtrout-enhanced blow and storm still prevents all of its spice', () => {
const before = spiceState();
before.spiceDeck = [trout, worms[0], land[0], ...land.slice(1)];
const harvester = baseDeck().find((c) => c.effect === 'harvester')!;
placeFixtureHand(before, 1, [harvester]);
let g = ready(before);
g = applyAction(g, 'a', { type: 'card', card: harvester.id });
assert.equal(
g.spice[`${land[0].territory}:${land[0].sector}`],
land[0].amount * 4,
);
const storm = structuredClone(before);
storm.storm = land[0].sector;
assert.equal(
ready(storm).spice[`${land[0].territory}:${land[0].sector}`],
undefined,
);
});
void test('first-turn ignored worms return to the deck without consuming a waiting Sandtrout', () => {
const before = spiceState(true);
before.turn = 1;
before.spiceDeck = [
trout,
worms[0],
land[0],
worms[1],
land[1],
...land.slice(2),
];
let g = ready(before);
assert.equal(g.sandtrout, true);
assert.equal(g.spiceWindow?.amount, land[0].amount);
g = ready(g);
assert.equal(g.spiceWindow?.amount, land[1].amount);
g = ready(g);
assert.equal(g.phase, 2);
assert.equal(g.sandtrout, true);
assert.equal(g.spiceDeck.filter((c) => 'worm' in c).length, 2);
});
void test('Atreides alone sees a next-card Sandtrout through the existing private foresight projection', () => {
const before = spiceState();
before.phase = 5;
before.spicePeekKnown = true;
before.spiceDeck = [trout, ...land];
assert.deepEqual(viewGame(before, 'a').spicePeek, trout);
assert.equal(viewGame(before, 'f').spicePeek, null);
assert.equal(viewGame(before, 'a').sandtrout, false);
});
void test('Sandtrout drawn after a normal worm does not erase that worm’s already-triggered Nexus', () => {
const before = spiceState();
before.spiceDiscard[0] = [land[0]];
before.spiceDeck = [worms[0], trout, land[1], ...land.slice(2)];
const g = ready(before);
assert.equal(g.nexus, true);
assert.equal(g.sandtrout, true);
});
void test('the real replacement worm preserves Fremen survival while the canceled alliance loses its protection offer', () => {
const before = spiceState(true);
before.spiceDiscard[0] = [land[0]];
for (const p of before.players) {
p.forces = { [`${land[0].territory}:${land[0].sector}`]: 3 };
p.reserves = 17;
}
before.players[0].ally = 'a';
before.players[1].ally = 'f';
before.spiceDeck = [trout, worms[0], worms[1], land[1], ...land.slice(2)];
let g = ready(before);
assert.equal(g.decision, null);
assert.equal(g.response, null);
g = allow(g);
assert.equal(g.players[0].tanks, 0);
assert.equal(g.players[1].tanks, 3);
assert.equal(g.nexus, true);
assert.deepEqual(g.wormRides, [land[0].territory]);
});
void test('reshuffling removes the suppression annotation so a recycled Shai-Hulud is normal again', () => {
const before = spiceState();
before.phase = 4;
before.spiceDeck = [];
before.spiceDiscard = [
[{ worm: true, suppressed: true }, trout, land[0]],
[],
];
const g = ready(before);
assert.equal(g.phase, 5);
assert.equal(g.spiceDeck.length, 3);
assert.ok(g.spiceDeck.filter((c) => 'worm' in c).every((c) => !c.suppressed));
assert.equal(g.spiceDeck.filter((c) => 'sandtrout' in c).length, 1);
assert.deepEqual(g.spiceDiscard, [[], []]);
});
void test('a summoned Fremen worm is not a drawn Shai-Hulud and leaves Sandtrout waiting', () => {
const before = spiceState(true);
before.sandtrout = true;
const karama = baseDeck().find((c) => c.effect === 'karama')!;
before.players[0].hand = [karama];
before.spiceDeck = [worms[0], land[0], ...land.slice(1)];
let g = applyAction(before, 'f', {
type: 'card',
mode: 'special',
card: karama.id,
territory: land[0].territory,
});
g = allow(g);
assert.equal(g.sandtrout, true);
g = ready(g);
assert.equal(g.sandtrout, false);
assert.equal(g.spiceWindow?.amount, land[0].amount * 2);
assert.equal(g.nexus, true);
});