Allow bribes during classic pending decisions
Verify and publish checkpoint container / container (push) Successful in 10m47s
Verify and publish checkpoint container / container (push) Successful in 10m47s
This commit is contained in:
1 parent
1e81ce8ad8
commit
6ecd26c8d1
8 files changed
+444
-5
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user