Files
Dune/tests/semuta-discovery-skills-ambassador-worm.test.ts
T
Quintine 6204efb7e4
Verify and publish checkpoint container / container (push) Successful in 11m47s
Support Semuta on Discovery Skills Ambassador discards
2026-10-30 18:02:06 +11:00

109 lines
6.4 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { territory } from '../game/board';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { selectedDiscoverySkillsStart } from './selected-discovery-skills-fixture';
import { actBasicSemutaAmbassador as act, nextBasicSemutaAmbassadorStep,
basicSemutaAmbassadorPlayer as player, basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
const owner = 'ecaz', holder = 'guild', rider = 'fremen';
type Effect = 'choam' | 'ixians';
/** The original lobby and Skills setup, real paid Ambassador placement, Spice
* Blow, Nexus and Fremen ride. Conserved staging chooses the supplied token,
* transfers Semuta and one Fremen force, and orders the unopened Spice deck. */
function source(effect: Effect) {
let g = selectedDiscoverySkillsStart({advanced:false,
factions:[owner,'richese',rider,holder], expansions:['ecaz','choam']}).game;
assert.equal(g.semutaPreview,true);
assert.ok(g.discoveries && g.leaderSkills);
const packet = g.ecazAmbassadors!;
const selected = packet.tokens.find(token => token.effect === effect)!;
if (!packet.cohort.includes(selected.id)) {
const displaced = packet.tokens.find(token => token.id === packet.cohort[0])!;
assert.equal(displaced.zone,'supply');
packet.cohort[0] = selected.id;
displaced.zone = 'pool'; selected.zone = 'supply';
}
const guild = player(g,holder), semutaIndex = g.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID);
assert.ok(semutaIndex >= 0 && guild.hand.length);
const semuta = g.richeseCache!.splice(semutaIndex,1)[0];
g.deck.push(guild.hand[0]); guild.hand[0] = semuta;
const fremen = player(g,rider), sourceForce = Object.keys(fremen.forces).find(key => fremen.forces[key] > 0)!;
assert.ok(sourceForce);
fremen.forces[sourceForce]--;
if (!fremen.forces[sourceForce]) delete fremen.forces[sourceForce];
fremen.forces['the_great_flat:15'] = (fremen.forces['the_great_flat:15'] ?? 0) + 1;
for (const [position, predicate] of [
[0, (card: Game['spiceDeck'][number]) => 'territory' in card && card.territory === 'the_great_flat'],
[1, (card: Game['spiceDeck'][number]) => 'worm' in card && !card.greatMaker],
] as const) {
const index = g.spiceDeck.findIndex((card, i) => i >= position && predicate(card));
assert.ok(index >= position);
[g.spiceDeck[position],g.spiceDeck[index]] = [g.spiceDeck[index],g.spiceDeck[position]];
}
const reach = (condition: (game: Game) => boolean) => {
for (let n = 0; n < 2000; n++) {
if (condition(g)) return;
assert.notEqual(g.status,'finished');
const next = g.decision?.kind === 'richeseCache' ? nextRicheseStrongholdsNativeStep(g) : nextBasicSemutaAmbassadorStep(g);
assert.ok(next, `Missing action at ${g.status}/${g.turn}/${g.phase}/${g.decision?.kind}`);
g = act(g,next.actor,next.action);
}
throw Error('The mixed game did not reach the worm ride');
};
reach(state => state.phase === 4 && !state.response && state.decision?.kind === 'ecazPlacement');
const destination = ['carthag','arrakeen','habbanya_ridge_sietch','tueks_sietch']
.find(id => territory(id).sectors.some(sector => sector !== g.storm))!;
const sector = territory(destination).sectors.find(value => value !== g.storm)!;
g = act(g,owner,{type:'decision',token:selected.id,territory:destination});
reach(state => !state.response && state.decision?.kind === 'ecazPlacement');
g = act(g,owner,{type:'decision',decline:true});
reach(state => state.turn === 2 && state.decision?.kind === 'wormRide' && state.decision.player === rider);
assert.equal(g.decision?.kind === 'wormRide' && g.decision.territory,'the_great_flat');
assert.notEqual(g.storm,15);
g = act(g,rider,{type:'decision',accept:true,territory:destination,sector,forces:{'the_great_flat:15':1}});
assert.equal(g.pendingAmbassador?.arrivalCause,'wormRide');
assert.equal(g.pendingAmbassador?.resume,'wormRide');
const event = g.pendingAmbassador!.event;
g = act(g,owner,{type:'decision',event,trigger:true,beneficiary:owner,choice:'effect'});
assert.equal(g.pendingAmbassador?.stage,'cards');
const cards = player(g,owner).hand.slice(0,effect === 'choam' ? 2 : 1).map(card => card.id);
assert.ok(cards.length >= 1);
return {g,event,cards,destination,sector,original:inventory(g)};
}
for (const effect of ['choam','ixians'] as const) for (const claim of [false,true])
void test(`Basic Discovery and Skills: worm ride ${effect} Semuta ${claim ? 'claim' : 'pass'}`, () => {
const f = source(effect), before = structuredClone(f.g);
const spice = player(before,owner).spice, top = before.deck[0]?.id;
let g = act(before,owner,{type:'decision',event:f.event,cards:f.cards});
const pending = g.pendingTreacheryDiscard!;
assert.equal(pending?.continuation.kind,'ambassador');
assert.equal(pending?.reaction?.stage,'offer');
assert.equal(pending.batch.cause,`ambassador:${effect}`);
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id),f.cards);
assert.ok(pending.batch.entries.every(entry => entry.discardedBy === owner && !entry.publicFace));
assert.deepEqual(g.deck,before.deck);
assert.equal(player(g,owner).spice,spice + (effect === 'choam' ? 3*f.cards.length : 0));
assert.equal(player(g,rider).forces[`${f.destination}:${f.sector}`],1);
for (const p of g.players) assert.deepEqual(viewGame(g,p.id).semutaReaction!.candidates,[]);
const event = pending.batch.event;
if (claim) {
g = act(g,holder,{type:'semutaCommit',event});
if (g.pendingTreacheryDiscard?.reaction?.stage === 'select')
g = act(g,holder,{type:'semutaSelect',event,card:f.cards[0]});
} else for (const p of g.players) g = act(g,p.id,{type:'semutaPass',event});
assert.equal(g.pendingTreacheryDiscard,null);
assert.equal(g.pendingAmbassador,null);
assert.equal(player(g,rider).forces[`${f.destination}:${f.sector}`],1);
assert.equal(player(g,owner).spice,spice + (effect === 'choam' ? 3*f.cards.length : 0));
assert.equal(player(g,holder).hand.some(card => card.id === f.cards[0]),claim);
if (effect === 'ixians') assert.ok(player(g,owner).hand.some(card => card.id === top));
assert.deepEqual(inventory(g),f.original);
assert.throws(() => act(g,holder,{type:'semutaCommit',event}));
assert.notEqual(g.decision?.kind,'ecazAmbassador');
});