93 lines
5.4 KiB
TypeScript
93 lines
5.4 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import { actKullPurpose, kullPurposeClean, kullPurposePlayer, nextKullPurposeStep,
|
|
type KullPurposeStep } from './fixture-kull-purpose';
|
|
|
|
export const act = actKullPurpose;
|
|
export const player = kullPurposePlayer;
|
|
export const clean = kullPurposeClean;
|
|
|
|
/** Includes both decks and all visible physical card zones used here. */
|
|
export function inventory(game: Game): string[] {
|
|
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
|
|
...(game.richeseRemoved ?? []), ...game.players.flatMap(p => p.hand),
|
|
...(game.auction?.cards.slice(game.auction.index) ?? [])].map(card => card.id).sort();
|
|
assert.equal(new Set(cards).size,cards.length,'Each original Treachery card occupies one physical zone');
|
|
return cards;
|
|
}
|
|
|
|
/** Native choices only. The original Richese cache auction is deliberately
|
|
* Silent: Richese pays one, every other eligible player submits zero. */
|
|
function next(game: Game): KullPurposeStep | null {
|
|
const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction;
|
|
if (pending && reaction?.stage === 'offer') {
|
|
const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
|
|
return {actor:actor.id,action:{type:'semutaPass',event:pending.batch.event}};
|
|
}
|
|
if (!game.phaseOpening && !game.response && game.decision?.kind === 'richeseCache')
|
|
return {actor:game.decision.player,action:{type:'decision',event:game.richeseBidding!.event,
|
|
card:SEMUTA_DRUG_ID,method:'silent'}};
|
|
const lot = game.richeseAuction;
|
|
if (lot && !lot.outcome && !game.phaseOpening && !game.response && !game.decision) {
|
|
assert.equal(lot.method,'silent');
|
|
const actor = lot.eligible.find(id => !Object.hasOwn(lot.sealed,id)); assert.ok(actor);
|
|
return {actor,action:{type:'richeseBid',event:lot.event,amount:actor === 'richese' ? 1 : 0,allyPayment:0}};
|
|
}
|
|
return nextKullPurposeStep(game);
|
|
}
|
|
export function advance(game: Game, until: (state: Game) => boolean, actions: KullPurposeStep[] = []): Game {
|
|
for (let count=0;count<1800;count++) {
|
|
if (until(game)) return game;
|
|
assert.notEqual(game.status,'finished','Original Kull/Semuta programme ended before its requested window');
|
|
const step=next(game); assert.ok(step,'Resolve the current native choice before continuing');
|
|
game=act(game,step.actor,step.action,actions);
|
|
}
|
|
throw Error('Original Kull/Semuta programme did not reach the requested window');
|
|
}
|
|
|
|
/** Public Basic opt-in. Original card order and a safe unopened Spice position
|
|
* are selected before use; Richese actually buys Semuta in its native auction.
|
|
* No played hand, spice, board force, pending event or phase is installed. */
|
|
export function createKullRicheseSemutaFixture() {
|
|
let game=createGame('KULLSEMUTA',newPlayer('choam','CHOAM','choam'),false,['choam','ix']);
|
|
for (const id of ['richese','emperor','harkonnen'] as const) joinGame(game,newPlayer(id,id,id));
|
|
const actions:KullPurposeStep[]=[];
|
|
const send=(actor:string,action:Action)=>{game=act(game,actor,action,actions);};
|
|
const reach=(until:(state:Game)=>boolean)=>{game=advance(game,until,actions);};
|
|
send('choam',{type:'moduleOption',module:'kullPreview',enabled:true});
|
|
for (const p of game.players) send(p.id,{type:'ready'});
|
|
const lobby=structuredClone(game);
|
|
send('choam',{type:'start'});
|
|
assert.equal(game.status,'setup');
|
|
assert.equal(game.kullPreview,true);
|
|
assert.equal(game.semutaPreview,true,'Native Richese must retain its Semuta discard opportunity');
|
|
assert.ok(game.players.every(p=>p.hand.length===0));
|
|
const originalCards=inventory(game), selected:Game['deck']=[];
|
|
for (const kind of ['kull','filler','karama','karama'] as const) {
|
|
const index=game.deck.findIndex(card=>kind==='kull' ? card.id==='ix-kull-wahad' :
|
|
kind==='karama' ? card.effect==='karama' : card.kind==='worthless' && card.id!=='ix-kull-wahad');
|
|
assert.ok(index>=0,`Missing unopened ${kind}`); selected.push(game.deck.splice(index,1)[0]);
|
|
}
|
|
game.deck.unshift(...selected);
|
|
reach(state=>state.status==='playing');
|
|
const [kull,,karama,counter]=selected.map(card=>card.id);
|
|
assert.ok(player(game,'choam').hand.some(card=>card.id===kull));
|
|
assert.ok(player(game,'emperor').hand.some(card=>card.id===karama));
|
|
assert.ok(player(game,'harkonnen').hand.some(card=>card.id===counter));
|
|
assert.ok(game.richeseCache?.some(card=>card.id===SEMUTA_DRUG_ID));
|
|
assert.deepEqual(inventory(game),originalCards);
|
|
const index=game.spiceDeck.findIndex(card=>'territory' in card && !card.discovery);
|
|
assert.ok(index>=0); game.spiceDeck.unshift(game.spiceDeck.splice(index,1)[0]);
|
|
reach(state=>player(state,'richese').hand.some(card=>card.id===SEMUTA_DRUG_ID) && clean(state));
|
|
const acquired=structuredClone(game);
|
|
assert.ok(actions.some(step=>step.actor==='richese' && step.action.type==='richeseBid' && step.action.amount===1));
|
|
reach(state=>state.phase===5 && state.active==='emperor' && clean(state));
|
|
assert.deepEqual(inventory(game),originalCards);
|
|
return {game,lobby,acquired,actions,originalCards,kull,karama,counter,semuta:SEMUTA_DRUG_ID,
|
|
actor:'emperor',reactor:'choam',counterOwner:'harkonnen',holder:'richese'};
|
|
}
|
|
export type KullRicheseSemutaFixture=ReturnType<typeof createKullRicheseSemutaFixture>;
|
|
export const attempt=(f:KullRicheseSemutaFixture):Action=>
|
|
({type:'card',mode:'shipment',card:f.karama,target:f.actor});
|