Enable Basic mixed-deck Emperor Extortion gifts
Verify and publish checkpoint container / container (push) Successful in 12m11s

This commit is contained in:
Quintine committed 2026-10-30 19:13:54 +11:00
1 parent dac1b432db
commit 3bfcd10140
7 files changed
+374 -9

No files matched your search

+10
View File
@@ -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
+15
View File
@@ -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,
+7
View File
@@ -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
+30 -8
View File
@@ -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<Record<string, Card>> =
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),
+1 -1
View File
@@ -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.',
@@ -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.players.length+2;n++) {
const seat=g.players.find(p=>!g.response!.passed.includes(p.id)); assert.ok(seat);
g=act(g,seat.id,{type:'passResponse'});
}
return g;
}
function passSemuta(g:Game) {
for(let n=0;g.pendingTreacheryDiscard?.reaction?.stage==='offer'&&n<g.players.length;n++) {
const pending=g.pendingTreacheryDiscard, reaction=pending.reaction;
assert.ok(reaction&&reaction.stage==='offer');
const seat=g.players.find(p=>!reaction.passed.includes(p.id)); assert.ok(seat);
g=act(g,seat.id,{type:'semutaPass',event:pending.batch.event});
}
return g;
}
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<g.players.length;n++)
g=act(g,g.decision.player,payment(g,false));
assert.equal(player(g,'m').spice,f.beforeAward+5+(success?3:0));
assert.equal(g.moritaniExtortion,undefined);
});
for(const counterId of ['karama','richese-karama']) for(const claim of [false,true]) void test(
`Basic mixed Extortion gift: ${counterId} counter prevents Kull and Semuta ${claim?'claims':'passes'}`,()=>{
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<g.players.length;n++)
g=act(g,g.decision.player,payment(g,false));
assert.equal(player(g,'m').spice,f.beforeAward+5);
assert.equal(g.moritaniExtortion,undefined);
});
@@ -0,0 +1,158 @@
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 { botActions, runBots } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
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 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<NonNullable<Game['decision']>,{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);
}
});