From dac1b432db95a26da261a730d55e0c84b6311dea Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:06:22 +1100 Subject: [PATCH] Extend Basic Emperor auction gifts to Nexus and Richese --- components/emperor-auction-gift.tsx | 4 +- docs/CURRENT_STATUS.md | 10 ++ docs/EMPEROR_ALLIANCE_TIMING.md | 26 +++- game/engine.ts | 58 +++++++- game/faction-reference.ts | 2 +- ...peror-auction-gift-cache-controls.test.tsx | 32 +++++ .../emperor-auction-gift-nexus-basic.test.ts | 115 ++++++++++++++++ ...or-richese-cache-gift-basic-public.test.ts | 126 ++++++++++++++++++ ...eror-richese-cache-gift-kull-basic.test.ts | 126 ++++++++++++++++++ tests/fixture-nexus-emperor-payment.ts | 4 + tests/nexus-emperor-payment-runtime.test.ts | 20 ++- 11 files changed, 509 insertions(+), 14 deletions(-) create mode 100644 tests/emperor-auction-gift-cache-controls.test.tsx create mode 100644 tests/emperor-auction-gift-nexus-basic.test.ts create mode 100644 tests/emperor-richese-cache-gift-basic-public.test.ts create mode 100644 tests/emperor-richese-cache-gift-kull-basic.test.ts diff --git a/components/emperor-auction-gift.tsx b/components/emperor-auction-gift.tsx index 4c62203e..721b125c 100644 --- a/components/emperor-auction-gift.tsx +++ b/components/emperor-auction-gift.tsx @@ -19,7 +19,9 @@ export function EmperorAuctionGift({ game, act, busy }: { return

Share spice with {recipient}

-

The winning bid and its committed ally payment split remain unchanged. Your ally chooses how to pay after the gift’s Karama response.

+

{offer.source === 'richese' + ? 'The winning bid and its committed ally payment split remain unchanged. Continue settles the Richese sale with that payment after any gift response.' + : 'The winning bid and its committed ally payment split remain unchanged. Your ally chooses how to pay after the gift’s Karama response.'}

setRaw(event.target.value)} /> diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index cb8cda4a..5ee89183 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,16 @@ open mode gates. ## Current checkpoint and work +**Basic Emperor Nexus and Richese cache auction gifts — 30 October. +Prototyped / Partial.** Supported Nexus ordinary auctions now offer the +Emperor's gift before the existing funding choice. Supported Richese Once +Around and Silent cache sales preserve the winner, bid and pledged split +through the gift, Karama, Kull and Semuta before continuing the original sale. +Thirteen related files pass 78 cases; type checking, lint and production build +pass. The ordering is provisional; combined cache and Nexus, other modules and +timing parents, complete games and deployed acceptance remain open. +[Scope](EMPEROR_ALLIANCE_TIMING.md#basic-nexus-and-richese-cache-auction-gifts--30-october-2026). + **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 diff --git a/docs/EMPEROR_ALLIANCE_TIMING.md b/docs/EMPEROR_ALLIANCE_TIMING.md index d2f3b30e..f182510a 100644 --- a/docs/EMPEROR_ALLIANCE_TIMING.md +++ b/docs/EMPEROR_ALLIANCE_TIMING.md @@ -1,5 +1,26 @@ # Emperor alliance gift timing +## Basic Nexus and Richese cache auction gifts — 30 October 2026 + +Supported Basic ordinary Nexus auctions offer the Emperor's gift before the +existing Nexus payment choice. Continuing preserves the original winning bid +and pledged shares; the existing Nexus funding or payment follows. Supported +Basic Richese Once Around and Silent cache sales also offer a gift before the +committed sale settles, retaining the quoted winner, price and funding split. +The cache control explains that Continue settles the original sale. Karama, +Kull and supported Semuta responses retain the same opportunity and physical +cards. Minimal bots can participate in these paths. + +The publisher FAQ cited below permits direct gifts; this ordering and Continue +are provisional online interpretations. Evidence includes public-start +[Nexus auctions](../tests/emperor-auction-gift-nexus-basic.test.ts), +[cache sales](../tests/emperor-richese-cache-gift-basic-public.test.ts), +[Kull and counter-Karama](../tests/emperor-richese-cache-gift-kull-basic.test.ts), +and [cache controls](../tests/emperor-auction-gift-cache-controls.test.tsx). +Fixtures stage selected balances, cards and unopened Spice order. Cache sales +combined with Nexus or other optional modules, other special auctions, wider +timing interactions, complete games and deployed acceptance remain open. + ## Basic allied auction payment — 30 October 2026 In a supported Basic ordinary auction, the Emperor can give spice to the @@ -16,8 +37,9 @@ 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. +At that checkpoint Nexus, special Richese auctions, other optional modules, +other timing parents, complete games and deployed acceptance remained open; +the newer section above describes the additional supported paths. ## Basic Emperor gift during Ix phase openings — 29 October 2026 diff --git a/game/engine.ts b/game/engine.ts index 0fd56a4c..0ee5f6ab 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -2127,6 +2127,8 @@ export type Game = { turn: number; signature: string; giftAmount?: number; + source?: 'richese'; + quote?: Extract; } | null; richeseGiftBlocked?: { turn: number; phase: number; cards: string[] }; richeseAllyOffer?: RicheseAllyOffer | null; @@ -6720,8 +6722,10 @@ function emperorAuctionGiftResumeMatches(g: Game, response: ResponseWindow, deci 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; + g.phase === 3 && !g.currentAuctionSale && (pending.source === 'richese' + ? !decision && !!g.richeseAuction && !g.auction && pending.quote?.winner === pending.recipient + : decision?.kind === 'auctionPayment' && decision.player === pending.recipient && + !!g.auction && g.auction.bidder === pending.recipient && !g.richeseAuction); } /** Preserve physical identity: a Shrine authorizes the role, not a manufactured card. */ function semutaKaramaRoleCost(g: Game, owner: string, card: Card): boolean { @@ -7202,7 +7206,8 @@ function kullSemutaParentSupported(g: Game, frame?: NonNullable): boolean { + if (g.advanced || g.status !== 'playing' || g.phase !== 3 || g.richeseAuction?.source !== 'cache' || + g.techTokens || g.strongholdCards || g.leaderSkills || g.homeworlds || g.nexusCards || + g.discoveryEnabled || g.sandtrout || g.coalitionState || g.ecazTreachery || + g.pendingEmperorAuctionGift || g.response || g.decision || g.phaseOpening || g.truthtrance || + g.pendingKarama || g.pendingTreacheryDiscard || g.pendingRicheseGift || g.pendingNullentropy || + g.currentAuctionSale || g.auction || g.ixAuction) return false; + const emperor = byFaction(g,'emperor'), buyer = g.players.find(player => player.id === quote.winner); + if (!emperor || !buyer || emperor.id === buyer.id || emperor.ally !== buyer.id || buyer.ally !== emperor.id) return false; + g.pendingEmperorAuctionGift = {event:crypto.randomUUID(), owner:emperor.id, recipient:buyer.id, + turn:g.turn, source:'richese', quote:structuredClone(quote), signature:''}; + g.pendingEmperorAuctionGift.signature = emperorAuctionGiftSignature(g); + return true; +} /** 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.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.techTokens || g.strongholdCards || g.leaderSkills || g.homeworlds || (g.advanced && g.nexusCards) || g.discoveryEnabled || g.sandtrout || g.coalitionState || g.pendingEmperorAuctionGift || g.response || g.phaseOpening || g.truthtrance || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingRicheseGift || g.pendingNullentropy || @@ -18968,8 +19000,12 @@ function emperorAuctionGiftIntegrity(g: Game) { 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 && - decision?.kind === 'auctionPayment' && decision.player === recipient.id && - g.auction?.bidder === recipient.id && pending.signature === emperorAuctionGiftSignature(g), + (pending.source === 'richese' + ? !decision && !g.auction && g.richeseAuction?.source === 'cache' && !!pending.quote && + pending.quote.winner === recipient.id && JSON.stringify(pending.quote) === JSON.stringify(richeseSettlementQuote(g)) + : pending.source === undefined && pending.quote === undefined && + 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 = parked?.response ?? (g.pendingKarama?.use.kind === 'cancel' ? g.pendingKarama.use.response : g.response); @@ -44636,6 +44672,13 @@ function applyActionInner( requireRule(Object.keys(action).sort().join(',') === 'event,type' && action.event === pending.event, 'This Emperor auction opportunity is no longer current.'); g.pendingEmperorAuctionGift = null; + if (pending.source === 'richese') { + requireRule(pending.quote, 'The Richese auction gift lost its committed sale.'); + settleRicheseSoldLot(g,pending.quote,false); + return g; + } + if (!g.advanced && g.nexusCards && !beginNexusEmperorPayment(g)) + recoverAuctionPayment(g, getPlayer(g, pending.recipient)); return g; } requireRule(t === 'emperorGift' && Object.keys(action).sort().join(',') === 'amount,type', @@ -50122,6 +50165,7 @@ export function viewGame(state: Game, id: string, now?: number) { ? { owner: g.pendingEmperorAuctionGift.owner } : null, emperorAuctionGift: g.pendingEmperorAuctionGift?.owner === id && !g.response && !g.pendingKarama ? { event: g.pendingEmperorAuctionGift.event, recipient: g.pendingEmperorAuctionGift.recipient, + ...(g.pendingEmperorAuctionGift.source ? {source:g.pendingEmperorAuctionGift.source} : {}), maximum: emperorAuctionGiftMaximum(g, me) } : null, coalitionEmperor: (() => { const group = coalitionEmperorGroup(g, me); diff --git a/game/faction-reference.ts b/game/faction-reference.ts index 15ed5a12..80116e55 100644 --- a/game/faction-reference.ts +++ b/game/faction-reference.ts @@ -86,7 +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 Basic ordinary auctions, including Nexus games, and Richese cache sales, 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-cache-controls.test.tsx b/tests/emperor-auction-gift-cache-controls.test.tsx new file mode 100644 index 00000000..c9eb63a4 --- /dev/null +++ b/tests/emperor-auction-gift-cache-controls.test.tsx @@ -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, /]*disabled)[^>]*>Share spice<\/button>/); + assert.match(html, /]*disabled)[^>]*>Continue to payment<\/button>/); + assert.match(render(view('richese'), true), /]*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/); +}); diff --git a/tests/emperor-auction-gift-nexus-basic.test.ts b/tests/emperor-auction-gift-nexus-basic.test.ts new file mode 100644 index 00000000..da115b67 --- /dev/null +++ b/tests/emperor-auction-gift-nexus-basic.test.ts @@ -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); +}); diff --git a/tests/emperor-richese-cache-gift-basic-public.test.ts b/tests/emperor-richese-cache-gift-basic-public.test.ts new file mode 100644 index 00000000..4ea86723 --- /dev/null +++ b/tests/emperor-richese-cache-gift-basic-public.test.ts @@ -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.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!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); +}); diff --git a/tests/emperor-richese-cache-gift-kull-basic.test.ts b/tests/emperor-richese-cache-gift-kull-basic.test.ts new file mode 100644 index 00000000..92d106df --- /dev/null +++ b/tests/emperor-richese-cache-gift-kull-basic.test.ts @@ -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.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!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); + }); diff --git a/tests/fixture-nexus-emperor-payment.ts b/tests/fixture-nexus-emperor-payment.ts index a907c3f0..8796ad60 100644 --- a/tests/fixture-nexus-emperor-payment.ts +++ b/tests/fixture-nexus-emperor-payment.ts @@ -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); diff --git a/tests/nexus-emperor-payment-runtime.test.ts b/tests/nexus-emperor-payment-runtime.test.ts index 27d4644a..c9880c2f 100644 --- a/tests/nexus-emperor-payment-runtime.test.ts +++ b/tests/nexus-emperor-payment-runtime.test.ts @@ -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);