147 lines
7.9 KiB
TypeScript
147 lines
7.9 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { baseDeck, spiceDeck } from '../game/cards';
|
|
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
|
|
|
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 location = 'red_chasm:7';
|
|
|
|
/** The unlimited ledger, Nexus and later Spice Blow are staged. All pair and
|
|
* group admissions, the worm draw and its choices/responses are actual engine
|
|
* actions. Physical forces and the actual Karama cards are conserved. */
|
|
function source(entrantIds: readonly string[], fremenPresent = false) {
|
|
let game = createGame('UNLIMITEDWORM', newPlayer('f', 'Fremen', 'fremen'), false);
|
|
game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('a', 'Atreides', 'atreides'),
|
|
newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('g', 'Guild', 'guild'),
|
|
newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
|
|
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true,
|
|
order:['f','e','a','h','g','b'], ready:[], response:null, decision:null,
|
|
phaseOpening:null, storm:18, deck:baseDeck()});
|
|
game.coalitionState = emptyCoalitionState('unlimited');
|
|
game = act(game, 'f', {type:'alliance', target:'e'});
|
|
game = act(game, 'e', {type:'alliance', target:'f'});
|
|
for (const entrant of entrantIds) {
|
|
const group = coalitionOf(game.players, game.coalitionState, 'f'); assert.ok(group);
|
|
game = act(game, 'f', {type:'coalitionPropose', groupId:group.id, entrant,
|
|
expectedRevision:game.coalitionState!.revision});
|
|
for (const id of [...group.members.filter(member => member !== 'f'), entrant]) {
|
|
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 = [];
|
|
}
|
|
const group = coalitionOf(game.players, game.coalitionState, 'f'); assert.ok(group);
|
|
const outsider = game.players.find(player => !group.members.includes(player.id)); assert.ok(outsider);
|
|
for (const id of [...group.members.filter(member => member !== 'f'), outsider.id,
|
|
...(fremenPresent ? ['f'] : [])]) {
|
|
seat(game,id).forces = {[location]: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); outsider.hand.push(karama);
|
|
// Keep a distinct physical counter available for the later survival
|
|
// response after the outsider spends the first Karama on one ally.
|
|
let survivalKarama: typeof karama | undefined;
|
|
if (fremenPresent) {
|
|
const counter = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(counter >= 0);
|
|
[survivalKarama] = game.deck.splice(counter,1);
|
|
seat(game,'b').hand.push(survivalKarama);
|
|
}
|
|
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 = {[location]:8};
|
|
for (const player of game.players) game = act(game, player.id, {type:'ready'});
|
|
assert.equal(game.decision?.kind,'wormProtection');
|
|
return {game, karama, survivalKarama, outsider:outsider.id};
|
|
}
|
|
|
|
function pass(game: Game, kind: string): Game {
|
|
for (let count = 0; game.response?.kind === kind && count < 32; count++) {
|
|
const actor = game.players.find(player => !game.response!.passed.includes(player.id));
|
|
assert.ok(actor); game = act(game, actor.id, {type:'passResponse'});
|
|
}
|
|
assert.notEqual(game.response?.kind, kind);
|
|
return game;
|
|
}
|
|
function choose(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});
|
|
}
|
|
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);
|
|
}
|
|
|
|
void test('four-member group resolves three distinct worm protections before Fremen survival', () => {
|
|
const initial = source(['a','h'], true);
|
|
let {game} = initial;
|
|
assert.deepEqual(game.coalitionWormProtection?.order,['e','a','h']);
|
|
assert.equal(game.decision?.kind,'wormProtection'); assert.equal(game.decision.ally,'e');
|
|
game = pass(choose(game,true),'wormAllyProtection');
|
|
assert.equal(game.decision?.kind,'wormProtection'); assert.equal(game.decision.ally,'a');
|
|
game = choose(game,true);
|
|
assert.equal(game.response?.kind,'wormAllyProtection'); assert.equal(game.response.recipient,'a');
|
|
game = act(game, initial.outsider, {type:'card',mode:'cancel',card:initial.karama.id});
|
|
game = pass(game,'wormAllyProtection');
|
|
assert.equal(game.decision?.kind,'wormProtection'); assert.equal(game.decision.ally,'h');
|
|
game = choose(game,false);
|
|
assert.equal(game.response?.kind,'wormSurvival');
|
|
assert.deepEqual(game.coalitionWormProtection?.outcomes,[
|
|
{ally:'e',outcome:'allowed'}, {ally:'a',outcome:'canceled'}, {ally:'h',outcome:'declined'},
|
|
]);
|
|
game = pass(game,'wormSurvival');
|
|
for (const id of ['e','f']) assert.equal(seat(game,id).forces[location],3);
|
|
for (const id of ['a','h',initial.outsider]) {
|
|
assert.equal(seat(game,id).forces[location] ?? 0,0); assert.equal(seat(game,id).tanks,3);
|
|
}
|
|
assert.equal(game.discard.filter(card => card.id === initial.karama.id).length,1);
|
|
assert.ok(initial.survivalKarama && seat(game,'b').hand.some(card => card.id === initial.survivalKarama!.id));
|
|
assert.equal(game.spice[location],undefined);
|
|
assert.deepEqual(game.wormRides,['red_chasm']);
|
|
assert.equal(game.coalitionWormProtection,undefined);
|
|
conserved(game);
|
|
});
|
|
|
|
void test('five-member group offers every threatened ally in order with legal bot choices', () => {
|
|
const initial = source(['a','h','g']);
|
|
let game = initial.game;
|
|
const {outsider} = initial;
|
|
assert.deepEqual(game.coalitionWormProtection?.order,['e','a','h','g']);
|
|
const seen:string[] = [];
|
|
let first:Action | undefined;
|
|
while (game.decision?.kind === 'wormProtection') {
|
|
const {ally,event} = game.decision; assert.ok(event); seen.push(ally);
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(game,'f');
|
|
view.players.find(player => player.id === 'f')!.bot = difficulty;
|
|
const action = botActions(view).find(candidate => candidate.type === 'decision' && typeof candidate.accept === 'boolean');
|
|
assert.ok(action, difficulty); assert.equal(action.event,event); assert.equal(action.ally,ally);
|
|
assert.doesNotThrow(() => act(game,'f',action));
|
|
}
|
|
if (first) {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => act(game,'f',first!)); assert.deepEqual(game,before);
|
|
} else first = {type:'decision',accept:false,event,ally};
|
|
game = choose(game,ally === 'g');
|
|
game = pass(game,'wormAllyProtection');
|
|
}
|
|
assert.deepEqual(seen,['e','a','h','g']);
|
|
assert.equal(seat(game,'g').forces[location],3);
|
|
for (const id of ['e','a','h',outsider]) assert.equal(seat(game,id).tanks,3);
|
|
assert.equal(game.spice[location],undefined);
|
|
assert.equal(game.nexus,true);
|
|
conserved(game);
|
|
});
|