162 lines
8.5 KiB
TypeScript
162 lines
8.5 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, createGame, handLimit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { placeTerror } from '../game/moritani-terror';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import type { Card } from '../game/cards';
|
|
|
|
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
|
const seat = (game: Game, id: string) => {
|
|
const player = game.players.find(row => row.id === id);
|
|
assert.ok(player); return player;
|
|
};
|
|
const inventory = (game: Game) => [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
|
|
...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
|
|
const take = (game: Game, predicate: (card: Card) => boolean): Card => {
|
|
for (const zone of [game.deck, game.discard, game.richeseCache ?? []]) {
|
|
const index = zone.findIndex(predicate);
|
|
if (index >= 0) return zone.splice(index, 1)[0];
|
|
}
|
|
throw Error('An original physical card is unavailable.');
|
|
};
|
|
const card = (game: Game) => take(game, item => ['projectile','poison','shield','snooper','worthless'].includes(item.kind));
|
|
|
|
/** Original public Basic setup with independent Ecaz Treachery Cards. The
|
|
* later phase-five position, wallets, physical card hands and Terror placement
|
|
* are staged with conserved pieces; shipment and Terror choices are actions. */
|
|
function fixture(kind: 'sabotage' | 'robbery') {
|
|
let game = createGame('SEMUTANATIVETERROR', newPlayer('m','Moritani','moritani'), false, ['choam','ecaz']);
|
|
joinGame(game, newPlayer('r','Richese','richese'));
|
|
joinGame(game, newPlayer('a','Atreides','atreides'));
|
|
game = act(game,'m',{type:'moduleOption',module:'ecazTreachery',enabled:true});
|
|
for (const player of game.players) game = act(game,player.id,{type:'ready'});
|
|
game = act(game,'m',{type:'start'});
|
|
assert.equal(game.status,'setup');
|
|
assert.equal(game.semutaRecruitsPreview,true);
|
|
for (let count = 0; game.status === 'setup' && count < 160; count++) {
|
|
let next: {id:string; action:Action} | undefined;
|
|
for (const player of game.players) {
|
|
const view = viewGame(game,player.id);
|
|
view.players.find(row => row.id === player.id)!.bot = 'Medium';
|
|
const action = botActions(view)[0];
|
|
if (action) {next = {id:player.id,action}; break;}
|
|
}
|
|
assert.ok(next, 'The original setup must have a legal action.');
|
|
game = act(game,next.id,next.action);
|
|
}
|
|
assert.equal(game.status,'playing');
|
|
assert.equal(game.ecazTreachery,true);
|
|
assert.equal(game.semutaPreview,undefined);
|
|
const original = inventory(game);
|
|
game.deck.push(...game.players.flatMap(player => player.hand));
|
|
for (const player of game.players) {
|
|
player.hand = [];
|
|
player.reserves += player.tanks + Object.values(player.forces).reduce((sum,n) => sum+n,0);
|
|
player.forces = {}; player.tanks = 0; player.spice = 20;
|
|
player.shipped = false; player.moved = 0;
|
|
assert.equal(player.reserves,20);
|
|
}
|
|
const semuta = take(game,item => item.id === SEMUTA_DRUG_ID);
|
|
seat(game,'r').hand.push(semuta);
|
|
const gift = card(game);
|
|
if (kind === 'sabotage') {
|
|
seat(game,'m').hand.push(gift);
|
|
seat(game,'a').hand.push(card(game),card(game));
|
|
} else {
|
|
game.deck.push(gift);
|
|
for (let count = 0; count < 4; count++) seat(game,'m').hand.push(card(game));
|
|
}
|
|
const token = game.moritaniTerror!.tokens.find(row => row.kind === kind); assert.ok(token);
|
|
game.moritaniTerror = placeTerror(game.moritaniTerror!,token.id,'carthag',1);
|
|
Object.assign(game,{turn:2,phase:5,storm:18,active:'a',order:['a','m','r'],
|
|
movementRemaining:['a','m','r'],ready:[],response:null,decision:null,phaseOpening:null});
|
|
assert.deepEqual(inventory(game),original);
|
|
game = act(game,'a',{type:'ship',territory:'carthag',sector:11,amount:2,elite:0,allyPayment:0});
|
|
assert.equal(game.pendingTerrorEntry?.stage,'offer');
|
|
game = act(game,'m',{type:'decision',reveal:true});
|
|
if (kind === 'robbery') {
|
|
assert.equal(game.pendingTerrorEntry?.stage,'robbery');
|
|
game = act(game,'m',{type:'decision',choice:'card'});
|
|
assert.equal(game.pendingTerrorEntry?.stage,'discard');
|
|
game = act(game,'m',{type:'decision',card:seat(game,'m').hand[0].id});
|
|
}
|
|
return {game, original, token:token.id, gift:gift.id, semuta:semuta.id};
|
|
}
|
|
|
|
function pass(game: Game): Game {
|
|
const event = game.pendingTreacheryDiscard!.batch.event;
|
|
for (const player of game.players) game = act(game,player.id,{type:'semutaPass',event});
|
|
return game;
|
|
}
|
|
function claim(game: Game, owner: string, selected: string): Game {
|
|
const event = game.pendingTreacheryDiscard!.batch.event;
|
|
game = act(game,owner,{type:'semutaCommit',event});
|
|
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
|
game = act(game,owner,{type:'semutaSelect',event,card:selected});
|
|
return game;
|
|
}
|
|
|
|
for (const kind of ['sabotage','robbery'] as const) for (const claimed of [false,true]) {
|
|
void test(`Basic independent cards: ${kind} private discard offers Semuta ${claimed ? 'claim' : 'pass'}`, () => {
|
|
const f = fixture(kind), pending = f.game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
|
assert.equal(pending.continuation.kind,'terrorDiscard');
|
|
assert.equal(pending.batch.cause,kind === 'sabotage' ? 'terror:sabotage' : 'terror:robberyOverflow');
|
|
assert.equal(pending.batch.entries.length,1);
|
|
assert.equal(pending.batch.entries[0].publicFace,false);
|
|
assert.equal(pending.batch.entries[0].discardedBy,kind === 'sabotage' ? 'a' : 'm');
|
|
const target = pending.batch.entries[0].card.id, event = pending.batch.event;
|
|
assert.deepEqual(viewGame(f.game,'r').semutaReaction?.candidates,[]);
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(f.game,'r'); view.players.find(player => player.id === 'r')!.bot = difficulty;
|
|
assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass'));
|
|
}
|
|
let game = claimed ? claim(f.game,'r',target) : pass(f.game);
|
|
assert.equal(seat(game,'r').hand.some(item => item.id === target),claimed);
|
|
assert.equal(game.discard.some(item => item.id === f.semuta),claimed);
|
|
assert.equal(game.moritaniTerror!.tokens.find(row => row.id === f.token)!.status,'removed');
|
|
assert.throws(() => act(game,'r',{type:'semutaCommit',event}));
|
|
if (kind === 'sabotage') {
|
|
assert.equal(game.pendingTerrorEntry?.stage,'gift');
|
|
game = act(game,'m',{type:'decision',card:f.gift});
|
|
assert.ok(seat(game,'a').hand.some(item => item.id === f.gift));
|
|
}
|
|
assert.equal(game.pendingTerrorEntry ?? null,null);
|
|
assert.equal(seat(game,'a').forces['carthag:11'],2);
|
|
assert.equal(seat(game,'a').reserves,18);
|
|
assert.equal(seat(game,'a').shipped,true);
|
|
assert.deepEqual(inventory(game),f.original);
|
|
});
|
|
}
|
|
|
|
for (const keepGift of [false,true]) for (const claimed of [false,true]) {
|
|
void test(`Basic independent cards: Sabotage gift overflow discards ${keepGift ? 'an existing card' : 'the gift'} and Semuta ${claimed ? 'claims' : 'passes'}`, () => {
|
|
const f = fixture('sabotage');
|
|
let game = pass(f.game);
|
|
assert.equal(game.pendingTerrorEntry?.stage,'gift');
|
|
// Stage the exceptional intervening full hand with original physical cards.
|
|
while (seat(game,'a').hand.length < handLimit(seat(game,'a')))
|
|
seat(game,'a').hand.push(card(game));
|
|
assert.deepEqual(inventory(game),f.original);
|
|
const existing = seat(game,'a').hand[0].id;
|
|
game = act(game,'m',{type:'decision',card:f.gift});
|
|
assert.equal(game.pendingTerrorEntry?.stage,'giftDiscard');
|
|
assert.equal(game.decision?.player,'a');
|
|
const selected = keepGift ? existing : f.gift;
|
|
game = act(game,'a',{type:'decision',card:selected});
|
|
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
|
assert.equal(pending.batch.cause,'terror:sabotageGiftOverflow');
|
|
assert.deepEqual(pending.batch.entries.map(entry => [entry.card.id,entry.discardedBy,entry.publicFace]),
|
|
[[selected,'a',false]]);
|
|
assert.deepEqual(viewGame(game,'r').semutaReaction?.candidates,[]);
|
|
game = claimed ? claim(game,'r',selected) : pass(game);
|
|
assert.equal(seat(game,'r').hand.some(item => item.id === selected),claimed);
|
|
assert.equal(seat(game,'a').hand.some(item => item.id === f.gift),keepGift);
|
|
assert.equal(game.pendingTerrorEntry ?? null,null);
|
|
assert.equal(game.moritaniTerror!.tokens.find(row => row.id === f.token)!.status,'removed');
|
|
assert.equal(seat(game,'a').forces['carthag:11'],2);
|
|
assert.deepEqual(inventory(game),f.original);
|
|
});
|
|
}
|