Files
Dune/tests/semuta-recruits-native-rosters.test.ts
T
Quintine 0f8e0b4153
Verify and publish checkpoint container / container (push) Successful in 10m7s
Enforce Atomics shipment ban across transport
2026-10-29 03:03:18 +11:00

114 lines
6.8 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { TERRITORIES } from '../game/board';
import { RECRUITS_CARD_ID } from '../game/recruits';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import { basicSemutaAmbassadorClean as clean, basicSemutaAmbassadorInventory as inventory,
basicSemutaAmbassadorPlayer as seat } from './fixture-basic-semuta-ambassador';
/** Public selected-deck setup, original auctions and actual Revival. Only the
* initial unopened ordinary/Spice order is selected to make this journey short. */
function nativeFixture(native: 'tleilaxu' | 'ecaz') {
let game = createGame('SEMUTANATIVERECRUITS', newPlayer('atreides', 'Atreides', 'atreides'), false,
['choam', 'ix', 'ecaz']);
for (const faction of ['richese', 'guild', native] as const)
joinGame(game, newPlayer(faction, faction, faction));
const owner = 'atreides', holder = 'guild';
const act = (actor: string, action: Action) => { game = applyAction(game, actor, action); };
const reach = (until: (state: Game) => boolean) => {
for (let i = 0; i < 1600; i++) {
if (until(game)) return;
assert.notEqual(game.status, 'finished');
const step = nextRicheseStrongholdsNativeStep(game);
assert.ok(step, `Native action required at ${game.turn}/${game.phase}/${game.decision?.kind}`);
act(step.actor, step.action);
}
throw Error('The native Semuta/Recruits game 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);
const original = inventory(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);
for (const [position, id] of [...starting, RECRUITS_CARD_ID].entries()) {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
}
for (let i = 0; game.status === 'setup' && i < 100; i++) {
const step = nextRicheseStrongholdsNativeStep(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 => clean(state) && !!state.richeseAuction && !state.richeseAuction.outcome);
for (let i = 0; !seat(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID) && i < 8; i++) {
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(clean);
}
assert.ok(seat(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
reach(state => state.phase === 3 && clean(state) && state.auction?.active === owner);
assert.equal(game.auction!.cards[game.auction!.index].id, RECRUITS_CARD_ID);
act(owner, {type:'bid', amount:1, allyPayment:0});
reach(state => clean(state) && seat(state, owner).hand.some(card => card.id === RECRUITS_CARD_ID));
reach(state => state.phase === 5 && state.active === owner && clean(state));
const sector = game.storm % 18 + 1;
const destination = TERRITORIES.find(place => place.type === 'sand' && place.sectors.includes(sector) &&
game.players.every(player => !Object.keys(player.forces).some(key => key.startsWith(`${place.id}:`))));
assert.ok(destination);
const location = `${destination.id}:${sector}`;
act(owner, {type:'ship', territory:destination.id, sector, amount:3, elite:0, allyPayment:0});
reach(state => state.turn === 2 && state.phase === 4 && clean(state));
assert.equal(seat(game, owner).forces[location], undefined);
assert.equal(seat(game, owner).tanks, 3);
assert.deepEqual(inventory(game), original);
return {game, owner, holder, original};
}
for (const native of ['tleilaxu','ecaz'] as const) for (const claim of [false,true]) {
void test(`Basic ${native} selected-family Recruits discard offers Semuta: ${claim ? 'claim' : 'pass'}`, () => {
const {game, owner, holder, original} = nativeFixture(native);
const pending = applyAction(game, owner, {type:'card', card:RECRUITS_CARD_ID});
const frame = pending.pendingTreacheryDiscard; assert.ok(frame);
assert.equal(frame.continuation.kind, 'ordinaryCardDiscard');
assert.equal(frame.reaction?.stage, 'offer');
assert.deepEqual(frame.batch.entries.map(entry => entry.card.id), [RECRUITS_CARD_ID]);
assert.equal(pending.recruits?.turn, pending.turn);
const event = frame.batch.event;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending, holder); view.players.find(player => player.id === holder)!.bot = difficulty;
assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass'));
}
let done = pending;
if (claim) {
done = applyAction(done, holder, {type:'semutaCommit', event});
if (done.pendingTreacheryDiscard?.reaction?.stage === 'select')
done = applyAction(done, holder, {type:'semutaSelect', event, card:RECRUITS_CARD_ID});
} else for (const player of pending.players) done = applyAction(done, player.id, {type:'semutaPass', event});
assert.equal(done.pendingTreacheryDiscard, null);
assert.equal(viewGame(done, owner).revival.freeRemaining, 4);
assert.equal(viewGame(done, owner).revival.limit, 7);
assert.equal(seat(done, holder).hand.some(card => card.id === RECRUITS_CARD_ID), claim);
assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
assert.deepEqual(inventory(done), original);
const tanks = seat(done, owner).tanks, reserves = seat(done, owner).reserves;
done = applyAction(done, owner, {type:'revive', amount:3, elite:0});
assert.equal(seat(done, owner).tanks, tanks - 3);
assert.equal(seat(done, owner).reserves, reserves + 3);
});
}