116 lines
7.4 KiB
TypeScript
116 lines
7.4 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
|
import { TERRITORIES } from '../game/board';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
|
import {
|
|
actBasicSemutaAmbassador, basicSemutaAmbassadorClean,
|
|
basicSemutaAmbassadorInventory, basicSemutaAmbassadorPlayer,
|
|
type BasicSemutaAmbassadorStep,
|
|
} from './fixture-basic-semuta-ambassador';
|
|
|
|
export const actIndependentOrdinary = actBasicSemutaAmbassador;
|
|
export const independentOrdinaryInventory = basicSemutaAmbassadorInventory;
|
|
export const independentOrdinaryPlayer = basicSemutaAmbassadorPlayer;
|
|
export const independentOrdinaryClean = basicSemutaAmbassadorClean;
|
|
|
|
function next(game: Game): BasicSemutaAmbassadorStep | null {
|
|
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
|
|
const reaction = game.pendingTreacheryDiscard.reaction;
|
|
const actor = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(actor);
|
|
return { actor: actor.id, action: {type: 'semutaPass', event: game.pendingTreacheryDiscard.batch.event} };
|
|
}
|
|
return nextRicheseStrongholdsNativeStep(game);
|
|
}
|
|
|
|
/** Continue the original phase using native player actions, never a staged jump. */
|
|
export function advanceIndependentOrdinary(game: Game, until: (state: Game) => boolean): Game {
|
|
for (let count = 0; count < 1600; count++) {
|
|
if (until(game)) return game;
|
|
const step = next(game); assert.ok(step, `Native continuation required at ${game.turn}/${game.phase}`);
|
|
game = actIndependentOrdinary(game, step.actor, step.action);
|
|
}
|
|
throw Error('The original Semuta phase did not reach its requested boundary.');
|
|
}
|
|
|
|
/** Genuine public setup and auctions: only unopened deal and Spice order are
|
|
* selected; Guild buys Semuta and Atreides buys the relevant physical card. */
|
|
export function createIndependentOrdinaryFixture(effect: 'hajr' | 'ghola' | 'weather' | 'harvester' | 'atomics') {
|
|
let game = createGame('SEMUTARECRUITS', newPlayer('atreides', 'Atreides', 'atreides'), false, ['choam']);
|
|
for (const faction of ['emperor', 'richese', 'guild'] as const)
|
|
joinGame(game, newPlayer(faction, faction, faction));
|
|
const owner = 'atreides', holder = 'guild', actions: BasicSemutaAmbassadorStep[] = [];
|
|
const act = (actor: string, action: Action) => { game = actIndependentOrdinary(game, actor, action, actions); };
|
|
const reach = (until: (state: Game) => boolean) => {
|
|
for (let count = 0; count < 1600; count++) {
|
|
if (until(game)) return;
|
|
assert.notEqual(game.status, 'finished');
|
|
const step = next(game); assert.ok(step, `Native action required at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
|
act(step.actor, step.action);
|
|
}
|
|
throw Error('The original Semuta source did not reach its requested boundary.');
|
|
};
|
|
act(owner, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
|
|
for (const player of game.players) act(player.id, { type: 'ready' });
|
|
act(owner, { type: 'start' });
|
|
assert.equal(game.status, 'setup'); assert.equal(game.semutaRecruitsPreview, true);
|
|
assert.ok(game.ecazTreachery && !game.techTokens && !game.leaderSkills && !game.nexusCards && !game.homeworlds);
|
|
const originalInventory = independentOrdinaryInventory(game);
|
|
const starting = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind))
|
|
.slice(0, game.players.length).map(card => card.id);
|
|
assert.equal(starting.length, game.players.length);
|
|
const card = game.deck.find(item => item.effect === effect); assert.ok(card);
|
|
const selectedDeal = [...starting, card.id];
|
|
for (let position = 0; position < selectedDeal.length; position++) {
|
|
const from = game.deck.findIndex(card => card.id === selectedDeal[position]); assert.ok(from >= position);
|
|
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
|
}
|
|
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
|
const step = next(game); assert.ok(step); act(step.actor, step.action);
|
|
}
|
|
assert.equal(game.status, 'playing');
|
|
for (let position = 0; position < 4; position++) {
|
|
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
|
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
|
}
|
|
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache');
|
|
act('richese', { type: 'decision', event: game.richeseBidding!.event, card: SEMUTA_DRUG_ID,
|
|
method: 'onceAround', direction: 'counterclockwise' });
|
|
reach(state => independentOrdinaryClean(state) && !!state.richeseAuction && !state.richeseAuction.outcome);
|
|
for (let count = 0; !independentOrdinaryPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID) && count < 8; count++) {
|
|
const lot = game.richeseAuction; assert.ok(lot && lot.active && !lot.outcome);
|
|
act(lot.active, { type: 'richeseBid', event: lot.event, amount: lot.active === holder ? 1 : null, allyPayment: 0 });
|
|
reach(independentOrdinaryClean);
|
|
}
|
|
assert.ok(independentOrdinaryPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
|
reach(state => state.phase === 3 && independentOrdinaryClean(state) && state.auction?.active === owner);
|
|
assert.equal(game.auction!.cards[game.auction!.index].id, card.id);
|
|
act(owner, { type: 'bid', amount: 1, allyPayment: 0 });
|
|
reach(state => independentOrdinaryClean(state) && independentOrdinaryPlayer(state, owner).hand.some(held => held.id === card.id));
|
|
reach(state => state.phase === 5 && state.active === owner && independentOrdinaryClean(state));
|
|
if (effect === 'hajr') return { game, owner, holder, card: card.id, originalInventory, actions,
|
|
play: {type: 'card', card: card.id} as Action };
|
|
const sector = effect === 'atomics' ? [8, 9].find(value => value !== game.storm)! : game.storm % 18 + 1;
|
|
const destination = TERRITORIES.find(place => (effect === 'atomics' ? place.id === 'shield_wall' : place.type === 'sand') &&
|
|
place.sectors.includes(sector) && game.players.every(player =>
|
|
!Object.keys(player.forces).some(key => key.startsWith(`${place.id}:`))));
|
|
assert.ok(destination, 'The next Storm path has an empty legal sand sector');
|
|
const casualtyLocation = `${destination.id}:${sector}`;
|
|
act(owner, { type: 'ship', territory: destination.id, sector, amount: 3, elite: 0, allyPayment: 0 });
|
|
if (effect === 'weather' || effect === 'atomics') {
|
|
reach(state => state.turn === 2 && state.phase === 0 && state.stormPending !== null && independentOrdinaryClean(state));
|
|
} else if (effect === 'harvester') {
|
|
reach(state => state.turn === 2 && state.phase === 1 && !!state.spiceWindow &&
|
|
!state.spiceWindow.harvested && independentOrdinaryClean(state));
|
|
} else {
|
|
reach(state => state.turn === 2 && state.phase === 4 && independentOrdinaryClean(state));
|
|
assert.equal(independentOrdinaryPlayer(game, owner).forces[casualtyLocation], undefined);
|
|
assert.equal(independentOrdinaryPlayer(game, owner).tanks, 3);
|
|
}
|
|
assert.deepEqual(independentOrdinaryInventory(game), originalInventory);
|
|
return { game, owner, holder, card: card.id, originalInventory, actions, casualtyLocation,
|
|
play: (effect === 'weather' ? {type: 'card', card: card.id, amount: 1}
|
|
: effect === 'ghola' ? {type: 'card', card: card.id, amount: 3, elite: 0}
|
|
: {type: 'card', card: card.id}) as Action };
|
|
}
|