Files
Dune/tests/coalition-fremen-worm.test.ts
T
Quintine 40c9651b80
Verify and publish checkpoint container / container (push) Successful in 10m58s
Support group Fremen worm protection
2026-10-29 00:05:12 +11:00

231 lines
10 KiB
TypeScript
Raw 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 assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { baseDeck, spiceDeck } from '../game/cards';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { botActions } from '../game/bots';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' :
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true};
},
});
const {GameTable} = await import('../components/game-table');
aliases.deregister();
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
const key = 'red_chasm:7';
/** The option is not exposed in a lobby. Stage a Basic Nexus, form the pair
* and group through actual actions, then place conserved forces and the next
* real worm card at a staged Spice Blow. */
function source(group = true, fremenPresent = false) {
let game = createGame('GROUPWORM', newPlayer('f', 'Fremen', 'fremen'), false);
game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('a', 'Atreides', 'atreides'),
newPlayer('h', 'Harkonnen', 'harkonnen'));
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true,
order:['f','e','a','h'], ready:[], response:null, decision:null,
phaseOpening:null, storm:18, deck:baseDeck()});
game.coalitionState = emptyCoalitionState();
game = act(game, 'f', {type:'alliance', target:'e'});
game = act(game, 'e', {type:'alliance', target:'f'});
if (group) {
const pair = coalitionOf(game.players, game.coalitionState, 'f'); assert.ok(pair);
game = act(game, 'f', {type:'coalitionPropose', groupId:pair.id, entrant:'a',
expectedRevision:game.coalitionState!.revision});
for (const id of ['e','a']) {
const proposal = game.coalitionState!.proposal!;
game = act(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus,
turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision});
}
}
Object.assign(game, {phase:1, turn:3, nexus:false, ready:[], response:null, decision:null,
phaseOpening:null, storm:18, active:null, stormPending:null});
for (const player of game.players) {
player.hand = []; player.forces = {}; player.reserves = 20; player.tanks = 0;
player.traitors = []; player.traitorChoices = [];
}
for (const id of ['e','a','h', ...(fremenPresent ? ['f'] : [])]) {
seat(game,id).forces = {[key]:3}; seat(game,id).reserves = 17;
}
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const [karama] = game.deck.splice(index,1); seat(game,'h').hand.push(karama);
const spice = spiceDeck();
const land = spice.find(card => 'territory' in card && card.territory === 'red_chasm');
const worm = spice.find(card => 'worm' in card);
assert.ok(land && worm);
game.spiceDiscard = [[land], []];
game.spiceDeck = [worm, ...spice.filter(card => card !== land && card !== worm && 'territory' in card)];
game.spice = {[key]:8};
for (const player of game.players) game = act(game, player.id, {type:'ready'});
assert.equal(game.decision?.kind,'wormProtection');
return {game, karama};
}
function pass(game: Game, kind: string): Game {
for (let n = 0; game.response?.kind === kind && n < 24; n++) {
const next = game.players.find(player => !game.response!.passed.includes(player.id));
assert.ok(next);
game = act(game, next.id, {type:'passResponse'});
}
assert.notEqual(game.response?.kind,kind);
return game;
}
function conserved(game: Game) {
for (const player of game.players)
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((a,b) => a+b,0),20);
}
function chooseGroupProtection(game: Game, accept: boolean): Game {
assert.equal(game.decision?.kind,'wormProtection');
assert.ok(game.decision.event);
return act(game,'f',{type:'decision',accept,event:game.decision.event,ally:game.decision.ally});
}
for (const protectedIds of [[], ['a'], ['e'], ['a','e']] as const)
void test(`Fremen may protect exactly ${protectedIds.length ? protectedIds.join(' and ') : 'neither group ally'} from one worm`, () => {
let {game} = source();
const seen: string[] = [];
while (game.decision?.kind === 'wormProtection') {
const ally = game.decision.ally; seen.push(ally);
game = chooseGroupProtection(game,protectedIds.includes(ally as never));
game = pass(game,'wormAllyProtection');
}
assert.deepEqual(seen.sort(),['a','e']);
for (const ally of ['a','e']) {
assert.equal(seat(game,ally).forces[key] ?? 0, protectedIds.includes(ally as never) ? 3 : 0);
assert.equal(seat(game,ally).tanks, protectedIds.includes(ally as never) ? 0 : 3);
}
assert.equal(seat(game,'h').tanks,3);
assert.equal(game.spice[key],undefined);
assert.equal(game.nexus,true);
conserved(game);
});
void test('Karama prevents only the selected group ally protection and the other can still be protected', () => {
const initial = source();
let {game} = initial;
const {karama} = initial;
assert.equal(game.decision?.kind,'wormProtection');
const denied = game.decision.ally;
game = chooseGroupProtection(game,true);
assert.equal(game.response?.kind,'wormAllyProtection');
assert.equal(game.response?.recipient,denied);
game = act(game,'h',{type:'card',mode:'cancel',card:karama.id});
game = pass(game,'wormAllyProtection');
assert.equal(game.decision?.kind,'wormProtection');
const allowed = game.decision.ally;
assert.notEqual(allowed,denied);
game = chooseGroupProtection(game,true);
game = pass(game,'wormAllyProtection');
assert.equal(seat(game,denied).tanks,3);
assert.equal(seat(game,allowed).forces[key],3);
assert.equal(game.discard.filter(card => card.id === karama.id).length,1);
assert.equal(game.nexus,true);
conserved(game);
});
void test('Fremen survival remains a separate response after protecting both group allies', () => {
let {game} = source(true,true);
while (game.decision?.kind === 'wormProtection') {
game = chooseGroupProtection(game,true);
game = pass(game,'wormAllyProtection');
}
assert.equal(game.response?.kind,'wormSurvival');
game = pass(game,'wormSurvival');
for (const id of ['f','a','e']) assert.equal(seat(game,id).forces[key],3);
assert.equal(seat(game,'h').tanks,3);
assert.deepEqual(game.wormRides,['red_chasm']);
conserved(game);
});
void test('Karama cancels Fremen survival without undoing either group ally protection', () => {
const initial = source(true,true);
let {game} = initial;
const {karama} = initial;
while (game.decision?.kind === 'wormProtection') {
game = chooseGroupProtection(game,true);
game = pass(game,'wormAllyProtection');
}
assert.equal(game.response?.kind,'wormSurvival');
game = act(game,'h',{type:'card',mode:'cancel',card:karama.id});
game = pass(game,'wormSurvival');
assert.equal(seat(game,'f').forces[key] ?? 0,0);
assert.equal(seat(game,'f').tanks,3);
for (const id of ['a','e']) {
assert.equal(seat(game,id).forces[key],3);
assert.equal(seat(game,id).tanks,0);
}
assert.equal(seat(game,'h').tanks,3);
assert.equal(game.discard.filter(card => card.id === karama.id).length,1);
assert.equal(game.spice[key],undefined);
assert.deepEqual(game.wormRides,[]);
assert.equal(game.nexus,true);
conserved(game);
});
void test('the Fremen table names the current threatened ally and offers both protection choices', () => {
const {game} = source();
assert.equal(game.decision?.kind,'wormProtection');
const name = seat(game,game.decision.ally).name;
const html = renderToStaticMarkup(createElement(GameTable,{
game:viewGame(game,'f'), busy:false, send:async () => {}, onExit() {},
}));
assert.match(html, new RegExp(`ally ${name}’s forces`));
assert.match(html, /Protect my ally/);
assert.match(html, /Do not protect/);
});
void test('a stale group protection choice cannot answer the next ally', () => {
let {game} = source();
assert.equal(game.decision?.kind,'wormProtection');
const first = {type:'decision',accept:false,event:game.decision.event,ally:game.decision.ally} as Action;
game = act(game,'f',first);
assert.equal(game.decision?.kind,'wormProtection');
assert.notEqual(game.decision.ally,first.ally);
const before = structuredClone(game);
assert.throws(() => act(game,'f',first));
assert.deepEqual(game,before);
game = chooseGroupProtection(game,false);
assert.equal(game.nexus,true);
conserved(game);
});
void test('the original pair keeps one Fremen ally choice', () => {
let {game} = source(false);
assert.equal(game.decision?.kind,'wormProtection');
assert.equal(game.decision.ally,'e');
game = act(game,'f',{type:'decision',accept:true});
game = pass(game,'wormAllyProtection');
assert.equal(seat(game,'e').forces[key],3);
assert.equal(seat(game,'a').tanks,3);
assert.equal(seat(game,'h').tanks,3);
conserved(game);
});
void test('a Medium Fremen bot can make each legal group protection choice', () => {
let {game} = source();
for (let n = 0; n < 2; n++) {
assert.equal(game.decision?.kind,'wormProtection');
const view = viewGame(game,'f');
view.players.find(player => player.id === 'f')!.bot = 'Medium';
const decision = botActions(view).find(action => action.type === 'decision' && typeof action.accept === 'boolean');
assert.ok(decision);
game = act(game,'f',decision);
game = pass(game,'wormAllyProtection');
}
assert.equal(game.nexus,true);
conserved(game);
});