diff --git a/components/bribes.tsx b/components/bribes.tsx index 71f1fd14..c55db852 100644 --- a/components/bribes.tsx +++ b/components/bribes.tsx @@ -19,7 +19,10 @@ export function Bribes({ game: g, act, busy }: { return (
Bribes{options.incoming > 0 ? ` · ${options.incoming} spice awaiting Mentat` : ''} -

Pay another non-allied faction now. The amount is public; the recipient collects their share at the start of Mentat Pause. Payments made during Mentat Pause wait until the start of next turn's Mentat Pause. A message or promise alone does not pay spice.

+

Pay another non-allied faction now. The amount is public; the recipient collects their share at the start of Mentat Pause. Payments made after its opening wait until the start of next turn's Mentat Pause. A message or promise alone does not pay spice.

+

{options.receiptTiming === 'nextTurn' + ? 'This payment will be collected at the start of next turn’s Mentat Pause.' + : 'This payment will be collected at the upcoming Mentat Pause.'}

Your incoming bribes: {options.incoming} spice, separate from your spendable supply.

{options.blocked ? {options.blocked} : null} {options.targets.length === 0 ?

No non-allied recipient is available.

: ( diff --git a/docs/BRIBE_CONTROLS.md b/docs/BRIBE_CONTROLS.md index 4f189a1d..9f5755b0 100644 --- a/docs/BRIBE_CONTROLS.md +++ b/docs/BRIBE_CONTROLS.md @@ -1,5 +1,29 @@ # Ordinary bribe controls +## Basic pending-opening and Extortion payments — 29 October 2026 + +Direct ordinary bribes are available during a clean Basic Ix-deck Amal opening +and a clean Basic Moritani Extortion payment decision, including out of turn. +They use the normal uncommitted payer spice, nonally restriction and deferred +recipient ledger. The original opening passes or Extortion queue and decision +remain unchanged. An Extortion recipient cannot use the deferred bribe to pay +that current decision. A bribe made before deferred Mentat initialization is +collected at that imminent opening; one made after collection, including during +Extortion, waits for the next turn's opening. The owner's control states which +collection applies. + +The publisher [base rules, printed p. 12](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf) +allow nonallied bribery without prescribing a digital decision priority. These +interruptions are a provisional online interpretation, not a publisher timing +ruling. The initial profiles exclude optional modules, other expansion families, +competing continuations and the payer's active commitments. Deal-linked payments +retain their previous quiet-window restrictions. A bot can still progress the +original opening or decision before a human pays; response and other decision +parents, Advanced play and full acceptance remain open. +[Native runtime](../tests/bribe-pending-basic.test.ts) includes public setup, +explicitly staged Extortion placement and a real next-turn collection; +[rendered controls](../tests/bribe-pending-basic-controls.test.tsx) cover timing. + **26 October follow-up:** [quiet Mentat payments](MENTAT_BRIBES.md) are now allowed after opening collection, with their receipts deferred to the next turn's actual opening. This supersedes the original blanket Mentat restriction. @@ -33,8 +57,8 @@ sealed bids, paid support, Banker spending and other reserved costs. Existing Truthtrance completion checks further constrain voluntary spending on isolated copies; this projection does not commit actions or live continuations. The target-specific Bureaucrat guard can cap unsupported combinations below five. -Supported payments use the existing saved redirect/allow decision. A pending -response, decision or forced continuation disables the payment controls. +Supported payments use the existing saved redirect/allow decision. Other pending responses, decisions or forced continuations disable the payment +controls, except for the supported Basic opening and Extortion cases above. The client retains an invalid amount or removed recipient as a disabled draft rather than silently changing a payment. The explicit action uses existing diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index a37dcfbf..43c0e754 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,20 @@ open mode gates. ## Current checkpoint and work +**Basic bribes during pending openings and Extortion — 29 October. +Prototyped / Partial.** Direct nonallied bribes now work during supported Basic +Ix Amal openings and Moritani Extortion decisions without advancing the original +parent. The payer spends immediately, but the recipient collects at the next +actual Mentat opening: before deferred initialization that is the upcoming +opening; during Extortion it is next turn. Native journeys and rendered controls +cover both timings, later collection, legal restrictions and the unchanged +deal-payment boundary. The 13-file batch passes 81 cases; type checking, lint +and production build pass. The fixture for an older Extortion Karama case now +finds the physical card even when another player initially received it. +Other pending parents, active payer commitments, optional modules and bot +priority remain open; deployed behavior is unverified. +[Scope](BRIBE_CONTROLS.md#basic-pending-opening-and-extortion-payments--29-october-2026). + **Basic Ixian and Tleilaxu opening gifts — 29 October. Prototyped / Partial.** The Basic Ix-deck phase-opening gift now supports both native expansion factions, including either as the Emperor's ally. Public Basic setup with staged mutual diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index ed94ef2e..7eb51af1 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,13 @@ # Rule decision index +## Basic bribes during pending openings and Extortion — 29 October 2026 + +The printed nonallied bribe rules do not prescribe digital interruption +priority. The supported Basic Ix opening and Moritani Extortion payments use a +provisional timing interpretation and preserve the original parent; recipient +collection remains tied to the next actual Mentat opening. +[Scope](BRIBE_CONTROLS.md#basic-pending-opening-and-extortion-payments--29-october-2026). + ## Basic Emperor gift during Ix phase openings — 29 October 2026 The publisher FAQ permits immediate direct gifts to an ally. Allowing a gift diff --git a/game/bribe-options.ts b/game/bribe-options.ts index f2c04126..9fd5685e 100644 --- a/game/bribe-options.ts +++ b/game/bribe-options.ts @@ -3,6 +3,8 @@ import type { Action, Game } from './engine'; export interface BribeOptions { available: number; incoming: number; + /** When an ordinary payment made now becomes spendable. */ + receiptTiming?: 'upcoming' | 'nextTurn'; blocked: string | null; targets: { id: string; maximum: number; limitation: string | null }[]; } @@ -21,6 +23,11 @@ export function bribeTimingBlock(g: Game): string | null { return null; } +/** A phase-8 opening may still precede the phase's actual collection. */ +export function bribeReceiptTiming(g: Pick): 'upcoming' | 'nextTurn' { + return g.phase === 8 && !g.phaseOpening?.initialize ? 'nextTurn' : 'upcoming'; +} + /** The existing automatic token lifecycle will turn an unflipped Cancel into * Double at this opening. An owed payment must precede that known closure. */ export function bribesCloseBeforeNextMentat(g: Pick): boolean { diff --git a/game/engine.ts b/game/engine.ts index b5e521e5..b450097d 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -109,7 +109,7 @@ import { PhaseBidPromiseError, phaseBidPromiseModeSupported, livePhaseBidPromise type PhaseBidPromise } from './turn-bid-promises'; import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile'; import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3LeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; -import { bribeTimingBlock, bribesCloseBeforeNextMentat, maximumBribe, type BribeOptions } from './bribe-options'; +import { bribeReceiptTiming, bribeTimingBlock, bribesCloseBeforeNextMentat, maximumBribe, type BribeOptions } from './bribe-options'; import { quoteAtreidesFullPlan } from './atreides-full-plan'; import { quoteTleilaxuSpecialKarama } from './tleilaxu-special-karama'; import { quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError, type AdvisorStance, type AutomaticAdvisorFlipSource, type AutomaticAdvisorTransport, type AutomaticAdvisorGamont, type AutomaticAdvisorBattle, type AutomaticAdvisorHomeworldTransport, type AutomaticAdvisorFaceDance, type AutomaticAdvisorStorm, type AutomaticAdvisorRelocation } from './advisor-automatic-flip'; @@ -17299,8 +17299,38 @@ function bribePaymentPreservesPromises(g: Game, p: Player, recipient: string, am ? !!turnActionCompletion(trial, p.id) : !hasBribePromises(trial, p.id) || !!bribePromiseCompletion(trial, actor); } catch (error) { if (error instanceof BribeExpressionPromiseError || error instanceof BribePromiseError || error instanceof RuleError) return false; throw error; } } +/** Selected Basic parents accept a direct payment without replacing or + * advancing their pending state. Existing whole-promise planners retain their + * original timing boundary until those compositions are implemented. */ +function basicBribeInterrupt(g: Game, p: Player): boolean { + if (g.status !== 'playing' || g.advanced || g.expansions.length !== 1 || + g.inflation?.side === 'double' || g.techTokens || g.strongholdCards || g.leaderSkills || + g.homeworlds || g.nexusCards || g.discoveryEnabled || g.sandtrout || g.coalitionState || + g.ecazTreachery || g.semutaPreview || g.semutaRecruitsPreview || g.truthtrance || g.response || + g.pendingKarama || g.pendingKull || g.pendingTreacheryDiscard || g.pendingNullentropy || + g.bureaucratPayments?.pending || g.battle || g.pendingRicheseGift || g.pendingShipment || + g.pendingGuildTransport || g.pendingArrivalOverlap || g.pendingAmbassador || g.pendingEmperorAuctionGift || + liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length || + hasMovementPromises(g, p.id) || hasBribePromises(g, p.id) || + liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length || + liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length || + liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length || + liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length || + livePhaseBidPromises(g.phaseBidPromises ?? [], p.id, g.turn).length || + (g.pledgePromises ?? []).some(row => row.player === p.id && row.turn === g.turn && !row.fulfilled && !row.released) || + (g.deals ?? []).some(row => row.status === 'active' && row.payment?.payer === p.id && row.paymentStatus === 'pending')) return false; + const classic = ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen']; + if (g.expansions[0] === 'ix') return !!g.phaseOpening && !!g.amalWindowOpen && !g.decision && + g.players.every(seat => [...classic, 'ixians', 'tleilaxu'].includes(seat.faction)); + const extortion = g.moritaniExtortion, decision = g.decision; + return g.expansions[0] === 'ecaz' && !g.phaseOpening && g.phase === 8 && + g.players.every(seat => [...classic, 'moritani'].includes(seat.faction)) && + extortion?.stage === 'payment' && decision?.kind === 'moritaniExtortion' && + decision.event === extortionEvent(extortion) && decision.player === extortion.queue[extortion.cursor]; +} function projectedBribes(g: Game, p: Player): BribeOptions { - const available = Math.max(0, uncommittedSpice(g, p)), blocked = bribeTimingBlock(g); + const available = Math.max(0, uncommittedSpice(g, p)), + blocked = basicBribeInterrupt(g, p) ? null : bribeTimingBlock(g); const constrained = liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length > 0 || hasBribePromises(g, p.id) || g.battle?.truthPromises?.some(promise => promise.player === p.id && !promise.released && !g.battle?.revealed && !g.battle?.plans[p.id]) || liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length || @@ -17308,7 +17338,7 @@ function projectedBribes(g: Game, p: Player): BribeOptions { liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) || liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) || liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).some(promise => promise.answer); - return { available, incoming: p.bribes, blocked, + return { available, incoming: p.bribes, receiptTiming: bribeReceiptTiming(g), blocked, targets: g.players.filter(target => target.id !== p.id && !bribeAllied(g, p, target.id)).map(target => { let limit = !blocked && constrained ? maximumBribe(available, amount => bribePaymentPreservesPromises(g, p, target.id, amount)) : available; @@ -42205,6 +42235,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi if (action?.type === 'dealPay') { requireRule(state.status === 'playing' && Object.keys(action).sort().join(',') === 'deal,type' && typeof action.deal === 'string', 'Choose your pending deal payment.'); + requireRule(!bribeTimingBlock(state), 'Finish the current decision or response before paying a deal.'); const g = structuredClone(state), index = (g.deals ?? []).findIndex(deal => deal.id === action.deal); requireRule(index >= 0, 'This deal is no longer available.'); const current = g.deals![index], payment = current.payment; @@ -42275,6 +42306,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi // The payer consented to these immutable terms. Run the native action // on the same cloned state so a failed payment also rolls back consent. try { + requireRule(!bribeTimingBlock(g), 'Finish the current decision or response before paying a deal.'); requireRule(!getPlayer(g, next.payment.payer).autopilot, 'The payer is not controlling this seat.'); return applyActionWithInitiator(g, next.payment.payer, { type: 'bribe', target: next.payment.recipient, amount: next.payment.amount, @@ -43009,6 +43041,25 @@ export function normalizeAutomaticGame(state: Game): Game { return g; } +function payOrdinaryBribe(g: Game, p: Player, action: Action): void { + const blocked = bribeTimingBlock(g); + requireRule(!blocked || basicBribeInterrupt(g, p), blocked ?? 'Bribes are unavailable.'); + const target = getPlayer(g, stringField(action.target)); + requireRule( + target.id !== p.id && !bribeAllied(g, p, target.id), + 'Bribes are between different, non-allied factions.', + ); + const n = integer(action.amount, 1, uncommittedSpice(g, p), 'Spice'); + checkBureaucratContributors(g,target.id,[{payer:p.id,amount:n}]); + recordBribePayment(g, p.id, target.id, n); + p.spice -= n; + log( + g, + `${p.name} paid a ${n}-spice bribe to ${target.name}; its recipient share becomes spendable ${bribeReceiptTiming(g) === 'nextTurn' ? 'at the start of next turn’s Mentat Pause' : 'at the next Mentat Pause opening'}.`, + ); + const source = stampBureaucratPayment(g,'bribe',p.id,target.id,n).bureaucratPayment; + if (!offerBureaucratPayment(g,source,{kind:'bribe'})) target.bribes += n; +} function applyActionInner( state: Game, id: string, @@ -43553,6 +43604,10 @@ function applyActionInner( decideBiddingEnd(g, p, action); return g; } + if (t === 'bribe' && basicBribeInterrupt(g, p)) { + payOrdinaryBribe(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', @@ -45834,23 +45889,7 @@ function applyActionInner( return g; } if (t === 'bribe') { - const blocked = bribeTimingBlock(g); - requireRule(!blocked, blocked ?? 'Bribes are unavailable.'); - const target = getPlayer(g, stringField(action.target)); - requireRule( - target.id !== id && !bribeAllied(g, p, target.id), - 'Bribes are between different, non-allied factions.', - ); - const n = integer(action.amount, 1, uncommittedSpice(g, p), 'Spice'); - checkBureaucratContributors(g,target.id,[{payer:p.id,amount:n}]); - recordBribePayment(g, p.id, target.id, n); - p.spice -= n; - log( - g, - `${p.name} paid a ${n}-spice bribe to ${target.name}; its recipient share becomes spendable ${g.phase === 8 ? 'at the start of next turn’s Mentat Pause' : 'at the next Mentat Pause opening'}.`, - ); - const source = stampBureaucratPayment(g,'bribe',p.id,target.id,n).bureaucratPayment; - if (!offerBureaucratPayment(g,source,{kind:'bribe'})) target.bribes += n; + payOrdinaryBribe(g, p, action); return g; } if (t === 'stormDial') { diff --git a/game/reference.ts b/game/reference.ts index 4b44f404..38597823 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -296,7 +296,7 @@ export const RULE_TOPICS: RuleTopic[] = [ steps: [ 'Open Bribes beneath the table. Choose a non-allied faction and a positive whole amount, then use the named Pay button. The amount is public and leaves your supply immediately. A message or promise alone does not pay spice.', 'The recipient keeps paid spice separately until the next Mentat opening. A payment after the current opening waits for next turn’s opening. Your incoming bribes are shown separately from your spendable supply; they cannot fund bids, shipments or another bribe yet.', - 'You may pay out of turn when no decision or response is pending, including during Mentat after opening collection. You cannot bribe yourself or your ally, or while Inflation shows Double. Emperor gifts to allies use their own power controls.', + 'You may pay out of turn when no decision or response is pending, including during Mentat after opening collection. In supported Basic Ix-deck games, you can also pay during the Amal opening; in supported Basic Moritani games, you can pay during an Extortion choice. The original opening or choice remains pending. You cannot bribe yourself or your ally, or while Inflation shows Double. Emperor gifts to allies use their own power controls.', 'Your payment limit excludes reserved spending and preserves binding Truthtrance answers. If an alliance, pending decision or available supply changes, recheck the recipient and amount. A rejected payment does not change the game.', 'In the Basic group preview, group allies are excluded from ordinary bribes and nonallied deals. Existing agreements remain recorded and can be released; a deferred bribe to a newly allied recipient stays pending while it is unavailable, subject to its original deadline.', 'A qualifying Bureaucrat may divert two spice to the Bank without reducing the payer’s cost. The remainder stays deferred. Unsupported combinations remain unavailable and do not imply an official rule prohibition.', @@ -312,7 +312,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Player controls', status: 'Partial', detail: 'Public deal proposals, responses and mutual release, named bribe recipient, amount, explicit payment, deferred incoming supply and current availability guidance.', evidence: ['components/public-deals.tsx', 'components/bribes.tsx'] }, { area: 'AI', status: 'Partial', detail: 'Passive receipt/collection and existing Bureaucrat responses work. Strategic bribe initiation and negotiation wait for feature completion.' }, { area: 'Documentation', status: 'Partial', detail: 'Public deal consent, player-managed terms, payment, escrow, restrictions and remaining boundaries are recorded.', evidence: ['docs/PUBLIC_DEALS.md', 'docs/BRIBE_CONTROLS.md', 'docs/MENTAT_BRIBES.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Public deal consent, release, immediate and deferred native payments, authenticated visibility and legal bot continuation have focused coverage, including staged group and legacy records. Original late Mentat/next-turn receipt journeys supplement existing bribe projection, budgets and Bureaucrat coverage. Full integrated acceptance remains open.', evidence: ['tests/coalition-bribe-deals.test.ts', 'tests/coalition-bribe-truth.test.ts', 'tests/public-deals.test.ts', 'tests/public-deals-engine.test.ts', 'tests/public-deals-multiparty-engine.test.ts', 'tests/public-deals-payment-engine.test.ts', 'tests/public-deals-deferred-runtime.test.ts', 'tests/public-deals-deferred-truthtrance.test.ts', 'tests/public-deals-deferred-charity.test.ts', 'tests/public-deals-deferred-income.test.ts', 'tests/public-deals-deferred-http.test.ts', 'tests/public-deals-controls.test.tsx', 'tests/public-deals-http.test.ts', 'tests/public-deals-multiparty-http.test.ts', 'tests/public-deals-payment-http.test.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts'] }, + { area: 'Verification', status: 'Partial', detail: 'Supported Basic pending opening and Extortion payments retain their original parent and deferred collection; the actual opening and next-turn collection have focused runtime and rendered-control coverage. Public deal consent, release, immediate and deferred native payments, authenticated visibility and legal bot continuation have focused coverage, including staged group and legacy records. Original late Mentat/next-turn receipt journeys supplement existing bribe projection, budgets and Bureaucrat coverage. Full integrated acceptance remains open.', evidence: ['tests/bribe-pending-basic.test.ts', 'tests/bribe-pending-basic-controls.test.tsx', 'tests/coalition-bribe-deals.test.ts', 'tests/coalition-bribe-truth.test.ts', 'tests/public-deals.test.ts', 'tests/public-deals-engine.test.ts', 'tests/public-deals-multiparty-engine.test.ts', 'tests/public-deals-payment-engine.test.ts', 'tests/public-deals-deferred-runtime.test.ts', 'tests/public-deals-deferred-truthtrance.test.ts', 'tests/public-deals-deferred-charity.test.ts', 'tests/public-deals-deferred-income.test.ts', 'tests/public-deals-deferred-http.test.ts', 'tests/public-deals-controls.test.tsx', 'tests/public-deals-http.test.ts', 'tests/public-deals-multiparty-http.test.ts', 'tests/public-deals-payment-http.test.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts'] }, ], }, { diff --git a/tests/bribe-controls.test.tsx b/tests/bribe-controls.test.tsx index 53a4b186..5d1362cf 100644 --- a/tests/bribe-controls.test.tsx +++ b/tests/bribe-controls.test.tsx @@ -132,13 +132,13 @@ void test('bribe controls render recipient choices, private escrow and disabled assert.match(html, /Bribe recipient/); assert.match(html, /Harkonnen recipient/); assert.match(html, /collects their share at the start of Mentat Pause/); - assert.match(html, /Payments made during Mentat Pause wait until the start of next turn's Mentat Pause/); + assert.match(html, /Payments made after its opening wait until the start of next turn's Mentat Pause/); assert.match(html, /disabled=""[^>]*>Pay bribe/); assert.match(html, /rules\?topic=bribes#bribes/); assert.ok((render(g, true).match(/disabled=""/g) ?? []).length >= 3); g.phase = 8; assert.equal(options(g).blocked, null); - assert.match(render(g), /Payments made during Mentat Pause wait until the start of next turn's Mentat Pause/); + assert.match(render(g), /Payments made after its opening wait until the start of next turn's Mentat Pause/); g.status = 'lobby'; assert.equal(render(g), ''); }); diff --git a/tests/bribe-pending-basic-controls.test.tsx b/tests/bribe-pending-basic-controls.test.tsx new file mode 100644 index 00000000..fbe99b57 --- /dev/null +++ b/tests/bribe-pending-basic-controls.test.tsx @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { registerHooks } from 'node:module'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { bribeAction } from '../game/bribe-options'; +import { territory } from '../game/board'; +import { createTruthTurnAmalFixture } from './fixture-truth-turn-amal'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; +import { placeTerror } from '../game/moritani-terror'; + +const aliases = registerHooks({ resolve(specifier, context, next) { + return next(specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); +} }); +const { Bribes } = await import('../components/bribes'); +aliases.deregister(); + +const act = (game: Game, actor: string, action: Parameters[2]) => applyAction(game, actor, action); +const seat = (game: Game, id: string) => { + const player = game.players.find(row => row.id === id); assert.ok(player); return player; +}; +const render = (game: Game, actor: string, busy = false) => + renderToStaticMarkup(createElement(Bribes, { game: viewGame(game, actor), busy, act() {} })); + +void test('actual Basic Ix Mentat opening offers a bribe for the upcoming initialization', () => { + const f = createTruthTurnAmalFixture({phase:8, incoming:4}); + let game = f.game; + assert.deepEqual(game.phaseOpening, {passed:[], initialize:true}); + const quote = viewGame(game, 'harkonnen').bribeOptions; + assert.equal(quote.receiptTiming, 'upcoming'); + assert.equal(quote.blocked, null); + assert.ok(quote.targets.some(target => target.id === 'emperor' && target.maximum >= 1)); + const html = render(game, 'harkonnen'); + assert.match(html, /This payment will be collected at the upcoming Mentat Pause\./); + assert.match(html, /]*disabled)[^>]*>/); + assert.match(html, /Emperor/); + assert.match(html, /]*disabled=""[^>]*>Pay bribe<\/button>/); + assert.match(render(game, 'harkonnen', true), /]*disabled=""/); + const action = bribeAction(quote, 'emperor', 1); + assert.deepEqual(action, {type:'bribe', target:'emperor', amount:1}); + const before = {cash:seat(game,'emperor').spice, incoming:seat(game,'emperor').bribes}; + game = act(game, 'harkonnen', action!); + assert.equal(seat(game,'emperor').spice, before.cash); + assert.equal(seat(game,'emperor').bribes, before.incoming + 1); + assert.deepEqual(game.phaseOpening, {passed:[], initialize:true}); + for (const id of ['harkonnen','atreides','emperor']) game = act(game, id, {type:'ready'}); + assert.equal(game.phaseOpening, null); + assert.equal(seat(game,'emperor').bribes, 0); + assert.equal(seat(game,'emperor').spice, before.cash + before.incoming + 1); + const late = viewGame(game, 'harkonnen').bribeOptions; + assert.equal(late.receiptTiming, 'nextTurn'); + assert.equal(late.blocked, null); + assert.match(render(game,'harkonnen'), /This payment will be collected at the start of next turn’s Mentat Pause\./); +}); + +/** Public E3 setup and actual native controls reach Moritani's first Mentat + * placement; no pending decision or funds are manufactured by the test. */ +function moritaniPlacement(): Game { + let game = createGame('BRIBENATIVE', newPlayer('moritani','Moritani','moritani'), false, ['ecaz']); + joinGame(game, newPlayer('atreides','Atreides','atreides')); + joinGame(game, newPlayer('emperor','Emperor','emperor')); + for (const p of game.players) game = act(game,p.id,{type:'ready'}); + game = act(game,'moritani',{type:'start'}); + for (let i = 0; i < 1800; i++) { + if (game.phase === 8 && game.decision?.kind === 'moritaniPlacement') return game; + assert.notEqual(game.status,'finished'); + const next = game.decision?.kind === 'moritaniSetup' + ? {actor:'moritani', action:{type:'decision' as const, territory:'sietch_tabr', sector:territory('sietch_tabr').sectors[0]}} + : nextStrongholdFactionsNativeStep(game); + assert.ok(next, 'Native setup and turns must reach the original Mentat placement'); + game = act(game,next.actor,next.action); + } + throw Error('Native Mentat placement did not open'); +} + +void test('a different pending Mentat decision stays blocked with next-turn receipt timing', () => { + const game = moritaniPlacement(); + assert.equal(game.decision?.kind, 'moritaniPlacement'); + const quote = viewGame(game, 'atreides').bribeOptions; + assert.equal(quote.receiptTiming,'nextTurn'); + assert.ok(quote.blocked); + assert.equal(bribeAction(quote,'emperor',1),null); + const html = render(game,'atreides'); + assert.match(html,/This payment will be collected at the start of next turn’s Mentat Pause\./); + assert.match(html,/]*disabled=""/); +}); + +/** Public E3 setup and real shipment/Extortion reveal. The later clean movement + * boundary and prior physical token placement are explicitly staged. */ +function moritaniExtortion(): Game { + let game = createGame('BRIBEEXTORTION', newPlayer('moritani','Moritani','moritani'), false, ['ecaz']); + for (const faction of ['emperor','atreides','harkonnen'] as const) + joinGame(game,newPlayer(faction,faction,faction)); + for (const p of game.players) game = act(game,p.id,{type:'ready'}); + game = act(game,'moritani',{type:'start'}); + for (let i = 0; i < 100 && game.status === 'setup'; i++) { + const next = game.decision?.kind === 'moritaniSetup' + ? {actor:'moritani', action:{type:'decision' as const, territory:'sietch_tabr', sector:territory('sietch_tabr').sectors[0]}} + : nextStrongholdFactionsNativeStep(game); + assert.ok(next); game = act(game,next.actor,next.action); + } + assert.equal(game.status,'playing'); + const token = game.moritaniTerror!.tokens.find(row => row.kind === 'extortion'); assert.ok(token); + game.moritaniTerror = placeTerror(game.moritaniTerror!,token.id,'habbanya_ridge_sietch',1); + Object.assign(game,{phase:5,turn:2,storm:18,order:['emperor','atreides','harkonnen','moritani'], + active:'emperor',movementRemaining:['emperor','atreides','harkonnen','moritani'], + response:null,decision:null,phaseOpening:null,ready:[]}); + game = act(game,'emperor',{type:'ship',territory:'habbanya_ridge_sietch',sector:17,amount:1}); + assert.equal(game.decision?.kind,'moritaniTerror'); + game = act(game,'moritani',{type:'decision',reveal:true}); + game.phase = 7; game.ready = []; + for (const p of game.players) game = act(game,p.id,{type:'ready'}); + assert.equal(game.decision?.kind,'moritaniPlacement'); + game = act(game,'moritani',{type:'decision',decline:true}); + assert.equal(game.decision?.kind,'moritaniExtortion'); + return game; +} + +void test('an actual Basic Extortion decision has its own next-turn bribe quote', () => { + const game = moritaniExtortion(); + assert.equal(game.phase,8); + assert.equal(game.decision?.kind,'moritaniExtortion'); + const quote = viewGame(game,'emperor').bribeOptions; + assert.equal(quote.receiptTiming,'nextTurn'); + assert.equal(quote.blocked,null); + const target = quote.targets.find(candidate => candidate.maximum >= 1); assert.ok(target); + assert.deepEqual(bribeAction(quote,target.id,1), {type:'bribe',target:target.id,amount:1}); + const html = render(game,'emperor'); + assert.match(html,/This payment will be collected at the start of next turn’s Mentat Pause\./); + assert.match(html,/]*disabled)[^>]*>/); + assert.match(render(game,'emperor',true),/]*disabled=""/); + const paid = act(game,'emperor',bribeAction(quote,target.id,1)!); + assert.deepEqual(paid.decision,game.decision); + assert.equal(seat(paid,target.id).bribes,seat(game,target.id).bribes + 1); +}); diff --git a/tests/bribe-pending-basic.test.ts b/tests/bribe-pending-basic.test.ts new file mode 100644 index 00000000..252ae2dd --- /dev/null +++ b/tests/bribe-pending-basic.test.ts @@ -0,0 +1,171 @@ +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 { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; +import { createTruthTurnAmalFixture, truthTurnAmalInventory as inventory } from './fixture-truth-turn-amal'; +import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards'; + +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)); +const pay = (target: string, amount: number): Action => ({type:'bribe', target, amount}); + +/** Public Basic Ix Harkonnen/Atreides/Emperor start. The Ix fixture uses a + * real native phase-opening chronology and selects only unopened card order. */ +void test('Basic Ix opening bribe preserves passes and the native Amal uses the post-payment wallets', () => { + const f = createTruthTurnAmalFixture(); + let game = f.game; + const original = inventory(game); + // Move the physical Amal to an unpassed seat so its later real play is possible. + const owner = seat(game, 'emperor'), index = owner.hand.findIndex(card => card.id === f.amal); + assert.ok(index >= 0); seat(game, 'atreides').hand.push(owner.hand.splice(index, 1)[0]); + assert.deepEqual(inventory(game), original); + game = act(game, 'harkonnen', {type:'ready'}); + game = act(game, 'emperor', {type:'ready'}); + const opening = structuredClone(game.phaseOpening), payer = seat(game, 'harkonnen').spice; + const recipient = seat(game, 'emperor').spice, deferred = seat(game, 'emperor').bribes; + assert.equal(viewGame(game, 'harkonnen').bribeOptions.blocked, null); + assert.equal(viewGame(game, 'harkonnen').bribeOptions.receiptTiming, 'upcoming'); + game = act(game, 'harkonnen', pay('emperor', 2)); + assert.deepEqual(game.phaseOpening, opening); + assert.equal(seat(game, 'harkonnen').spice, payer - 2); + assert.equal(seat(game, 'emperor').spice, recipient); + assert.equal(seat(game, 'emperor').bribes, deferred + 2); + const beforeAmal = game.players.map(player => player.spice); + game = act(game, 'atreides', {type:'card', card:f.amal}); + assert.deepEqual(game.phaseOpening?.passed, []); + assert.deepEqual(game.players.map(player => player.spice), beforeAmal.map(amount => Math.floor(amount / 2))); + assert.equal(seat(game, 'emperor').bribes, deferred + 2); + assert.equal(game.discard.filter(card => card.id === f.amal).length, 1); + for (const player of game.players) game = act(game, player.id, {type:'ready'}); + assert.equal(game.phaseOpening, null); assert.deepEqual(inventory(game), original); +}); + +void test('bribe before real Mentat initialization is collected at that opening', () => { + const f = createTruthTurnAmalFixture({phase:8, incoming:4}); + let game = f.game; + assert.equal(game.phaseOpening?.initialize, true); + game = act(game, 'atreides', {type:'ready'}); + const opening = structuredClone(game.phaseOpening), before = seat(game, 'emperor').spice; + assert.equal(seat(game, 'emperor').bribes, 4); + assert.equal(viewGame(game, 'atreides').bribeOptions.receiptTiming, 'upcoming'); + game = act(game, 'atreides', pay('emperor', 2)); + assert.deepEqual(game.phaseOpening, opening); + assert.equal(seat(game, 'emperor').spice, before); + assert.equal(seat(game, 'emperor').bribes, 6); + game = act(game, 'emperor', {type:'ready'}); + game = act(game, 'harkonnen', {type:'ready'}); + assert.equal(game.phaseOpening, null); + assert.equal(seat(game, 'emperor').spice, before + 6); + assert.equal(seat(game, 'emperor').bribes, 0); +}); + +/** Public Basic Ecaz-deck Moritani setup. As in the original Extortion gift + * fixture, stage only a legal prior physical Terror placement, phase/turn and + * two wallets; actual shipment, reveal, bank award and queue are native. */ +function extortion() { + let game = createGame('BRIBEEXTORTION', newPlayer('m', 'Moritani', 'moritani'), false, ['ecaz']); + joinGame(game, newPlayer('e', 'Emperor', 'emperor')); + joinGame(game, newPlayer('a', 'Atreides', 'atreides')); + joinGame(game, newPlayer('h', 'Harkonnen', 'harkonnen')); + for (const player of game.players) game = act(game, player.id, {type:'ready'}); + game = act(game, 'm', {type:'start'}); + for (let i = 0; i < 100 && game.status === 'setup'; i++) { + if (game.decision?.kind === 'moritaniSetup') + game = act(game, 'm', {type:'decision', territory:'sietch_tabr', sector:territory('sietch_tabr').sectors[0]}); + else { + const step = nextStrongholdFactionsNativeStep(game); assert.ok(step); + game = act(game, step.actor, step.action); + } + } + assert.equal(game.status, 'playing'); + const token = game.moritaniTerror!.tokens.find(row => row.kind === 'extortion'); assert.ok(token); + game.moritaniTerror = placeTerror(game.moritaniTerror!, token.id, 'habbanya_ridge_sietch', 1); + Object.assign(game, {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(game, 'e').spice = 10; seat(game, 'a').spice = 2; + game = act(game, 'e', {type:'ship', territory:'habbanya_ridge_sietch', sector:17, amount:1}); + assert.equal(game.decision?.kind, 'moritaniTerror'); + game = act(game, 'm', {type:'decision', reveal:true}); + assert.equal(game.moritaniTerror!.tokens.find(row => row.id === token.id)?.status, 'extortion'); + game.phase = 7; game.ready = []; + for (const player of game.players) game = act(game, player.id, {type:'ready'}); + assert.equal(game.decision?.kind, 'moritaniPlacement'); + game = act(game, 'm', {type:'decision', decline:true}); + assert.equal(game.decision?.kind, 'moritaniExtortion'); + game = act(game, 'e', {type:'decision', event:game.decision.event, pay:false}); + assert.equal(game.decision?.kind, 'moritaniExtortion'); assert.equal(game.decision.player, 'a'); + return {game, token:token.id}; +} + +void test('Basic Extortion bribe leaves the queue intact and reaches the next real Mentat collection', () => { + const f = extortion(); let game = f.game; + const decision = structuredClone(game.decision), obligation = structuredClone(game.moritaniExtortion); + const payer = seat(game, 'h').spice, current = seat(game, 'a').spice; + assert.equal(viewGame(game, 'h').bribeOptions.blocked, null); + assert.equal(viewGame(game, 'h').bribeOptions.receiptTiming, 'nextTurn'); + assert.equal(viewGame(game, 'a').extortion.pending?.canPay, false); + game = act(game, 'h', pay('a', 4)); + assert.deepEqual(game.decision, decision); assert.deepEqual(game.moritaniExtortion, obligation); + assert.equal(seat(game, 'h').spice, payer - 4); + assert.equal(seat(game, 'a').spice, current); assert.equal(seat(game, 'a').bribes, 4); + assert.equal(viewGame(game, 'a').extortion.pending?.canPay, false); + assert.ok(game.decision?.kind === 'moritaniExtortion'); + game = act(game, 'a', {type:'decision', event:game.decision.event, pay:false}); + assert.ok(game.decision?.kind === 'moritaniExtortion'); assert.equal(game.decision.player, 'h'); + game = act(game, 'h', {type:'decision', event:game.decision.event, pay:true}); + assert.equal(game.moritaniExtortion, undefined); + assert.equal(game.moritaniTerror!.tokens.find(row => row.id === f.token)?.status, 'removed'); + // Continue with real native actions until the last readiness before the + // next Mentat. Only the unopened turn-three Spice position is selected to + // avoid an incidental worm; no future resource, phase, turn or escrow is staged. + let ordered = false; + for (let i = 0; i < 1900; i++) { + if (!ordered && game.turn === 3 && beforeIxSpiceDraw(game)) { + orderIxSpiceCards(game, ['land']); ordered = true; + } + if (game.turn === 3 && game.phase === 7 && !game.response && !game.decision && + !game.phaseOpening && game.players.filter(player => !game.ready.includes(player.id)).length === 1) break; + assert.notEqual(game.status, 'finished'); + const next = nextStrongholdFactionsNativeStep(game); assert.ok(next); + game = act(game, next.actor, next.action); + } + assert.equal(game.turn, 3); assert.equal(game.phase, 7); + assert.equal(seat(game, 'a').bribes, 4); + const before = seat(game, 'a').spice; + const last = game.players.find(player => !game.ready.includes(player.id)); assert.ok(last); + game = act(game, last.id, {type:'ready'}); + assert.equal(game.phase, 8); assert.equal(seat(game, 'a').spice, before + 4); + assert.equal(seat(game, 'a').bribes, 0); +}); + +void test('invalid Basic pending bribes preserve their original parent and game', () => { + const f = createTruthTurnAmalFixture(); const game = f.game; + seat(game, 'emperor').ally = 'atreides'; seat(game, 'atreides').ally = 'emperor'; + const before = copy(game); + for (const [actor, action] of [ + ['emperor', pay('atreides', 1)], ['emperor', pay('emperor', 1)], + ['emperor', pay('harkonnen', 0)], ['emperor', pay('harkonnen', seat(game, 'emperor').spice + 1)], + ] as [string, Action][]) { + assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before); + } + const pending = extortion().game, saved = copy(pending); + assert.throws(() => act(pending, 'h', pay('a', seat(pending, 'h').spice + 1))); + assert.deepEqual(pending, saved); +}); + +void test('an immediate deal cannot bypass its unchanged timing during an opening', () => { + let game = createTruthTurnAmalFixture().game; + game = act(game, 'harkonnen', {type:'dealOffer', target:'emperor', terms:'Information for spice.', + payment:{kind:'immediateBribe', payer:'harkonnen', recipient:'emperor', amount:1}}); + const deal = game.deals?.at(-1); assert.ok(deal); + const before = copy(game); + assert.throws(() => act(game, 'emperor', {type:'dealRespond', deal:deal.id, accept:true})); + assert.deepEqual(game, before); + assert.equal(viewGame(game, 'harkonnen').bribeOptions.blocked, null); + game = act(game, 'harkonnen', pay('emperor', 1)); + assert.deepEqual(game.phaseOpening, before.phaseOpening); + assert.equal(game.deals?.at(-1)?.status, 'offered'); +}); diff --git a/tests/emperor-extortion-gift.test.ts b/tests/emperor-extortion-gift.test.ts index e747cef6..ed3c9d1a 100644 --- a/tests/emperor-extortion-gift.test.ts +++ b/tests/emperor-extortion-gift.test.ts @@ -26,8 +26,12 @@ 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); + // Starting cards are shuffled: the physical Karama may already be in + // another hand rather than the unopened deck. + const owner = g.players.find(player => player.id !== p.id && player.hand.some(row => row.effect === 'karama')); + const source = owner?.hand ?? g.deck; + const index = source.findIndex(row => row.effect === 'karama'); assert.ok(index >= 0); + [card] = source.splice(index, 1); p.hand.push(card); } assert.ok(card); return card.id; }