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

@@ -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.');
});