diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 0df2a505..cb8cda4a 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,16 @@ open mode gates. ## Current checkpoint and work +**Basic Emperor allied auction gifts — 30 October. Prototyped / Partial.** +In supported Basic ordinary auctions, the Emperor can give spice to the +winning mutual ally before payment. The original bid and pledged split survive +gift, cancellation, Kull and supported Semuta responses. Seven related files +pass 32 cases; public-start journeys use real alliances and auctions with +staged balances and conserved card custody. The uniform online priority is +provisional; optional modules, special auctions, complete games and deployed +acceptance remain open. +[Scope](EMPEROR_ALLIANCE_TIMING.md#basic-allied-auction-payment--30-october-2026). + **Basic three-deck Nexus Kull and Ixian replacement — 30 October. Prototyped / Partial.** Fresh Basic Nexus games with all three selected decks retain Kull, including actual Ecaz/Moritani setup; opting out retains Semuta. Ixian Nexus Secret diff --git a/docs/EMPEROR_ALLIANCE_TIMING.md b/docs/EMPEROR_ALLIANCE_TIMING.md index 5f41f279..d2f3b30e 100644 --- a/docs/EMPEROR_ALLIANCE_TIMING.md +++ b/docs/EMPEROR_ALLIANCE_TIMING.md @@ -1,5 +1,24 @@ # Emperor alliance gift timing +## Basic allied auction payment — 30 October 2026 + +In a supported Basic ordinary auction, the Emperor can give spice to the +mutual ally who has won the current lot before its original payment. The +neutral opportunity does not reveal the Emperor's private balance or the +buyer's cards. Allowance transfers the gift once; Karama cancellation transfers +nothing, and Continue returns to the same bid, pledged split and payment. +Supported native-faction games also retain the existing Kull and Semuta +responses to cancellation: their own costs and claim/pass finish before the +same gift opportunity resumes. The existing Basic payment routing remains. + +The [publisher FAQ, p. 5](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf) +permits the Emperor to give a mutual ally spice directly for any reason. This +uniform online priority and explicit Continue are provisional timing, not a +publisher ruling. Public-start fixtures use real setup, worm, alliance and +bids with staged balances, card custody and selected unopened Spice order. +Nexus and other optional modules, special Richese auctions, other timing +parents, complete games and deployed acceptance remain open. + ## Basic Emperor gift during Ix phase openings — 29 October 2026 In a clean Basic game with the Ix deck, classic factions and native Ixians or diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 129834e8..516f64d8 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,12 @@ # Rule decision index +## Basic Emperor allied auction gift — 30 October 2026 + +The Emperor may offer the existing mutual-ally gift before a supported Basic +ordinary auction payment, retaining the pledged split and original response. +Uniform online priority and Continue are provisional. +[Scope](EMPEROR_ALLIANCE_TIMING.md#basic-allied-auction-payment--30-october-2026). + ## Basic three-deck Nexus Kull and Ixian replacement — 30 October 2026 The existing Basic Kull preview composes with selected Ecaz/Moritani decks diff --git a/game/engine.ts b/game/engine.ts index c2924447..0fd56a4c 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -6715,6 +6715,14 @@ function semutaEmperorShareKaramaResponse(g: Game, response: ResponseWindow): bo response.amount! > 0 && (response.elite === undefined || response.elite === 0 || response.elite === 1) && !g.battle && !g.pendingShipment && !g.pendingGuildTransport; } +/** The auction gift may park only its exact original buyer/payment decision. */ +function emperorAuctionGiftResumeMatches(g: Game, response: ResponseWindow, decision: Game['decision']): boolean { + const pending = g.pendingEmperorAuctionGift; + return !!pending && response.kind === 'emperorGift' && response.emperorAuctionGiftEvent === pending.event && + response.owner === pending.owner && response.recipient === pending.recipient && + decision?.kind === 'auctionPayment' && decision.player === pending.recipient && + g.phase === 3 && !!g.auction && g.auction.bidder === pending.recipient && !g.currentAuctionSale && !g.richeseAuction; +} /** Preserve physical identity: a Shrine authorizes the role, not a manufactured card. */ function semutaKaramaRoleCost(g: Game, owner: string, card: Card): boolean { return card.effect === 'karama' || (card.effect === 'truthtrance' && @@ -8723,7 +8731,8 @@ function treacheryDiscardIntegrity(g: Game) { c.resume.response?.kind === c.use.response.kind && JSON.stringify(c.resume.response) === JSON.stringify(c.use.response) && (c.kind === 'karamaBgConversionCancelDiscard' || - ((c.kind === 'karamaTerminalDiscard' || c.kind === 'karamaCombatDiscard' || !c.resume.decision) && + ((c.kind === 'karamaTerminalDiscard' || c.kind === 'karamaCombatDiscard' || !c.resume.decision || + c.kind === 'karamaEmperorShareDiscard' && emperorAuctionGiftResumeMatches(g, c.use.response, c.resume.decision)) && !c.resume.pendingKarama && !c.resume.phaseOpening)) && batch.cause === `karama:${c.use.response.kind}` && batch.entries.length === 1 && @@ -10171,7 +10180,9 @@ function semutaOfferSupported( : continuation.kind === 'karamaShipmentDiscard' ? semutaGuildShipmentKaramaResponse(g, continuation.use.response) : ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) && - (terminalDiscard || combatDiscard || !continuation.resume.decision) && !continuation.resume.pendingKarama && + (terminalDiscard || combatDiscard || !continuation.resume.decision || + continuation.kind === 'karamaEmperorShareDiscard' && + emperorAuctionGiftResumeMatches(g, continuation.use.response, continuation.resume.decision)) && !continuation.resume.pendingKarama && !continuation.resume.phaseOpening && !g.pendingKarama && (!g.response || JSON.stringify(g.response) === JSON.stringify(continuation.resume.response)) : true) && @@ -10243,7 +10254,9 @@ function semutaOfferSupported( continuation.use.response.kind === 'emperorIncome' ? !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal' ? !!g.auction && !g.richeseAuction : !!g.richeseAuction && !g.auction) - : ordinaryAuctionDiscard || (continuation.kind === 'saphoAuctionDiscard' && continuation.cyclic === 'normal') || !g.auction)) && + : ordinaryAuctionDiscard || (continuation.kind === 'saphoAuctionDiscard' && continuation.cyclic === 'normal') || + continuation.kind === 'karamaEmperorShareDiscard' && + emperorAuctionGiftResumeMatches(g, continuation.use.response, continuation.resume.decision) || !g.auction)) && (advisorDiscard || auctionCostDiscard || bonusDiscard || auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' || continuation.kind === 'ixAllyCard' || (continuation.kind === 'karamaIncomeDiscard' && @@ -18911,14 +18924,21 @@ function auctionNext(g: Game) { a.active = next.player; g.active = a.active; } +function emperorAuctionGiftParked(g: Game) { + const c = g.pendingTreacheryDiscard?.continuation; + if (c?.kind === 'kullCounterDiscard') return c.frame.resume; + if (c?.kind === 'kullSuccessDiscard' || c?.kind === 'karamaEmperorShareDiscard') return c.resume; + return g.pendingKull?.resume ?? null; +} function emperorAuctionGiftSignature(g: Game): string { - return JSON.stringify([g.turn, g.phase, g.active, g.decision, g.order, g.auction, g.aid]); + return JSON.stringify([g.turn, g.phase, g.active, g.decision ?? emperorAuctionGiftParked(g)?.decision, g.order, g.auction, g.aid]); } /** The public alliance and winning seat alone determine this first refusal. * Only auctionNext creates it: a restored payment never reopens the offer. */ function beginEmperorAuctionGift(g: Game) { - if (!g.advanced || g.status !== 'playing' || g.phase !== 3 || - g.expansions.length !== 0 || g.players.some(seat => !['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction)) || + if (g.status !== 'playing' || g.phase !== 3 || + (g.advanced ? g.expansions.length !== 0 || g.players.some(seat => !['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction)) + : g.expansions.length > 0 && !selectedPhaseModuleRoster(g)) || g.techTokens || g.strongholdCards || g.leaderSkills || g.homeworlds || g.nexusCards || g.discoveryEnabled || g.sandtrout || g.coalitionState || g.pendingEmperorAuctionGift || g.response || g.phaseOpening || g.truthtrance || g.pendingKarama || @@ -18943,14 +18963,16 @@ function emperorAuctionGiftIntegrity(g: Game) { } const owner = g.players.find(player => player.id === pending.owner); const recipient = g.players.find(player => player.id === pending.recipient); - requireRule(g.status === 'playing' && g.advanced && g.phase === 3 && g.turn === pending.turn && + const parked = emperorAuctionGiftParked(g); + const decision = g.decision ?? parked?.decision; + requireRule(g.status === 'playing' && g.phase === 3 && g.turn === pending.turn && typeof pending.event === 'string' && !!pending.event && owner?.faction === 'emperor' && recipient && owner.ally === recipient.id && recipient.ally === owner.id && - g.decision?.kind === 'auctionPayment' && g.decision.player === recipient.id && + decision?.kind === 'auctionPayment' && decision.player === recipient.id && g.auction?.bidder === recipient.id && pending.signature === emperorAuctionGiftSignature(g), 'The Emperor auction gift lost its original payment opportunity.'); - const response = g.pendingKarama?.use.kind === 'cancel' - ? g.pendingKarama.use.response : g.response; + const response = parked?.response ?? (g.pendingKarama?.use.kind === 'cancel' + ? g.pendingKarama.use.response : g.response); if (response) requireRule(response.kind === 'emperorGift' && response.owner === owner.id && response.recipient === recipient.id && response.emperorAuctionGiftEvent === pending.event && @@ -20801,7 +20823,9 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered g.response = null; if (bgConversionCancelDiscard) { g.decision = null; g.pendingKarama = null; g.phaseOpening = null; - } else if (continuation.kind === 'karamaTerminalDiscard' || continuation.kind === 'karamaCombatDiscard') g.decision = null; + } else if (continuation.kind === 'karamaTerminalDiscard' || continuation.kind === 'karamaCombatDiscard' || + continuation.kind === 'karamaEmperorShareDiscard' && + emperorAuctionGiftResumeMatches(g, continuation.use.response, continuation.resume.decision)) g.decision = null; continuation.stateSignature = karamaResponseDiscardSignature(g, continuation); stageTreacheryDiscard(g, `karama:${use.response.kind}`, entries, continuation); return; @@ -44604,7 +44628,7 @@ function applyActionInner( revealPlayerNoField(g, p, 'voluntary'); return g; } - if (g.pendingEmperorAuctionGift && !g.response && !g.pendingKarama) { + if (g.pendingEmperorAuctionGift && !g.response && !g.pendingKarama && !g.pendingKull) { const pending = g.pendingEmperorAuctionGift; if (t === 'advanceBots') return g; requireRule(id === pending.owner, 'Wait for the Emperor to finish the allied gift opportunity.'); @@ -44623,7 +44647,7 @@ function applyActionInner( return g; } if (g.pendingEmperorAuctionGift && (g.response || g.pendingKarama) && t !== 'advanceBots') - requireRule(t === 'passResponse' || t === 'card' && action.mode === 'cancel', + requireRule(t === 'passResponse' || t === 'card' && action.mode === 'cancel' || t === 'kullDecision' && !!g.pendingKull, 'Resolve the Emperor gift response before another action.'); if (g.nexusFremenRideBetrayal?.stage === 'choose') { if (t === 'advanceBots') return g; diff --git a/game/faction-reference.ts b/game/faction-reference.ts index 4a3547ba..15ed5a12 100644 --- a/game/faction-reference.ts +++ b/game/faction-reference.ts @@ -86,6 +86,7 @@ export const FACTION_RULES: Record< 'Start with all twenty forces in reserve and ten spice. One force revival is free each turn.', 'Receive payments for other players’ auction purchases. Your own purchases pay the bank; a Karama purchase pays no spice. A cancellation sends one auction payment to the bank.', 'Give spice directly to your ally. After the response window it is immediately spendable, unlike a deferred bribe between non-allies.', + 'In a supported Basic ordinary auction, you may give your mutual ally spice after they win and before their original payment. Continue preserves the bid and pledged split; a canceled gift transfers nothing.', 'In supported Moritani games, you may also give spice while your ally is choosing whether to pay Extortion. The original choice resumes after the gift’s Karama response.', 'In supported Basic Ix-deck games with classic factions, Ixians or Tleilaxu, you may give an ally spice during the Amal phase opening, even after you have passed. The original opening resumes after the gift response.', 'During Revival, pay two spice per extra allied force, up to three extra forces per turn. These are separate from the ally’s normal three-force allowance and may be purchased before or after ordinary revivals.', diff --git a/tests/emperor-auction-gift-basic-expansion.test.ts b/tests/emperor-auction-gift-basic-expansion.test.ts new file mode 100644 index 00000000..46da8c5f --- /dev/null +++ b/tests/emperor-auction-gift-basic-expansion.test.ts @@ -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.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!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); +}); diff --git a/tests/emperor-auction-gift-basic-native.test.ts b/tests/emperor-auction-gift-basic-native.test.ts new file mode 100644 index 00000000..1a4e6273 --- /dev/null +++ b/tests/emperor-auction-gift-basic-native.test.ts @@ -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); +}); diff --git a/tests/emperor-auction-gift-basic-public.test.ts b/tests/emperor-auction-gift-basic-public.test.ts new file mode 100644 index 00000000..d4fa17a3 --- /dev/null +++ b/tests/emperor-auction-gift-basic-public.test.ts @@ -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)); + } +});