Allow Emperor gifts during Moritani extortion
Verify and publish checkpoint container / container (push) Successful in 11m51s

This commit is contained in:
Quintine committed 2026-10-29 06:28:09 +11:00
1 parent 6cec4ed78b
commit 538247e777
8 files changed
+307 -4

No files matched your search

+7 -4
View File
@@ -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 <section className="notice min-w-0 space-y-3 break-words" aria-label="Emperor spice gift">
<h3>Share spice with {recipient}</h3>
<p className="fine">The Guild timing decision resumes after the Karama response to your gift.</p>
<p className="fine">{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.'}</p>
<label htmlFor={`${id}-amount`}>Spice to share</label>
<input id={`${id}-amount`} type="number" inputMode="numeric" min={1} max={offer.maximum}
step={1} value={raw} disabled={busy} onChange={event => setRaw(event.target.value)} />
+10
View File
@@ -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
+18
View File
@@ -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,
+8
View File
@@ -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
+69
View File
@@ -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
+1
View File
@@ -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.',
],
+174
View File
@@ -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'}));
});
@@ -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), /<button\b[^>]*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, /<button\b(?![^>]*disabled)[^>]*>Share spice<\/button>/);
assert.equal(component(observer), '');
assert.match(component(own, true), /<button\b[^>]*disabled=""[^>]*>Share spice<\/button>/);
assert.match(table(own), /Moritani Extortion decision resumes after the Karama response/);
assert.doesNotMatch(table(observer), /Share spice with Harkonnen/);
});