diff --git a/components/emperor-timing-gift.tsx b/components/emperor-timing-gift.tsx index 5f79266c..44ca5533 100644 --- a/components/emperor-timing-gift.tsx +++ b/components/emperor-timing-gift.tsx @@ -3,8 +3,8 @@ import { useId, useState } from 'react'; import type { Action, GameView } from '@/game/engine'; -/** The engine projects this opportunity only to the Emperor at a supported - * Guild timing decision. Its maximum already accounts for committed spice. */ +/** The engine projects these opportunities only to the Emperor. Their + * maximum already accounts for committed spice. */ export function EmperorTimingGift({ game, act, busy }: { game: GameView; act: (action: Action) => void; @@ -12,7 +12,8 @@ export function EmperorTimingGift({ game, act, busy }: { }) { const id = useId(); const [raw, setRaw] = useState('1'); - const offer = game.emperorGuildTimingGift; + const extortion = game.emperorExtortionGift; + const offer = extortion ?? game.emperorGuildTimingGift; if (!offer) return null; const amount = /^[1-9]\d*$/.test(raw) ? Number(raw) : NaN; const canShare = !busy && Number.isSafeInteger(amount) && amount >= 1 && amount <= offer.maximum; @@ -20,7 +21,9 @@ export function EmperorTimingGift({ game, act, busy }: { return

Share spice with {recipient}

-

The Guild timing decision resumes after the Karama response to your gift.

+

{extortion + ? 'The Moritani Extortion decision resumes after the Karama response to your gift.' + : 'The Guild timing decision resumes after the Karama response to your gift.'}

setRaw(event.target.value)} /> diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index f2f045b5..88443a79 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,16 @@ open mode gates. ## Current checkpoint and work +**Emperor gift during Moritani Extortion — 29 October. Prototyped / Partial.** +In clean Basic and Advanced Moritani games, the Emperor can give the current +allied payer spice before their Extortion choice; the original queue, token and +bank award survive allowance or Karama cancellation. A previously unaffordable +payment, rendered controls and legal bot responses pass. Thirty-one related +cases, TypeScript, lint and production build pass. A bot payer can still act +before a human Emperor gifts; other interruptions and deployed acceptance +remain open. +[Scope](EMPEROR_ALLIANCE_TIMING.md#emperor-gift-during-moritani-extortion--29-october-2026). + **Emperor gift before Advanced allied auction settlement — 29 October. Prototyped / Partial.** A uniform opportunity precedes supported allied winning lots with or without a buyer's Karama, preserving the committed funding split diff --git a/docs/EMPEROR_ALLIANCE_TIMING.md b/docs/EMPEROR_ALLIANCE_TIMING.md index 3beaeb04..3b3b7ffe 100644 --- a/docs/EMPEROR_ALLIANCE_TIMING.md +++ b/docs/EMPEROR_ALLIANCE_TIMING.md @@ -1,5 +1,23 @@ # Emperor alliance gift timing +## Emperor gift during Moritani Extortion — 29 October 2026 + +In supported Basic and Advanced games with Moritani, the Emperor can give +spice to the current Extortion payer when they are mutual allies. Allowing +the gift updates the payer's balance before the original pay-or-decline choice; +Karama cancellation transfers nothing. The ordered queue, collected five-spice +bank award and physical Terror token remain committed through that response. + +The [publisher FAQ, p. 5](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf) +allows the Emperor to give allied spice directly for any reason, collected +immediately. The user-authorized revised Advanced sheet, physical p. 31, +explicitly says any time. Applying the Basic gift at this decision is a +provisional online timing interpretation. This initial entry is limited to the +Ecaz/Moritani expansion with native Moritani and classic factions, without +independent Ecaz cards, optional modules or competing parents. An automated payer can still act before +a human Emperor chooses to gift; broader priority and other interrupted windows +remain unfinished. [Runtime cases](../tests/emperor-extortion-gift.test.ts). + ## 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 e417134c..bcc8888c 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,13 @@ # Rule decision index +## Emperor gift during Moritani Extortion — 29 October 2026 + +The publisher FAQ permits direct allied spice gifts for any reason; the +authorized Advanced sheet explicitly permits them at any time. The first clean +Extortion payment interruption uses that rule in both modes; the Basic timing +composition is provisional, and the original queue and token stay committed. +[Scope](EMPEROR_ALLIANCE_TIMING.md#emperor-gift-during-moritani-extortion--29-october-2026). + ## Advanced Emperor gift before allied auction payment — 29 October 2026 The supplied Advanced rules permit an allied gift at any time. The first diff --git a/game/engine.ts b/game/engine.ts index 8e8bf1ef..30ad0c6d 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1234,6 +1234,8 @@ type SpiceBankerIncomePayment = { export type ResponseWindow = { /** The original public Guild timing decision remains live during this gift. */ emperorGuildTimingGift?: { event: string; signature: string }; + /** The original Extortion payment decision remains live during this gift. */ + emperorExtortionGift?: { event: string; signature: string }; /** Links an allied auction gift to the still-pending payment opportunity. */ emperorAuctionGiftEvent?: string; automaticAdvisorFlip?: AutomaticAdvisorFlipSource; @@ -19429,6 +19431,7 @@ function validateKaramaUse( requireRule(!kullBlock, kullBlock ?? 'Kull Wahad prevents this Karama effect.'); if (use.kind === 'cancel') { validateEmperorGuildTimingGift(g, use.response); + validateEmperorExtortionGift(g, use.response); requireRule( use.response && use.response.owner !== p.id, 'Karama cancels another faction’s power.', @@ -29398,6 +29401,50 @@ function emperorGuildTimingGiftIntegrity(g: Game) { if (g.pendingKarama?.use.kind === 'cancel') validateEmperorGuildTimingGift(g, g.pendingKarama.use.response); } +function emperorExtortionGiftSignature(g: Game, owner: string, recipient: string, amount: number): string { + return JSON.stringify([g.turn, g.phase, g.active, g.order, g.decision, + g.moritaniExtortion, g.moritaniTerror?.placementTurn, owner, recipient, amount]); +} +function emperorExtortionGiftQuote(g: Game, p: Player): {recipient: string; maximum: number} | null { + 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)) || + 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 || + g.pendingRicheseGift || g.pendingNullentropy || g.pendingShipment || g.pendingArrivalOverlap || + !extortion || extortion.stage !== 'payment' || decision?.kind !== 'moritaniExtortion' || + decision.event !== extortionEvent(extortion) || decision.player !== extortion.queue[extortion.cursor] || + p.faction !== 'emperor' || !p.ally || decision.player !== p.ally || + !g.players.some(seat => seat.id === p.ally && seat.ally === p.id) || + liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length || + hasMovementPromises(g, p.id, true) || hasBribePromises(g, p.id, true) || + liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(row => row.answer) || + liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(row => row.answer) || + liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).some(row => row.answer) || + liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length || + livePhaseBidPromises(g.phaseBidPromises ?? [], p.id, g.turn).some(row => row.answer)) return null; + const maximum = Math.max(0, uncommittedSpice(g, p)); + return maximum > 0 ? {recipient: p.ally, maximum} : null; +} +function validateEmperorExtortionGift(g: Game, response: ResponseWindow) { + const parent = response.emperorExtortionGift; + if (!parent) return; + const owner = g.players.find(player => player.id === response.owner); + const recipient = g.players.find(player => player.id === response.recipient); + requireRule(response.kind === 'emperorGift' && typeof parent.event === 'string' && !!parent.event && + owner?.faction === 'emperor' && recipient && owner.ally === recipient.id && recipient.ally === owner.id && + typeof response.amount === 'number' && Number.isSafeInteger(response.amount) && response.amount > 0 && + response.amount <= uncommittedSpice(g, owner) && g.decision?.kind === 'moritaniExtortion' && + g.decision.player === recipient.id && + parent.signature === emperorExtortionGiftSignature(g, owner.id, recipient.id, response.amount), + 'The Emperor gift lost its original Extortion payment decision.'); +} +function emperorExtortionGiftIntegrity(g: Game) { + if (g.response) validateEmperorExtortionGift(g, g.response); + if (g.pendingKarama?.use.kind === 'cancel') validateEmperorExtortionGift(g, g.pendingKarama.use.response); +} function chooseGuildTiming(g: Game, take: boolean) { const guild = byFaction(g, 'guild')!; if (take) g.active = guild.id; @@ -37166,6 +37213,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl currentFactionPayment(g); const response = g.response!; validateEmperorGuildTimingGift(g, response); + validateEmperorExtortionGift(g, response); const advisorPreparationSource = currentAdvisorPreparationResponse(g, response); const automaticAdvisorFlip = response.automaticAdvisorFlip ? currentAutomaticAdvisorFlip(g, response) : null; const fremenGrant = response.kind === 'fremenRevivalGrant' ? currentFremenRevivalGrant(g, response) : null; @@ -42005,8 +42053,14 @@ export function applyAction(state: Game, id: string, action: Action): Game { * automatic deal payment; it is never supplied by a client action. */ function applyActionWithInitiator(state: Game, id: string, action: Action, initiator: string, options?: {insolventDealPayment?: boolean}): Game { + emperorExtortionGiftIntegrity(state); emperorAuctionGiftIntegrity(state); emperorGuildTimingGiftIntegrity(state); + 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', + 'Resolve the Emperor gift response before another action.'); if (state.pendingEmperorAuctionGift) requireRule(!['dealPay', 'dealOffer', 'dealRespond', 'dealWithdraw', 'dealRelease'].includes(action?.type), 'Finish the Emperor auction gift opportunity before changing a public deal.'); @@ -42754,6 +42808,7 @@ function settleAutomaticContinuations(g: Game) { } /** Internal authoritative continuation. Callers must persist with their usual CAS fence. */ export function normalizeAutomaticGame(state: Game): Game { + emperorExtortionGiftIntegrity(state); emperorAuctionGiftIntegrity(state); emperorGuildTimingGiftIntegrity(state); twoPlayerControllersIntegrity(state); @@ -43511,6 +43566,17 @@ function applyActionInner( specialKarama(g, p, action); return g; } + if (t === 'emperorGift' && g.decision?.kind === 'moritaniExtortion' && !g.response) { + const quote = emperorExtortionGiftQuote(g, p); + requireRule(quote && Object.keys(action).sort().join(',') === 'amount,type', + 'This Extortion gift is unavailable or no longer current.'); + const amount = integer(action.amount, 1, quote.maximum, 'Spice'); + g.response = { kind: 'emperorGift', owner: id, recipient: quote.recipient, + amount, elite: 0, passed: [], emperorExtortionGift: { + event: crypto.randomUUID(), signature: emperorExtortionGiftSignature(g, id, quote.recipient, amount), + } }; + return g; + } if (t === 'emperorGift' && g.decision?.kind === 'guildTiming' && !g.response) { const quote = emperorGuildTimingGiftQuote(g, p); requireRule(quote && Object.keys(action).sort().join(',') === 'amount,type', @@ -47308,6 +47374,7 @@ function applyActionInner( throw new RuleError('That action is not available.'); } export function viewGame(state: Game, id: string, now?: number) { + emperorExtortionGiftIntegrity(state); emperorAuctionGiftIntegrity(state); emperorGuildTimingGiftIntegrity(state); twoPlayerControllersIntegrity(state); @@ -48362,6 +48429,7 @@ export function viewGame(state: Game, id: string, now?: number) { ? { amount: undefined } : {}), ...(g.response.emperorGuildTimingGift ? { emperorGuildTimingGift: undefined } : {}), + ...(g.response.emperorExtortionGift ? { emperorExtortionGift: undefined } : {}), ...(g.response.emperorAuctionGiftEvent ? { emperorAuctionGiftEvent: undefined } : {}), } : null, @@ -48429,6 +48497,7 @@ export function viewGame(state: Game, id: string, now?: number) { : 0, }, emperorGuildTimingGift: emperorGuildTimingGiftQuote(g, me), + emperorExtortionGift: emperorExtortionGiftQuote(g, me), emperorAuctionGiftPending: g.pendingEmperorAuctionGift ? { owner: g.pendingEmperorAuctionGift.owner } : null, emperorAuctionGift: g.pendingEmperorAuctionGift?.owner === id && !g.response && !g.pendingKarama diff --git a/game/faction-reference.ts b/game/faction-reference.ts index 2ff5fc01..8a5ac99c 100644 --- a/game/faction-reference.ts +++ b/game/faction-reference.ts @@ -86,6 +86,7 @@ export const FACTION_RULES: Record< 'Start with all twenty forces in reserve and ten spice. One force revival is free each turn.', 'Receive payments for other players’ auction purchases. Your own purchases pay the bank; a Karama purchase pays no spice. A cancellation sends one auction payment to the bank.', 'Give spice directly to your ally. After the response window it is immediately spendable, unlike a deferred bribe between non-allies.', + 'In 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.', '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.test.ts b/tests/emperor-extortion-gift.test.ts new file mode 100644 index 00000000..e747cef6 --- /dev/null +++ b/tests/emperor-extortion-gift.test.ts @@ -0,0 +1,174 @@ +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 } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +const seat = (g: Game, id: string) => { const p = g.players.find(row => row.id === id); assert.ok(p); return p; }; +const act = (g: Game, id: string, action: Action) => applyAction(g, id, action); +const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); +function allow(g: Game) { + for (let i = 0; g.response && i < 30; i++) { + const p = g.players.find(row => !g.response!.passed.includes(row.id)); assert.ok(p); + g = act(g, p.id, {type:'passResponse'}); + } + assert.equal(g.response, null); + return g; +} +function payment(g: Game, pay: boolean): Action { + const d = g.decision; assert.ok(d?.kind === 'moritaniExtortion'); + return {type:'decision', event:d.event, pay}; +} +function counter(g: Game) { + const p = seat(g, 'h'); + let card = p.hand.find(row => row.effect === 'karama'); + if (!card) { + const index = g.deck.findIndex(row => row.effect === 'karama'); assert.ok(index >= 0); + [card] = g.deck.splice(index, 1); p.hand.push(card); + } + assert.ok(card); return card.id; +} + +/** Genuine public E3/Basic or Advanced start and native setup. Stage the + * reciprocal alliance, clean movement boundary, a physical Extortion token's + * legal prior placement and the named test wallets. A real shipment triggers + * revelation, and actual Mentat controls collect bank5 and create the queue. */ +function pending(advanced: boolean) { + let g = createGame('EMPEROREXTORTION', newPlayer('m', 'Moritani', 'moritani'), advanced, ['ecaz']); + joinGame(g, newPlayer('e', 'Emperor', 'emperor')); + joinGame(g, newPlayer('a', 'Atreides', 'atreides')); + joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen')); + for (const p of g.players) g = act(g, p.id, {type:'ready'}); + g = act(g, 'm', advanced ? {type:'start', advancedPreview:true} : {type:'start'}); + for (let i = 0; i < 100 && g.status === 'setup'; i++) { + if (g.decision?.kind === 'moritaniSetup') { + g = act(g, 'm', {type:'decision', territory:'sietch_tabr', sector:territory('sietch_tabr').sectors[0]}); + } else { + const step = nextStrongholdFactionsNativeStep(g); assert.ok(step); + g = act(g, step.actor, step.action); + } + } + assert.equal(g.status, 'playing'); + seat(g, 'e').ally = 'a'; seat(g, 'a').ally = 'e'; + // A stopped ordinary shipment is not involved: the original entry commits + // before the native Terror decision. The source physical token is unchanged. + 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','h','m'], active:'e', + movementRemaining:['e','a','h','m'], response:null, decision:null, phaseOpening:null, ready:[]}); + seat(g, 'e').spice = 10; seat(g, 'a').spice = 2; + const karama = counter(g); + 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 = seat(g, 'm').spice; + // Enter the original Mentat pipeline after the played reveal, like the + // existing Extortion engine fixture; the physical reveal remains intact. + g.phase = 7; g.ready = []; + for (const p of g.players) g = act(g, p.id, {type:'ready'}); + assert.equal(g.phase, 8); + assert.equal(g.decision?.kind, 'moritaniPlacement'); + g = act(g, 'm', {type:'decision', decline:true}); + assert.equal(seat(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, karama, beforeAward}; +} +function parent(g: Game) { + return {decision:structuredClone(g.decision), obligation:structuredClone(g.moritaniExtortion), + order:[...g.order], turn:g.turn, phase:g.phase}; +} + +for (const advanced of [false, true]) { + void test(`${advanced ? 'Advanced' : 'Basic'} Emperor gift lets an insolvent ally pay the original Extortion`, () => { + const fixture = pending(advanced), {token, beforeAward} = fixture; + let g = fixture.g; + const decision = g.decision; assert.ok(decision?.kind === 'moritaniExtortion'); + const before = parent(g), emperor = seat(g, 'e').spice, event = decision.event; + assert.deepEqual(viewGame(g, 'e').emperorExtortionGift, {recipient:'a', maximum:emperor}); + for (const other of ['a','h','m']) assert.equal(viewGame(g, other).emperorExtortionGift, null); + g = act(g, 'e', {type:'emperorGift', amount:1}); + assert.equal(g.response?.kind, 'emperorGift'); + assert.equal(viewGame(g, 'e').response?.amount, 1); + assert.equal(viewGame(g, 'a').response?.amount, 1); + assert.equal(viewGame(g, 'h').response?.amount, undefined); + assert.equal(viewGame(g, 'm').response?.amount, undefined); + assert.equal(seat(g, 'a').spice, 2); + g = allow(reload(g)); + assert.deepEqual(parent(g), before); + assert.equal(seat(g, 'e').spice, emperor - 1); + assert.equal(seat(g, 'a').spice, 3); + assert.equal(seat(g, 'm').spice, beforeAward + 5); + assert.equal(viewGame(g, 'a').extortion.pending?.canPay, true); + g = act(g, 'a', {type:'decision', event, pay:true}); + assert.equal(seat(g, 'a').spice, 0); + assert.equal(seat(g, 'm').spice, beforeAward + 8); + assert.equal(g.moritaniTerror!.tokens.find(row => row.id === token)?.status, 'removed'); + assert.equal(g.moritaniExtortion, undefined); + }); + + void test(`${advanced ? 'Advanced' : 'Basic'} Karama cancels the gift without changing the unaffordable payer or event`, () => { + const fixture = pending(advanced), {token, karama, beforeAward} = fixture; + let g = fixture.g; + const before = parent(g), emperor = seat(g, 'e').spice; + g = act(g, 'e', {type:'emperorGift', amount:1}); + assert.equal(g.response?.kind, 'emperorGift'); + g = act(reload(g), 'h', {type:'card', card:karama, mode:'cancel'}); + assert.equal(g.response, null); + assert.deepEqual(parent(g), before); + assert.equal(seat(g, 'e').spice, emperor); + assert.equal(seat(g, 'a').spice, 2); + assert.equal(viewGame(g, 'a').extortion.pending?.canPay, false); + assert.equal(seat(g, 'm').spice, beforeAward + 5); + assert.equal(g.moritaniTerror!.tokens.find(row => row.id === token)?.status, 'extortion'); + assert.equal(g.discard.filter(card => card.id === karama).length, 1); + const snapshot = reload(g); + assert.throws(() => act(g, 'a', payment(g, true))); + assert.deepEqual(g, snapshot); + g = act(g, 'a', payment(g, false)); + assert.equal(seat(g, 'm').spice, beforeAward + 5); + }); +} + +void test('unauthorized, invalid, and stale Extortion actions do not change the live opportunity', () => { + const {g} = pending(false), before = reload(g); + for (const [actor, action] of [ + ['a', {type:'emperorGift', amount:1}], ['h', {type:'emperorGift', amount:1}], + ['e', {type:'emperorGift', amount:0}], ['e', {type:'emperorGift', amount:seat(g, 'e').spice + 1}], + ['a', {type:'decision', event:'stale', pay:false}], + ] as [string, Action][]) assert.throws(() => act(g, actor, action)); + assert.deepEqual(g, before); +}); + +void test('all minimal bot profiles choose a legal response to the original gift', () => { + let {g} = pending(false); + g = act(g, 'e', {type:'emperorGift', amount:1}); + assert.equal(g.response?.kind, 'emperorGift'); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(g, 'h'); + view.players.find(p => p.id === 'h')!.bot = difficulty; + const actions = botActions(view); assert.ok(actions.length > 0); + assert.doesNotThrow(() => act(reload(g), 'h', actions[0])); + } +}); + +void test('the active gift remains bound to the original Extortion decision and amount', () => { + const {g: start} = pending(false); + const g = act(start, 'e', {type:'emperorGift', amount:1}); + const before = reload(g); + assert.throws(() => act(g, 'a', payment(g, false))); + assert.deepEqual(g, before); + const changed = reload(g); + assert.ok(changed.response); + changed.response.amount = 2; + assert.throws(() => viewGame(changed, 'a')); + assert.throws(() => act(changed, 'h', {type:'passResponse'})); +}); diff --git a/tests/emperor-timing-gift-controls.test.tsx b/tests/emperor-timing-gift-controls.test.tsx index 213f9ea9..528fe7cd 100644 --- a/tests/emperor-timing-gift-controls.test.tsx +++ b/tests/emperor-timing-gift-controls.test.tsx @@ -78,3 +78,23 @@ void test('the real Guild timing table exposes the gift only to the Emperor', () assert.doesNotMatch(table(observer), /Share spice with Guild/); assert.match(table(own, true), /]*disabled=""[^>]*>Share spice<\/button>/); }); + +void test('an Extortion quote presents the recipient and restores its original decision after Karama', () => { + const game = fixture(); + // Isolate the component contract: the underlying Guild fixture supplies the + // seats, while this projected Extortion choice and quote are simulated. + const decision = {kind:'moritaniExtortion' as const, player:'h', event:'extortion-test'}; + const own: GameView = {...viewGame(game, 'e'), decision, emperorGuildTimingGift:null, + emperorExtortionGift:{recipient:'h', maximum:4}}; + const observer: GameView = {...viewGame(game, 'h'), decision, emperorGuildTimingGift:null, + emperorExtortionGift:null}; + const html = component(own); + assert.match(html, /Share spice with Harkonnen/); + assert.match(html, /Moritani Extortion decision resumes after the Karama response/); + assert.match(html, /type="number"[^>]*min="1"[^>]*max="4"/); + assert.match(html, /]*disabled)[^>]*>Share spice<\/button>/); + assert.equal(component(observer), ''); + assert.match(component(own, true), /]*disabled=""[^>]*>Share spice<\/button>/); + assert.match(table(own), /Moritani Extortion decision resumes after the Karama response/); + assert.doesNotMatch(table(observer), /Share spice with Harkonnen/); +});