226 lines
7.8 KiB
TypeScript
226 lines
7.8 KiB
TypeScript
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
createGame,
|
|
newPlayer,
|
|
joinGame,
|
|
applyAction,
|
|
viewGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { spiceDeck, ixSpecialCards, 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 thumper = ixSpecialCards().find((c) => c.effect === 'thumper')!;
|
|
function withThumper(advanced = false) {
|
|
const g = spiceState(advanced);
|
|
g.players[0].hand = [thumper];
|
|
g.spiceDiscard[0] = [land[0]];
|
|
g.spiceDeck = [...land.slice(1), ...worms];
|
|
return g;
|
|
}
|
|
const play = (g: Game) =>
|
|
applyAction(g, 'f', { type: 'card', card: thumper.id });
|
|
void test('Thumper replaces a reveal, consumes its treachery card once and devours the existing pile-A territory', () => {
|
|
const before = withThumper();
|
|
before.players[1].forces = { [`${land[0].territory}:${land[0].sector}`]: 3 };
|
|
before.players[1].reserves = 17;
|
|
before.spice[`${land[0].territory}:${land[0].sector}`] = 8;
|
|
const g = play(before);
|
|
assert.equal(g.players[1].tanks, 3);
|
|
assert.equal(g.spice[`${land[0].territory}:${land[0].sector}`], undefined);
|
|
assert.equal(g.players[0].hand.length, 0);
|
|
assert.equal(g.discard.filter((c) => c.effect === 'thumper').length, 1);
|
|
assert.deepEqual(g.spiceDeck, before.spiceDeck.slice(1));
|
|
assert.deepEqual(g.spiceDiscard[0], [
|
|
land[0],
|
|
{ worm: true, thumper: true },
|
|
land[1],
|
|
]);
|
|
assert.equal(g.nexus, true);
|
|
assert.equal(g.spiceWindow?.territory, land[1].territory);
|
|
assert.equal(viewGame(g, 'f').beforeSpiceDraw, false);
|
|
});
|
|
void test('Fremen protection pauses before any actual spice-card draw and resumes across JSON reconnect', () => {
|
|
const before = withThumper();
|
|
before.players[1].hand = [baseDeck().find((c) => c.effect === 'karama')!];
|
|
before.players[0].forces = { [`${land[0].territory}:${land[0].sector}`]: 4 };
|
|
before.players[0].reserves = 16;
|
|
let g = play(before);
|
|
assert.equal(g.response?.kind, 'wormSurvival');
|
|
assert.deepEqual(g.spiceDeck, before.spiceDeck);
|
|
assert.equal(g.discard.filter((c) => c.effect === 'thumper').length, 1);
|
|
g = allow(JSON.parse(JSON.stringify(g)));
|
|
assert.equal(g.players[0].tanks, 0);
|
|
assert.deepEqual(g.wormRides, [land[0].territory]);
|
|
assert.equal(g.spiceWindow?.territory, land[1].territory);
|
|
g = ready(g);
|
|
assert.equal(g.nexus, true);
|
|
g = ready(g);
|
|
assert.equal(g.decision?.kind, 'wormRide');
|
|
});
|
|
void test('allied worm protection uses the existing Fremen decision before a Thumper devours anyone', () => {
|
|
const before = withThumper();
|
|
before.players[0].ally = 'a';
|
|
before.players[1].ally = 'f';
|
|
before.players[1].forces = { [`${land[0].territory}:${land[0].sector}`]: 3 };
|
|
before.players[1].reserves = 17;
|
|
let g = play(before);
|
|
assert.equal(g.decision?.kind, 'wormProtection');
|
|
g = applyAction(g, 'f', { type: 'decision', accept: true });
|
|
g = allow(g);
|
|
assert.equal(g.players[1].tanks, 0);
|
|
assert.equal(g.nexus, true);
|
|
});
|
|
void test('an actual worm after Thumper is an additional advanced worm and does not consume the land card before the placement choice', () => {
|
|
const before = withThumper(true);
|
|
before.spiceDeck = [worms[0], land[1], ...land.slice(2)];
|
|
const g = play(before);
|
|
assert.equal(g.decision?.kind, 'wormPlacement');
|
|
assert.equal(g.decision?.player, 'f');
|
|
assert.deepEqual(g.spiceDeck[0], land[1]);
|
|
});
|
|
void test('advanced pile B continues normally after Thumper and its pile-A Nexus', () => {
|
|
let g = play(withThumper(true));
|
|
g = ready(g);
|
|
assert.equal(g.nexus, true);
|
|
g = ready(g);
|
|
assert.equal(g.spiceSequence?.pile, 1);
|
|
assert.equal(g.spiceWindow?.territory, land[2].territory);
|
|
assert.deepEqual(g.spiceDiscard[1], [land[2]]);
|
|
});
|
|
void test('waiting Sandtrout suppresses Thumper and doubles the next land without creating a real worm card', () => {
|
|
const before = withThumper();
|
|
before.sandtrout = true;
|
|
const g = play(before);
|
|
assert.equal(g.sandtrout, false);
|
|
assert.equal(g.nexus, false);
|
|
assert.equal(g.spiceWindow?.amount, land[1].amount * 2);
|
|
assert.ok(
|
|
g.spiceDiscard[0].some((c) => 'worm' in c && c.thumper && c.suppressed),
|
|
);
|
|
assert.equal(
|
|
[...g.spiceDeck, ...g.spiceDiscard.flat()].filter(
|
|
(c) => 'worm' in c && !c.thumper,
|
|
).length,
|
|
6,
|
|
);
|
|
});
|
|
void test('a first-turn Thumper is ignored without adding a worm to the first-turn reshuffle', () => {
|
|
const before = withThumper(true);
|
|
before.turn = 1;
|
|
let g = play(before);
|
|
assert.equal(g.nexus, false);
|
|
assert.deepEqual(g.spiceDiscard[0], [land[0], land[1]]);
|
|
g = ready(g);
|
|
g = ready(g);
|
|
assert.equal(g.phase, 2);
|
|
assert.equal(
|
|
[...g.spiceDeck, ...g.spiceDiscard.flat()].filter((c) => 'worm' in c)
|
|
.length,
|
|
6,
|
|
);
|
|
});
|
|
void test('spice-deck refill removes virtual Thumper markers but preserves real worm cards', () => {
|
|
const before = withThumper();
|
|
before.phase = 4;
|
|
before.spiceDeck = [];
|
|
before.spiceDiscard = [
|
|
[
|
|
{ worm: true, thumper: true },
|
|
{ worm: true, thumper: true, suppressed: true },
|
|
worms[0],
|
|
land[0],
|
|
],
|
|
[],
|
|
];
|
|
const g = ready(before);
|
|
assert.equal(g.spiceDeck.length, 2);
|
|
assert.equal(g.spiceDeck.filter((c) => 'worm' in c).length, 1);
|
|
assert.ok(g.spiceDeck.every((c) => !('worm' in c && c.thumper)));
|
|
});
|
|
void test('Thumper is rejected outside the phase opening or after a reveal, without discarding it', () => {
|
|
for (const phase of [0, 2, 3, 4, 5, 6, 7, 8]) {
|
|
const g = withThumper();
|
|
g.phase = phase;
|
|
const snapshot = structuredClone(g);
|
|
assert.throws(() => play(g), /beginning of Spice Blow/);
|
|
assert.deepEqual(g, snapshot);
|
|
}
|
|
let g = ready(withThumper());
|
|
assert.equal(viewGame(g, 'f').beforeSpiceDraw, false);
|
|
assert.throws(() => play(g), /beginning of Spice Blow/);
|
|
g = ready(g);
|
|
// No worm was drawn, so this is now Charity, not another opening to thump.
|
|
assert.throws(() => play(g), /beginning of Spice Blow/);
|
|
});
|
|
void test('AI considers public enemy exposure and retains Thumper when Sandtrout would suppress it', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = withThumper();
|
|
g.players[0].bot = difficulty;
|
|
g.players[1].forces = { [`${land[0].territory}:${land[0].sector}`]: 3 };
|
|
g.players[1].reserves = 17;
|
|
const action = botActions(viewGame(g, 'f'))[0];
|
|
assert.ok(applyAction(g, 'f', action));
|
|
if (difficulty !== 'Easy') assert.equal(action.card, thumper.id);
|
|
g.sandtrout = true;
|
|
assert.notEqual(botActions(viewGame(g, 'f'))[0]?.card, thumper.id);
|
|
}
|
|
});
|