Enable Basic mixed-deck Emperor Extortion gifts
Verify and publish checkpoint container / container (push) Successful in 12m11s
Verify and publish checkpoint container / container (push) Successful in 12m11s
This commit is contained in:
1 parent
dac1b432db
commit
3bfcd10140
7 files changed
+374
-9
No files matched your search
@@ -0,0 +1,153 @@
|
||||
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 {territory} from '../game/board';
|
||||
import {placeTerror} from '../game/moritani-terror';
|
||||
import {takeKullCard} from './fixture-choam-kull';
|
||||
import {nextStrongholdFactionsNativeStep} from './fixture-stronghold-factions';
|
||||
|
||||
const act = (g:Game, id:string, action:Action) => applyAction(g, id, action);
|
||||
const player = (g:Game, id:string) => { const p=g.players.find(row=>row.id===id); assert.ok(p); return p; };
|
||||
function passResponse(g:Game) {
|
||||
for(let n=0;g.response&&n<g.players.length+2;n++) {
|
||||
const seat=g.players.find(p=>!g.response!.passed.includes(p.id)); assert.ok(seat);
|
||||
g=act(g,seat.id,{type:'passResponse'});
|
||||
}
|
||||
return g;
|
||||
}
|
||||
function passSemuta(g:Game) {
|
||||
for(let n=0;g.pendingTreacheryDiscard?.reaction?.stage==='offer'&&n<g.players.length;n++) {
|
||||
const pending=g.pendingTreacheryDiscard, reaction=pending.reaction;
|
||||
assert.ok(reaction&&reaction.stage==='offer');
|
||||
const seat=g.players.find(p=>!reaction.passed.includes(p.id)); assert.ok(seat);
|
||||
g=act(g,seat.id,{type:'semutaPass',event:pending.batch.event});
|
||||
}
|
||||
return g;
|
||||
}
|
||||
function payment(g:Game,pay:boolean):Action {
|
||||
assert.ok(g.decision?.kind==='moritaniExtortion');
|
||||
return {type:'decision',event:g.decision.event,pay};
|
||||
}
|
||||
function parent(g:Game) {
|
||||
return {decision:structuredClone(g.decision),obligation:structuredClone(g.moritaniExtortion),
|
||||
order:[...g.order],turn:g.turn,phase:g.phase};
|
||||
}
|
||||
|
||||
/** Real public mixed-faction Basic start and native setup. Stage the alliance,
|
||||
* physical token, hands and clean movement boundary; a real shipment reveals
|
||||
* Extortion and native Mentat awards bank spice and opens the payer queue. */
|
||||
function fixture(counterId='karama') {
|
||||
let g=createGame('EMPEXTKULL',newPlayer('m','Moritani','moritani'),false,['ecaz','choam','ix']);
|
||||
for(const [id,faction] of [['e','emperor'],['a','atreides'],['c','choam'],['r','richese']] as const)
|
||||
joinGame(g,newPlayer(id,faction,faction));
|
||||
for(const p of g.players)g=act(g,p.id,{type:'ready'});
|
||||
g=act(g,'m',{type:'start'});
|
||||
assert.equal(g.kullPreview,true); assert.equal(g.semutaPreview,true);
|
||||
for(let n=0;n<100&&g.status==='setup';n++) {
|
||||
if(g.decision?.kind==='moritaniSetup')
|
||||
g=act(g,'m',{type:'decision',territory:'sietch_tabr',sector:territory('sietch_tabr').sectors[0]});
|
||||
else { const next=nextStrongholdFactionsNativeStep(g); assert.ok(next); g=act(g,next.actor,next.action); }
|
||||
}
|
||||
assert.equal(g.status,'playing');
|
||||
player(g,'e').ally='a'; player(g,'a').ally='e';
|
||||
const token=g.moritaniTerror!.tokens.find(row=>row.kind==='extortion'); assert.ok(token);
|
||||
g.moritaniTerror=placeTerror(g.moritaniTerror!,token.id,'habbanya_ridge_sietch',1);
|
||||
Object.assign(g,{phase:5,turn:2,storm:18,order:['e','a','c','r','m'],active:'e',
|
||||
movementRemaining:['e','a','c','r','m'],response:null,decision:null,phaseOpening:null,ready:[]});
|
||||
player(g,'e').spice=10; player(g,'a').spice=2;
|
||||
for(const p of g.players)g.deck.push(...p.hand.splice(0));
|
||||
const kull=takeKullCard(g,'ix-kull-wahad'); player(g,'c').hand.push(kull);
|
||||
const karama=takeKullCard(g,'karama'); player(g,'m').hand.push(karama);
|
||||
// A Richese Karama counter is a conserved physical cache card, staged as
|
||||
// already acquired by the ally; ordinary counters come from the real deck.
|
||||
const counter=takeKullCard(g,counterId); player(g,'a').hand.push(counter);
|
||||
const semuta=takeKullCard(g,'richese-semuta-drug'); player(g,'r').hand.push(semuta);
|
||||
g=act(g,'e',{type:'ship',territory:'habbanya_ridge_sietch',sector:17,amount:1});
|
||||
assert.equal(g.decision?.kind,'moritaniTerror');
|
||||
g=act(g,'m',{type:'decision',reveal:true});
|
||||
assert.equal(g.moritaniTerror!.tokens.find(row=>row.id===token.id)?.status,'extortion');
|
||||
const beforeAward=player(g,'m').spice;
|
||||
g.phase=7; g.ready=[];
|
||||
for(const p of g.players)g=act(g,p.id,{type:'ready'});
|
||||
for(let n=0;g.decision?.kind!=='moritaniPlacement'&&n<30;n++) {
|
||||
const next=nextStrongholdFactionsNativeStep(g); assert.ok(next, 'Original Collection continuation must remain playable');
|
||||
g=act(g,next.actor,next.action);
|
||||
}
|
||||
assert.equal(g.phase,8); assert.equal(g.decision?.kind,'moritaniPlacement');
|
||||
g=act(g,'m',{type:'decision',decline:true});
|
||||
assert.equal(player(g,'m').spice,beforeAward+5);
|
||||
assert.equal(g.decision?.kind,'moritaniExtortion'); assert.equal(g.decision.player,'e');
|
||||
g=act(g,'e',payment(g,false));
|
||||
assert.equal(g.decision?.kind,'moritaniExtortion'); assert.equal(g.decision.player,'a');
|
||||
assert.equal(viewGame(g,'a').extortion.pending?.canPay,false);
|
||||
return {g,token:token.id,kull:kull.id,karama:karama.id,counter:counter.id,semuta:semuta.id,beforeAward};
|
||||
}
|
||||
|
||||
for(const success of [false,true]) for(const claim of [false,true]) void test(
|
||||
`Basic mixed-faction Extortion gift: Kull ${success?'succeeds':'declines'} and Semuta ${claim?'claims':'passes'}`,()=>{
|
||||
const f=fixture(),before=parent(f.g),emperor=player(f.g,'e').spice;
|
||||
assert.deepEqual(viewGame(f.g,'e').emperorExtortionGift,{recipient:'a',maximum:emperor});
|
||||
let g=act(f.g,'e',{type:'emperorGift',amount:1});
|
||||
g=act(g,'m',{type:'card',mode:'cancel',card:f.karama});
|
||||
assert.equal(g.pendingKull?.stage,'offer');
|
||||
g=act(g,'c',success
|
||||
? {type:'kullDecision',event:g.pendingKull!.event,source:'printed',card:f.kull}
|
||||
: {type:'kullDecision',event:g.pendingKull!.event,decline:true});
|
||||
if(success) { assert.equal(g.pendingKull?.stage,'counter'); g=passResponse(g); }
|
||||
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage,'offer');
|
||||
const physical=success?f.kull:f.karama;
|
||||
assert.equal(g.pendingTreacheryDiscard!.batch.entries[0].card.id,physical);
|
||||
const event=g.pendingTreacheryDiscard!.batch.event;
|
||||
if(claim)g=act(g,'r',{type:'semutaCommit',event});else g=passSemuta(g);
|
||||
assert.equal(player(g,'r').hand.some(card=>card.id===physical),claim);
|
||||
if(g.pendingTreacheryDiscard?.reaction?.stage==='offer')g=passSemuta(g);
|
||||
if(g.response)g=passResponse(g);
|
||||
assert.deepEqual(parent(g),before);
|
||||
assert.equal(player(g,'e').spice,emperor-(success?1:0));
|
||||
assert.equal(player(g,'a').spice,success?3:2);
|
||||
assert.equal(player(g,'m').hand.some(card=>card.id===f.karama),success);
|
||||
assert.equal(g.discard.some(card=>card.id===f.semuta),claim);
|
||||
assert.equal(player(g,'m').spice,f.beforeAward+5);
|
||||
assert.equal(g.moritaniTerror!.tokens.find(row=>row.id===f.token)?.status,'extortion');
|
||||
assert.equal(viewGame(g,'a').extortion.pending?.canPay,success);
|
||||
g=act(g,'a',payment(g,success));
|
||||
for(let n=0;g.decision?.kind==='moritaniExtortion'&&n<g.players.length;n++)
|
||||
g=act(g,g.decision.player,payment(g,false));
|
||||
assert.equal(player(g,'m').spice,f.beforeAward+5+(success?3:0));
|
||||
assert.equal(g.moritaniExtortion,undefined);
|
||||
});
|
||||
|
||||
for(const counterId of ['karama','richese-karama']) for(const claim of [false,true]) void test(
|
||||
`Basic mixed Extortion gift: ${counterId} counter prevents Kull and Semuta ${claim?'claims':'passes'}`,()=>{
|
||||
const f=fixture(counterId),before=parent(f.g),emperor=player(f.g,'e').spice;
|
||||
const counter=player(f.g,'a').hand.find(card=>card.id===f.counter); assert.ok(counter);
|
||||
if(counterId==='richese-karama') {
|
||||
assert.equal(counter.id,'richese-karama');
|
||||
assert.equal(f.g.richeseCache!.some(card=>card.id===counter.id),false);
|
||||
}
|
||||
let g=act(f.g,'e',{type:'emperorGift',amount:1});
|
||||
g=act(g,'m',{type:'card',mode:'cancel',card:f.karama});
|
||||
assert.equal(g.pendingKull?.stage,'offer');
|
||||
g=act(g,'c',{type:'kullDecision',event:g.pendingKull!.event,source:'printed',card:f.kull});
|
||||
assert.equal(g.pendingKull?.stage,'counter');
|
||||
assert.ok(viewGame(g,'a').responseControls?.cancelCards.includes(counter.id));
|
||||
g=act(g,'a',{type:'card',mode:'cancel',card:counter.id});
|
||||
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage,'offer');
|
||||
assert.equal(g.pendingTreacheryDiscard!.batch.entries[0].card.id,counter.id);
|
||||
const event=g.pendingTreacheryDiscard!.batch.event;
|
||||
if(claim)g=act(g,'r',{type:'semutaCommit',event});else g=passSemuta(g);
|
||||
assert.equal(player(g,'r').hand.some(card=>card.id===counter.id),claim);
|
||||
if(g.pendingTreacheryDiscard?.reaction?.stage==='offer')g=passSemuta(g);
|
||||
if(g.response)g=passResponse(g);
|
||||
assert.deepEqual(parent(g),before);
|
||||
assert.equal(player(g,'e').spice,emperor); assert.equal(player(g,'a').spice,2);
|
||||
assert.equal(player(g,'c').hand.some(card=>card.id===f.kull),true);
|
||||
assert.equal(player(g,'m').hand.some(card=>card.id===f.karama),false);
|
||||
assert.equal(g.discard.some(card=>card.id===f.semuta),claim);
|
||||
assert.equal(viewGame(g,'a').extortion.pending?.canPay,false);
|
||||
g=act(g,'a',payment(g,false));
|
||||
for(let n=0;g.decision?.kind==='moritaniExtortion'&&n<g.players.length;n++)
|
||||
g=act(g,g.decision.player,payment(g,false));
|
||||
assert.equal(player(g,'m').spice,f.beforeAward+5);
|
||||
assert.equal(g.moritaniExtortion,undefined);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
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 { territory } from '../game/board';
|
||||
import { placeTerror } from '../game/moritani-terror';
|
||||
import { botActions, runBots } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
||||
const seat = (game: Game, id: string) => {
|
||||
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
|
||||
};
|
||||
const copy = (game: Game): Game => structuredClone(game);
|
||||
function passResponse(game: Game): Game {
|
||||
for (let count = 0; game.response && count < 30; count++) {
|
||||
const player = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(player);
|
||||
game = act(game, player.id, {type:'passResponse'});
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
function payment(game: Game, pay: boolean): Action {
|
||||
assert.ok(game.decision?.kind === 'moritaniExtortion');
|
||||
return {type:'decision', event:game.decision.event, pay};
|
||||
}
|
||||
/** Actual mixed-deck public setup, physical Extortion placement, shipment,
|
||||
* reveal and Mentat queue. Alliance, turn boundary, wallets and physical
|
||||
* Karama custody are staged, as in the existing native Extortion fixture. */
|
||||
function pending(native: 'ixians' | 'tleilaxu') {
|
||||
let game = createGame('MIXEDEXTORTION', newPlayer('m','Moritani','moritani'), false, ['ecaz','ix']);
|
||||
for (const [id, faction] of [['e','emperor'], ['a','atreides'], ['h','harkonnen'], ['i',native]] as const)
|
||||
joinGame(game, newPlayer(id, faction, faction));
|
||||
for (const player of game.players) game = act(game, player.id, {type:'ready'});
|
||||
game = act(game, 'm', {type:'start'});
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
if (game.decision?.kind === 'moritaniSetup')
|
||||
game = act(game, 'm', {type:'decision', territory:'sietch_tabr', sector:territory('sietch_tabr').sectors[0]});
|
||||
else {
|
||||
const step = nextStrongholdFactionsNativeStep(game); assert.ok(step);
|
||||
game = act(game, step.actor, step.action);
|
||||
}
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
seat(game,'e').ally = 'a'; seat(game,'a').ally = 'e';
|
||||
const token = game.moritaniTerror!.tokens.find(row => row.kind === 'extortion'); assert.ok(token);
|
||||
game.moritaniTerror = placeTerror(game.moritaniTerror!, token.id, 'habbanya_ridge_sietch', 1);
|
||||
Object.assign(game, {phase:5, turn:2, storm:18, order:['e','a','h','i','m'], active:'e',
|
||||
movementRemaining:['e','a','h','i','m'], response:null, decision:null, phaseOpening:null, ready:[]});
|
||||
seat(game,'e').spice = 11; seat(game,'a').spice = 2;
|
||||
const harkonnen = seat(game,'h');
|
||||
let card = harkonnen.hand.find(row => row.effect === 'karama');
|
||||
if (!card) {
|
||||
const sourceOwner = game.players.find(row => row.id !== 'h' && row.hand.some(item => item.effect === 'karama'));
|
||||
const source = sourceOwner?.hand ?? game.deck;
|
||||
const index = source.findIndex(item => item.effect === 'karama'); assert.ok(index >= 0);
|
||||
[card] = source.splice(index, 1); harkonnen.hand.push(card);
|
||||
}
|
||||
const karama = card.id;
|
||||
game = act(game,'e',{type:'ship',territory:'habbanya_ridge_sietch',sector:17,amount:1});
|
||||
assert.equal(game.decision?.kind,'moritaniTerror');
|
||||
game = act(game,'m',{type:'decision',reveal:true});
|
||||
assert.equal(game.moritaniTerror!.tokens.find(row => row.id === token.id)?.status,'extortion');
|
||||
const beforeAward = seat(game,'m').spice;
|
||||
game.phase = 7; game.ready = [];
|
||||
for (const player of game.players) game = act(game,player.id,{type:'ready'});
|
||||
assert.equal(game.phase,8);
|
||||
while (game.phaseOpening) {
|
||||
const step = nextStrongholdFactionsNativeStep(game); assert.ok(step);
|
||||
game = act(game,step.actor,step.action);
|
||||
}
|
||||
assert.equal(game.decision?.kind,'moritaniPlacement');
|
||||
game = act(game,'m',{type:'decision',decline:true});
|
||||
assert.equal(seat(game,'m').spice,beforeAward + 5);
|
||||
assert.equal(game.decision?.kind,'moritaniExtortion');
|
||||
assert.equal(game.decision.player,'e');
|
||||
game = act(game,'e',payment(game,false));
|
||||
assert.equal(game.decision?.kind,'moritaniExtortion');
|
||||
assert.equal(game.decision.player,'a');
|
||||
assert.equal(viewGame(game,'a').extortion.pending?.canPay,false);
|
||||
return {game, token:token.id, karama, beforeAward};
|
||||
}
|
||||
function original(game: Game) {
|
||||
return {decision:copy(game).decision, obligation:copy(game).moritaniExtortion,
|
||||
order:[...game.order], turn:game.turn, phase:game.phase};
|
||||
}
|
||||
for (const native of ['ixians','tleilaxu'] as const) {
|
||||
void test(`Basic mixed ${native} Emperor gift enables the original Extortion payment`, () => {
|
||||
const f = pending(native); let game = f.game;
|
||||
const before = original(game), event = (game.decision as Extract<NonNullable<Game['decision']>,{kind:'moritaniExtortion'}>).event;
|
||||
assert.deepEqual(viewGame(game,'e').emperorExtortionGift,{recipient:'a',maximum:10});
|
||||
for (const id of ['a','h','i','m']) assert.equal(viewGame(game,id).emperorExtortionGift,null);
|
||||
game = act(game,'e',{type:'emperorGift',amount:1});
|
||||
assert.equal(game.response?.kind,'emperorGift');
|
||||
game = passResponse(game);
|
||||
assert.deepEqual(original(game),before);
|
||||
assert.equal(seat(game,'e').spice,9); assert.equal(seat(game,'a').spice,3);
|
||||
assert.equal(viewGame(game,'a').extortion.pending?.canPay,true);
|
||||
game = act(game,'a',{type:'decision',event,pay:true});
|
||||
assert.equal(seat(game,'a').spice,0); assert.equal(seat(game,'m').spice,f.beforeAward + 8);
|
||||
assert.equal(game.moritaniTerror!.tokens.find(row => row.id === f.token)?.status,'removed');
|
||||
assert.equal(game.moritaniExtortion,undefined);
|
||||
});
|
||||
void test(`Basic mixed ${native} Karama cancels the gift and preserves the payer`, () => {
|
||||
const f = pending(native); let game = f.game;
|
||||
const before = original(game);
|
||||
game = act(game,'e',{type:'emperorGift',amount:1});
|
||||
game = act(game,'h',{type:'card',mode:'cancel',card:f.karama});
|
||||
assert.equal(game.response,null); assert.deepEqual(original(game),before);
|
||||
assert.equal(seat(game,'e').spice,10); assert.equal(seat(game,'a').spice,2);
|
||||
assert.equal(viewGame(game,'a').extortion.pending?.canPay,false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length,1);
|
||||
const unchanged = copy(game); assert.throws(() => act(game,'a',payment(game,true)));
|
||||
assert.deepEqual(game,unchanged);
|
||||
game = act(game,'a',payment(game,false));
|
||||
assert.equal(seat(game,'m').spice,f.beforeAward + 5);
|
||||
});
|
||||
}
|
||||
void test('all four minimal bot profiles can respond to a mixed Basic Extortion gift', () => {
|
||||
let {game} = pending('ixians');
|
||||
game = act(game,'e',{type:'emperorGift',amount:1});
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game,'h'); view.players.find(player => player.id === 'h')!.bot = difficulty;
|
||||
const actions = botActions(view); assert.ok(actions.length > 0);
|
||||
assert.doesNotThrow(() => act(copy(game),'h',actions[0]));
|
||||
}
|
||||
});
|
||||
|
||||
for (const native of ['ixians','tleilaxu'] as const) void test(
|
||||
`Basic mixed ${native} bot ally waits for the human Emperor gift and Continue`, () => {
|
||||
const initial = pending(native);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = copy(initial.game);
|
||||
seat(game,'a').bot = difficulty;
|
||||
assert.ok(game.decision?.kind === 'moritaniExtortion');
|
||||
const event = game.decision.event;
|
||||
assert.deepEqual(viewGame(game,'e').emperorExtortionPriority,{event,recipient:'a',canContinue:true});
|
||||
assert.deepEqual(viewGame(game,'a').emperorExtortionPriority,{event,recipient:'a',canContinue:false});
|
||||
assert.ok(!botActions(viewGame(game,'a')).some(action => action.type === 'decision' && action.event === event));
|
||||
let waiting = runBots(game,5);
|
||||
assert.equal(waiting.decision?.kind,'moritaniExtortion');
|
||||
assert.equal(waiting.decision.event,event);
|
||||
assert.equal(seat(waiting,'a').spice,2);
|
||||
game = act(game,'e',{type:'emperorGift',amount:1});
|
||||
game = passResponse(game);
|
||||
assert.equal(seat(game,'a').spice,3);
|
||||
assert.deepEqual(viewGame(game,'e').emperorExtortionPriority,{event,recipient:'a',canContinue:true});
|
||||
waiting = runBots(game,5);
|
||||
assert.equal(waiting.decision?.kind,'moritaniExtortion');
|
||||
assert.equal(waiting.decision.event,event);
|
||||
game = act(game,'e',{type:'emperorExtortionContinue',event});
|
||||
const action = botActions(viewGame(game,'a')).find(row => row.type === 'decision' && row.event === event);
|
||||
assert.ok(action, `${difficulty} must have a legal original Extortion response`);
|
||||
assert.doesNotThrow(() => act(copy(game),'a',action));
|
||||
game = runBots(game,1);
|
||||
assert.ok(game.decision?.kind !== 'moritaniExtortion' || game.decision.event !== event);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user