Extend Basic Emperor auction gifts to Nexus and Richese
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-30 19:06:22 +11:00
1 parent b258c6867f
commit dac1b432db
11 files changed
+509 -14

No files matched your search

@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { GameView } from '../game/engine';
import { EmperorAuctionGift } from '../components/emperor-auction-gift';
/** Synthetic owner projection: this verifies the rendered distinction, not
* a claim that a real cache-sale journey has already reached this control. */
const view = (source?: 'richese') => ({
players: [{id:'ally', name:'Ally'}],
emperorAuctionGift: {event:'gift-event', recipient:'ally', maximum:5, ...(source ? {source} : {})},
}) as unknown as GameView;
const render = (game: GameView, busy = false) => renderToStaticMarkup(
createElement(EmperorAuctionGift, {game, busy, act() {}}),
);
void test('Richese sale describes the committed payment and retains gift and Continue controls', () => {
const html = render(view('richese'));
assert.match(html, /Share spice with Ally/);
assert.match(html, /Continue settles the Richese sale with that payment after any gift response/);
assert.doesNotMatch(html, /Your ally chooses how to pay/);
assert.match(html, /<button\b(?![^>]*disabled)[^>]*>Share spice<\/button>/);
assert.match(html, /<button\b(?![^>]*disabled)[^>]*>Continue to payment<\/button>/);
assert.match(render(view('richese'), true), /<button\b[^>]*disabled=""[^>]*>Continue to payment<\/button>/);
});
void test('ordinary auction keeps its original buyer-payment guidance', () => {
const html = render(view());
assert.match(html, /Your ally chooses how to pay after the gift’s Karama response/);
assert.doesNotMatch(html, /Continue settles the Richese sale/);
});
@@ -0,0 +1,115 @@
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 {advanceSemutaNexusTrade, swapSemutaNexusTradeIdentity} from './fixture-semuta-nexus-trade';
import {beforeIxSpiceDraw, orderIxSpiceCards} from './fixture-ix-spice-cards';
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;
}
/** A public Basic lobby earns its Nexus draw through a real worm and alliance.
* Only unopened Spice order, force locations, wallets and physical cards are staged. */
function fixture() {
let game=createGame('BASICNEXUSGIFT',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));
game=act(game,'c',{type:'moduleOption',module:'nexusCards',enabled:true});
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');
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card=>card===null));
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.nexusCards?.phase?.stage==='drawing');
assert.ok(game.nexusCards!.phase!.eligible.includes('a'));
game=act(game,'a',{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
swapSemutaNexusTradeIdentity(game,'a','emperor');
// Stage physical cards before the original auction draws its lot, so none of
// these required cards can be randomly sealed in that lot.
const counter=acquire(game,'a',card=>card.effect==='karama');
const buyerKarama=acquire(game,'h',card=>card.effect==='karama'&&card.id!==counter.id);
const semuta=acquire(game,'r',card=>card.id===SEMUTA_DRUG_ID);
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;
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');
assert.ok(game.pendingNexusEmperorPayment==null,'Gift precedes the Nexus funding receipt');
const offer=viewGame(game,'e').emperorAuctionGift;assert.ok(offer);
return {game,original,counter:counter.id,buyerKarama:buyerKarama.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-use','allow-pass','cancel-claim-use','cancel-pass-pass'] as const) void test(`Basic public Nexus auction gift ${mode} resumes original sale`,()=>{
const f=fixture(),cancel=mode.startsWith('cancel'),use=mode.endsWith('use');
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,'CHOAM may interrupt the actual Karama');
game=act(game,'c',{type:'kullDecision',event:game.pendingKull.event,decline:true});
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage,'offer');
const event=game.pendingTreacheryDiscard!.batch.event;
if(mode==='cancel-claim-use')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-use');
assert.equal(game.discard.some(card=>card.id===f.semuta),mode==='cancel-claim-use');
} else game=passResponse(game);
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);
for(const difficulty of DIFFICULTIES) {
const view=viewGame(game,'e');view.players.find(p=>p.id==='e')!.bot=difficulty;
const action=botActions(view).find(row=>row.type==='emperorAuctionGiftContinue');assert.ok(action);
assert.doesNotThrow(()=>act(game,'e',action));
}
game=act(game,'e',{type:'emperorAuctionGiftContinue',event:f.event});
assert.equal(game.decision?.kind,'nexusEmperorPayment');
while(game.decision?.kind==='nexusEmperorPayment') {
const actor=game.decision.player, offer=viewGame(game,actor).nexusEmperorPayment;assert.ok(offer);
game=act(game,actor,{type:'nexusEmperorPayment',event:offer.event,use:use&&actor==='a'});
}
const receipt=game.nexusEmperorPayment!.completed.at(-1)!;
assert.equal(receipt.stage,use?'used':'passed');
assert.equal(game.nexusCards!.cards!.hands.a,use?null:'emperor');
if(!use) {
assert.equal(game.decision?.kind,'auctionPayment');
assert.equal(game.decision.player,'h');
assert.equal(seat(game,'h').spice,cancel?4:6);
assert.ok(seat(game,'h').hand.some(card=>card.id===f.buyerKarama));
game=act(game,'h',{type:'decision',karama:false});
}
assert.equal(seat(game,'h').spice,use?(cancel?4:6):(cancel?0:2));
assert.equal(seat(game,'e').spice,use?(cancel?2:0):(cancel?6:4));
assert.equal(game.response?.kind,'emperorIncome');
assert.equal(game.currentAuctionSale?.winner,'h');assert.equal(game.currentAuctionSale?.amount,6);
assert.equal(seat(game,'h').hand.filter(card=>card.id===f.lot).length,1);
game=passResponse(game);
assert.equal(seat(game,'e').spice,use?(cancel?8:6):(cancel?12:10));
assert.deepEqual(inventory(game),f.original);
});
@@ -0,0 +1,126 @@
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 {takeKullCard} from './fixture-choam-kull';
import {advanceSemutaNexusTrade, nextSemutaNexusTradeNativeStep} 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) {
const cards=[...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(row=>row.hand)].map(card=>card.id).sort();
assert.equal(new Set(cards).size,cards.length);return cards;
}
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;
}
/** Genuine public Basic setup, qualifying worm, alliance and paid cache bid.
* Staged only unopened Spice, reserve positions, purses and conserved physical
* hand/deck ownership before Ix's auction pool is drawn. */
function fixture(method:'onceAround'|'silent') {
let g=createGame('BASICRICHGIFT',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((sum,n)=>sum+n,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'});
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.decision?.kind==='richeseCache');
const selected=g.richeseCache!.find(card=>card.id!==semuta.id);assert.ok(selected);
const original=inventory(g),card=selected.id;
g=act(g,'r',{type:'decision',event:g.richeseBidding!.event,card,method,
...(method==='onceAround'?{direction:'clockwise'}:{})});
g=act(g,'e',{type:'pledgeAid',amount:1});
let bid=false;
for(let n=0;!g.pendingEmperorAuctionGift&&n<300;n++) {
const lot=g.richeseAuction;
if(lot&&!lot.outcome&&!g.response&&!g.decision&&lot.method==='onceAround'&&lot.active) {
const seat=lot.active, buy=seat==='g'&&!bid;
g=act(g,seat,{type:'richeseBid',event:lot.event,amount:buy?3:null,...(buy?{allyPayment:1}:{})});
if(buy)bid=true;
} else if(lot&&!lot.outcome&&!g.response&&!g.decision&&lot.method==='silent') {
const seat=lot.eligible.find(id=>!Object.hasOwn(lot.sealed,id));assert.ok(seat);
const buy=seat==='g'&&!bid;
g=act(g,seat,{type:'richeseBid',event:lot.event,amount:buy?3:0,...(buy?{allyPayment:1}:{})});
if(buy)bid=true;
} else {
const next=nextSemutaNexusTradeNativeStep(g);g=act(g,next.actor,next.action);
}
}
assert.equal(bid,true);assert.ok(g.pendingEmperorAuctionGift,'Emperor receives a prepayment opportunity');
assert.equal(g.richeseAuction?.outcome?.kind,'sold');assert.equal(g.currentAuctionSale??null,null);
assert.equal(viewGame(g,'e').emperorAuctionGift?.recipient,'g');
return {g,card,kull:kull.id,karama:karama.id,original};
}
function finishSale(g:Game, card:string) {
const pending=g.pendingEmperorAuctionGift;assert.ok(pending);
g=act(g,'e',{type:'emperorAuctionGiftContinue',event:pending.event});
g=passResponse(g);
assert.ok(player(g,'g').hand.some(row=>row.id===card));
return g;
}
for(const method of ['onceAround','silent'] as const) for(const share of [false,true]) void test(
`Basic ${method} cache sale retains committed split after Emperor ${share?'gift':'continue'}`,()=>{
const f=fixture(method),before=structuredClone(f.g.richeseAuction), funding=structuredClone(f.g.richeseFunding);
const e=player(f.g,'e').spice,buyer=player(f.g,'g').spice,seller=player(f.g,'r').spice;
const view=viewGame(f.g,'e');for(const level of DIFFICULTIES) {
const bot=structuredClone(view);bot.players.find(row=>row.id==='e')!.bot=level;
const choice=botActions(bot).find(action=>action.type==='emperorAuctionGiftContinue');assert.ok(choice);
assert.doesNotThrow(()=>act(f.g,'e',choice));
}
let g=f.g;
if(share){g=act(g,'e',{type:'emperorGift',amount:2});g=passResponse(g);}
assert.deepEqual(g.richeseAuction,before);assert.deepEqual(g.richeseFunding,funding);
assert.equal(player(g,'e').spice,e-(share?2:0));assert.equal(player(g,'g').spice,buyer+(share?2:0));
g=finishSale(g,f.card);
assert.equal(player(g,'g').spice,buyer+(share?2:0)-2);
assert.equal(player(g,'e').spice,e-(share?2:0));
assert.equal(g.aid.e?.amount??0,0);
assert.equal(player(g,'r').spice,seller+3);assert.deepEqual(inventory(g),f.original);
});
for(const claim of [false,true]) void test(`Basic cache sale gift cancellation survives Kull decline and Semuta ${claim?'claim':'pass'}`,()=>{
const f=fixture('onceAround'),e=player(f.g,'e').spice,buyer=player(f.g,'g').spice;
const event=f.g.pendingEmperorAuctionGift!.event;
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');g=act(g,'c',{type:'kullDecision',event:g.pendingKull!.event,decline:true});
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage,'offer');const discard=g.pendingTreacheryDiscard!.batch.event;
if(claim)g=act(g,'r',{type:'semutaCommit',event:discard});else g=passSemuta(g);
assert.equal(player(g,'r').hand.some(card=>card.id===f.karama),claim);
assert.equal(player(g,'e').spice,e);assert.equal(player(g,'g').spice,buyer);
assert.equal(g.pendingEmperorAuctionGift?.event,event);g=finishSale(g,f.card);
assert.equal(player(g,'e').spice,e);assert.equal(g.aid.e?.amount??0,0);
assert.equal(player(g,'g').spice,buyer-2);
assert.deepEqual(inventory(g),f.original);
});
@@ -0,0 +1,126 @@
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, nextSemutaNexusTradeNativeStep} 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) {
const cards=[...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(row=>row.hand)].map(card=>card.id).sort();
assert.equal(new Set(cards).size,cards.length);return cards;
}
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;
}
/** Genuine public Basic setup, qualifying worm, alliance and paid cache bid.
* Staged only unopened Spice, reserve positions, purses and conserved physical
* hand/deck ownership before Ix's auction pool is drawn. */
function fixture(method:'onceAround'|'silent') {
let g=createGame('BASICRICHGIFT',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((sum,n)=>sum+n,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'});
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);
const counter=takeKullCard(g,'karama');player(g,'g').hand.push(counter);
player(g,'g').spice=7;player(g,'e').spice=8;
g=advanceSemutaNexusTrade(g,s=>s.decision?.kind==='richeseCache');
const selected=g.richeseCache!.find(card=>card.id!==semuta.id);assert.ok(selected);
const original=inventory(g),card=selected.id;
g=act(g,'r',{type:'decision',event:g.richeseBidding!.event,card,method,
...(method==='onceAround'?{direction:'clockwise'}:{})});
g=act(g,'e',{type:'pledgeAid',amount:1});
let bid=false;
for(let n=0;!g.pendingEmperorAuctionGift&&n<300;n++) {
const lot=g.richeseAuction;
if(lot&&!lot.outcome&&!g.response&&!g.decision&&lot.method==='onceAround'&&lot.active) {
const seat=lot.active, buy=seat==='g'&&!bid;
g=act(g,seat,{type:'richeseBid',event:lot.event,amount:buy?3:null,...(buy?{allyPayment:1}:{})});
if(buy)bid=true;
} else if(lot&&!lot.outcome&&!g.response&&!g.decision&&lot.method==='silent') {
const seat=lot.eligible.find(id=>!Object.hasOwn(lot.sealed,id));assert.ok(seat);
const buy=seat==='g'&&!bid;
g=act(g,seat,{type:'richeseBid',event:lot.event,amount:buy?3:0,...(buy?{allyPayment:1}:{})});
if(buy)bid=true;
} else {
const next=nextSemutaNexusTradeNativeStep(g);g=act(g,next.actor,next.action);
}
}
assert.equal(bid,true);assert.ok(g.pendingEmperorAuctionGift,'Emperor receives a prepayment opportunity');
assert.equal(g.richeseAuction?.outcome?.kind,'sold');assert.equal(g.currentAuctionSale??null,null);
assert.equal(viewGame(g,'e').emperorAuctionGift?.recipient,'g');
return {g,card,kull:kull.id,karama:karama.id,counter:counter.id,semuta:semuta.id,original};
}
function finishSale(g:Game, card:string) {
const pending=g.pendingEmperorAuctionGift;assert.ok(pending);
g=act(g,'e',{type:'emperorAuctionGiftContinue',event:pending.event});
g=passResponse(g);
assert.ok(player(g,'g').hand.some(row=>row.id===card));
return g;
}
for(const counterKull of [false,true]) for(const claim of [false,true]) void test(
`Basic cache gift Kull ${counterKull?'countered':'succeeds'} and Semuta ${claim?'claims':'passes'} preserve the original sale`,()=>{
const f=fixture('onceAround'), auction=structuredClone(f.g.richeseAuction);
const funding=structuredClone(f.g.richeseFunding), pending=structuredClone(f.g.pendingEmperorAuctionGift);
const emperor=player(f.g,'e').spice, buyer=player(f.g,'g').spice, seller=player(f.g,'r').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');
g=act(g,'c',{type:'kullDecision',event:g.pendingKull!.event,source:'printed',card:f.kull});
assert.equal(g.pendingKull?.stage,'counter');
if(counterKull) {
assert.ok(viewGame(g,'g').responseControls?.cancelCards.includes(f.counter));
g=act(g,'g',{type:'card',mode:'cancel',card:f.counter});
} else g=passResponse(g);
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage,'offer');
assert.equal(g.pendingTreacheryDiscard!.batch.entries[0].card.id,counterKull?f.counter:f.kull);
const discard=g.pendingTreacheryDiscard!.batch.event;
if(claim)g=act(g,'r',{type:'semutaCommit',event:discard});else g=passSemuta(g);
assert.equal(player(g,'r').hand.some(card=>card.id===(counterKull?f.counter:f.kull)),claim);
if(g.pendingTreacheryDiscard?.reaction?.stage==='offer')g=passSemuta(g);
if(g.response)g=passResponse(g);
assert.deepEqual(g.pendingEmperorAuctionGift,pending);
assert.deepEqual(g.richeseAuction,auction);assert.deepEqual(g.richeseFunding,funding);
assert.equal(player(g,'e').spice,emperor-(counterKull?0:2));
assert.equal(player(g,'g').spice,buyer+(counterKull?0:2));
assert.equal(player(g,'a').hand.some(card=>card.id===f.karama),!counterKull);
assert.equal(g.discard.some(card=>card.id===f.semuta),claim);
g=finishSale(g,f.card);
assert.equal(player(g,'g').hand.filter(card=>card.id===f.card).length,1);
assert.equal(player(g,'g').spice,buyer+(counterKull?0:2)-2);
assert.equal(player(g,'e').spice,emperor-(counterKull?0:2));
assert.equal(player(g,'r').spice,seller+3);
assert.equal(g.aid.e?.amount??0,0);
assert.deepEqual(inventory(g),f.original);
});
+4
View File
@@ -65,6 +65,10 @@ export function stepNexusEmperorPayment(game: Game, step: NexusEmperorPaymentSte
return scalarEntropy(() => applyAction(game, step.actor, step.action));
}
export function nextNexusEmperorPaymentStep(game: Game): NexusEmperorPaymentStep {
if (!game.response && game.pendingEmperorAuctionGift) {
const pending = game.pendingEmperorAuctionGift;
return { actor: pending.owner, action: { type: 'emperorAuctionGiftContinue', event: pending.event } };
}
if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') {
const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id));
assert.ok(actor);
+17 -3
View File
@@ -14,8 +14,8 @@ function rejectsUnchanged(game: Game, actor: string, action: Action): void {
}
for (const advanced of [false, true]) {
for (const prepaidAid of [0, 1]) for (const cancelIncome of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} genuine allied purchase reuses ${prepaidAid} prepaid spice and ${cancelIncome ? 'cancels' : 'allows'} original Emperor income`, () => {
for (const prepaidAid of [0, 1]) for (const cancelIncome of (advanced ? [false] : [false, true])) void test(
`${advanced ? 'Advanced' : 'Basic'} genuine allied purchase reuses ${prepaidAid} prepaid spice and ${advanced ? 'sends Emperor contribution to Bank' : `${cancelIncome ? 'cancels' : 'allows'} original Emperor income`}`, () => {
const f = createNexusEmperorPaymentFixture({ advanced, prepaidAid });
assert.equal(f.game.decision!.player, f.neutral);
assert.deepEqual(f.game.pendingNexusEmperorPayment!.receipt.required, [f.neutral, f.owner]);
@@ -51,6 +51,20 @@ for (const advanced of [false, true]) {
assert.equal(nexusEmperorPaymentPlayer(game, f.emperor).spice, f.wallets.emperor - (f.price - prepaidAid),
'Prepaid credit is already deducted; only the missing spice is newly escrowed');
assert.deepEqual(game.aid[f.emperor], { recipient: f.winner, amount: 0 });
if (advanced) {
// The entire invoice is now Emperor's contribution, which Advanced
// sends to Bank. No Emperor income exists to cancel; the original lot
// therefore advances directly to the next Atreides inspection.
assert.equal(nexusEmperorPaymentPlayer(game, f.emperor).spice, f.wallets.emperorBeforeAid - f.price);
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).hand.filter(card => card.id === f.acquired).length, 1);
assert.equal(game.auction!.index, f.game.auction!.index + 1);
assert.equal(game.response!.kind, 'atreidesAuction');
assert.equal(game.nexusEmperorPayment!.completed.length, 1);
rejectsUnchanged(game, f.owner, { type: 'nexusEmperorPayment', event: f.event, use: true });
assert.deepEqual(nexusEmperorPaymentCards(game), f.originalCards);
nexusEmperorPaymentInventory(game);
return;
}
assert.equal(game.auction!.bid, f.price); assert.equal(game.auction!.bidder, f.winner);
assert.equal(game.auction!.allyPayment, f.price);
assert.equal(game.auction!.index, f.game.auction!.index);
@@ -107,7 +121,7 @@ for (const advanced of [false, true]) {
game = stepNexusEmperorPayment(game, { actor: f.winner, action: { type: 'decision', karama: false } });
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).spice, f.wallets.winner - f.price + prepaidAid);
assert.equal(nexusEmperorPaymentPlayer(game, f.emperor).spice, f.wallets.emperor);
assert.equal(game.response!.kind, 'emperorIncome');
assert.equal(game.response!.kind, advanced && prepaidAid === f.price ? 'atreidesAuction' : 'emperorIncome');
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).hand.filter(card => card.id === f.acquired).length, 1);
assert.equal(game.nexusEmperorPayment!.completed.length, 1, 'Ordinary settlement does not reopen the same obligation');
nexusEmperorPaymentInventory(game);