Allow Emperor gifts during Basic Ix phase openings
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 06:33:41 +11:00
1 parent 538247e777
commit d4abc6bb83
9 files changed
+294 -4

No files matched your search

+7 -4
View File
@@ -12,8 +12,9 @@ export function EmperorTimingGift({ game, act, busy }: {
}) {
const id = useId();
const [raw, setRaw] = useState('1');
const phaseOpening = game.emperorPhaseOpeningGift;
const extortion = game.emperorExtortionGift;
const offer = extortion ?? game.emperorGuildTimingGift;
const offer = phaseOpening ?? 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;
@@ -21,9 +22,11 @@ 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">{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>
<p className="fine">{phaseOpening
? 'Phase opening 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.'}</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)} />
+1
View File
@@ -2547,6 +2547,7 @@ export function GameTable({
<>
<span className="eyebrow">Phase opening</span>
<h2>Before {PHASES[g.phase]}</h2>
<EmperorTimingGift game={g} act={act} busy={busy} />
<p>
Play Amal now, or pass to begin this phase. Phase effects wait
until everyone has passed.
+11
View File
@@ -17,6 +17,17 @@ open mode gates.
## Current checkpoint and work
**Emperor gift during Basic Ix phase openings — 29 October. Prototyped /
Partial.** A clean classic-faction Ix-deck opening now allows an Emperor to
share spice with a mutual ally even after passing; allowance or Karama
cancellation resumes the original opening and passes. Real Amal applies to the
updated wallets, and a Mentat opening retains its deferred initialization.
Thirty-seven related cases, type checking, lint and the production build pass.
Expansion factions, optional modules and other interrupted parents remain open.
As with Extortion, bot seats may finish the opening before a human Emperor acts;
deployed acceptance also remains open.
[Scope](EMPEROR_ALLIANCE_TIMING.md#basic-emperor-gift-during-ix-phase-openings--29-october-2026).
**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
+18
View File
@@ -1,5 +1,23 @@
# Emperor alliance gift timing
## Basic Emperor gift during Ix phase openings — 29 October 2026
In a clean Basic game with the Ix deck and classic factions, the Emperor may
share spice with a mutual ally during an Amal phase opening, including after
passing. The opening and its exact pass history pause while the gift's Karama
response resolves; allowance transfers the spice immediately and cancellation
transfers nothing. The same opening then resumes, and a subsequent Amal applies
to the updated balances and resets passes normally.
The [publisher FAQ, p. 5](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
permits direct allied gifts for any reason and immediate collection. Applying
that power during the Basic opening is a provisional online timing
interpretation. The initial scope excludes expansion factions, optional modules,
competing continuations and outstanding commitments requiring a combined
planner. Advanced and other interruption windows remain unfinished.
[Runtime cases](../tests/emperor-phase-opening-gift.test.ts) and
[rendered controls](../tests/emperor-timing-gift-controls.test.tsx).
## Emperor gift during Moritani Extortion — 29 October 2026
In supported Basic and Advanced games with Moritani, the Emperor can give
+7
View File
@@ -1,5 +1,12 @@
# Rule decision index
## Basic Emperor gift during Ix phase openings — 29 October 2026
The publisher FAQ permits immediate direct gifts to an ally. Allowing a gift
inside an Amal opening is a provisional Basic online timing interpretation;
the original pass history resumes after allowance or cancellation.
[Scope](EMPEROR_ALLIANCE_TIMING.md#basic-emperor-gift-during-ix-phase-openings--29-october-2026).
## Emperor gift during Moritani Extortion — 29 October 2026
The publisher FAQ permits direct allied spice gifts for any reason; the
+76
View File
@@ -1236,6 +1236,8 @@ export type ResponseWindow = {
emperorGuildTimingGift?: { event: string; signature: string };
/** The original Extortion payment decision remains live during this gift. */
emperorExtortionGift?: { event: string; signature: string };
/** Suspends the original Ix opening until the gift response finishes. */
emperorPhaseOpeningGift?: { event: string; opening: { passed: string[]; initialize: boolean }; signature: string };
/** Links an allied auction gift to the still-pending payment opportunity. */
emperorAuctionGiftEvent?: string;
automaticAdvisorFlip?: AutomaticAdvisorFlipSource;
@@ -19432,6 +19434,7 @@ function validateKaramaUse(
if (use.kind === 'cancel') {
validateEmperorGuildTimingGift(g, use.response);
validateEmperorExtortionGift(g, use.response);
validateEmperorPhaseOpeningGift(g, use.response);
requireRule(
use.response && use.response.owner !== p.id,
'Karama cancels another faction’s power.',
@@ -29401,6 +29404,53 @@ function emperorGuildTimingGiftIntegrity(g: Game) {
if (g.pendingKarama?.use.kind === 'cancel')
validateEmperorGuildTimingGift(g, g.pendingKarama.use.response);
}
function emperorPhaseOpeningGiftSignature(g: Game, opening: {passed: string[]; initialize: boolean}, owner: string, recipient: string, amount: number): string {
return JSON.stringify([g.turn, g.phase, g.active, g.order, g.ready, g.amalWindowOpen ?? false,
opening, owner, recipient, amount]);
}
/** Initial Basic Ix opening support, with no independent competing windows. */
function emperorPhaseOpeningGiftQuote(g: Game, p: Player): {recipient: string; maximum: number} | null {
if (g.advanced || g.status !== 'playing' || g.expansions.length !== 1 || g.expansions[0] !== 'ix' ||
g.players.some(seat => !['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction)) ||
g.techTokens || g.strongholdCards || g.leaderSkills || g.homeworlds || g.nexusCards ||
g.discoveryEnabled || g.sandtrout || g.coalitionState || g.semutaPreview || g.semutaRecruitsPreview ||
!g.phaseOpening || !g.amalWindowOpen ||
g.response || g.decision || g.truthtrance || g.pendingKarama || g.pendingTreacheryDiscard || g.battle ||
g.pendingRicheseGift || g.pendingNullentropy || g.pendingShipment || g.pendingGuildTransport ||
g.pendingArrivalOverlap || g.pendingAmbassador || g.pendingEmperorAuctionGift ||
p.faction !== 'emperor' || !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 validateEmperorPhaseOpeningGift(g: Game, response: ResponseWindow) {
const parent = response.emperorPhaseOpeningGift;
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 &&
parent.opening && Array.isArray(parent.opening.passed) && typeof parent.opening.initialize === 'boolean' &&
parent.opening.passed.length < g.players.length &&
new Set(parent.opening.passed).size === parent.opening.passed.length &&
parent.opening.passed.every(id => g.players.some(player => player.id === id)) &&
!g.phaseOpening && !g.decision && 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) &&
parent.signature === emperorPhaseOpeningGiftSignature(g, parent.opening, owner.id, recipient.id, response.amount),
'The Emperor gift lost its original phase opening.');
}
function emperorPhaseOpeningGiftIntegrity(g: Game) {
if (g.response) validateEmperorPhaseOpeningGift(g, g.response);
if (g.pendingKarama?.use.kind === 'cancel') validateEmperorPhaseOpeningGift(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]);
@@ -37214,6 +37264,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
const response = g.response!;
validateEmperorGuildTimingGift(g, response);
validateEmperorExtortionGift(g, response);
validateEmperorPhaseOpeningGift(g, response);
const advisorPreparationSource = currentAdvisorPreparationResponse(g, response);
const automaticAdvisorFlip = response.automaticAdvisorFlip ? currentAutomaticAdvisorFlip(g, response) : null;
const fremenGrant = response.kind === 'fremenRevivalGrant' ? currentFremenRevivalGrant(g, response) : null;
@@ -38375,6 +38426,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
`${emperor.name} ${response.kind === 'emperorGift' ? 'shared spice with' : `offered ${amount} extra force revivals to`} ${recipient.name}.`,
);
}
if (response.emperorPhaseOpeningGift) g.phaseOpening = structuredClone(response.emperorPhaseOpeningGift.opening);
if (response.emperorAuctionGiftEvent) {
requireRule(g.pendingEmperorAuctionGift?.event === response.emperorAuctionGiftEvent,
'The Emperor gift lost its original auction opportunity.');
@@ -42053,9 +42105,15 @@ 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 {
emperorPhaseOpeningGiftIntegrity(state);
emperorExtortionGiftIntegrity(state);
emperorAuctionGiftIntegrity(state);
emperorGuildTimingGiftIntegrity(state);
const phaseGiftResponse = state.response?.emperorPhaseOpeningGift ||
(state.pendingKarama?.use.kind === 'cancel' && state.pendingKarama.use.response.emperorPhaseOpeningGift);
if (phaseGiftResponse && action?.type !== 'advanceBots')
requireRule(action?.type === 'passResponse' || action?.type === 'card' && action.mode === 'cancel',
'Resolve the Emperor gift response before another action.');
const extortionGiftResponse = state.response?.emperorExtortionGift ||
(state.pendingKarama?.use.kind === 'cancel' && state.pendingKarama.use.response.emperorExtortionGift);
if (extortionGiftResponse && action?.type !== 'advanceBots')
@@ -42808,6 +42866,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
emperorPhaseOpeningGiftIntegrity(state);
emperorExtortionGiftIntegrity(state);
emperorAuctionGiftIntegrity(state);
emperorGuildTimingGiftIntegrity(state);
@@ -43494,6 +43553,20 @@ function applyActionInner(
decideBiddingEnd(g, p, action);
return g;
}
if (t === 'emperorGift' && g.phaseOpening && !g.response) {
const quote = emperorPhaseOpeningGiftQuote(g, p);
requireRule(quote && Object.keys(action).sort().join(',') === 'amount,type',
'This phase opening gift is unavailable or no longer current.');
const amount = integer(action.amount, 1, quote.maximum, 'Spice');
const opening = structuredClone(g.phaseOpening);
g.phaseOpening = null;
g.response = { kind: 'emperorGift', owner: id, recipient: quote.recipient,
amount, elite: 0, passed: [], emperorPhaseOpeningGift: {
event: crypto.randomUUID(), opening,
signature: emperorPhaseOpeningGiftSignature(g, opening, id, quote.recipient, amount),
} };
return g;
}
if (g.phaseOpening) {
requireRule(
g.status === 'playing',
@@ -47374,6 +47447,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string, now?: number) {
emperorPhaseOpeningGiftIntegrity(state);
emperorExtortionGiftIntegrity(state);
emperorAuctionGiftIntegrity(state);
emperorGuildTimingGiftIntegrity(state);
@@ -48430,6 +48504,7 @@ export function viewGame(state: Game, id: string, now?: number) {
: {}),
...(g.response.emperorGuildTimingGift ? { emperorGuildTimingGift: undefined } : {}),
...(g.response.emperorExtortionGift ? { emperorExtortionGift: undefined } : {}),
...(g.response.emperorPhaseOpeningGift ? { emperorPhaseOpeningGift: undefined } : {}),
...(g.response.emperorAuctionGiftEvent ? { emperorAuctionGiftEvent: undefined } : {}),
}
: null,
@@ -48498,6 +48573,7 @@ export function viewGame(state: Game, id: string, now?: number) {
},
emperorGuildTimingGift: emperorGuildTimingGiftQuote(g, me),
emperorExtortionGift: emperorExtortionGiftQuote(g, me),
emperorPhaseOpeningGift: emperorPhaseOpeningGiftQuote(g, me),
emperorAuctionGiftPending: g.pendingEmperorAuctionGift
? { owner: g.pendingEmperorAuctionGift.owner } : null,
emperorAuctionGift: g.pendingEmperorAuctionGift?.owner === id && !g.response && !g.pendingKarama
+1
View File
@@ -87,6 +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 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 Basic Ix-deck games, 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.',
],
+144
View File
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createTruthTurnAmalFixture, truthTurnAmalInventory as inventory } from './fixture-truth-turn-amal';
const act = (game: Game, id: string, action: Action) => applyAction(game, id, 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 => JSON.parse(JSON.stringify(game));
/** Original public Basic Ix Harkonnen/Atreides/Emperor setup opens the first
* real phase. Stage only the pair and conserved Amal/Karama custody; passes,
* gift, response and phase initialization use native actions. */
function fixture() {
const source = createTruthTurnAmalFixture();
let game = source.game;
const original = inventory(game);
seat(game, 'emperor').ally = 'atreides'; seat(game, 'atreides').ally = 'emperor';
const amalIndex = seat(game, 'emperor').hand.findIndex(card => card.effect === 'amal');
assert.ok(amalIndex >= 0);
seat(game, 'harkonnen').hand.push(seat(game, 'emperor').hand.splice(amalIndex, 1)[0]);
// A real held opposing Karama keeps the gift's response open; existing
// physical copies remain in their actual zones, never fabricated.
for (const player of game.players) {
const cards = player.hand.filter(card => card.effect === 'karama');
player.hand = player.hand.filter(card => card.effect !== 'karama');
game.deck.push(...cards);
}
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const [counter] = game.deck.splice(index, 1); seat(game, 'harkonnen').hand.push(counter);
assert.deepEqual(inventory(game), original);
assert.ok(game.phaseOpening);
game = act(game, 'emperor', {type:'ready'});
game = act(game, 'atreides', {type:'ready'});
assert.deepEqual(game.phaseOpening?.passed, ['emperor','atreides']);
return {game, original, counter:counter.id, amal:source.amal};
}
function passResponses(game: Game) {
for (let count = 0; game.response && count < 15; 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;
}
for (const cancel of [false,true]) {
void test(`Basic Ix opening gift ${cancel ? 'cancellation' : 'allowance'} resumes exact passes through the original opening`, () => {
const f = fixture(), initial = f.game, opening = structuredClone(initial.phaseOpening);
const emperor = seat(initial, 'emperor').spice, ally = seat(initial, 'atreides').spice;
const phase = initial.phase, turn = initial.turn;
assert.deepEqual(viewGame(initial, 'emperor').emperorPhaseOpeningGift, {recipient:'atreides', maximum:emperor});
assert.equal(viewGame(initial, 'atreides').emperorPhaseOpeningGift, null);
assert.equal(viewGame(initial, 'harkonnen').emperorPhaseOpeningGift, null);
let game = act(initial, 'emperor', {type:'emperorGift', amount:3});
assert.equal(game.response?.kind, 'emperorGift');
assert.equal(game.phaseOpening, null);
assert.equal(seat(game, 'emperor').spice, emperor);
assert.equal(seat(game, 'atreides').spice, ally);
assert.equal(viewGame(game, 'emperor').response?.amount, 3);
assert.equal(viewGame(game, 'atreides').response?.amount, 3);
assert.equal(viewGame(game, 'harkonnen').response?.amount, undefined);
assert.equal(viewGame(game, 'emperor').emperorPhaseOpeningGift, null);
assert.throws(() => act(game, 'emperor', {type:'emperorGift', amount:1}));
game = cancel ? act(copy(game), 'harkonnen', {type:'card', card:f.counter, mode:'cancel'}) : passResponses(copy(game));
assert.equal(game.response, null);
assert.deepEqual(game.phaseOpening, opening);
assert.equal(game.phase, phase); assert.equal(game.turn, turn);
assert.equal(seat(game, 'emperor').spice, emperor - (cancel ? 0 : 3));
assert.equal(seat(game, 'atreides').spice, ally + (cancel ? 0 : 3));
assert.equal(game.discard.filter(card => card.id === f.counter).length, Number(cancel));
assert.throws(() => act(game, 'harkonnen', {type:'passResponse'}));
// The unpassed Harkonnen can still play the original physical Amal.
const beforeAmal = game.players.map(player => player.spice);
game = act(game, 'harkonnen', {type:'card', card:f.amal});
assert.deepEqual(game.players.map(player => player.spice), beforeAmal.map(amount => Math.floor(amount / 2)));
assert.deepEqual(game.phaseOpening?.passed, []);
assert.equal(game.discard.filter(card => card.id === f.amal).length, 1);
for (const id of ['emperor','atreides','harkonnen']) game = act(game, id, {type:'ready'});
assert.equal(game.phaseOpening, null);
assert.equal(game.phase, phase); assert.equal(game.turn, turn);
assert.deepEqual(game.players.map(player => player.spice), beforeAmal.map(amount => Math.floor(amount / 2)));
assert.throws(() => act(game, 'harkonnen', {type:'card', card:f.amal}));
assert.deepEqual(inventory(game), f.original);
});
}
void test('all minimal bots can legally respond to an opening gift without losing its parent', () => {
const f = fixture(), opening = structuredClone(f.game.phaseOpening);
const pending = act(f.game, 'emperor', {type:'emperorGift', amount:1});
for (const difficulty of DIFFICULTIES) {
let game = copy(pending);
const view = viewGame(game, 'harkonnen'); view.players.find(player => player.id === 'harkonnen')!.bot = difficulty;
const action = botActions(view).find(item => item.type === 'passResponse' || item.type === 'card');
assert.ok(action); game = act(game, 'harkonnen', action);
game = passResponses(game);
assert.deepEqual(game.phaseOpening, opening);
assert.equal(game.phase, f.game.phase);
}
});
void test('invalid opening gifts and a mismatched original opening cannot commit', () => {
const f = fixture(), before = copy(f.game);
for (const [actor, action] of [
['atreides', {type:'emperorGift', amount:1}],
['emperor', {type:'emperorGift', amount:0}],
['emperor', {type:'emperorGift', amount:11}],
] as [string, Action][]) {
assert.throws(() => act(f.game, actor, action));
assert.deepEqual(f.game, before);
}
const unallied = copy(f.game);
seat(unallied, 'emperor').ally = null; seat(unallied, 'atreides').ally = null;
assert.equal(viewGame(unallied, 'emperor').emperorPhaseOpeningGift, null);
assert.throws(() => act(unallied, 'emperor', {type:'emperorGift', amount:1}));
const pending = act(f.game, 'emperor', {type:'emperorGift', amount:1});
const altered = copy(pending);
altered.response!.emperorPhaseOpeningGift!.opening.passed = [];
assert.throws(() => viewGame(altered, 'emperor'), /original phase opening/);
assert.throws(() => act(altered, 'harkonnen', {type:'passResponse'}), /original phase opening/);
});
void test('a deferred Mentat initialization follows a gift and credits the original bribe once', () => {
const f = createTruthTurnAmalFixture({phase:8, incoming:4});
let game = f.game;
seat(game, 'emperor').ally = 'atreides'; seat(game, 'atreides').ally = 'emperor';
assert.equal(game.phaseOpening?.initialize, true);
assert.equal(seat(game, 'emperor').spice, 10); assert.equal(seat(game, 'emperor').bribes, 4);
game = act(game, 'emperor', {type:'ready'});
assert.deepEqual(viewGame(game, 'emperor').emperorPhaseOpeningGift, {recipient:'atreides', maximum:10});
assert.throws(() => act(game, 'emperor', {type:'emperorGift', amount:11}));
game = act(game, 'emperor', {type:'emperorGift', amount:3});
game = passResponses(game);
assert.deepEqual(game.phaseOpening, {passed:['emperor'], initialize:true});
assert.equal(seat(game, 'emperor').spice, 7); assert.equal(seat(game, 'emperor').bribes, 4);
game = act(game, 'atreides', {type:'ready'});
game = act(game, 'harkonnen', {type:'ready'});
assert.equal(game.phaseOpening, null);
assert.equal(seat(game, 'emperor').spice, 11); assert.equal(seat(game, 'emperor').bribes, 0);
});
@@ -7,6 +7,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game, type GameView } from '../game/engine';
import { EmperorTimingGift } from '../components/emperor-timing-gift';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { createTruthTurnAmalFixture } from './fixture-truth-turn-amal';
const aliases = registerHooks({
resolve(specifier, context, next) {
@@ -98,3 +99,31 @@ void test('an Extortion quote presents the recipient and restores its original d
assert.match(table(own), /Moritani Extortion decision resumes after the Karama response/);
assert.doesNotMatch(table(observer), /Share spice with Harkonnen/);
});
void test('a phase-opening quote gives only its owner the gift control', () => {
const game = fixture();
// Exercise the isolated projected contract; the engine owns its eligibility.
const own: GameView = {...viewGame(game, 'e'), emperorGuildTimingGift:null,
emperorExtortionGift:null, emperorPhaseOpeningGift:{recipient:'g', maximum:6}};
const observer: GameView = {...viewGame(game, 'h'), emperorGuildTimingGift:null,
emperorExtortionGift:null, emperorPhaseOpeningGift:null};
const html = component(own);
assert.match(html, /Share spice with Guild/);
assert.match(html, /Phase opening resumes after the Karama response to your gift/);
assert.match(html, /type="number"[^>]*min="1"[^>]*max="6"/);
assert.match(html, /<button\b(?![^>]*disabled)[^>]*>Share spice<\/button>/);
assert.match(component(own, true), /<button\b[^>]*disabled=""[^>]*>Share spice<\/button>/);
assert.equal(component(observer), '');
assert.equal(component({...own, emperorPhaseOpeningGift:null}), '');
});
void test('the real Basic Ix opening table exposes the control only to the Emperor', () => {
const {game} = createTruthTurnAmalFixture();
game.players.find(player => player.id === 'emperor')!.ally = 'atreides';
game.players.find(player => player.id === 'atreides')!.ally = 'emperor';
const own = table(viewGame(game, 'emperor'));
const observer = table(viewGame(game, 'harkonnen'));
assert.match(own, /Before Storm/);
assert.match(own, /Phase opening resumes after the Karama response to your gift/);
assert.doesNotMatch(observer, /Phase opening resumes after the Karama response to your gift/);
});