Connect Semuta to native faction discards
Verify and publish checkpoint container / container (push) Successful in 10m35s

This commit is contained in:
Quintine committed 2026-10-29 03:21:32 +11:00
1 parent 59579486a4
commit 87d1887139
7 files changed
+441 -13

No files matched your search

@@ -0,0 +1,118 @@
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 { placeAmbassador } from '../game/ecaz-ambassadors';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { basicSemutaAmbassadorInventory as inventory, nextBasicSemutaAmbassadorStep } from './fixture-basic-semuta-ambassador';
const seat = (game: Game, id: string) => {
const found = game.players.find(player => player.id === id); assert.ok(found); return found;
};
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
/** Original public selected-family setup and physical component inventory.
* The later board, wallets, selected Ambassador cohort/token and physical hand
* transfers are staged; shipment, trigger, discard and reactions are actions. */
function fixture(effect: 'choam' | 'ixians') {
let game = createGame('SEMUTAINDAMBASSADOR', newPlayer('ecaz','Ecaz','ecaz'), false,
['choam','ix','ecaz']);
for (const faction of ['richese','guild','emperor'] as const)
joinGame(game,newPlayer(faction,faction,faction));
game = act(game,'ecaz',{type:'moduleOption',module:'ecazTreachery',enabled:true});
for (const player of game.players) game = act(game,player.id,{type:'ready'});
game = act(game,'ecaz',{type:'start'});
assert.equal(game.status,'setup'); assert.equal(game.semutaRecruitsPreview,true);
for (let count = 0; game.status === 'setup' && count < 150; count++) {
const step = nextBasicSemutaAmbassadorStep(game); assert.ok(step);
game = act(game,step.actor,step.action);
}
assert.equal(game.status,'playing');
const original = inventory(game);
for (const player of game.players) {
game.deck.push(...player.hand); player.hand = [];
player.reserves += player.tanks + Object.values(player.forces).reduce((sum,n) => sum+n,0);
player.forces = {}; player.tanks = 0;
if (player.elites) {
player.elites.reserves += player.elites.tanks + Object.values(player.elites.forces).reduce((sum,n) => sum+n,0);
player.elites.forces = {}; player.elites.tanks = 0;
}
Object.assign(player,{spice:20,shipped:false,moved:0});
}
const take = (predicate: (card: Game['deck'][number]) => boolean) => {
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('The original physical card is unavailable.');
};
seat(game,'guild').hand.push(take(card => card.id === SEMUTA_DRUG_ID));
seat(game,'ecaz').hand.push(take(card => card.kind === 'projectile'),take(card => card.kind === 'shield'));
const state = structuredClone(game.ecazAmbassadors!);
const token = state.tokens.find(item => item.effect === effect); assert.ok(token);
if (!state.cohort.includes(token.id)) {
const replaced = state.tokens.find(item => item.id === state.cohort[0]); assert.ok(replaced);
replaced.zone = 'pool'; token.zone = 'supply'; state.cohort[0] = token.id;
}
game.ecazAmbassadors = placeAmbassador(state,token.id,{turn:1,availableSpice:20,
destination:{id:'arrakeen',stronghold:true,inStorm:false,allowed:true}}).state;
Object.assign(game,{turn:2,phase:5,storm:18,active:'emperor',order:['emperor','ecaz','richese','guild'],
movementRemaining:['emperor','ecaz','richese','guild'],ready:[],response:null,decision:null,
phaseOpening:null,stormPending:null,nexus:false});
assert.deepEqual(inventory(game),original);
game = act(game,'emperor',{type:'ship',territory:'arrakeen',sector:10,amount:1,elite:0,allyPayment:0});
for (let count=0;game.response && count<20;count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = act(game,actor.id,{type:'passResponse'});
}
assert.equal(game.pendingAmbassador?.stage,'offer');
const event = game.pendingAmbassador!.event;
game = act(game,'ecaz',{type:'decision',event,trigger:true,beneficiary:'ecaz',choice:'effect'});
assert.equal(game.pendingAmbassador?.stage,'cards');
const chosen = seat(game,'ecaz').hand.slice(0,effect === 'choam' ? 2 : 1).map(card => card.id);
assert.equal(chosen.length,effect === 'choam' ? 2 : 1);
const deckTop = game.deck[0]?.id; assert.ok(deckTop);
return {game,event,chosen,deckTop,original};
}
for (const effect of ['choam','ixians'] as const) for (const claim of [false,true]) {
void test(`Basic independent cards: ${effect} Ambassador discard offers Semuta ${claim ? 'claim' : 'pass'}`, () => {
const f = fixture(effect), before = structuredClone(f.game), spice = seat(f.game,'ecaz').spice;
let game = act(f.game,'ecaz',{type:'decision',event:f.event,cards:f.chosen});
assert.deepEqual(f.game,before);
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
assert.equal(pending.continuation.kind,'ambassador');
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id),f.chosen);
assert.ok(pending.batch.entries.every(entry => !entry.publicFace && entry.discardedBy === 'ecaz'));
assert.equal(seat(game,'ecaz').spice,spice + (effect === 'choam' ? 3*f.chosen.length : 0));
assert.deepEqual(game.deck,before.deck);
for (const player of game.players) assert.deepEqual(viewGame(game,player.id).semutaReaction?.candidates,[]);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game,'guild');
const owner = view.players.find(player => player.id === 'guild'); assert.ok(owner); owner.bot = difficulty;
assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass'));
}
const event = pending.batch.event;
if (claim) {
game = act(game,'guild',{type:'semutaCommit',event});
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
assert.deepEqual(viewGame(game,'guild').semutaReaction!.candidates.map(card => card.id),f.chosen);
assert.deepEqual(viewGame(game,'ecaz').semutaReaction!.candidates,[]);
game = act(game,'guild',{type:'semutaSelect',event,card:f.chosen[0]});
}
} else for (const player of game.players) game = act(game,player.id,{type:'semutaPass',event});
assert.equal(game.pendingTreacheryDiscard,null); assert.equal(game.pendingAmbassador,null);
assert.equal(seat(game,'ecaz').spice,spice + (effect === 'choam' ? 3*f.chosen.length : 0));
assert.equal(seat(game,'guild').hand.some(card => card.id === f.chosen[0]),claim);
if (effect === 'ixians') {
assert.ok(seat(game,'ecaz').hand.some(card => card.id === f.deckTop));
assert.deepEqual(game.deck.map(card => card.id),before.deck.slice(1).map(card => card.id));
} else assert.deepEqual(game.deck,before.deck);
assert.equal(game.discard.some(card => card.id === SEMUTA_DRUG_ID),claim);
assert.deepEqual(inventory(game),f.original);
assert.throws(() => act(game,'guild',{type:'semutaCommit',event}));
game = act(game,'emperor',{type:'endMovement'});
assert.ok(!game.movementRemaining?.includes('emperor'));
});
}
@@ -0,0 +1,116 @@
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 { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import { basicSemutaAmbassadorInventory as inventory, basicSemutaAmbassadorPlayer as seat } from './fixture-basic-semuta-ambassador';
import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
type Kind = 'sale' | 'kulon' | 'laLaLa' | 'baliset' | 'gamont' | 'jubba';
const names: Record<Kind, string> = {sale:'Baliset',kulon:'Kulon',laLaLa:'La La La',
baliset:'Baliset',gamont:'Trip to Gamont',jubba:'Jubba Cloak'};
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
/** Native selected-family public setup; the relevant physical cards and later
* phase/board position are explicitly staged. The power, cancellation queue,
* payment, Semuta and continuation are all original actions. */
function fixture(kind: Kind) {
let game = createGame('SEMUTAINDEPENDENTCHOAM', newPlayer('c','CHOAM','choam'), false,
['choam','ix','ecaz']);
for (const faction of ['richese','atreides','ecaz'] as const)
joinGame(game,newPlayer(faction,faction,faction));
game = act(game,'c',{type:'moduleOption',module:'ecazTreachery',enabled:true});
for (const p of game.players) game = act(game,p.id,{type:'ready'});
game = act(game,'c',{type:'start'});
assert.equal(game.status,'setup'); assert.equal(game.semutaRecruitsPreview,true);
for (let n=0; game.status==='setup' && n<160; n++) {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
game = act(game,next.actor,next.action);
}
assert.equal(game.status,'playing');
const cards = inventory(game);
const card = [game.deck,game.discard,game.richeseCache!,...game.players.map(p=>p.hand)]
.flat().find(item=>item.name===names[kind]); assert.ok(card);
moveSemutaEmperorCard(game,SEMUTA_DRUG_ID,'richese');
moveSemutaEmperorCard(game,card.id,'c');
assert.deepEqual(inventory(game),cards);
if (kind==='sale') {
for (let n=0;game.decision?.kind!=='choamMarket' && n<160;n++) {
const next=nextRicheseStrongholdsNativeStep(game); assert.ok(next);
game=act(game,next.actor,next.action);
}
assert.equal(game.decision?.kind,'choamMarket');
return {game, card:card.id, cards, action:{type:'decision',mode:'sell',card:card.id} as Action};
}
for (const p of game.players) {
p.reserves += Object.values(p.forces).reduce((sum,n)=>sum+n,0);
p.forces={};
}
seat(game,'c').reserves--;seat(game,'c').forces['red_chasm:7']=1;
seat(game,'atreides').reserves--;seat(game,'atreides').forces['imperial_basin:10']=1;
Object.assign(game,{turn:2,phase:kind==='laLaLa'?4:kind==='gamont'?8:kind==='jubba'?0:5,
storm:6,active:'c',ready:[],response:null,decision:null,phaseOpening:null,
choamMarket:null,movementRemaining:['c','richese','atreides','ecaz']});
for (const p of game.players) {p.shipped=false;p.moved=0;}
if (kind==='jubba') {
game.stormPending=1;
game.stormResolution={from:6,distance:1,traversed:0,pending:[]};
game.decision={kind:'choamStorm',player:'c',
territories:[{territory:'red_chasm',amount:1,sectors:[7]}],protected:[]};
}
const selection: Partial<Record<'target'|'territory'|'from'|'elite',string|number>> =
kind==='laLaLa' ? {target:'atreides'} : kind==='baliset' ? {target:'atreides',territory:'red_chasm'} :
kind==='gamont' ? {target:'atreides',from:'imperial_basin:10',elite:0} :
kind==='jubba' ? {territory:'red_chasm'} : {};
return {game,card:card.id,cards,action:{type:'card',mode:'choam',card:card.id,...selection} as Action};
}
function offer(f: ReturnType<typeof fixture>) {
let game=act(f.game,'c',f.action);
for (let n=0;game.response && n<32;n++) {
const p=game.players.find(row=>!game.response!.passed.includes(row.id));assert.ok(p);
game=act(game,p.id,{type:'passResponse'});
}
assert.equal(game.response??null,null);
return game;
}
for (const kind of ['sale','kulon','laLaLa','baliset','gamont','jubba'] as const)
for (const claim of [false,true]) void test(`Basic selected native CHOAM ${kind}: Semuta ${claim?'claim':'pass'} preserves the printed effect`,()=>{
const f=fixture(kind), before=structuredClone(f.game), pending=offer(f);
assert.deepEqual(f.game,before);
const batch=pending.pendingTreacheryDiscard; assert.ok(batch?.reaction);
assert.equal(batch.reaction.stage,'offer');
assert.deepEqual(batch.batch.entries.map(entry=>entry.card.id),[f.card]);
assert.equal(viewGame(pending,'richese').semutaReaction?.canCommit,true);
if (kind==='sale') assert.equal(seat(pending,'c').spice,seat(f.game,'c').spice+2);
if (kind==='kulon') for (const difficulty of DIFFICULTIES) {
const view=viewGame(pending,'richese');view.players.find(p=>p.id==='richese')!.bot=difficulty;
assert.ok(botActions(view).some(action=>action.type==='semutaCommit'||action.type==='semutaPass'));
}
const event=batch.batch.event;
let done=pending;
if (claim) {
done=act(done,'richese',{type:'semutaCommit',event});
if(done.pendingTreacheryDiscard?.reaction?.stage==='select')
done=act(done,'richese',{type:'semutaSelect',event,card:f.card});
} else for (const p of pending.players) done=act(done,p.id,{type:'semutaPass',event});
assert.equal(done.pendingTreacheryDiscard,null);
assert.equal(seat(done,'richese').hand.some(card=>card.id===f.card),claim);
assert.equal(done.discard.some(card=>card.id===f.card),!claim);
assert.equal(done.discard.some(card=>card.id===SEMUTA_DRUG_ID),claim);
assert.deepEqual(inventory(done),f.cards);
if (kind==='sale') {
assert.equal(seat(done,'c').spice,seat(f.game,'c').spice+2);
assert.equal(done.decision?.kind,'choamMarket');
} else if (kind==='kulon') assert.equal(viewGame(done,'c').choamMovementBonus,1);
else if (kind==='laLaLa') assert.equal(viewGame(done,'atreides').revival.freeBlocked,true);
else if (kind==='baliset') assert.ok(viewGame(done,'atreides').balisetRestrictions.some(row=>row.territory==='red_chasm'));
else if (kind==='gamont') {
assert.equal(seat(done,'atreides').reserves,seat(f.game,'atreides').reserves+1);
assert.equal(seat(done,'atreides').forces['imperial_basin:10']??0,0);
} else assert.equal(seat(done,'c').forces['red_chasm:7'],1);
assert.throws(()=>act(done,'richese',{type:'semutaCommit',event}));
});
@@ -0,0 +1,161 @@
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);
});
}