Allow Basic Emperor gifts before auction payment
Verify and publish checkpoint container / container (push) Successful in 10m39s
Verify and publish checkpoint container / container (push) Successful in 10m39s
This commit is contained in:
1 parent
a1b09c5b02
commit
b258c6867f
8 files changed
+342
-13
No files matched your search
@@ -0,0 +1,102 @@
|
||||
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 {takeKullCard} from './fixture-choam-kull';
|
||||
import {advanceSemutaNexusTrade} from './fixture-semuta-nexus-trade';
|
||||
import {beforeIxSpiceDraw, orderIxSpiceCards} from './fixture-ix-spice-cards';
|
||||
|
||||
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; };
|
||||
const quiet = (g:Game) => !g.phaseOpening && !g.response && !g.decision && !g.truthtrance && !g.pendingTreacheryDiscard;
|
||||
function inventory(g:Game) {
|
||||
return [...g.deck,...g.discard,...(g.richeseCache??[]),...(g.richeseRemoved??[]),
|
||||
...(g.auction?.cards.slice(g.auction.index + Number(g.currentAuctionSale?.origin==='normal'))??[]),
|
||||
...(g.ixAuction?.cards??[]),...g.players.flatMap(p=>p.hand)].map(card=>card.id).sort();
|
||||
}
|
||||
|
||||
/** Public Basic five-faction setup, natural Worm/Nexus and real winning bid.
|
||||
* Only unopened Spice order, conserved forces/cards and wallet values are staged. */
|
||||
function fixture() {
|
||||
let g=createGame('BASICGIFTEXP',newPlayer('e','Emperor','emperor'),false,['choam','ix']);
|
||||
for(const [id,faction] of [['g','guild'],['c','choam'],['r','richese'],['a','atreides']] as const)
|
||||
joinGame(g,newPlayer(id,faction,faction));
|
||||
for(const p of g.players) g=act(g,p.id,{type:'ready'});
|
||||
g=act(g,'e',{type:'start'});
|
||||
assert.equal(g.kullPreview,true); assert.equal(g.semutaPreview,true);
|
||||
g=advanceSemutaNexusTrade(g,s=>s.status==='playing');
|
||||
for(const p of g.players) {p.reserves+=Object.values(p.forces).reduce((a,b)=>a+b,0);p.forces={};}
|
||||
g=advanceSemutaNexusTrade(g,beforeIxSpiceDraw);orderIxSpiceCards(g,['land','land']);
|
||||
g=advanceSemutaNexusTrade(g,s=>s.turn===2&&beforeIxSpiceDraw(s));orderIxSpiceCards(g,['worm','land','land']);
|
||||
g=advanceSemutaNexusTrade(g,s=>!!s.nexus&&!s.spiceWindow&&quiet(s));
|
||||
g=act(g,'e',{type:'alliance',target:'g'});g=act(g,'g',{type:'alliance',target:'e'});
|
||||
// Stage physical holdings before Ix's Bidding opening deals the ordinary
|
||||
// auction pool; otherwise the singleton Kull may already be escrowed there.
|
||||
g=advanceSemutaNexusTrade(g,s=>s.phase===3&&s.phaseOpening?.initialize===true);
|
||||
for(const p of g.players) g.deck.push(...p.hand.splice(0));
|
||||
const semuta=takeKullCard(g,'richese-semuta-drug');player(g,'r').hand.push(semuta);
|
||||
const kull=takeKullCard(g,'ix-kull-wahad');player(g,'c').hand.push(kull);
|
||||
const karama=takeKullCard(g,'karama');player(g,'a').hand.push(karama);
|
||||
player(g,'g').spice=7;player(g,'e').spice=8;
|
||||
g=advanceSemutaNexusTrade(g,s=>s.phase===3&&!!s.auction&&quiet(s));
|
||||
while(g.auction!.active!=='g') g=act(g,g.auction!.active,{type:'passBid'});
|
||||
const original=inventory(g),card=g.auction!.cards[g.auction!.index].id;
|
||||
g=act(g,'g',{type:'bid',amount:3,allyPayment:0});
|
||||
for(let n=0;!g.pendingEmperorAuctionGift&&!g.response&&!g.decision&&n<g.players.length+2;n++)
|
||||
g=act(g,g.auction!.active,{type:'passBid'});
|
||||
assert.ok(g.pendingEmperorAuctionGift,'Emperor gets the original Basic payment opportunity');
|
||||
assert.equal(g.decision?.kind,'auctionPayment');
|
||||
assert.equal(viewGame(g,'e').emperorAuctionGift?.recipient,'g');
|
||||
return {g,card,karama:karama.id,kull:kull.id,semuta:semuta.id,original};
|
||||
}
|
||||
function passResponse(g:Game) {
|
||||
for(let n=0;g.response&&n<g.players.length+2;n++) {
|
||||
const p=g.players.find(row=>!g.response!.passed.includes(row.id)); assert.ok(p);
|
||||
g=act(g,p.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 p=g.players.find(row=>!reaction.passed.includes(row.id));assert.ok(p);
|
||||
g=act(g,p.id,{type:'semutaPass',event:pending.batch.event});
|
||||
}
|
||||
return g;
|
||||
}
|
||||
function finishSale(g:Game,card:string) {
|
||||
const pending=g.pendingEmperorAuctionGift;assert.ok(pending);
|
||||
g=act(g,'e',{type:'emperorAuctionGiftContinue',event:pending.event});
|
||||
if(g.decision?.kind==='auctionPayment') g=act(g,'g',{type:'decision',karama:false});
|
||||
g=passResponse(g);
|
||||
assert.ok(player(g,'g').hand.some(row=>row.id===card),'The original paid lot is delivered once');
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('Basic native expansion allied auction offers Emperor gift before the original payment',()=>{
|
||||
const f=fixture(), before=structuredClone(f.g.auction), e=player(f.g,'e').spice, buyer=player(f.g,'g').spice;
|
||||
let g=act(f.g,'e',{type:'emperorGift',amount:2});g=passResponse(g);
|
||||
assert.deepEqual(g.auction,before);assert.equal(player(g,'e').spice,e-2);assert.equal(player(g,'g').spice,buyer+2);
|
||||
g=finishSale(g,f.card);assert.equal(player(g,'g').spice,buyer-1);assert.deepEqual(inventory(g),f.original);
|
||||
});
|
||||
|
||||
for(const allowKull of [false,true]) for(const claim of [false,true]) void test(`Basic allied auction gift cancellation retains Kull ${allowKull?'use':'decline'}, Semuta ${claim?'claim':'pass'} and original lot`,()=>{
|
||||
const f=fixture(), e=player(f.g,'e').spice, buyer=player(f.g,'g').spice;
|
||||
let g=act(f.g,'e',{type:'emperorGift',amount:2});
|
||||
g=act(g,'a',{type:'card',mode:'cancel',card:f.karama});
|
||||
assert.equal(g.pendingKull?.stage,'offer');
|
||||
const event=g.pendingKull!.event;
|
||||
g=act(g,'c',allowKull?{type:'kullDecision',event,source:'printed',card:f.kull}:{type:'kullDecision',event,decline:true});
|
||||
if(allowKull) g=passResponse(g);
|
||||
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage,'offer');
|
||||
assert.equal(g.pendingTreacheryDiscard!.batch.entries[0].card.id,allowKull?f.kull:f.karama);
|
||||
const discardEvent=g.pendingTreacheryDiscard!.batch.event;
|
||||
if(claim) g=act(g,'r',{type:'semutaCommit',event:discardEvent});
|
||||
else g=passSemuta(g);
|
||||
assert.equal(player(g,'r').hand.some(card=>card.id===(allowKull?f.kull:f.karama)),claim);
|
||||
if(g.response) g=passResponse(g);
|
||||
assert.ok(g.pendingEmperorAuctionGift,'Return to the same original gift opportunity');
|
||||
assert.equal(player(g,'e').spice,e-(allowKull?2:0));
|
||||
assert.equal(player(g,'g').spice,buyer+(allowKull?2:0));
|
||||
g=finishSale(g,f.card);assert.deepEqual(inventory(g),f.original);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
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 {advanceSemutaNexusTrade} from './fixture-semuta-nexus-trade';
|
||||
import {beforeIxSpiceDraw, orderIxSpiceCards} from './fixture-ix-spice-cards';
|
||||
import {SEMUTA_DRUG_ID} from '../game/semuta-drug';
|
||||
|
||||
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 clean=(game:Game)=>!game.response&&!game.decision&&!game.phaseOpening&&!game.truthtrance&&!game.pendingTreacheryDiscard;
|
||||
function inventory(game:Game):string[] {
|
||||
const cards=[...game.deck,...game.discard,...(game.richeseCache??[]),...(game.richeseRemoved??[]),
|
||||
...(game.auction?.cards.slice(game.auction.index+Number(game.currentAuctionSale?.origin==='normal'))??[]),
|
||||
...(game.ixAuction?.cards??[]),...game.players.flatMap(player=>player.hand)].map(card=>card.id).sort();
|
||||
assert.equal(new Set(cards).size,cards.length);return cards;
|
||||
}
|
||||
function acquire(game:Game, owner:string, predicate:(card:Game['deck'][number])=>boolean) {
|
||||
const player=seat(game,owner), held=player.hand.find(predicate);if(held)return held;
|
||||
const sources=[game.deck,game.discard,game.richeseCache!,...game.players.filter(row=>row.id!==owner).map(row=>row.hand)];
|
||||
const source=sources.find(zone=>zone.some(predicate));assert.ok(source);
|
||||
const index=source.findIndex(predicate),card=source[index], displaced=player.hand.pop();
|
||||
source.splice(index,1,...(displaced?[displaced]:[]));player.hand.push(card);return card;
|
||||
}
|
||||
/** Actual public expansion setup, second-turn worm and mutual alliance.
|
||||
* Unopened Spice order, board forces, wallets and physical cards are staged. */
|
||||
function fixture() {
|
||||
let game=createGame('BASICNATIVEGIFT',newPlayer('c','CHOAM','choam'),false,['choam','ix']);
|
||||
for(const [id,faction] of [['r','richese'],['e','emperor'],['h','harkonnen'],['a','atreides']] as const)
|
||||
joinGame(game,newPlayer(id,faction,faction));
|
||||
for(const player of game.players) game=act(game,player.id,{type:'ready'});
|
||||
game=act(game,'c',{type:'start'});assert.equal(game.semutaPreview,true);assert.equal(game.kullPreview,true);
|
||||
game=advanceSemutaNexusTrade(game,state=>state.status==='playing');
|
||||
const original=inventory(game);
|
||||
for(const player of game.players) {player.reserves+=Object.values(player.forces).reduce((a,b)=>a+b,0);player.forces={};}
|
||||
game=advanceSemutaNexusTrade(game,beforeIxSpiceDraw);orderIxSpiceCards(game,['land','land']);
|
||||
game=advanceSemutaNexusTrade(game,state=>state.turn===2&&beforeIxSpiceDraw(state));
|
||||
orderIxSpiceCards(game,['worm','land','land']);
|
||||
game=advanceSemutaNexusTrade(game,state=>!!state.nexus&&!state.spiceWindow&&clean(state));
|
||||
game=act(game,'e',{type:'alliance',target:'h'});game=act(game,'h',{type:'alliance',target:'e'});
|
||||
game=advanceSemutaNexusTrade(game,state=>state.phase===3&&!!state.auction&&!state.richeseAuction&&clean(state));
|
||||
while(game.auction!.active!=='h')game=act(game,game.auction!.active,{type:'passBid'});
|
||||
seat(game,'e').spice=8;seat(game,'h').spice=4;
|
||||
const counter=acquire(game,'a',card=>card.effect==='karama');
|
||||
const semuta=acquire(game,'r',card=>card.id===SEMUTA_DRUG_ID);
|
||||
assert.deepEqual(inventory(game),original);
|
||||
game=act(game,'e',{type:'pledgeAid',amount:2});
|
||||
const lot=game.auction!.cards[game.auction!.index].id;
|
||||
game=act(game,'h',{type:'bid',amount:6,allyPayment:2});
|
||||
for(let n=0;game.auction&&!game.response&&!game.decision&&n<15;n++)game=act(game,game.auction.active,{type:'passBid'});
|
||||
assert.equal(game.decision?.kind,'auctionPayment');
|
||||
const offer=viewGame(game,'e').emperorAuctionGift;assert.ok(offer);
|
||||
return {game,original,counter:counter.id,semuta:semuta.id,lot,event:offer.event};
|
||||
}
|
||||
function passResponse(game:Game) {
|
||||
for(let n=0;game.response&&n<20;n++) {const p=game.players.find(row=>!game.response!.passed.includes(row.id));assert.ok(p);game=act(game,p.id,{type:'passResponse'});}
|
||||
return game;
|
||||
}
|
||||
for(const mode of ['allow','cancel-claim','cancel-pass'] as const) void test(`Basic native CHOAM/Richese ally auction gift ${mode} preserves payment`,()=>{
|
||||
const cancel=mode!=='allow';
|
||||
const f=fixture();let game=act(f.game,'e',{type:'emperorGift',amount:2});
|
||||
if(cancel) {
|
||||
game=act(game,'a',{type:'card',mode:'cancel',card:f.counter});
|
||||
assert.ok(game.pendingKull,'The original printed Kull may interrupt this Karama');
|
||||
game=act(game,'c',{type:'kullDecision',event:game.pendingKull.event,decline:true});
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage,'offer','The used Karama is offered to Semuta');
|
||||
const event=game.pendingTreacheryDiscard!.batch.event;
|
||||
if(mode==='cancel-claim')game=act(game,'r',{type:'semutaCommit',event});
|
||||
else for(const player of game.players)game=act(game,player.id,{type:'semutaPass',event});
|
||||
assert.equal(seat(game,'r').hand.some(card=>card.id===f.counter),mode==='cancel-claim');
|
||||
assert.equal(game.discard.some(card=>card.id===f.semuta),mode==='cancel-claim');
|
||||
} else game=passResponse(game);
|
||||
assert.equal(game.response,null);assert.equal(viewGame(game,'e').emperorAuctionGift!.event,f.event);
|
||||
assert.equal(seat(game,'h').spice,cancel?4:6);assert.equal(seat(game,'e').spice,cancel?6:4);
|
||||
game=act(game,'e',{type:'emperorAuctionGiftContinue',event:f.event});
|
||||
if(game.decision?.kind==='auctionPayment')game=act(game,'h',{type:'decision',karama:false});
|
||||
game=passResponse(game);
|
||||
assert.ok(seat(game,'h').hand.some(card=>card.id===f.lot));
|
||||
assert.deepEqual(inventory(game),f.original);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {applyAction, viewGame, type Action, type Game} from '../game/engine';
|
||||
import {botActions} from '../game/bots';
|
||||
import {DIFFICULTIES} from '../game/bot-profiles';
|
||||
import {advanceTruthAlliance, createTruthAllianceFixture, truthAllianceClean} from './fixture-truth-alliance';
|
||||
|
||||
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;};
|
||||
function passResponse(state:Game):Game {
|
||||
let game=state;
|
||||
for(let n=0;game.response && n<30;n++) {
|
||||
const actor=game.players.find(player=>!game.response!.passed.includes(player.id));assert.ok(actor);
|
||||
game=act(game,actor.id,{type:'passResponse'});
|
||||
}
|
||||
return game;
|
||||
}
|
||||
/** Public Basic setup and real later worm/alliance, bids and payment. Only
|
||||
* wallets and conserved actual card locations are staged before bidding. */
|
||||
function auction(counter:boolean) {
|
||||
let game=createTruthAllianceFixture({advanced:false,consent:'none'}).game;
|
||||
game=act(game,'harkonnen',{type:'alliance',target:'emperor'});
|
||||
game=act(game,'emperor',{type:'alliance',target:'harkonnen'});
|
||||
game=advanceTruthAlliance(game,state=>state.phase===3 && !!state.auction && truthAllianceClean(state));
|
||||
while(game.auction!.active!=='harkonnen') game=act(game,game.auction!.active,{type:'passBid'});
|
||||
const buyer=seat(game,'harkonnen'), emperor=seat(game,'emperor');
|
||||
buyer.spice=4;emperor.spice=8;
|
||||
if(counter) {
|
||||
const other=seat(game,'atreides');
|
||||
if(!other.hand.some(card=>card.effect==='karama')) {
|
||||
const sources=[game.deck,...game.players.filter(player=>player.id!=='atreides').map(player=>player.hand)];
|
||||
const source=sources.find(cards=>cards.some(card=>card.effect==='karama'));assert.ok(source);
|
||||
const index=source.findIndex(card=>card.effect==='karama');
|
||||
const displaced=other.hand.pop();assert.ok(displaced);
|
||||
[source[index],other.hand[other.hand.length]]=[displaced,source[index]];
|
||||
}
|
||||
}
|
||||
game=act(game,'emperor',{type:'pledgeAid',amount:2});
|
||||
const lot=game.auction!.cards[game.auction!.index].id;
|
||||
game=act(game,'harkonnen',{type:'bid',amount:6,allyPayment:2});
|
||||
for(let n=0;game.auction && !game.response && !game.decision && n<15;n++)
|
||||
game=act(game,game.auction.active,{type:'passBid'});
|
||||
assert.equal(game.decision?.kind,'auctionPayment');
|
||||
assert.equal(game.decision.player,'harkonnen');
|
||||
const offer=viewGame(game,'emperor').emperorAuctionGift;assert.ok(offer);
|
||||
return {game,lot,event:offer.event};
|
||||
}
|
||||
|
||||
void test('Basic public allied auction gift retains pledged funding and original payment',()=>{
|
||||
const f=auction(false), before=structuredClone(f.game), offer=viewGame(f.game,'emperor').emperorAuctionGift!;
|
||||
assert.equal(offer.recipient,'harkonnen');assert.equal(offer.maximum,6);
|
||||
assert.equal(viewGame(f.game,'harkonnen').emperorAuctionGift,null);
|
||||
assert.throws(()=>act(f.game,'harkonnen',{type:'decision',karama:false}));
|
||||
assert.deepEqual(f.game,before);
|
||||
let game=act(f.game,'emperor',{type:'emperorGift',amount:2});
|
||||
game=passResponse(game);
|
||||
assert.equal(seat(game,'harkonnen').spice,6);assert.equal(seat(game,'emperor').spice,4);
|
||||
assert.equal(game.auction!.bid,6);assert.equal(game.auction!.allyPayment,2);
|
||||
assert.equal(viewGame(game,'emperor').emperorAuctionGift!.event,f.event);
|
||||
game=act(game,'emperor',{type:'emperorAuctionGiftContinue',event:f.event});
|
||||
if(game.decision?.kind==='auctionPayment') game=act(game,'harkonnen',{type:'decision',karama:false});
|
||||
game=passResponse(game);
|
||||
assert.ok(seat(game,'harkonnen').hand.some(card=>card.id===f.lot));
|
||||
assert.equal(seat(game,'harkonnen').spice,2);
|
||||
});
|
||||
|
||||
void test('Basic native Karama cancels the gift and retains its original auction payment',()=>{
|
||||
const f=auction(true), card=seat(f.game,'atreides').hand.find(row=>row.effect==='karama');assert.ok(card);
|
||||
let game=act(f.game,'emperor',{type:'emperorGift',amount:2});
|
||||
game=act(game,'atreides',{type:'card',mode:'cancel',card:card.id});
|
||||
assert.equal(seat(game,'harkonnen').spice,4);assert.equal(seat(game,'emperor').spice,6);
|
||||
assert.equal(game.auction!.bid,6);assert.equal(game.auction!.allyPayment,2);
|
||||
assert.equal(viewGame(game,'emperor').emperorAuctionGift!.event,f.event);
|
||||
game=act(game,'emperor',{type:'emperorAuctionGiftContinue',event:f.event});
|
||||
if(game.decision?.kind==='auctionPayment') game=act(game,'harkonnen',{type:'decision',karama:false});
|
||||
game=passResponse(game);
|
||||
assert.ok(seat(game,'harkonnen').hand.some(row=>row.id===f.lot));
|
||||
});
|
||||
|
||||
void test('Each minimal Basic bot can continue the owned auction gift opportunity',()=>{
|
||||
const {game,event}=auction(false);
|
||||
for(const difficulty of DIFFICULTIES) {
|
||||
const view=viewGame(game,'emperor');view.players.find(player=>player.id==='emperor')!.bot=difficulty;
|
||||
const action=botActions(view).find(row=>row.type==='emperorAuctionGiftContinue');assert.ok(action);
|
||||
assert.equal(action.event,event);assert.doesNotThrow(()=>act(game,'emperor',action));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user