Connect Moritani Extortion through Mentat settlement

This commit is contained in:
Quintine committed 2026-09-29 14:43:59 +10:00
1 parent 88829b7057
commit 745e4fa8ab
19 files changed
+901 -55

No files matched your search

+1
View File
@@ -45,6 +45,7 @@ const decisions = {
moritaniTerror: true,
moritaniSetup: true,
moritaniPlacement: true,
moritaniExtortion: true,
ecazPlacement: true,
ecazSpice: true,
choamStorm: true,
+5
View File
@@ -1674,6 +1674,11 @@ function policyActions(g: GameView): Action[] {
}
return [{ type: 'decision', decline: true }];
}
if (d.kind === 'moritaniExtortion') {
return g.extortion.pending?.event === d.event && g.extortion.pending.player === me.id
? [{ type: 'decision', event: d.event, pay: false }]
: [];
}
if (d.kind === 'moritaniAssassinate') {
const pending = g.moritaniAssassinate?.pending;
if (!pending || pending.event !== d.event) return [];
+132 -3
View File
@@ -453,9 +453,11 @@ import {
projectTerror,
revealTerror,
returnTerror,
retireExtortion,
type TerrorKind,
type TerrorState,
} from './moritani-terror';
import { reserveExtortion, collectExtortion, answerExtortion, validateExtortionState, type ExtortionState } from './moritani-extortion';
import { STORM_START_SECTOR, PLAYER_CIRCLE_SECTORS } from './player-positions';
import { cashInCards } from './choam-karama';
import { saleOptions, quoteSale, type ChoamMarket } from './choam-market';
@@ -763,6 +765,7 @@ export type Decision =
}
| { kind: 'moritaniSetup'; player: string }
| { kind: 'moritaniPlacement'; player: string }
| { kind: 'moritaniExtortion'; player: string; event: string }
| { kind: 'ecazPlacement'; player: string }
| { kind: 'ecazSpice'; player: string }
| {
@@ -1364,6 +1367,7 @@ export type Game = {
ambassadorEvent?: string;
} | null;
moritaniTerror?: TerrorState;
moritaniExtortion?: ExtortionState;
grummanCollection?: GrummanCollection;
pendingMoritaniPlacement?: {
token: string;
@@ -6862,7 +6866,74 @@ function setupComplete(g: Game) {
!g.players.some((p) => p.faction === 'moritani' && p.reserves === 20)
);
}
function extortionEvent(state: ExtortionState): string {
return JSON.stringify(['moritaniExtortion', state.turn, state.token, state.cursor]);
}
function extortionContext(g: Game) {
return { turn: g.turn, phase: g.phase, players: g.players.map(player => ({ id: player.id, spice: player.spice })) };
}
function extortionRule<T>(apply: () => T): T {
try { return apply(); }
catch (error) {
if (error instanceof Error) throw new RuleError(error.message);
throw error;
}
}
function moritaniExtortionIntegrity(g: Game) {
const pending = g.moritaniExtortion;
const revealed = g.moritaniTerror?.tokens.filter(token => token.status === 'extortion') ?? [];
requireRule(revealed.length === (pending ? 1 : 0) &&
(!pending || (revealed[0].id === pending.token && revealed[0].kind === 'extortion' &&
byFaction(g, 'moritani')?.id === pending.owner && g.status === 'playing')),
'Extortion has lost its revealed token or Moritani owner.');
if (!pending) return;
extortionRule(() => validateExtortionState(pending, extortionContext(g)));
const stormPayers = pending.stage === 'payment'
? g.order.filter(id => id !== pending.owner) : null;
const savedDecisions = pending.stage === 'payment'
? homeworldSavedDecisions(g).filter(decision => decision.kind === 'moritaniExtortion')
: [];
requireRule(pending.stage === 'reserved'
? g.phase <= 8
: pending.stage === 'payment' && g.phase === 8 &&
g.moritaniTerror?.placementTurn === g.turn &&
savedDecisions.length === 1 &&
savedDecisions[0].player === pending.queue[pending.cursor] &&
savedDecisions[0].event === extortionEvent(pending) &&
pending.queue.length === stormPayers?.length &&
pending.queue.every((seat, index) => seat === stormPayers?.[index]),
'Extortion payment must resume in the original Mentat storm order.');
}
function settleMoritaniExtortion(g: Game, state: ExtortionState, recover: boolean) {
requireRule(g.moritaniTerror && state.stage === 'settled',
'Extortion must settle with its revealed Terror token.');
g.moritaniTerror = recover
? returnTerror(g.moritaniTerror, state.token, random)
: retireExtortion(g.moritaniTerror, state.token);
delete g.moritaniExtortion;
log(g, recover
? 'No one paid Extortion. Its token returned to the hidden Terror supply.'
: 'Extortion was paid. Its revealed Terror token was removed from the game.');
finishMoritaniPlacement(g);
}
function finishMoritaniPlacement(g: Game) {
const obligation = g.moritaniExtortion;
if (obligation?.stage === 'reserved') {
requireRule(g.phase === 8 && g.moritaniTerror?.placementTurn === g.turn,
'Finish the Terror placement before collecting Extortion.');
const result = extortionRule(() => collectExtortion(obligation, {
...extortionContext(g), stormOrder: g.order,
}));
getPlayer(g, obligation.owner).spice += 5;
g.moritaniExtortion = result.state;
log(g, 'Moritani collected five reserved spice from the bank for Extortion.');
if (result.state.stage === 'payment') {
const payer = result.state.queue[result.state.cursor];
g.decision = { kind: 'moritaniExtortion', player: payer, event: extortionEvent(result.state) };
} else settleMoritaniExtortion(g, result.state, true);
return;
}
requireRule(!obligation, 'Finish the pending Extortion payments first.');
if (!byFaction(g, 'choam')) victory(g);
}
function finishSetup(g: Game) {
@@ -12799,7 +12870,7 @@ function terrorRevealBlocked(
): string | null {
const homeworldBlock = homeworldTerrorEntryBlock(g, entry.amount);
if (homeworldBlock) return homeworldBlock;
if (kind === 'robbery' || kind === 'sabotage' || kind === 'sneakAttack')
if (kind === 'robbery' || kind === 'sabotage' || kind === 'sneakAttack' || kind === 'extortion')
return null;
if (kind !== 'assassination')
return 'This Terror effect is still being implemented.';
@@ -13029,6 +13100,13 @@ function decideTerror(g: Game, p: Player, action: Action) {
} else if (token.kind === 'sneakAttack') {
entry.stage = 'sneakAttack';
reopen();
} else if (token.kind === 'extortion') {
requireRule(!g.moritaniExtortion, 'An Extortion obligation is already pending.');
g.moritaniExtortion = extortionRule(() => reserveExtortion({
owner: owner.id, token: token.id, turn: g.turn,
}, extortionContext(g))).state;
log(g, 'Five bank spice is reserved for Moritani until Mentat Pause; it is not yet spendable.');
finishTerrorEntry(g);
} else {
const victimCard = shuffle(entrant.hand)[0];
if (victimCard) {
@@ -13910,6 +13988,8 @@ function decideBiddingEnd(g: Game, p: Player, action: Action) {
}
}
function advancePhase(g: Game) {
if (g.phase === 8)
requireRule(!g.moritaniExtortion, 'Settle Extortion before leaving Mentat Pause.');
if (g.phase === 6) boardResolution(() => quoteBattlePhaseAdvance(g));
commitPhaseResources(g, currentPhaseResources(g));
g.ready = [];
@@ -13970,6 +14050,8 @@ function completePhase(g: Game) {
g.grummanCollection.outcome = 'expired';
g.grummanCollection.signature = grummanCollectionSignature(g.grummanCollection);
}
if (g.phase === 8)
requireRule(!g.moritaniExtortion, 'Settle Extortion before ending the turn.');
if (g.phase === 8) settleStrongholdOwnership(g);
g.phase++;
if (g.phase === 9) {
@@ -14068,7 +14150,7 @@ function beginPhase(g: Game) {
g.moritaniTerror.placementTurn !== g.turn
) {
g.decision = { kind: 'moritaniPlacement', player: moritani.id };
} else if (!byFaction(g, 'choam')) victory(g);
} else finishMoritaniPlacement(g);
}
if (g.phase === 0 && g.turn > 1) {
if (g.discoveryEnabled && g.discoveries) {
@@ -19087,7 +19169,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
);
}
g.pendingMoritaniPlacement = null;
if (placementCancellation?.finalVictory)
if (placementCancellation?.finalVictory && !g.moritaniExtortion)
victory(g, placementCancellation.finalVictory);
else finishMoritaniPlacement(g);
} else if (response.kind === 'choamWorthless') {
@@ -22096,6 +22178,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
ecazTreacheryIntegrity(state);
ixRicheseTechnologyIntegrity(state);
leaderSkillsIntegrity(state);
moritaniExtortionIntegrity(state);
discoveryIntegrity(state);
greatMakerIntegrity(state);
nexusChoamTradeIntegrity(state);
@@ -22277,6 +22360,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
homeworldShipmentIntegrity(g);
ixRicheseTechnologyIntegrity(g);
harkonnenExchangeIntegrity(g);
moritaniExtortionIntegrity(g);
return g;
}
function finishActionContinuations(g: Game) {
@@ -22331,6 +22415,12 @@ function finishActionContinuations(g: Game) {
)
finishSetup(g);
settleAdvisors(g);
if (g.status === 'playing' && g.phase === 8 &&
g.moritaniExtortion?.stage === 'reserved' &&
g.moritaniTerror?.placementTurn === g.turn &&
!g.decision && !g.response && !g.truthtrance && !g.phaseOpening &&
!g.pendingKarama && !g.pendingTerrorEntry && !g.pendingAmbassador)
finishMoritaniPlacement(g);
}
function settleAutomaticContinuations(g: Game) {
if (pendingNexusTraitors(g)) return;
@@ -22378,6 +22468,7 @@ export function normalizeAutomaticGame(state: Game): Game {
ecazTreacheryIntegrity(state);
ixRicheseTechnologyIntegrity(state);
leaderSkillsIntegrity(state);
moritaniExtortionIntegrity(state);
discoveryIntegrity(state);
greatMakerIntegrity(state);
nexusChoamTradeIntegrity(state);
@@ -22431,6 +22522,7 @@ export function normalizeAutomaticGame(state: Game): Game {
homeworldShipmentIntegrity(g);
ixRicheseTechnologyIntegrity(g);
harkonnenExchangeIntegrity(g);
moritaniExtortionIntegrity(g);
return g;
}
@@ -22859,6 +22951,29 @@ function applyActionInner(
g.decision = null;
requireRule(decision.kind !== 'leaderSkillVisibility' && decision.kind !== 'leaderSkillRevival', 'Use the Leader Skills controls for this decision.');
if (decision.kind === 'moritaniAssassinate') {actMoritaniAssassinate(g,decision,action);return g;}
if (decision.kind === 'moritaniExtortion') {
const obligation = g.moritaniExtortion;
const pay = action.pay;
requireRule(g.phase === 8 && obligation?.stage === 'payment' &&
obligation.queue[obligation.cursor] === id &&
decision.event === extortionEvent(obligation) &&
action.event === decision.event && typeof pay === 'boolean' &&
Object.keys(action).every(key => ['type', 'event', 'pay'].includes(key)),
'Choose the current Extortion opportunity in storm order.');
const result = extortionRule(() => answerExtortion(obligation, id, pay, extortionContext(g)));
if (result.transfer?.from === 'player') {
p.spice -= result.transfer.amount;
getPlayer(g, result.transfer.to).spice += result.transfer.amount;
log(g, `${p.name} paid Moritani three spice to prevent Extortion recovery.`);
} else log(g, `${p.name} declined the Extortion payment.`);
g.moritaniExtortion = result.state;
if (result.recover !== null) settleMoritaniExtortion(g, result.state, result.recover);
else {
const next = result.state.queue[result.state.cursor];
g.decision = { kind: 'moritaniExtortion', player: next, event: extortionEvent(result.state) };
}
return g;
}
if (decision.kind === 'mentatQuestion') {
actMentatQuestion(g, decision, action);
return g;
@@ -26215,6 +26330,7 @@ export function viewGame(state: Game, id: string) {
ecazTreacheryIntegrity(state);
ixRicheseTechnologyIntegrity(state);
leaderSkillsIntegrity(state);
moritaniExtortionIntegrity(state);
discoveryFlightIntegrity(state);
discoveryIntegrity(state);
greatMakerIntegrity(state);
@@ -26342,6 +26458,18 @@ export function viewGame(state: Game, id: string) {
moritaniTerror: g.moritaniTerror
? projectTerror(g.moritaniTerror, me.faction === 'moritani')
: null,
extortion: {
deferred: g.moritaniExtortion?.stage === 'reserved' ? 5 : 0,
pending: g.moritaniExtortion?.stage === 'payment' && g.decision?.kind === 'moritaniExtortion'
? {
event: g.decision.event,
owner: g.moritaniExtortion.owner,
player: g.decision.player,
amount: 3 as const,
canPay: me.id === g.decision.player ? me.spice >= 3 : null,
}
: null,
},
moritaniPendingPlacement:
me.faction === 'moritani' ? (g.pendingMoritaniPlacement ?? null) : null,
grummanCollection: projectedGrummanCollection(g, id),
@@ -26734,6 +26862,7 @@ export function viewGame(state: Game, id: string) {
g.status === 'playing' &&
(!!byFaction(g, 'choam') ||
g.decision?.kind === 'moritaniPlacement' ||
g.decision?.kind === 'moritaniExtortion' ||
g.response?.kind === 'moritaniPlacement'),
choamMovementBonus:
g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0,
+6
View File
@@ -119,6 +119,12 @@ function validate(state: ExtortionState, context: ExtortionContext) {
}
return players;
}
/** Check an unfinished saved obligation against its original turn and seat roster. */
export function validateExtortionState(state: ExtortionState, context: ExtortionContext): void {
validate(state, context);
}
function clone(state: ExtortionState): ExtortionState {
return {
owner: state.owner,
+13
View File
@@ -155,6 +155,19 @@ export function revealTerror(state: TerrorState, tokenId: string): TerrorState {
};
}
/** A paid Extortion cannot return to supply; the revealed physical token is spent. */
export function retireExtortion(state: TerrorState, tokenId: string): TerrorState {
const token = state.tokens.find(candidate => candidate.id === tokenId);
if (token?.kind !== 'extortion' || token.status !== 'extortion')
throw new Error('Only the revealed Extortion token can be removed.');
return {
...state,
tokens: state.tokens.map(candidate => candidate.id === tokenId
? { ...candidate, status: 'removed' as const }
: candidate),
};
}
/**
* Return an unrevealed token or recover Extortion without resolving its rule effect.
* Every available ID is replaced, and the supply is rebuilt in a fresh random order.
+9 -8
View File
@@ -2829,7 +2829,7 @@ export const RULE_TOPICS: RuleTopic[] = [
summary:
'Random assassination, territorial destruction, deferred income, theft, sabotage and reserve entry.',
steps: [
'Robbery, Sabotage, Sneak Attack and ordinary native-leader Assassination have controls for supported entry reactions. After a Robbery draw, finish any required discard; after Sabotage, give one of your own cards or decline. Enemy of My Enemy has partial alliance controls. Atomics, Extortion and complete Moritani games remain unfinished.',
'Robbery, Sabotage, Sneak Attack, ordinary native-leader Assassination and Extortion have controls for supported entry reactions. After a Robbery draw, finish any required discard; after Sabotage, give one of your own cards or decline. Enemy of My Enemy has partial alliance controls. Atomics and complete Moritani games remain unfinished.',
'Assassination: randomly select one of the entrant’s leaders and send it to the Tanks. Moritani collects spice for that leader’s value; Zoal pays three. Revealing the token resolves the random selection without letting Moritani choose a victim. This token needs neither a battle loss nor a matching Traitor Card. Current controls support ordinary native leaders; unresolved captured, foreign or special-leader pools block revelation and show Moritani the reason privately.',
'Atomics: send every faction’s forces in the territory to the Tanks and place Atomics Aftermath there. Its lasting shipment prohibition and hand-limit penalty are explained in the Aftermath topic.',
'Extortion: set aside five spice from the bank for collection during Mentat Pause. After collection, recover this token unless one player pays you three spice in the storm-order opportunity. The Extortion topic explains the two separate payments.',
@@ -2867,15 +2867,16 @@ export const RULE_TOPICS: RuleTopic[] = [
id: 'moritani-extortion',
title: 'Moritani Extortion: collection and recovery',
category: 'Advanced & expansions',
coverage: 'Planned',
coverage: 'Partial',
summary:
'Five deferred bank spice and a separate opportunity to prevent the token’s return.',
steps: [
'Revealing Extortion sets aside five spice from the bank in front of Moritani’s shield. This is collected during Mentat Pause; it is not immediately available to spend.',
'After collecting that spice, Moritani regains Extortion unless a player pays Moritani three spice. Offer the payment opportunity in storm order. One payment is sufficient to prevent recovery.',
'The three-spice payment is additional to the five-spice bank award. It prevents the token returning to supply; it does not cancel the bank income.',
'If nobody pays, return Extortion to Moritani’s supply. Other revealed Terror tokens have no corresponding recovery provision.',
'The ordering of this recovery against Moritani’s placement opportunity and other Mentat actions is not settled here. Do not assume that a recovered token can always be placed again in the same Mentat Pause.',
'Revealing Extortion sets aside five spice from the bank in front of Moritani’s shield. Moritani collects it during Mentat Pause; it is not available to spend when the token is revealed.',
'For this table’s selected timing, Moritani’s normal Mentat Terror placement or relocation opportunity resolves first, including any Karama response. Only afterward does Moritani collect the five spice and offer the Extortion payment opportunity. A token recovered afterward cannot be placed again during that same Mentat Pause.',
'If Extortion is revealed during Mentat after placement has already finished, resolve that committed entry and its interruptions first. Then collect the bank award and offer payment before any player can end the turn.',
'After collection, each non-Moritani player in storm order may pay Moritani three spice or decline. A player without three spice cannot pay but may decline. The first payment permanently removes Extortion from play and ends the offers; it does not undo the bank award.',
'If every eligible player declines, rotate Extortion and return it face down to Moritani’s hidden supply for a future placement. Other revealed Terror tokens have no corresponding recovery provision.',
'These controls cover Extortion collection, the payment choice and return or removal, not complete Moritani expansion games. Public module starts remain unavailable.',
],
related: ['moritani-terror', 'moritani-terror-effects', 'mentat'],
},
@@ -4049,7 +4050,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games without other optional modules. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. Karama or Guild special prevention preserves physical tokens and unpaid spice, with the ordinary stopped-shipment allowance policy. Other module combinations and the full faceup-component display remain gated.',
'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable.',
'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.',
'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules; this does not implement Atomics, Extortion or complete Moritani games.',
'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules. If Extortion was revealed this turn, its bank award and payment choices follow that placement response; this does not implement Atomics or complete Moritani games.',
'Hidden reactive Betrayal timing remains unresolved for other effects. Complete module games are unfinished, so public module starts remain disabled.',
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding'],
+1
View File
@@ -28,6 +28,7 @@ const DECISIONS = {
moritaniTerror: true,
moritaniSetup: true,
moritaniPlacement: true,
moritaniExtortion: true,
ecazPlacement: true,
ecazSpice: true,
homeworldRevivalDeployment: true,