Allow bribes during classic pending decisions
Verify and publish checkpoint container / container (push) Successful in 10m47s

This commit is contained in:
Quintine committed 2026-10-29 08:26:34 +11:00
1 parent 1e81ce8ad8
commit 6ecd26c8d1
8 files changed
+444 -5

No files matched your search

+13
View File
@@ -1,5 +1,18 @@
# Ordinary bribe controls
## Classic Basic auction and revival responses — 29 October 2026
In supported classic Basic tables, a direct nonallied bribe can also be paid
during a normal auction payment decision, an Atreides auction-inspection
response or a Fremen allied-revival grant response. The original decision,
response and pass history continue unchanged. The auction winner's committed
bid and pledged aid remain reserved; only uncommitted spice can be paid.
The recipient collects the bribe at the next Mentat opening, not during the
pending auction or revival choice. These are provisional online interruption
windows under the printed public-bribe rule cited below, not a publisher
priority ruling. Live commitments, nested decisions, optional modules and
deal-linked payments retain their existing boundaries.
## Basic pending-opening and Extortion payments — 29 October 2026
Direct ordinary bribes are available during a clean Basic Ix-deck Amal opening
+12
View File
@@ -17,6 +17,18 @@ open mode gates.
## Current checkpoint and work
**Classic Basic bribes during pending choices — 29 October. Prototyped /
Partial.** Direct ordinary bribes can interrupt a clean normal auction-payment
decision, Atreides auction inspection or Fremen allied-revival grant. The
original decision/response and passes continue, winning bids and allied pledges
remain reserved, and recipients collect at Mentat. Actual Basic-game journeys
cover payment, rejection, original continuation and four legal bot profiles;
the controls use the same private payer quote. Eleven related files pass 66
cases, including 13 new cases; type checking, lint and production build pass.
Timing is provisional; live commitments, nested parents, optional modules,
deal-linked payments and deployed acceptance remain outside this slice.
[Scope](BRIBE_CONTROLS.md#classic-basic-auction-and-revival-responses--29-october-2026).
**Later-turn travel agreements — 29 October. Prototyped / Partial.** In
supported Basic classic games, two to six human parties can schedule a travel
term for a later turn without reserving present resources; group consent,
+9
View File
@@ -1,5 +1,14 @@
# Rule decision index
## Classic Basic bribes during pending auction and revival — 29 October 2026
The publisher's base rulebook, printed p. 12, permits nonallied bribes but does
not prescribe online interruption priority. Direct payments during a normal
auction-payment decision, Atreides inspection or Fremen allied-revival grant
are provisional digital timing choices. The committed parent and any reserved
bid remain intact, and recipient collection follows the ordinary Mentat rule.
[Scope](BRIBE_CONTROLS.md#classic-basic-auction-and-revival-responses--29-october-2026).
## Later-turn travel deals — 29 October 2026
The publisher's base rulebook, printed p. 12, permits nonallied deals for
+34 -2
View File
@@ -17342,10 +17342,10 @@ function bribePaymentPreservesPromises(g: Game, p: Player, recipient: string, am
* 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 ||
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.ecazTreachery || g.semutaPreview || g.semutaRecruitsPreview || g.truthtrance ||
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 ||
@@ -17356,9 +17356,41 @@ function basicBribeInterrupt(g: Game, p: Player): boolean {
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.bidPromises ?? []).some(row => row.player === p.id && row.turn === g.turn && !row.fulfilled && !row.released) ||
(g.earlyRicheseBidPromises ?? []).some(row => row.player === p.id && row.turn === g.turn && !row.fulfilled && !row.released) ||
(g.richeseBidPromises ?? []).some(row => row.player === p.id && row.turn === g.turn && !row.fulfilled && !row.released) ||
(g.charityPromises ?? []).some(row => row.player === p.id && row.turn === g.turn && !row.fulfilled && !row.released) ||
(g.alliancePromises ?? []).some(row => row.player === p.id && row.turn === g.turn && !row.fulfilled && !row.released) ||
(g.allianceExpressionPromises ?? []).some(row => row.player === p.id && row.turn === g.turn && !row.fulfilled && !row.released) ||
(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.length) {
if (g.phaseOpening || !g.players.every(seat => classic.includes(seat.faction)) ||
Object.entries(g).some(([key, value]) => key.startsWith('pending') &&
(Array.isArray(value) ? value.length > 0 : value !== null && value !== undefined && value !== false))) return false;
if (g.decision?.kind === 'auctionPayment' && !g.response)
return g.phase === 3 && !!g.auction && !g.richeseAuction && !g.currentAuctionSale &&
g.auction.bidder === g.decision.player && !!g.auction.cards[g.auction.index];
const response = g.response;
if (!response || g.decision || !Array.isArray(response.passed) ||
new Set(response.passed).size !== response.passed.length ||
response.passed.some(id => !g.players.some(seat => seat.id === id))) return false;
const owner = g.players.find(seat => seat.id === response.owner);
const keys = Object.keys(response).sort().join(',');
if (response.kind === 'atreidesAuction') return keys === 'kind,owner,passed' && g.phase === 3 &&
owner?.faction === 'atreides' && !!g.auction && !g.richeseAuction && !g.currentAuctionSale &&
!!g.auction.cards[g.auction.index];
if (response.kind === 'fremenRevivalGrant') {
if (keys !== 'fremenGrant,kind,owner,passed,recipient' || g.phase !== 4 || owner?.faction !== 'fremen' ||
!response.fremenGrant || response.fremenGrant.owner !== response.owner ||
response.fremenGrant.recipient !== response.recipient) return false;
try { validateFremenRevivalGrant(g, response.fremenGrant); return true; }
catch (error) { if (error instanceof FremenRevivalGrantError) return false; throw error; }
}
return false;
}
if (g.response) return false;
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;
+3 -3
View File
@@ -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. 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.',
'You may pay out of turn when no decision or response is pending, including during Mentat after opening collection. In supported Basic classic games, direct bribes can also interrupt a normal auction-payment decision, Atreides auction inspection or Fremen allied-revival grant. Supported Basic Ix and Moritani games also allow payments during the Amal opening or an Extortion choice. The original parent and its passes remain 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.',
@@ -310,9 +310,9 @@ export const RULE_TOPICS: RuleTopic[] = [
related: ['table-discussion', 'alliance-funding', 'card-truthtrance', 'privacy'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Public two-to-six-human offer, unanimous consent and mutual release are recorded. Immediate and deferred deal payments, including later deadlines, use the native bribe action; prose compliance remains player managed. Funding, escrow and Bureaucrat continuation remain native. Complete negotiation and all module combinations remain open.', evidence: ['game/public-deals.ts', 'game/public-deal-obligations.ts', 'game/bribe-options.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Public deal proposals, responses and mutual release, named bribe recipient, explicit payment, and a structured current-turn shipment or movement term with public status and an owner-only native-action guide.', evidence: ['components/public-deals.tsx', 'components/travel-promises.tsx', 'components/bribes.tsx'] },
{ area: 'Player controls', status: 'Partial', detail: 'Public deal proposals, responses and mutual release, named bribe recipient, explicit payment, and structured current- or later-turn travel with public status and an owner-only native-action guide.', evidence: ['components/public-deals.tsx', 'components/travel-promises.tsx', 'components/bribes.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Passive receipt/collection, Bureaucrat responses and legal autopilot continuation of a previously accepted travel term work. Strategic bribe initiation and negotiation wait for feature completion.' },
{ area: 'Documentation', status: 'Partial', detail: 'Public deal consent, structured travel and payment, player-managed terms, escrow, restrictions and remaining boundaries are recorded.', evidence: ['docs/PUBLIC_DEALS.md', 'docs/BRIBE_CONTROLS.md', 'docs/MENTAT_BRIBES.md', 'tests/public-deals-travel-runtime.test.ts', 'tests/public-deals-travel-controls.test.tsx'] },
{ area: 'Documentation', status: 'Partial', detail: 'Public deal consent, structured travel and payment, player-managed terms, escrow, restrictions and remaining boundaries are recorded.', evidence: ['docs/PUBLIC_DEALS.md', 'docs/BRIBE_CONTROLS.md', 'docs/MENTAT_BRIBES.md', 'tests/bribe-classic-auction-runtime.test.ts', 'tests/bribe-classic-response-runtime.test.ts', 'tests/bribe-classic-pending-controls.test.tsx', 'tests/public-deals-travel-runtime.test.ts', 'tests/public-deals-travel-controls.test.tsx'] },
{ 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. Group travel has actual multi-seat consent, physical travel and release coverage. 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-group-travel-runtime.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'] },
],
},
+123
View File
@@ -0,0 +1,123 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { actTruthBid as act, advanceTruthBid as advance, createTruthBidFixture,
truthBidClean as clean, truthBidInventory as inventory, truthBidPlayer as player } from './fixture-truth-bid';
import { advanceTruthAlliance, closeTruthAllianceNexus, truthAllianceNexus } from './fixture-truth-alliance';
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before, 'A rejected bribe preserves the original auction.');
}
function pending(game: Game, winner: string, amount: number, allyPayment = 0) {
assert.equal(game.auction?.active, winner);
game = act(game, winner, {type:'bid', amount, allyPayment});
game = advance(game, state => state.decision?.kind === 'auctionPayment' && state.decision.player === winner);
assert.equal(game.phase, 3);
assert.equal(game.auction?.bidder, winner);
assert.equal(game.auction?.bid, amount);
assert.equal(game.auction?.allyPayment ?? 0, allyPayment);
assert.equal(game.currentAuctionSale ?? null, null);
return game;
}
function committed(game: Game) {
return {decision:structuredClone(game.decision), auction:structuredClone(game.auction),
aid:structuredClone(game.aid), response:structuredClone(game.response ?? null)};
}
/** Original Basic Harkonnen/Atreides/Emperor game, with an original Karama
* dealt to the Emperor. The shared source selects only unopened physical cards
* and scalar lotteries, then uses native bid/pass actions to create the live
* payment choice. No wallet, ally, commitment or decision is installed. */
function noAlliance() {
const f = createTruthBidFixture({targetKarama:true, window:'active'});
const game = pending(f.game, f.target, 4);
return {f, game, lot:game.auction!.cards[game.auction!.index].id};
}
void test('a winning bidder may bribe from uncommitted spice while the original purchase stays pending', () => {
const {f, game, lot} = noAlliance();
const saved = committed(game), payer = player(game, f.target), recipient = player(game, f.asker);
const originalCash = payer.spice, recipientCash = recipient.spice, escrow = recipient.bribes;
const options = viewGame(game, f.target).bribeOptions;
assert.equal(options.blocked, null);
assert.equal(options.available, originalCash - 4);
assert.equal(options.targets.find(row => row.id === f.asker)?.maximum, originalCash - 4);
reject(game, f.target, {type:'bribe', target:f.asker, amount:originalCash - 3});
reject(game, f.target, {type:'bribe', target:f.target, amount:1});
reject(game, f.target, {type:'bribe', target:f.asker, amount:0});
let after = act(game, f.target, {type:'bribe', target:f.asker, amount:originalCash - 4});
assert.deepEqual(committed(after), saved);
assert.equal(player(after, f.target).spice, 4);
assert.equal(player(after, f.asker).spice, recipientCash);
assert.equal(player(after, f.asker).bribes, escrow + originalCash - 4);
assert.deepEqual(inventory(after), f.originalCards);
after = act(after, f.target, {type:'decision', karama:false});
after = advance(after, state => player(state, f.target).hand.some(card => card.id === lot));
assert.equal(player(after, f.target).spice, 0);
assert.equal(player(after, f.asker).bribes, escrow + originalCash - 4);
assert.equal(player(after, f.target).hand.filter(card => card.id === lot).length, 1);
assert.deepEqual(inventory(after), f.originalCards);
});
void test('another seat may pay a bribe without changing the winner, bid or purchase choice', () => {
const {f, game, lot} = noAlliance(), saved = committed(game);
const donor = player(game, f.opponent), recipient = player(game, f.asker);
const donorCash = donor.spice, winnerCash = player(game, f.target).spice,
recipientCash = recipient.spice, escrow = recipient.bribes;
assert.ok(donorCash >= 2);
assert.equal(viewGame(game, f.opponent).bribeOptions.blocked, null);
let after = act(game, f.opponent, {type:'bribe', target:f.asker, amount:2});
assert.deepEqual(committed(after), saved);
assert.equal(player(after, f.opponent).spice, donorCash - 2);
assert.equal(player(after, f.target).spice, winnerCash);
assert.equal(player(after, f.asker).spice, recipientCash);
assert.equal(player(after, f.asker).bribes, escrow + 2);
after = act(after, f.target, {type:'decision', karama:false});
after = advance(after, state => player(state, f.target).hand.some(card => card.id === lot));
assert.equal(player(after, f.target).hand.filter(card => card.id === lot).length, 1);
assert.equal(player(after, f.asker).bribes, escrow + 2);
assert.deepEqual(inventory(after), f.originalCards);
});
/** The second turn uses a real worm and reciprocal Nexus offers; an actual
* Atreides pledge is committed into the Emperor's original bid. */
function alliedAuction() {
const f = createTruthBidFixture({targetKarama:true, window:'waiting'});
let game = advanceTruthAlliance(f.game, state => state.turn === 2 && beforeIxSpiceDraw(state));
orderIxSpiceCards(game, ['worm','land']);
game = advanceTruthAlliance(game, truthAllianceNexus);
game = act(game, f.opponent, {type:'alliance', target:f.target});
game = act(game, f.target, {type:'alliance', target:f.opponent});
assert.equal(player(game, f.opponent).ally, f.target);
assert.equal(player(game, f.target).ally, f.opponent);
game = closeTruthAllianceNexus(game);
game = advanceTruthAlliance(game, state => state.turn === 2 && state.phase === 3 && !!state.auction && clean(state));
game = act(game, f.opponent, {type:'pledgeAid', amount:2});
assert.equal(game.aid[f.opponent]?.amount, 2);
game = advanceTruthAlliance(game, state => state.phase === 3 && state.auction?.active === f.target && clean(state));
game = pending(game, f.target, 4, 2);
return {f, game, lot:game.auction!.cards[game.auction!.index].id};
}
void test('an ally-funded winning bid keeps both the pledged spice and the winner’s own share reserved', () => {
const {f, game, lot} = alliedAuction(), saved = committed(game);
const payer = player(game, f.target), donor = player(game, f.opponent), recipient = player(game, f.asker);
const payerCash = payer.spice, donorCash = donor.spice, escrow = recipient.bribes;
assert.equal(viewGame(game, f.target).bribeOptions.available, payerCash - 2);
reject(game, f.target, {type:'bribe', target:f.opponent, amount:1});
reject(game, f.target, {type:'bribe', target:f.asker, amount:payerCash - 1});
let after = act(game, f.opponent, {type:'bribe', target:f.asker, amount:1});
assert.deepEqual(committed(after), saved);
assert.equal(player(after, f.opponent).spice, donorCash - 1);
assert.equal(after.aid[f.opponent]?.amount, 2);
assert.equal(player(after, f.asker).bribes, escrow + 1);
after = act(after, f.target, {type:'decision', karama:false});
after = advance(after, state => player(state, f.target).hand.some(card => card.id === lot));
assert.equal(player(after, f.target).spice, payerCash - 2);
assert.equal(after.aid[f.opponent]?.amount ?? 0, 0);
assert.equal(player(after, f.target).hand.filter(card => card.id === lot).length, 1);
assert.deepEqual(inventory(after), f.originalCards);
});
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import * as jsxRuntime from 'react/jsx-runtime';
import ts from 'typescript';
import * as catalog from '../game/catalog';
import * as bribeOptions from '../game/bribe-options';
import { viewGame, type Action, type GameView } from '../game/engine';
import { actTruthBid, advanceTruthBid, createTruthBidFixture } from './fixture-truth-bid';
import { actFremenRevivalGrant, createFremenRevivalGrantFixture } from './fixture-fremen-revival-grant';
type BribesComponent = (props: {game: GameView; busy: boolean; act: (action: Action) => void}) => ReactNode;
type Element = ReactElement<{children?: ReactNode; [key: string]: unknown}>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<{children?: ReactNode; [key: string]: unknown}>(node)) return [];
return [node, ...elements(node.props.children)];
}
/** Exercise the actual component's callbacks with deterministic React hooks.
* This is a component harness, not a hydrated-browser assertion. */
function controls(game: GameView) {
const states:unknown[] = [], sent:Action[] = [];
let cursor = 0;
const exported:{Bribes?:BribesComponent} = {};
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/bribes.tsx', import.meta.url), 'utf8'), {
compilerOptions: {module:ts.ModuleKind.CommonJS, target:ts.ScriptTarget.ES2022, jsx:ts.JsxEmit.ReactJSX},
}).outputText, {exports:exported, require(name:string) {
if (name === 'react/jsx-runtime') return jsxRuntime;
if (name === 'react') return {useId:() => 'bribe-control-test', useState(initial:unknown) {
const index = cursor++;
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial;
return [states[index], (next:unknown) => {states[index] = typeof next === 'function' ? next(states[index]) : next;}];
}};
if (name === '@/game/catalog') return catalog;
if (name === '@/game/bribe-options') return bribeOptions;
if (name === '@/components/ui/button') return {Button:'button'};
throw Error(`Unexpected import ${name}`);
}});
assert.ok(exported.Bribes);
const renderTree = (busy = false) => {
cursor = 0;
return exported.Bribes!({game,busy,act(action) {sent.push(action);}});
};
return {render:(busy = false) => elements(renderTree(busy)), renderTree, sent};
}
/** Real classic setup and bidding establish the winning bid and its optional
* Karama payment choice; only original unopened starting cards are selected. */
function auctionPayment() {
const f = createTruthBidFixture({window:'active',targetKarama:true});
let game = actTruthBid(f.game, f.target, {type:'bid',amount:3});
game = advanceTruthBid(game, state => state.decision?.kind === 'auctionPayment' && !state.response);
assert.equal(game.auction?.bidder, f.target);
assert.equal(game.auction?.bid, 3);
return {game,payer:f.target,recipient:f.opponent};
}
/** The same source reaches the original Atreides inspection response before
* any bid; Emperor's genuinely dealt Karama keeps that response open. */
function atreidesAuction() {
const f = createTruthBidFixture({targetKarama:true});
const game = advanceTruthBid(structuredClone(f.afterSetup), state =>
state.phase === 3 && state.response?.kind === 'atreidesAuction');
assert.equal(game.response?.owner, 'atreides');
return {game,payer:f.target,recipient:f.opponent};
}
/** The fixture supplies a real battle, Nexus alliance and grant response. */
function fremenGrant() {
const f = createFremenRevivalGrantFixture();
const game = actFremenRevivalGrant(f.game, f.fremen, f.grant);
assert.equal(game.response?.kind, 'fremenRevivalGrant');
return {game,payer:f.observer,recipient:f.fremen};
}
for (const [label,source] of [
['auction payment',auctionPayment], ['Atreides inspection',atreidesAuction], ['Fremen grant',fremenGrant],
] as const) void test(`the ${label} exposes usable private native bribe controls`, () => {
const {game,payer,recipient} = source();
const view = viewGame(game,payer), quote = view.bribeOptions;
assert.equal(quote.blocked,null);
const option = quote.targets.find(row => row.id === recipient); assert.ok(option);
assert.ok(option.maximum >= 1);
assert.equal(quote.receiptTiming,'upcoming');
const ui = controls(view);
const initial = ui.render();
const select = initial.find(node => node.type === 'select'); assert.ok(select);
assert.equal(select.props.disabled,false);
const initialButton = initial.find(node => node.type === 'button'); assert.ok(initialButton);
assert.equal(initialButton.props.disabled,true);
(select.props.onChange as (event:{target:{value:string}}) => void)({target:{value:recipient}});
const chosen = ui.render();
const amount = chosen.find(node => node.type === 'input'); assert.ok(amount);
assert.equal(amount.props.max,option.maximum);
const payment = chosen.find(node => node.type === 'button'); assert.ok(payment);
assert.equal(payment.props.disabled,false);
(payment.props.onClick as () => void)();
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent)), [{type:'bribe',target:recipient,amount:1}]);
(amount.props.onChange as (event:{target:{value:string}}) => void)({target:{value:String(option.maximum + 1)}});
assert.equal(ui.render().find(node => node.type === 'button')?.props.disabled,true);
assert.equal(ui.render(true).find(node => node.type === 'select')?.props.disabled,true);
assert.equal(ui.render(true).find(node => node.type === 'input')?.props.disabled,true);
const html = renderToStaticMarkup(createElement(() => ui.renderTree()));
assert.match(html,/collected at the upcoming Mentat Pause/);
assert.match(html,/separate from your spendable supply/);
assert.equal(view.players.find(player => player.id === recipient)?.spice,undefined);
const changed = structuredClone(game);
const other = changed.players.find(player => player.id === recipient); assert.ok(other);
other.spice += 100; other.bribes += 100;
assert.deepEqual(viewGame(changed,payer).bribeOptions,quote,
'Another seat’s private resources must not alter this payer’s quote.');
});
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { actFremenRevivalGrant as act, advanceFremenRevivalGrant as advance,
createFremenRevivalGrantFixture as fixture, fremenRevivalGrantInventory as inventory,
fremenRevivalGrantPlayer as player } from './fixture-fremen-revival-grant';
const pay = (target: string, amount: number): Action => ({type:'bribe', target, amount});
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before, 'A rejected bribe leaves its pending parent unchanged.');
}
/** Real classic Basic setup, native first auction and an originally dealt
* Emperor Karama. The shared fixture selects unopened starting positions and
* uses actual setup actions; no response, wallet or auction is fabricated. */
function auctionSource() {
const f = fixture();
const game = advance(structuredClone(f.afterSetup), state => state.turn === 1 && state.phase === 3 &&
state.response?.kind === 'atreidesAuction' && !!state.auction && !state.richeseAuction);
assert.equal(game.response?.owner, f.ally);
assert.ok(player(game, f.observer).hand.some(card => card.id === f.karama));
return {f, game};
}
let auctionCache: ReturnType<typeof auctionSource> | undefined;
const auction = () => auctionCache ??= auctionSource();
/** Real original paid battle, casualties, later worm/Nexus and alliance lead
* to Fremen's actual revival grant, held open by Emperor's physical Karama. */
function grantSource(advanced = false) {
const f = fixture({advanced});
const game = act(f.game, f.fremen, f.grant);
assert.equal(game.response?.kind, 'fremenRevivalGrant');
assert.ok(player(game, f.observer).hand.some(card => card.id === f.karama));
return {f, game};
}
let grantCache: ReturnType<typeof grantSource> | undefined;
const grant = () => grantCache ??= grantSource();
void test('a Basic auction inspection response retains its original lot and passes during an out-of-turn bribe', () => {
const {f, game: source} = auction();
const before = structuredClone(source), response = structuredClone(before.response), lot = structuredClone(before.auction);
const oldPurse = player(before, f.fremen).spice, oldCash = player(before, f.observer).spice;
const oldBribes = player(before, f.observer).bribes, cards = inventory(before);
assert.ok(oldPurse >= 1);
assert.equal(viewGame(before, f.fremen).bribeOptions.blocked, null);
const paid = act(before, f.fremen, pay(f.observer, 1));
assert.deepEqual(paid.response, response); assert.deepEqual(paid.auction, lot);
assert.equal(player(paid, f.fremen).spice, oldPurse - 1);
assert.equal(player(paid, f.observer).spice, oldCash);
assert.equal(player(paid, f.observer).bribes, oldBribes + 1);
assert.deepEqual(inventory(paid), cards);
const control = act(before, f.observer, {type:'passResponse'});
const continued = act(paid, f.observer, {type:'passResponse'});
assert.notEqual(continued.response?.kind, 'atreidesAuction');
assert.deepEqual(continued.auction, control.auction);
assert.equal(player(continued, f.observer).bribes, oldBribes + 1);
assert.deepEqual(inventory(continued), cards);
});
void test('a Basic auction inspection can still be canceled by the original Karama after a bribe', () => {
const {f, game: source} = auction(), before = structuredClone(source);
const paid = act(before, f.fremen, pay(f.observer, 1));
const control = act(before, f.observer, {type:'card', card:f.karama, mode:'cancel'});
const canceled = act(paid, f.observer, {type:'card', card:f.karama, mode:'cancel'});
assert.deepEqual(canceled.auction, control.auction);
assert.equal(canceled.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(player(canceled, f.observer).bribes, player(before, f.observer).bribes + 1);
});
void test('a Basic Fremen grant response keeps its exact source and grants the ally after the original pass', () => {
const {f, game: source} = grant(), before = structuredClone(source);
const response = structuredClone(before.response), oldPurse = player(before, f.observer).spice;
const oldCash = player(before, f.ally).spice, oldBribes = player(before, f.ally).bribes, cards = inventory(before);
assert.ok(oldPurse >= 1);
assert.equal(viewGame(before, f.observer).bribeOptions.blocked, null);
const paid = act(before, f.observer, pay(f.ally, 1));
assert.deepEqual(paid.response, response);
assert.equal(player(paid, f.observer).spice, oldPurse - 1);
assert.equal(player(paid, f.ally).spice, oldCash);
assert.equal(player(paid, f.ally).bribes, oldBribes + 1);
assert.ok(!paid.freeRevival.includes(f.ally));
const control = act(before, f.observer, {type:'passResponse'});
const continued = act(paid, f.observer, {type:'passResponse'});
assert.notEqual(continued.response?.kind, 'fremenRevivalGrant');
assert.deepEqual(continued.freeRevival, control.freeRevival);
assert.ok(continued.freeRevival.includes(f.ally));
assert.deepEqual(inventory(continued), cards);
});
void test('a Basic Fremen grant can still be canceled after the payment', () => {
const {f, game: source} = grant(), before = structuredClone(source);
const paid = act(before, f.observer, pay(f.ally, 1));
const control = act(before, f.observer, {type:'card', card:f.karama, mode:'cancel'});
const canceled = act(paid, f.observer, {type:'card', card:f.karama, mode:'cancel'});
assert.deepEqual(canceled.freeRevival, control.freeRevival);
assert.ok(!canceled.freeRevival.includes(f.ally));
assert.equal(canceled.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(player(canceled, f.ally).bribes, player(before, f.ally).bribes + 1);
});
void test('invalid or allied bribes leave a pending Basic grant untouched', () => {
const {f, game: source} = grant(), game = structuredClone(source);
reject(game, f.fremen, pay(f.ally, 1));
reject(game, f.observer, pay(f.observer, 1));
reject(game, f.observer, pay(f.ally, 0));
reject(game, f.observer, pay(f.ally, player(game, f.observer).spice + 1));
});
void test('an Advanced grant remains outside the Basic interruption profile', () => {
const {f, game} = grantSource(true);
assert.ok(viewGame(game, f.observer).bribeOptions.blocked);
reject(game, f.observer, pay(f.ally, 1));
});
void test('all four minimal bot profiles can continue the preserved Basic grant response', () => {
const {f, game: source} = grant(), paid = act(source, f.observer, pay(f.ally, 1));
for (const difficulty of DIFFICULTIES) {
const game = structuredClone(paid), view = viewGame(game, f.observer);
view.players.find(seat => seat.id === f.observer)!.bot = difficulty;
const action = botActions(view).find(choice => choice.type === 'passResponse' ||
choice.type === 'card' && choice.mode === 'cancel');
assert.ok(action, `${difficulty} needs an original legal response`);
const next = act(game, f.observer, action);
assert.ok(next.response?.kind !== 'fremenRevivalGrant' || next.response.passed.includes(f.observer) ||
next.pendingKull || next.pendingKarama || next.pendingTreacheryDiscard,
`${difficulty} must advance the original response`);
assert.equal(player(next, f.ally).bribes, player(source, f.ally).bribes + 1);
}
});