diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 5ee89183..1d7dba23 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,16 @@ open mode gates. ## Current checkpoint and work +**Basic mixed-deck Emperor gifts during Extortion — 30 October. Prototyped / +Partial.** Supported Basic games with selected Ix and CHOAM decks now retain +the Emperor's gift while an ally chooses Extortion payment. The original +decision and queue resume after gift, Karama, Kull and Semuta; automated allies +wait for a human Emperor to Continue. A physical Richese Karama now also +passes Kull counter discard validation. Six related files pass 41 cases. The +online ordering is provisional; independent Ecaz cards, optional modules, +competing parents, complete games and deployed acceptance remain open. +[Scope](EMPEROR_ALLIANCE_TIMING.md#basic-mixed-selected-deck-extortion--30-october-2026). + **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 diff --git a/docs/EMPEROR_ALLIANCE_TIMING.md b/docs/EMPEROR_ALLIANCE_TIMING.md index f182510a..5c23fb9b 100644 --- a/docs/EMPEROR_ALLIANCE_TIMING.md +++ b/docs/EMPEROR_ALLIANCE_TIMING.md @@ -88,6 +88,21 @@ timing ruling. Other interrupted windows remain unfinished. [Runtime cases](../tests/emperor-extortion-gift.test.ts) and [bot-priority cases](../tests/emperor-extortion-bot-priority.test.ts). +### Basic mixed selected-deck Extortion — 30 October 2026 + +The same Basic opportunity is available with selected Ix and CHOAM decks and +their native factions, retaining the original payer, queue, token and award. +The human Emperor's automated ally waits for gift or Continue. A real Karama +may be interrupted by supported Kull and Semuta; their costs and responses +resume the same Extortion decision. The Kull discard's canonical identity +check also recognizes the real Richese cache Karama when used as a distinct +counter. [Ixian and Tleilaxu journeys](../tests/emperor-extortion-gift-mixed-basic.test.ts) +and [CHOAM/Richese reactions](../tests/emperor-extortion-gift-kull-semuta-basic.test.ts) +use public setup and native decisions with staged alliances, cards, positions +and wallets. The FAQ-based online ordering remains provisional. Independent +Ecaz cards, optional modules, competing parents, complete games and deployed +acceptance remain open; the Advanced envelope is unchanged. + ## Advanced allied auction payment — 29 October 2026 For a clean classic Advanced auction won by the Emperor's current mutual ally, diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 516f64d8..2de40798 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,12 @@ # Rule decision index +## Basic mixed-deck Emperor gift during Extortion — 30 October 2026 + +The existing provisional allied-gift and Continue ordering applies to +supported Basic mixed-deck Extortion. Kull and Semuta retain the original +payment decision; this composition is not a new publisher ruling. +[Scope](EMPEROR_ALLIANCE_TIMING.md#basic-mixed-selected-deck-extortion--30-october-2026). + ## Basic Emperor allied auction gift — 30 October 2026 The Emperor may offer the existing mutual-ally gift before a supported Basic diff --git a/game/engine.ts b/game/engine.ts index 0ee5f6ab..f5bb8b7c 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -6727,6 +6727,16 @@ function emperorAuctionGiftResumeMatches(g: Game, response: ResponseWindow, deci : decision?.kind === 'auctionPayment' && decision.player === pending.recipient && !!g.auction && g.auction.bidder === pending.recipient && !g.richeseAuction); } +/** A suspended Extortion gift must resume the very same payer and queue. */ +function emperorExtortionGiftResumeMatches(g: Game, response: ResponseWindow, decision: Game['decision']): boolean { + const parent = response.emperorExtortionGift, extortion = g.moritaniExtortion; + return !!parent && response.kind === 'emperorGift' && g.phase === 8 && + !!extortion && extortion.stage === 'payment' && decision?.kind === 'moritaniExtortion' && + decision.player === response.recipient && decision.player === extortion.queue[extortion.cursor] && + decision.event === extortionEvent(extortion) && Number.isSafeInteger(response.amount) && + typeof response.amount === 'number' && response.amount > 0 && + parent.signature === emperorExtortionGiftSignature({...g, decision}, response.owner, decision.player, response.amount); +} /** 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' && @@ -8737,7 +8747,8 @@ function treacheryDiscardIntegrity(g: Game) { JSON.stringify(c.resume.response) === JSON.stringify(c.use.response) && (c.kind === 'karamaBgConversionCancelDiscard' || ((c.kind === 'karamaTerminalDiscard' || c.kind === 'karamaCombatDiscard' || !c.resume.decision || - c.kind === 'karamaEmperorShareDiscard' && emperorAuctionGiftResumeMatches(g, c.use.response, c.resume.decision)) && + c.kind === 'karamaEmperorShareDiscard' && (emperorAuctionGiftResumeMatches(g, c.use.response, c.resume.decision) || + emperorExtortionGiftResumeMatches(g, c.use.response, c.resume.decision))) && !c.resume.pendingKarama && !c.resume.phaseOpening)) && batch.cause === `karama:${c.use.response.kind}` && batch.entries.length === 1 && @@ -10187,7 +10198,8 @@ function semutaOfferSupported( : ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) && (terminalDiscard || combatDiscard || !continuation.resume.decision || continuation.kind === 'karamaEmperorShareDiscard' && - emperorAuctionGiftResumeMatches(g, continuation.use.response, continuation.resume.decision)) && !continuation.resume.pendingKarama && + (emperorAuctionGiftResumeMatches(g, continuation.use.response, continuation.resume.decision) || + emperorExtortionGiftResumeMatches(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) && @@ -14159,6 +14171,12 @@ function moritaniExtortionIntegrity(g: Game) { const savedDecisions = pending.stage === 'payment' ? homeworldSavedDecisions(g).filter(decision => decision.kind === 'moritaniExtortion') : []; + const continuation = g.pendingTreacheryDiscard?.continuation; + const frame = g.pendingKull ?? (continuation?.kind === 'kullCounterDiscard' ? continuation.frame : null); + if (!savedDecisions.length && frame?.intent.kind === 'ordinary' && frame.intent.use.kind === 'cancel' && + frame.resume.decision?.kind === 'moritaniExtortion' && + emperorExtortionGiftResumeMatches(g, frame.intent.use.response, frame.resume.decision)) + savedDecisions.push(frame.resume.decision); requireRule(pending.stage === 'reserved' ? g.phase <= 8 : pending.stage === 'payment' && g.phase === 8 && @@ -20251,7 +20269,7 @@ function kullSettledRestriction(g: Game, frame: KullFrame): KullPhaseRestriction // Independent printed descriptors, assembled once rather than trusting a saved // hand to define its own identity or rebuilding the physical deck per quote. const kullCanonicalCards: Readonly> = - Object.fromEntries(treacheryDeck(['choam', 'ix']).map(card => [card.id, card])); + Object.fromEntries([...treacheryDeck(['choam', 'ix']), ...richeseCards()].map(card => [card.id, card])); function canonicalKullCard(card: Card): boolean { const expected = Object.hasOwn(kullCanonicalCards, card.id) ? kullCanonicalCards[card.id] : undefined; return !!expected && card.name === expected.name && card.kind === expected.kind && @@ -20861,7 +20879,8 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered g.decision = null; g.pendingKarama = null; g.phaseOpening = null; } else if (continuation.kind === 'karamaTerminalDiscard' || continuation.kind === 'karamaCombatDiscard' || continuation.kind === 'karamaEmperorShareDiscard' && - emperorAuctionGiftResumeMatches(g, continuation.use.response, continuation.resume.decision)) g.decision = null; + (emperorAuctionGiftResumeMatches(g, continuation.use.response, continuation.resume.decision) || + emperorExtortionGiftResumeMatches(g, continuation.use.response, continuation.resume.decision))) g.decision = null; continuation.stateSignature = karamaResponseDiscardSignature(g, continuation); stageTreacheryDiscard(g, `karama:${use.response.kind}`, entries, continuation); return; @@ -30550,9 +30569,10 @@ function emperorExtortionGiftSignature(g: Game, owner: string, recipient: string } function emperorExtortionGiftPublicEligible(g: Game, p: Player): boolean { const decision = g.decision, extortion = g.moritaniExtortion; - if (g.status !== 'playing' || g.phase !== 8 || g.expansions.length !== 1 || - g.expansions[0] !== 'ecaz' || g.players.some(seat => - !['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen', 'moritani'].includes(seat.faction)) || + if (g.status !== 'playing' || g.phase !== 8 || + (g.advanced ? g.expansions.length !== 1 || g.expansions[0] !== 'ecaz' || g.players.some(seat => + !['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen', 'moritani'].includes(seat.faction)) + : !g.expansions.includes('ecaz') || !selectedPhaseModuleRoster(g)) || g.ecazTreachery || g.techTokens || g.strongholdCards || g.leaderSkills || g.homeworlds || g.nexusCards || g.discoveryEnabled || g.sandtrout || g.coalitionState || g.response || g.phaseOpening || g.truthtrance || g.pendingKarama || g.pendingTreacheryDiscard || g.battle || @@ -43432,7 +43452,9 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi const extortionGiftResponse = state.response?.emperorExtortionGift || (state.pendingKarama?.use.kind === 'cancel' && state.pendingKarama.use.response.emperorExtortionGift); if (extortionGiftResponse && action?.type !== 'advanceBots') - requireRule(action?.type === 'passResponse' || action?.type === 'card' && action.mode === 'cancel', + requireRule(action?.type === 'passResponse' || action?.type === 'card' && action.mode === 'cancel' || + action?.type === 'kullDecision' && !!state.pendingKull && + state.pendingKull.resume.response?.emperorExtortionGift?.event === extortionGiftResponse.event, 'Resolve the Emperor gift response before another action.'); if (state.pendingEmperorAuctionGift) requireRule(!['dealPay', 'dealOffer', 'dealRespond', 'dealWithdraw', 'dealRelease'].includes(action?.type), diff --git a/game/faction-reference.ts b/game/faction-reference.ts index 80116e55..0704a761 100644 --- a/game/faction-reference.ts +++ b/game/faction-reference.ts @@ -87,7 +87,7 @@ export const FACTION_RULES: Record< '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 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 Moritani games, including Basic games with other selected faction decks, 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.', 'Spice gifts and extra revival uses have Karama response windows. Canceling a use spends neither the Emperor’s spice nor the extra-revival allowance.', diff --git a/tests/emperor-extortion-gift-kull-semuta-basic.test.ts b/tests/emperor-extortion-gift-kull-semuta-basic.test.ts new file mode 100644 index 00000000..737163a8 --- /dev/null +++ b/tests/emperor-extortion-gift-kull-semuta-basic.test.ts @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game} from '../game/engine'; +import {territory} from '../game/board'; +import {placeTerror} from '../game/moritani-terror'; +import {takeKullCard} from './fixture-choam-kull'; +import {nextStrongholdFactionsNativeStep} from './fixture-stronghold-factions'; + +const act = (g:Game, id:string, action:Action) => applyAction(g, id, action); +const player = (g:Game, id:string) => { const p=g.players.find(row=>row.id===id); assert.ok(p); return p; }; +function passResponse(g:Game) { + for(let n=0;g.response&&n!g.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; +} +function payment(g:Game,pay:boolean):Action { + assert.ok(g.decision?.kind==='moritaniExtortion'); + return {type:'decision',event:g.decision.event,pay}; +} +function parent(g:Game) { + return {decision:structuredClone(g.decision),obligation:structuredClone(g.moritaniExtortion), + order:[...g.order],turn:g.turn,phase:g.phase}; +} + +/** Real public mixed-faction Basic start and native setup. Stage the alliance, + * physical token, hands and clean movement boundary; a real shipment reveals + * Extortion and native Mentat awards bank spice and opens the payer queue. */ +function fixture(counterId='karama') { + let g=createGame('EMPEXTKULL',newPlayer('m','Moritani','moritani'),false,['ecaz','choam','ix']); + for(const [id,faction] of [['e','emperor'],['a','atreides'],['c','choam'],['r','richese']] as const) + joinGame(g,newPlayer(id,faction,faction)); + for(const p of g.players)g=act(g,p.id,{type:'ready'}); + g=act(g,'m',{type:'start'}); + assert.equal(g.kullPreview,true); assert.equal(g.semutaPreview,true); + for(let n=0;n<100&&g.status==='setup';n++) { + if(g.decision?.kind==='moritaniSetup') + g=act(g,'m',{type:'decision',territory:'sietch_tabr',sector:territory('sietch_tabr').sectors[0]}); + else { const next=nextStrongholdFactionsNativeStep(g); assert.ok(next); g=act(g,next.actor,next.action); } + } + assert.equal(g.status,'playing'); + player(g,'e').ally='a'; player(g,'a').ally='e'; + const token=g.moritaniTerror!.tokens.find(row=>row.kind==='extortion'); assert.ok(token); + g.moritaniTerror=placeTerror(g.moritaniTerror!,token.id,'habbanya_ridge_sietch',1); + Object.assign(g,{phase:5,turn:2,storm:18,order:['e','a','c','r','m'],active:'e', + movementRemaining:['e','a','c','r','m'],response:null,decision:null,phaseOpening:null,ready:[]}); + player(g,'e').spice=10; player(g,'a').spice=2; + for(const p of g.players)g.deck.push(...p.hand.splice(0)); + const kull=takeKullCard(g,'ix-kull-wahad'); player(g,'c').hand.push(kull); + const karama=takeKullCard(g,'karama'); player(g,'m').hand.push(karama); + // A Richese Karama counter is a conserved physical cache card, staged as + // already acquired by the ally; ordinary counters come from the real deck. + const counter=takeKullCard(g,counterId); player(g,'a').hand.push(counter); + const semuta=takeKullCard(g,'richese-semuta-drug'); player(g,'r').hand.push(semuta); + g=act(g,'e',{type:'ship',territory:'habbanya_ridge_sietch',sector:17,amount:1}); + assert.equal(g.decision?.kind,'moritaniTerror'); + g=act(g,'m',{type:'decision',reveal:true}); + assert.equal(g.moritaniTerror!.tokens.find(row=>row.id===token.id)?.status,'extortion'); + const beforeAward=player(g,'m').spice; + g.phase=7; g.ready=[]; + for(const p of g.players)g=act(g,p.id,{type:'ready'}); + for(let n=0;g.decision?.kind!=='moritaniPlacement'&&n<30;n++) { + const next=nextStrongholdFactionsNativeStep(g); assert.ok(next, 'Original Collection continuation must remain playable'); + g=act(g,next.actor,next.action); + } + assert.equal(g.phase,8); assert.equal(g.decision?.kind,'moritaniPlacement'); + g=act(g,'m',{type:'decision',decline:true}); + assert.equal(player(g,'m').spice,beforeAward+5); + assert.equal(g.decision?.kind,'moritaniExtortion'); assert.equal(g.decision.player,'e'); + g=act(g,'e',payment(g,false)); + assert.equal(g.decision?.kind,'moritaniExtortion'); assert.equal(g.decision.player,'a'); + assert.equal(viewGame(g,'a').extortion.pending?.canPay,false); + return {g,token:token.id,kull:kull.id,karama:karama.id,counter:counter.id,semuta:semuta.id,beforeAward}; +} + +for(const success of [false,true]) for(const claim of [false,true]) void test( + `Basic mixed-faction Extortion gift: Kull ${success?'succeeds':'declines'} and Semuta ${claim?'claims':'passes'}`,()=>{ + const f=fixture(),before=parent(f.g),emperor=player(f.g,'e').spice; + assert.deepEqual(viewGame(f.g,'e').emperorExtortionGift,{recipient:'a',maximum:emperor}); + let g=act(f.g,'e',{type:'emperorGift',amount:1}); + g=act(g,'m',{type:'card',mode:'cancel',card:f.karama}); + assert.equal(g.pendingKull?.stage,'offer'); + g=act(g,'c',success + ? {type:'kullDecision',event:g.pendingKull!.event,source:'printed',card:f.kull} + : {type:'kullDecision',event:g.pendingKull!.event,decline:true}); + if(success) { assert.equal(g.pendingKull?.stage,'counter'); g=passResponse(g); } + assert.equal(g.pendingTreacheryDiscard?.reaction?.stage,'offer'); + const physical=success?f.kull:f.karama; + assert.equal(g.pendingTreacheryDiscard!.batch.entries[0].card.id,physical); + const event=g.pendingTreacheryDiscard!.batch.event; + if(claim)g=act(g,'r',{type:'semutaCommit',event});else g=passSemuta(g); + assert.equal(player(g,'r').hand.some(card=>card.id===physical),claim); + if(g.pendingTreacheryDiscard?.reaction?.stage==='offer')g=passSemuta(g); + if(g.response)g=passResponse(g); + assert.deepEqual(parent(g),before); + assert.equal(player(g,'e').spice,emperor-(success?1:0)); + assert.equal(player(g,'a').spice,success?3:2); + assert.equal(player(g,'m').hand.some(card=>card.id===f.karama),success); + assert.equal(g.discard.some(card=>card.id===f.semuta),claim); + assert.equal(player(g,'m').spice,f.beforeAward+5); + assert.equal(g.moritaniTerror!.tokens.find(row=>row.id===f.token)?.status,'extortion'); + assert.equal(viewGame(g,'a').extortion.pending?.canPay,success); + g=act(g,'a',payment(g,success)); + for(let n=0;g.decision?.kind==='moritaniExtortion'&&n{ + const f=fixture(counterId),before=parent(f.g),emperor=player(f.g,'e').spice; + const counter=player(f.g,'a').hand.find(card=>card.id===f.counter); assert.ok(counter); + if(counterId==='richese-karama') { + assert.equal(counter.id,'richese-karama'); + assert.equal(f.g.richeseCache!.some(card=>card.id===counter.id),false); + } + let g=act(f.g,'e',{type:'emperorGift',amount:1}); + g=act(g,'m',{type:'card',mode:'cancel',card:f.karama}); + assert.equal(g.pendingKull?.stage,'offer'); + g=act(g,'c',{type:'kullDecision',event:g.pendingKull!.event,source:'printed',card:f.kull}); + assert.equal(g.pendingKull?.stage,'counter'); + assert.ok(viewGame(g,'a').responseControls?.cancelCards.includes(counter.id)); + g=act(g,'a',{type:'card',mode:'cancel',card:counter.id}); + assert.equal(g.pendingTreacheryDiscard?.reaction?.stage,'offer'); + assert.equal(g.pendingTreacheryDiscard!.batch.entries[0].card.id,counter.id); + const event=g.pendingTreacheryDiscard!.batch.event; + if(claim)g=act(g,'r',{type:'semutaCommit',event});else g=passSemuta(g); + assert.equal(player(g,'r').hand.some(card=>card.id===counter.id),claim); + if(g.pendingTreacheryDiscard?.reaction?.stage==='offer')g=passSemuta(g); + if(g.response)g=passResponse(g); + assert.deepEqual(parent(g),before); + assert.equal(player(g,'e').spice,emperor); assert.equal(player(g,'a').spice,2); + assert.equal(player(g,'c').hand.some(card=>card.id===f.kull),true); + assert.equal(player(g,'m').hand.some(card=>card.id===f.karama),false); + assert.equal(g.discard.some(card=>card.id===f.semuta),claim); + assert.equal(viewGame(g,'a').extortion.pending?.canPay,false); + g=act(g,'a',payment(g,false)); + for(let n=0;g.decision?.kind==='moritaniExtortion'&&n applyAction(game, actor, action); +const seat = (game: Game, id: string) => { + const player = game.players.find(row => row.id === id); assert.ok(player); return player; +}; +const copy = (game: Game): Game => structuredClone(game); +function passResponse(game: Game): Game { + for (let count = 0; game.response && count < 30; count++) { + const player = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(player); + game = act(game, player.id, {type:'passResponse'}); + } + assert.equal(game.response, null); + return game; +} +function payment(game: Game, pay: boolean): Action { + assert.ok(game.decision?.kind === 'moritaniExtortion'); + return {type:'decision', event:game.decision.event, pay}; +} +/** Actual mixed-deck public setup, physical Extortion placement, shipment, + * reveal and Mentat queue. Alliance, turn boundary, wallets and physical + * Karama custody are staged, as in the existing native Extortion fixture. */ +function pending(native: 'ixians' | 'tleilaxu') { + let game = createGame('MIXEDEXTORTION', newPlayer('m','Moritani','moritani'), false, ['ecaz','ix']); + for (const [id, faction] of [['e','emperor'], ['a','atreides'], ['h','harkonnen'], ['i',native]] as const) + joinGame(game, newPlayer(id, faction, faction)); + for (const player of game.players) game = act(game, player.id, {type:'ready'}); + game = act(game, 'm', {type:'start'}); + for (let count = 0; game.status === 'setup' && count < 100; count++) { + if (game.decision?.kind === 'moritaniSetup') + game = act(game, 'm', {type:'decision', territory:'sietch_tabr', sector:territory('sietch_tabr').sectors[0]}); + else { + const step = nextStrongholdFactionsNativeStep(game); assert.ok(step); + game = act(game, step.actor, step.action); + } + } + assert.equal(game.status, 'playing'); + seat(game,'e').ally = 'a'; seat(game,'a').ally = 'e'; + const token = game.moritaniTerror!.tokens.find(row => row.kind === 'extortion'); assert.ok(token); + game.moritaniTerror = placeTerror(game.moritaniTerror!, token.id, 'habbanya_ridge_sietch', 1); + Object.assign(game, {phase:5, turn:2, storm:18, order:['e','a','h','i','m'], active:'e', + movementRemaining:['e','a','h','i','m'], response:null, decision:null, phaseOpening:null, ready:[]}); + seat(game,'e').spice = 11; seat(game,'a').spice = 2; + const harkonnen = seat(game,'h'); + let card = harkonnen.hand.find(row => row.effect === 'karama'); + if (!card) { + const sourceOwner = game.players.find(row => row.id !== 'h' && row.hand.some(item => item.effect === 'karama')); + const source = sourceOwner?.hand ?? game.deck; + const index = source.findIndex(item => item.effect === 'karama'); assert.ok(index >= 0); + [card] = source.splice(index, 1); harkonnen.hand.push(card); + } + const karama = card.id; + game = act(game,'e',{type:'ship',territory:'habbanya_ridge_sietch',sector:17,amount:1}); + assert.equal(game.decision?.kind,'moritaniTerror'); + game = act(game,'m',{type:'decision',reveal:true}); + assert.equal(game.moritaniTerror!.tokens.find(row => row.id === token.id)?.status,'extortion'); + const beforeAward = seat(game,'m').spice; + game.phase = 7; game.ready = []; + for (const player of game.players) game = act(game,player.id,{type:'ready'}); + assert.equal(game.phase,8); + while (game.phaseOpening) { + const step = nextStrongholdFactionsNativeStep(game); assert.ok(step); + game = act(game,step.actor,step.action); + } + assert.equal(game.decision?.kind,'moritaniPlacement'); + game = act(game,'m',{type:'decision',decline:true}); + assert.equal(seat(game,'m').spice,beforeAward + 5); + assert.equal(game.decision?.kind,'moritaniExtortion'); + assert.equal(game.decision.player,'e'); + game = act(game,'e',payment(game,false)); + assert.equal(game.decision?.kind,'moritaniExtortion'); + assert.equal(game.decision.player,'a'); + assert.equal(viewGame(game,'a').extortion.pending?.canPay,false); + return {game, token:token.id, karama, beforeAward}; +} +function original(game: Game) { + return {decision:copy(game).decision, obligation:copy(game).moritaniExtortion, + order:[...game.order], turn:game.turn, phase:game.phase}; +} +for (const native of ['ixians','tleilaxu'] as const) { + void test(`Basic mixed ${native} Emperor gift enables the original Extortion payment`, () => { + const f = pending(native); let game = f.game; + const before = original(game), event = (game.decision as Extract,{kind:'moritaniExtortion'}>).event; + assert.deepEqual(viewGame(game,'e').emperorExtortionGift,{recipient:'a',maximum:10}); + for (const id of ['a','h','i','m']) assert.equal(viewGame(game,id).emperorExtortionGift,null); + game = act(game,'e',{type:'emperorGift',amount:1}); + assert.equal(game.response?.kind,'emperorGift'); + game = passResponse(game); + assert.deepEqual(original(game),before); + assert.equal(seat(game,'e').spice,9); assert.equal(seat(game,'a').spice,3); + assert.equal(viewGame(game,'a').extortion.pending?.canPay,true); + game = act(game,'a',{type:'decision',event,pay:true}); + assert.equal(seat(game,'a').spice,0); assert.equal(seat(game,'m').spice,f.beforeAward + 8); + assert.equal(game.moritaniTerror!.tokens.find(row => row.id === f.token)?.status,'removed'); + assert.equal(game.moritaniExtortion,undefined); + }); + void test(`Basic mixed ${native} Karama cancels the gift and preserves the payer`, () => { + const f = pending(native); let game = f.game; + const before = original(game); + game = act(game,'e',{type:'emperorGift',amount:1}); + game = act(game,'h',{type:'card',mode:'cancel',card:f.karama}); + assert.equal(game.response,null); assert.deepEqual(original(game),before); + assert.equal(seat(game,'e').spice,10); assert.equal(seat(game,'a').spice,2); + assert.equal(viewGame(game,'a').extortion.pending?.canPay,false); + assert.equal(game.discard.filter(card => card.id === f.karama).length,1); + const unchanged = copy(game); assert.throws(() => act(game,'a',payment(game,true))); + assert.deepEqual(game,unchanged); + game = act(game,'a',payment(game,false)); + assert.equal(seat(game,'m').spice,f.beforeAward + 5); + }); +} +void test('all four minimal bot profiles can respond to a mixed Basic Extortion gift', () => { + let {game} = pending('ixians'); + game = act(game,'e',{type:'emperorGift',amount:1}); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game,'h'); view.players.find(player => player.id === 'h')!.bot = difficulty; + const actions = botActions(view); assert.ok(actions.length > 0); + assert.doesNotThrow(() => act(copy(game),'h',actions[0])); + } +}); + +for (const native of ['ixians','tleilaxu'] as const) void test( + `Basic mixed ${native} bot ally waits for the human Emperor gift and Continue`, () => { + const initial = pending(native); + for (const difficulty of DIFFICULTIES) { + let game = copy(initial.game); + seat(game,'a').bot = difficulty; + assert.ok(game.decision?.kind === 'moritaniExtortion'); + const event = game.decision.event; + assert.deepEqual(viewGame(game,'e').emperorExtortionPriority,{event,recipient:'a',canContinue:true}); + assert.deepEqual(viewGame(game,'a').emperorExtortionPriority,{event,recipient:'a',canContinue:false}); + assert.ok(!botActions(viewGame(game,'a')).some(action => action.type === 'decision' && action.event === event)); + let waiting = runBots(game,5); + assert.equal(waiting.decision?.kind,'moritaniExtortion'); + assert.equal(waiting.decision.event,event); + assert.equal(seat(waiting,'a').spice,2); + game = act(game,'e',{type:'emperorGift',amount:1}); + game = passResponse(game); + assert.equal(seat(game,'a').spice,3); + assert.deepEqual(viewGame(game,'e').emperorExtortionPriority,{event,recipient:'a',canContinue:true}); + waiting = runBots(game,5); + assert.equal(waiting.decision?.kind,'moritaniExtortion'); + assert.equal(waiting.decision.event,event); + game = act(game,'e',{type:'emperorExtortionContinue',event}); + const action = botActions(viewGame(game,'a')).find(row => row.type === 'decision' && row.event === event); + assert.ok(action, `${difficulty} must have a legal original Extortion response`); + assert.doesNotThrow(() => act(copy(game),'a',action)); + game = runBots(game,1); + assert.ok(game.decision?.kind !== 'moritaniExtortion' || game.decision.event !== event); + } + });