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

+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);
}
});