231 lines
10 KiB
TypeScript
231 lines
10 KiB
TypeScript
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);
|
||
});
|