Initial commit

This commit is contained in:
Quintine committed 2026-09-08 07:37:52 +10:00
commit 8c709fb52f
771 files changed
+222983

No files matched your search

+282
View File
@@ -0,0 +1,282 @@
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);
});