From d4abc6bb831befc667a00da8483c7b6e6d9f69d4 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:33:41 +1100 Subject: [PATCH] Allow Emperor gifts during Basic Ix phase openings --- components/emperor-timing-gift.tsx | 11 +- components/game-table.tsx | 1 + docs/CURRENT_STATUS.md | 11 ++ docs/EMPEROR_ALLIANCE_TIMING.md | 18 +++ docs/RULE_DECISIONS.md | 7 + game/engine.ts | 76 +++++++++++ game/faction-reference.ts | 1 + tests/emperor-phase-opening-gift.test.ts | 144 ++++++++++++++++++++ tests/emperor-timing-gift-controls.test.tsx | 29 ++++ 9 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 tests/emperor-phase-opening-gift.test.ts diff --git a/components/emperor-timing-gift.tsx b/components/emperor-timing-gift.tsx index 44ca5533..f870ddda 100644 --- a/components/emperor-timing-gift.tsx +++ b/components/emperor-timing-gift.tsx @@ -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

Share spice with {recipient}

-

{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.'}

+

{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.'}

setRaw(event.target.value)} /> diff --git a/components/game-table.tsx b/components/game-table.tsx index ee779d18..63fd9497 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -2547,6 +2547,7 @@ export function GameTable({ <> Phase opening

Before {PHASES[g.phase]}

+

Play Amal now, or pass to begin this phase. Phase effects wait until everyone has passed. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 88443a79..559c047f 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 diff --git a/docs/EMPEROR_ALLIANCE_TIMING.md b/docs/EMPEROR_ALLIANCE_TIMING.md index 3b3b7ffe..a6184546 100644 --- a/docs/EMPEROR_ALLIANCE_TIMING.md +++ b/docs/EMPEROR_ALLIANCE_TIMING.md @@ -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 diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index bcc8888c..0864d467 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -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 diff --git a/game/engine.ts b/game/engine.ts index 30ad0c6d..cd212a0a 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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 diff --git a/game/faction-reference.ts b/game/faction-reference.ts index 8a5ac99c..85a24992 100644 --- a/game/faction-reference.ts +++ b/game/faction-reference.ts @@ -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.', ], diff --git a/tests/emperor-phase-opening-gift.test.ts b/tests/emperor-phase-opening-gift.test.ts new file mode 100644 index 00000000..304abcad --- /dev/null +++ b/tests/emperor-phase-opening-gift.test.ts @@ -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); +}); diff --git a/tests/emperor-timing-gift-controls.test.tsx b/tests/emperor-timing-gift-controls.test.tsx index 528fe7cd..e8b1a86b 100644 --- a/tests/emperor-timing-gift-controls.test.tsx +++ b/tests/emperor-timing-gift-controls.test.tsx @@ -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, /]*disabled)[^>]*>Share spice<\/button>/); + assert.match(component(own, true), /]*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/); +});