Extend Advanced Semuta to CHOAM and Residual Poison
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
b2bc3f2f01
commit
3a27c29a03
8 files changed
+302
-10
No files matched your search
@@ -0,0 +1,117 @@
|
||||
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 public Advanced 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('ADVSEMUTACHOAM', newPlayer('c','CHOAM','choam'), true, ['choam']);
|
||||
for (const faction of ['richese','atreides'] as const)
|
||||
joinGame(game,newPlayer(faction,faction,faction));
|
||||
for (const p of game.players) game = act(game,p.id,{type:'ready'});
|
||||
game = act(game,'c',{type:'start',advancedPreview:true});
|
||||
assert.equal(game.status,'setup'); assert.equal(game.semutaAdvancedOrdinaryPreview,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']});
|
||||
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(`Advanced public 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);
|
||||
assert.deepEqual(viewGame(pending,'richese').semutaReaction?.candidates,[]);
|
||||
assert.equal(viewGame(pending,'atreides').semutaReaction?.canCommit,false);
|
||||
assert.deepEqual(viewGame(pending,'atreides').semutaReaction?.candidates,[]);
|
||||
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}));
|
||||
});
|
||||
Reference in new issue
Block a user