Support phase-wide Truthtrance bid commitments
This commit is contained in:
1 parent
474674f7fe
commit
2916b4d48f
19 files changed
+1512
-15
No files matched your search
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Game } from '../game/engine';
|
||||
import { actTruthBid, advanceTruthBid, createTruthBidFixture, declareTruthBid,
|
||||
nextTruthBid, truthBidClean, truthBidPlayer, type TruthBidStep } from './fixture-truth-bid';
|
||||
|
||||
export type TruthTurnBidStep = TruthBidStep;
|
||||
export const actTruthTurnBid = actTruthBid;
|
||||
export const truthTurnBidPlayer = truthBidPlayer;
|
||||
export const truthTurnBidClean = truthBidClean;
|
||||
export const advanceTruthTurnBid = advanceTruthBid;
|
||||
|
||||
/** Original H/A/E (optional G) setup, starting deal, ordinary lot creation,
|
||||
* and native controls from the shared fixture; only unopened original deal,
|
||||
* lot/Spice positions and scalar lotteries are selected. */
|
||||
export function createTruthTurnBidFixture(options: { advanced?: boolean; extraGuild?: boolean } = {}) {
|
||||
const original = createTruthBidFixture({ advanced: options.advanced, extraGuild: options.extraGuild, window:'waiting' });
|
||||
return { ...original, phaseQuestion: { kind:'phaseBid' as const, target:original.target,
|
||||
turn:original.game.turn, minimum:4 },
|
||||
sourceDisclosure: `${original.sourceDisclosure} Phase-wide question, answer and all subsequent bids remain caller actions.` };
|
||||
}
|
||||
export type TruthTurnBidFixture = ReturnType<typeof createTruthTurnBidFixture>;
|
||||
|
||||
export function bindTruthTurnBid(f: TruthTurnBidFixture, answer: 'yes'|'no', minimum = 4, state = f.game) {
|
||||
const declared = declareTruthBid(f,state), actions = [...declared.actions];
|
||||
let game = actTruthBid(declared.game,f.asker,{ type:'truthAsk', question: {
|
||||
kind:'phaseBid', target:f.target, turn:declared.game.turn, minimum,
|
||||
} },actions);
|
||||
const asked = structuredClone(game);
|
||||
game = actTruthBid(game,f.target,{type:'truthAnswer',answer},actions);
|
||||
return { game, declared, asked, actions };
|
||||
}
|
||||
|
||||
/** Finish exactly the existing lot after a genuine winning bid; no future bid
|
||||
* or pool is installed. Neutral native continuations pay and advance it. */
|
||||
export function finishTruthTurnBidLot(game: Game, actions: TruthBidStep[] = []) {
|
||||
assert.ok(game.auction && game.auction.bidder && game.auction.bid > 0);
|
||||
const turn = game.turn, index = game.auction.index;
|
||||
return advanceTruthBid(game,state => truthBidClean(state) &&
|
||||
(state.turn !== turn || state.phase !== 3 || !state.auction || state.auction.index !== index),actions);
|
||||
}
|
||||
|
||||
/** Purchase this actual lot for one spice by the named player. Other bidders
|
||||
* pass using native controls; native payment and Emperor income settle. */
|
||||
export function purchaseTruthTurnBidLot(game: Game, winner: string, actions: TruthBidStep[] = []) {
|
||||
assert.ok(game.auction); const turn = game.turn, index = game.auction.index;
|
||||
const card = game.auction.cards[index].id, before = structuredClone(game);
|
||||
for (let n = 0; n < 150; n++) {
|
||||
if (truthBidClean(game) && (game.turn !== turn || game.phase !== 3 || !game.auction || game.auction.index !== index)) {
|
||||
assert.ok(truthBidPlayer(game,winner).hand.some(c => c.id === card));
|
||||
return {game,before,card};
|
||||
}
|
||||
if (truthBidClean(game) && game.auction && game.auction.index === index && !game.currentAuctionSale) {
|
||||
const actor = game.auction.active;
|
||||
game = actTruthBid(game,actor,actor === winner && !game.auction.bidder
|
||||
? {type:'bid',amount:1,allyPayment:0} : {type:'passBid'},actions);
|
||||
} else {
|
||||
const step = nextTruthBid(game); assert.ok(step);
|
||||
game = actTruthBid(game,step.actor,step.action,actions);
|
||||
}
|
||||
}
|
||||
throw Error('The actual funded ordinary purchase did not finish');
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { PhaseBidClaimFields, phaseBidClaimInputError } from '../components/phase-bid-claim-fields';
|
||||
import { PhaseBidPromises, PhaseBidPromiseGuideButton } from '../components/phase-bid-promises';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import type { PhaseBidClaim } from '../game/turn-bid-promises';
|
||||
|
||||
type ElementProps = { children?: ReactNode; [key: string]: unknown };
|
||||
function elements(node: ReactNode): ReactElement<ElementProps>[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<ElementProps>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
function edit(props: Parameters<typeof PhaseBidClaimFields>[0], minimum: number) {
|
||||
const input = elements(PhaseBidClaimFields(props)).find(element =>
|
||||
element.props.id === 'truth-phase-bid-minimum' && typeof element.props.onChange === 'function');
|
||||
assert.ok(input);
|
||||
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber: minimum } });
|
||||
}
|
||||
/** Controlled projections exercise the rendered owned control, not a real question. */
|
||||
function projected(type: 'bid' | 'passBid' = 'bid'): GameView {
|
||||
const source = createGame('TRUTHPHASEBIDCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
|
||||
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
|
||||
const game = viewGame(source, 'p');
|
||||
game.status = 'playing'; game.phase = 3; game.turn = 2;
|
||||
game.auction = { bid: 1, bidder: 'a', active: 'p', remaining: 2, inspectionBlocked: null, card: null };
|
||||
game.bidPromiseLot = { turn: 2, lot: 1 };
|
||||
game.phaseBidPromises = [{ event: 'owned-event', player: 'p', asker: 'a', turn: 2, minimum: 4, answer: type === 'bid' }];
|
||||
game.phaseBidPromiseGuide = { action: type === 'bid'
|
||||
? { type: 'bid', amount: 4, allyPayment: 1, phaseBidPromiseEvent: 'owned-event', phaseBidPromiseLot: 1 }
|
||||
: { type: 'passBid', phaseBidPromiseEvent: 'owned-event', phaseBidPromiseLot: 1 } };
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('phase bid editor preserves the current turn and accepts only whole positive minima', () => {
|
||||
const value: PhaseBidClaim = { turn: 2, minimum: 4 }, changes: PhaseBidClaim[] = [];
|
||||
const props = { value, onChange(next: PhaseBidClaim) { changes.push(next); } };
|
||||
const html = renderToStaticMarkup(createElement(PhaseBidClaimFields, props));
|
||||
assert.match(html, /Turn 2/);
|
||||
assert.match(html, /before Bidding ends this turn/);
|
||||
assert.match(html, /Earlier bids do not count/);
|
||||
for (const minimum of [1, 7, Number.MAX_SAFE_INTEGER]) edit(props, minimum);
|
||||
assert.deepEqual(changes, [1, 7, Number.MAX_SAFE_INTEGER].map(minimum => ({ ...value, minimum })));
|
||||
for (const minimum of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
assert.ok(phaseBidClaimInputError({ ...value, minimum })); edit(props, minimum);
|
||||
}
|
||||
edit({ ...props, disabled: true }, 8);
|
||||
assert.equal(changes.length, 3);
|
||||
});
|
||||
|
||||
void test('phase promises remain visible and do not expose another player’s promises', () => {
|
||||
const game = projected();
|
||||
game.phaseBidPromises.push(
|
||||
{ event: 'no', player: 'p', asker: 'a', turn: 2, minimum: 8, answer: false },
|
||||
{ event: 'done', player: 'p', asker: 'a', turn: 2, minimum: 2, answer: true, fulfilled: true },
|
||||
{ event: 'released', player: 'p', asker: 'a', turn: 2, minimum: 12, answer: true, released: true },
|
||||
{ event: 'foreign', player: 'a', asker: 'p', turn: 2, minimum: 99, answer: true },
|
||||
);
|
||||
game.auction!.active = 'a'; game.phaseBidPromiseGuide = null;
|
||||
const before = structuredClone(game);
|
||||
const html = renderToStaticMarkup(createElement(PhaseBidPromises, { game, busy: false, send() {} }));
|
||||
assert.match(html, /Your Bidding phase promises/);
|
||||
assert.match(html, /No forbids qualifying bids through Bidding close/);
|
||||
assert.match(html, /Fulfilled: this commitment is complete/);
|
||||
assert.match(html, /Released: this commitment is no longer possible/);
|
||||
assert.match(html, /Your next bid opportunity is still ahead/);
|
||||
assert.doesNotMatch(html, /99 spice|foreign/);
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('the real guide callback preserves its event and rejects stale or busy projections', () => {
|
||||
for (const type of ['bid', 'passBid'] as const) {
|
||||
const game = projected(type), sent: Action[] = [];
|
||||
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
|
||||
const button = PhaseBidPromiseGuideButton(props); assert.ok(button);
|
||||
button.props.onClick();
|
||||
assert.deepEqual(sent, [game.phaseBidPromiseGuide!.action]);
|
||||
assert.equal(sent[0], game.phaseBidPromiseGuide!.action);
|
||||
const stale = PhaseBidPromiseGuideButton(props); assert.ok(stale);
|
||||
game.phaseBidPromises[0].event = 'different-event'; stale.props.onClick();
|
||||
assert.equal(sent.length, 1);
|
||||
}
|
||||
const game = projected(), sent: Action[] = [];
|
||||
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
|
||||
const button = PhaseBidPromiseGuideButton(props); assert.ok(button);
|
||||
props.busy = true; button.props.onClick();
|
||||
assert.deepEqual(sent, []);
|
||||
props.busy = false;
|
||||
const priorLotButton = PhaseBidPromiseGuideButton(props); assert.ok(priorLotButton);
|
||||
game.bidPromiseLot = { turn: 2, lot: 2 };
|
||||
priorLotButton.props.onClick();
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { PhaseBidPromiseGuideButton } from '../components/phase-bid-promises';
|
||||
import { actTruthTurnBid, advanceTruthTurnBid, bindTruthTurnBid,
|
||||
createTruthTurnBidFixture, truthTurnBidClean } from './fixture-truth-turn-bid';
|
||||
import { truthBidInventory } from './fixture-truth-bid';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
||||
},
|
||||
});
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
const { Truthtrance } = await import('../components/truthtrance');
|
||||
aliases.deregister();
|
||||
|
||||
function table(game: GameView) {
|
||||
return renderToStaticMarkup(createElement(GameTable, { game, busy: false, send: async () => {}, onExit() {} }));
|
||||
}
|
||||
function truth(game: GameView) {
|
||||
return renderToStaticMarkup(createElement(Truthtrance, { game, busy: false, act() {} }));
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} actual phase-bid question, private answer and current owned guide connect to the original table`, () => {
|
||||
// Genuine original public setup/card deal and actual question/answer; the
|
||||
// selected unopened deck/Spice positions are documented by the source fixture.
|
||||
const f = createTruthTurnBidFixture({ advanced }), b = bindTruthTurnBid(f, 'yes');
|
||||
const asker = viewGame(b.declared.game, f.asker);
|
||||
assert.ok(asker.truthPhaseBidTargets.includes(f.target));
|
||||
const authored = truth(asker);
|
||||
assert.match(authored, /<option value="phaseBid">Bind a bid this Bidding phase<\/option>/);
|
||||
assert.match(table(asker), /Bind a bid this Bidding phase/);
|
||||
// Server rendering does not operate the stateful question selector; the
|
||||
// fixture's actual native question is submitted by its engine action.
|
||||
const respondent = viewGame(b.asked, f.target);
|
||||
assert.deepEqual(respondent.truthtrance?.question, f.phaseQuestion);
|
||||
assert.ok(respondent.truthPhaseBidAnswers?.includes('yes'));
|
||||
assert.ok(respondent.truthPhaseBidAnswers?.includes('no'));
|
||||
assert.equal(viewGame(b.asked, f.asker).truthPhaseBidAnswers, null);
|
||||
const answers = truth(respondent);
|
||||
assert.match(answers, /Answer Yes/);
|
||||
assert.match(answers, /Answer No/);
|
||||
assert.match(table(respondent), /Answer Yes/);
|
||||
let game = advanceTruthTurnBid(b.game,
|
||||
state => truthTurnBidClean(state) && state.phase === 3 && state.auction?.active === f.target);
|
||||
const own = viewGame(game, f.target), sent: Action[] = [];
|
||||
assert.ok(own.phaseBidPromises.some(promise => promise.player === f.target && promise.answer));
|
||||
assert.ok(own.phaseBidPromiseGuide);
|
||||
const html = table(own);
|
||||
assert.match(html, /Your Bidding phase promises/);
|
||||
assert.match(html, /data-testid="phase-bid-promise-guide"/);
|
||||
const button = PhaseBidPromiseGuideButton({ game: own, busy: false, send(action) { sent.push(action); } });
|
||||
assert.ok(button);
|
||||
assert.equal(button.props.disabled, false);
|
||||
button.props.onClick();
|
||||
assert.deepEqual(sent, [own.phaseBidPromiseGuide.action]);
|
||||
assert.equal(typeof sent[0].phaseBidPromiseEvent, 'string');
|
||||
assert.equal(sent[0].phaseBidPromiseLot, own.bidPromiseLot!.lot);
|
||||
game = actTruthTurnBid(game, f.target, sent[0]);
|
||||
assert.equal(game.phaseBidPromises!.find(promise => promise.event === sent[0].phaseBidPromiseEvent)?.fulfilled, true);
|
||||
assert.deepEqual(truthBidInventory(game), f.originalInventory);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,383 @@
|
||||
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, askTruthBid, answerTruthBid,
|
||||
declareTruthBid, truthBidClean as clean, truthBidInventory as inventory } from './fixture-truth-bid';
|
||||
import { bindTruthTurnBid, createTruthTurnBidFixture, finishTruthTurnBidLot,
|
||||
purchaseTruthTurnBidLot } from './fixture-truth-turn-bid';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game,actor,action)); assert.deepEqual(game,before);
|
||||
}
|
||||
/** Fork the genuine public setup before its selected unopened Spice positions,
|
||||
* reapply only that original selection, and follow native controls to Charity.
|
||||
* No played purse, hand, phase or promise is staged. */
|
||||
function early(advanced: boolean) {
|
||||
const f = createTruthTurnBidFixture({advanced});
|
||||
let game = structuredClone(f.afterSetup);
|
||||
for (const {position,from,card} of f.spiceOrder) {
|
||||
const [selected] = game.spiceDeck.splice(from,1);
|
||||
assert.deepEqual(selected,card); game.spiceDeck.splice(position,0,selected);
|
||||
}
|
||||
game = advance(game,g => g.turn === 1 && g.phase === 2 && clean(g));
|
||||
return {f,game};
|
||||
}
|
||||
function phaseAsk(game: Game, asker: string, target: string, minimum = 4) {
|
||||
return act(game,asker,{type:'truthAsk',question:{kind:'phaseBid',target,turn:game.turn,minimum}});
|
||||
}
|
||||
for (const advanced of [false,true]) {
|
||||
const mode = advanced ? 'Advanced' : 'Basic';
|
||||
for (const answer of ['yes','no'] as const) void test(`${mode} pre-Bidding phase ${answer} binds a real later auction declaration`, () => {
|
||||
const {f,game:source} = early(advanced);
|
||||
assert.equal(source.phase,2); assert.equal(source.auction,null);
|
||||
const bound = bindTruthTurnBid(f,answer,4,source);
|
||||
assert.ok(viewGame(bound.game,f.target).phaseBidPromises.some(p => p.minimum === 4 && p.answer === (answer === 'yes')));
|
||||
let game = advance(bound.game,g => clean(g) && g.phase === 3 && g.auction?.active === f.target);
|
||||
if (answer === 'yes') {
|
||||
reject(game,f.target,{type:'passBid'});
|
||||
game = act(game,f.target,{type:'bid',amount:4,allyPayment:0});
|
||||
assert.ok(viewGame(game,f.target).phaseBidPromises.some(p => p.fulfilled));
|
||||
} else {
|
||||
reject(game,f.target,{type:'bid',amount:4,allyPayment:0});
|
||||
game = act(game,f.target,{type:'bid',amount:1,allyPayment:0});
|
||||
}
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
void test(`${mode} existing current-lot Yes prevents a contradictory phase-wide No`, () => {
|
||||
const f = createTruthTurnBidFixture({advanced});
|
||||
let game = declareTruthBid(f,f.game,{copies:2}).game;
|
||||
game = askTruthBid(game,f.asker,f.target,4);
|
||||
game = answerTruthBid(game,f.target,'yes');
|
||||
game = phaseAsk(game,f.asker,f.target,4);
|
||||
assert.ok(!viewGame(game,f.target).truthPhaseBidAnswers?.includes('no'));
|
||||
reject(game,f.target,{type:'truthAnswer',answer:'no'});
|
||||
game = answerTruthBid(game,f.target,'yes');
|
||||
game = advance(game,g => clean(g) && g.auction?.active === f.target);
|
||||
game = act(game,f.target,{type:'bid',amount:4,allyPayment:0});
|
||||
assert.ok(game.bidPromises?.some(p => p.player === f.target && p.fulfilled));
|
||||
assert.ok(game.phaseBidPromises?.some(p => p.player === f.target && p.fulfilled));
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
void test(`${mode} phase Yes and current-lot No coexist when a real committed sale opens a later lot`, () => {
|
||||
const f = createTruthTurnBidFixture({advanced});
|
||||
let game = act(f.game,f.asker,{type:'bid',amount:1,allyPayment:0});
|
||||
game = act(game,f.opponent,{type:'passBid'});
|
||||
assert.equal(game.auction?.active,f.target); assert.equal(game.auction?.bidder,f.asker);
|
||||
game = declareTruthBid(f,game,{copies:2}).game;
|
||||
game = phaseAsk(game,f.asker,f.target,4);
|
||||
game = answerTruthBid(game,f.target,'yes');
|
||||
game = askTruthBid(game,f.asker,f.target,4);
|
||||
assert.ok(viewGame(game,f.target).truthBidAnswers?.includes('no'));
|
||||
game = answerTruthBid(game,f.target,'no');
|
||||
reject(game,f.target,{type:'bid',amount:4,allyPayment:0});
|
||||
game = act(game,f.target,{type:'passBid'});
|
||||
if (game.auction?.index === 0) game = finishTruthTurnBidLot(game);
|
||||
assert.equal(game.auction?.index,1);
|
||||
game = advance(game,g => clean(g) && g.auction?.active === f.target);
|
||||
game = act(game,f.target,{type:'bid',amount:4,allyPayment:0});
|
||||
assert.ok(game.phaseBidPromises?.some(p => p.player === f.target && p.fulfilled));
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
void test(`${mode} phase Yes keeps its funded bid and cannot rely on a future buyer after everyone passes`, () => {
|
||||
const f = createTruthTurnBidFixture({advanced});
|
||||
let game = bindTruthTurnBid(f,'yes',4).game;
|
||||
reject(game,f.target,{type:'bribe',target:f.asker,amount:7});
|
||||
game = act(game,f.target,{type:'bribe',target:f.asker,amount:6});
|
||||
assert.equal(game.players.find(p => p.id === f.target)!.spice,4);
|
||||
game = act(game,f.asker,{type:'passBid'});
|
||||
game = act(game,f.opponent,{type:'passBid'});
|
||||
assert.equal(game.auction?.bidder,null); assert.equal(game.auction?.active,f.target);
|
||||
reject(game,f.target,{type:'passBid'});
|
||||
game = act(game,f.target,{type:'bid',amount:4,allyPayment:0});
|
||||
assert.ok(game.phaseBidPromises?.some(p => p.player === f.target && p.fulfilled));
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
void test(`${mode} an original phase bid guide is stale after its lot actually retires`, () => {
|
||||
const f = createTruthTurnBidFixture({advanced});
|
||||
let game = act(f.game,f.asker,{type:'bid',amount:1,allyPayment:0});
|
||||
game = act(game,f.opponent,{type:'passBid'});
|
||||
game = bindTruthTurnBid(f,'no',4,game).game;
|
||||
const guide = viewGame(game,f.target).phaseBidPromiseGuide; assert.ok(guide);
|
||||
const packet = structuredClone(guide.action);
|
||||
assert.equal(packet.type,'passBid');
|
||||
game = act(game,f.target,packet);
|
||||
if (game.auction?.index === 0) game = finishTruthTurnBidLot(game);
|
||||
assert.equal(game.auction?.index,1);
|
||||
game = advance(game,g => clean(g) && g.auction?.active === f.target);
|
||||
reject(game,f.target,packet);
|
||||
assert.ok(viewGame(game,f.target).phaseBidPromiseGuide);
|
||||
});
|
||||
}
|
||||
|
||||
/** Controlled supplement only: install the two already-answered, canonical
|
||||
* current-lot receipts on the genuine live auction, with its exact original
|
||||
* physical lot binding. No card/force/lot is manufactured. */
|
||||
function stagedLotBounds(game: Game, target: string, asker: string, yesMinimum = 5, noMinimum = 6) {
|
||||
assert.ok(game.auction);
|
||||
const source = JSON.stringify(['ordinaryBid',game.turn,game.auction.index,game.auction.cards[game.auction.index].id]);
|
||||
game.bidPromises = [
|
||||
{turn:game.turn,lot:game.auction.index+1,minimum:yesMinimum,event:'controlled-lot-yes',source,player:target,asker,answer:true},
|
||||
{turn:game.turn,lot:game.auction.index+1,minimum:noMinimum,event:'controlled-lot-no',source,player:target,asker,answer:false},
|
||||
];
|
||||
}
|
||||
function askAs(game: Game, asker: string, card: string, target: string, minimum: number) {
|
||||
game = act(game,asker,{type:'card',card});
|
||||
for (let count=0; game.truthtrance?.stage === 'priority' && count<game.players.length; count++) {
|
||||
const p = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(p);
|
||||
game = act(game,p.id,{type:'truthPass'});
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage,'ask');
|
||||
return phaseAsk(game,asker,target,minimum);
|
||||
}
|
||||
|
||||
for (const advanced of [false,true]) {
|
||||
const mode = advanced ? 'Advanced' : 'Basic';
|
||||
void test(`${mode} a new lot Yes cannot erase the funded future needed by an existing phase Yes`, () => {
|
||||
const f = createTruthTurnBidFixture({advanced});
|
||||
let game = act(f.game,f.asker,{type:'bid',amount:4,allyPayment:0});
|
||||
game = act(game,f.opponent,{type:'passBid'});
|
||||
assert.equal(game.auction?.active,f.target); assert.ok(game.auction?.cards[1]);
|
||||
game = structuredClone(game);
|
||||
// Controlled existing answers and wallet: H's real committed4 sale leaves
|
||||
// E's six spice for its existing phase Yes6. Current-lot No6 prevents E6;
|
||||
// a proposed lot Yes5 would compel a purchase and leave only one spice.
|
||||
game.players.find(p => p.id === f.target)!.spice = 6;
|
||||
stagedLotBounds(game,f.target,f.asker);
|
||||
game.bidPromises = game.bidPromises!.filter(promise => !promise.answer);
|
||||
game.phaseBidPromises = [{turn:game.turn,minimum:6,event:'controlled-phase-yes',
|
||||
player:f.target,asker:f.asker,answer:true}];
|
||||
game = declareTruthBid(f,game).game;
|
||||
game = askTruthBid(game,f.asker,f.target,5);
|
||||
assert.ok(!viewGame(game,f.target).truthBidAnswers?.includes('yes'));
|
||||
reject(game,f.target,{type:'truthAnswer',answer:'yes'});
|
||||
game = answerTruthBid(game,f.target,'no');
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
void test(`${mode} a live whole-turn No for the last lot excludes the same phase-wide Yes`, () => {
|
||||
const f = createTruthTurnBidFixture({advanced}); let game = f.game;
|
||||
// Both purchases and the two independent Truth cards are native; no
|
||||
// question, wallet or auction state is installed for this source.
|
||||
for (let n=0;n<2;n++) game = purchaseTruthTurnBidLot(game,f.opponent).game;
|
||||
assert.equal(game.auction?.index,2);
|
||||
game = advance(game,g => clean(g) && g.auction?.active === f.target);
|
||||
game = declareTruthBid(f,game).game;
|
||||
game = act(game,f.asker,{type:'truthAsk',question:{kind:'turnActions',target:f.target,
|
||||
turn:game.turn,mixed:{kind:'action',action:{kind:'bid',lot:3,minimum:6}}}});
|
||||
assert.ok(viewGame(game,f.target).truthTurnActionAnswers?.includes('no'));
|
||||
game = answerTruthBid(game,f.target,'no');
|
||||
assert.ok(game.turnActionPromises?.some(p => p.player === f.target && !p.answer && !p.fulfilled && !p.released));
|
||||
game = askAs(game,f.asker,f.truths[1],f.target,6);
|
||||
assert.ok(!viewGame(game,f.target).truthPhaseBidAnswers?.includes('yes'));
|
||||
reject(game,f.target,{type:'truthAnswer',answer:'yes'});
|
||||
game = answerTruthBid(game,f.target,'no');
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
void test(`${mode} phase Yes cannot ignore a forced current-lot payment that consumes future funding`, () => {
|
||||
const f = createTruthTurnBidFixture({advanced});
|
||||
let game = act(f.game,f.asker,{type:'bid',amount:1,allyPayment:0});
|
||||
game = act(game,f.opponent,{type:'passBid'});
|
||||
assert.equal(game.auction?.active,f.target); assert.ok(game.auction?.cards[1]);
|
||||
// Explicit controlled wallet/answers; the physical auction and H1 bid are
|
||||
// actual native actions. E6 must bid exactly5 under its prior Yes5/No6.
|
||||
game = structuredClone(game);
|
||||
game.players.find(p => p.id === f.target)!.spice = 6;
|
||||
stagedLotBounds(game,f.target,f.asker);
|
||||
game = askAs(game,f.asker,f.truth,f.target,6);
|
||||
assert.ok(!viewGame(game,f.target).truthPhaseBidAnswers?.includes('yes'));
|
||||
reject(game,f.target,{type:'truthAnswer',answer:'yes'});
|
||||
game = answerTruthBid(game,f.target,'no');
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
void test(`${mode} phase Yes cannot assume Harkonnen will avoid its purchase bonus to make a later slot`, () => {
|
||||
const f = createTruthTurnBidFixture({advanced});
|
||||
let game = structuredClone(f.game);
|
||||
const hark = game.players.find(p => p.id === f.asker)!;
|
||||
const atreides = game.players.find(p => p.id === f.opponent)!;
|
||||
// Explicit conserved prior-card transfers: Atreides holds the original
|
||||
// Truthtrance to ask; Harkonnen has six held cards (two open slots). The
|
||||
// original undealt deck retains a possible Harkonnen bonus card.
|
||||
const index = hark.hand.findIndex(c => c.id === f.truth); assert.ok(index >= 0);
|
||||
atreides.hand.push(hark.hand.splice(index,1)[0]);
|
||||
while (hark.hand.length < 6) {
|
||||
const from = game.deck.findIndex(c => ['projectile','poison','shield','snooper','worthless'].includes(c.kind));
|
||||
assert.ok(from >= 0); hark.hand.push(game.deck.splice(from,1)[0]);
|
||||
}
|
||||
assert.ok(game.deck.length > 0); assert.equal(hark.hand.length,6);
|
||||
game = act(game,f.asker,{type:'passBid'});
|
||||
game = act(game,f.opponent,{type:'passBid'});
|
||||
game = act(game,f.target,{type:'bid',amount:1,allyPayment:0});
|
||||
assert.equal(game.auction?.active,f.asker); assert.ok(game.auction?.cards[1]);
|
||||
// Harkonnen has ten spice. Its existing Yes2/No4 permits a bid2,
|
||||
// leaving eight spice after payment; capacity alone removes the future.
|
||||
assert.equal(game.players.find(p => p.id === f.asker)!.spice,10);
|
||||
stagedLotBounds(game,f.asker,f.opponent,2,4);
|
||||
game = askAs(game,f.opponent,f.truth,f.asker,4);
|
||||
assert.ok(!viewGame(game,f.asker).truthPhaseBidAnswers?.includes('yes'));
|
||||
reject(game,f.asker,{type:'truthAnswer',answer:'yes'});
|
||||
game = answerTruthBid(game,f.asker,'no');
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
void test(`${mode} an opponent's unfunded Karama-only high bid cannot certify a future lot`, () => {
|
||||
const f = createTruthTurnBidFixture({advanced});
|
||||
let game = structuredClone(f.game);
|
||||
// Explicit conserved prior acquisition: Harkonnen holds an undealt real
|
||||
// Karama, and can legally declare11 on its actual ten-spice purse. The
|
||||
// unrelated bidder's future choice to spend that card is not assumed.
|
||||
const from = game.deck.findIndex(c => c.effect === 'karama'); assert.ok(from >= 0);
|
||||
game.players.find(p => p.id === f.asker)!.hand.push(game.deck.splice(from,1)[0]);
|
||||
game = act(game,f.asker,{type:'bid',amount:11,allyPayment:0});
|
||||
game = act(game,f.opponent,{type:'passBid'});
|
||||
assert.equal(game.auction?.active,f.target); assert.equal(game.auction?.bidder,f.asker);
|
||||
assert.ok(game.auction?.cards[1]);
|
||||
game = askAs(game,f.asker,f.truth,f.target,6);
|
||||
assert.ok(!viewGame(game,f.target).truthPhaseBidAnswers?.includes('yes'));
|
||||
reject(game,f.target,{type:'truthAnswer',answer:'yes'});
|
||||
game = answerTruthBid(game,f.target,'no');
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false,true]) {
|
||||
const mode = advanced ? 'Advanced' : 'Basic';
|
||||
void test(`${mode} a phase bid Yes cannot spend the funds required by a live whole-turn bribe`, () => {
|
||||
const f = createTruthTurnBidFixture({advanced}); let game = f.game;
|
||||
// Actual A1 purchases pay E one spice each; E then really commits a
|
||||
// separate bribe2, leaving ten. Both Truth cards and the last lot are
|
||||
// original components. No wallet, promise or auction state is staged.
|
||||
for (let n=0;n<2;n++) game = purchaseTruthTurnBidLot(game,f.opponent).game;
|
||||
assert.equal(game.auction?.index,2);
|
||||
game = act(game,f.target,{type:'bribe',target:f.asker,amount:2});
|
||||
assert.equal(game.players.find(p => p.id === f.target)!.spice,10);
|
||||
game = advance(game,g => clean(g) && g.auction?.active === f.target);
|
||||
game = declareTruthBid(f,game).game;
|
||||
game = act(game,f.asker,{type:'truthAsk',question:{kind:'turnActions',target:f.target,
|
||||
turn:game.turn,mixed:{kind:'action',action:{kind:'payment',recipient:f.asker,minimum:5}}}});
|
||||
assert.ok(viewGame(game,f.target).truthTurnActionAnswers?.includes('yes'));
|
||||
game = answerTruthBid(game,f.target,'yes');
|
||||
const prior = structuredClone(game.turnActionPromises![0]);
|
||||
game = askAs(game,f.asker,f.truths[1],f.target,6);
|
||||
assert.ok(!viewGame(game,f.target).truthPhaseBidAnswers?.includes('yes'));
|
||||
reject(game,f.target,{type:'truthAnswer',answer:'yes'});
|
||||
assert.deepEqual(game.turnActionPromises![0],prior);
|
||||
game = answerTruthBid(game,f.target,'no');
|
||||
assert.deepEqual(game.turnActionPromises![0],prior);
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
}
|
||||
|
||||
/** Use the second real physical Truth card to ask one whole-turn action after
|
||||
* a phase-bid answer. Keep the normal declaration and priority actions. */
|
||||
function askTurnActionAs(game: Game, asker: string, card: string, target: string,
|
||||
action: {kind:'payment';recipient:string;minimum:number} | {kind:'bid';lot:number;minimum:number}) {
|
||||
game = act(game,asker,{type:'card',card});
|
||||
for (let count=0; game.truthtrance?.stage === 'priority' && count<game.players.length; count++) {
|
||||
const p = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(p);
|
||||
game = act(game,p.id,{type:'truthPass'});
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage,'ask');
|
||||
return act(game,asker,{type:'truthAsk',question:{kind:'turnActions',target,turn:game.turn,
|
||||
mixed:{kind:'action',action}}});
|
||||
}
|
||||
for (const advanced of [false,true]) {
|
||||
const mode = advanced ? 'Advanced' : 'Basic';
|
||||
void test(`${mode} an existing phase bid Yes excludes an unfunded later whole-turn bribe Yes`, () => {
|
||||
const f = createTruthTurnBidFixture({advanced}); let game = f.game;
|
||||
// Real A1 purchases twice give E two income; E's real bribe2 leaves ten.
|
||||
// The still-unresolved original last lot and both Truth cards are native.
|
||||
for (let n=0;n<2;n++) game = purchaseTruthTurnBidLot(game,f.opponent).game;
|
||||
assert.equal(game.auction?.index,2);
|
||||
game = act(game,f.target,{type:'bribe',target:f.asker,amount:2});
|
||||
assert.equal(game.players.find(p => p.id === f.target)!.spice,10);
|
||||
game = advance(game,g => clean(g) && g.auction?.active === f.target);
|
||||
game = bindTruthTurnBid(f,'yes',6,game).game;
|
||||
const prior = structuredClone(game.phaseBidPromises![0]);
|
||||
game = askTurnActionAs(game,f.asker,f.truths[1],f.target,
|
||||
{kind:'payment',recipient:f.asker,minimum:5});
|
||||
assert.ok(!viewGame(game,f.target).truthTurnActionAnswers?.includes('yes'));
|
||||
reject(game,f.target,{type:'truthAnswer',answer:'yes'});
|
||||
assert.deepEqual(game.phaseBidPromises![0],prior);
|
||||
game = answerTruthBid(game,f.target,'no');
|
||||
assert.deepEqual(game.phaseBidPromises![0],prior);
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
void test(`${mode} an existing phase bid No excludes a later whole-turn bid Yes on the final lot`, () => {
|
||||
const f = createTruthTurnBidFixture({advanced}); let game = f.game;
|
||||
// Two actual purchases leave the actual third and last prepared lot.
|
||||
for (let n=0;n<2;n++) game = purchaseTruthTurnBidLot(game,f.opponent).game;
|
||||
assert.equal(game.auction?.index,2);
|
||||
game = advance(game,g => clean(g) && g.auction?.active === f.target);
|
||||
game = bindTruthTurnBid(f,'no',6,game).game;
|
||||
const prior = structuredClone(game.phaseBidPromises![0]);
|
||||
game = askTurnActionAs(game,f.asker,f.truths[1],f.target,
|
||||
{kind:'bid',lot:game.auction!.index+1,minimum:6});
|
||||
assert.ok(!viewGame(game,f.target).truthTurnActionAnswers?.includes('yes'));
|
||||
reject(game,f.target,{type:'truthAnswer',answer:'yes'});
|
||||
assert.deepEqual(game.phaseBidPromises![0],prior);
|
||||
game = answerTruthBid(game,f.target,'no');
|
||||
assert.deepEqual(game.phaseBidPromises![0],prior);
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false,true]) {
|
||||
const mode = advanced ? 'Advanced' : 'Basic';
|
||||
void test(`${mode} an offered independent bribe Yes can be accepted before Bidding alongside a funded phase bid Yes`, () => {
|
||||
const {f,game:source} = early(advanced);
|
||||
// Genuine Charity source: E has ten spice; original H/A/E setup and the
|
||||
// two actual Truth cards are retained. The two requirements cost nine.
|
||||
assert.equal(source.phase,2);
|
||||
assert.equal(source.players.find(p => p.id === f.target)!.spice,10);
|
||||
let game = bindTruthTurnBid(f,'yes',6,source).game;
|
||||
const phasePromise = structuredClone(game.phaseBidPromises![0]);
|
||||
game = act(game,f.asker,{type:'card',card:f.truths[1]});
|
||||
for (let count=0; game.truthtrance?.stage === 'priority' && count<game.players.length; count++) {
|
||||
const p = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(p);
|
||||
game = act(game,p.id,{type:'truthPass'});
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage,'ask');
|
||||
game = act(game,f.asker,{type:'truthAsk',question:{kind:'bribe',target:f.target,
|
||||
turn:game.turn,recipient:f.asker,minimum:3}});
|
||||
assert.ok(viewGame(game,f.target).truthBribeAnswers?.includes('yes'));
|
||||
game = answerTruthBid(game,f.target,'yes');
|
||||
assert.equal(game.truthtrance,null);
|
||||
assert.deepEqual(game.phaseBidPromises![0],phasePromise);
|
||||
assert.ok(game.bribePromises?.some(p => p.player === f.target && p.recipient === f.asker && p.answer));
|
||||
game = act(game,f.target,{type:'bribe',target:f.asker,amount:3});
|
||||
assert.equal(game.players.find(p => p.id === f.target)!.spice,7);
|
||||
assert.ok(game.bribePromises?.some(p => p.player === f.target && p.fulfilled));
|
||||
game = advance(game,g => clean(g) && g.phase === 3 && g.auction?.active === f.target);
|
||||
game = act(game,f.target,{type:'bid',amount:6,allyPayment:0});
|
||||
assert.ok(game.phaseBidPromises?.some(p => p.player === f.target && p.fulfilled));
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false,true]) {
|
||||
const mode = advanced ? 'Advanced' : 'Basic';
|
||||
void test(`${mode} an underfunded independent bribe Yes is unavailable before Bidding while phase bid Yes survives`, () => {
|
||||
const {f,game:source} = early(advanced);
|
||||
assert.equal(source.phase,2);
|
||||
assert.equal(source.players.find(p => p.id === f.target)!.spice,10);
|
||||
let game = bindTruthTurnBid(f,'yes',6,source).game;
|
||||
const phasePromise = structuredClone(game.phaseBidPromises![0]);
|
||||
game = act(game,f.asker,{type:'card',card:f.truths[1]});
|
||||
for (let count=0; game.truthtrance?.stage === 'priority' && count<game.players.length; count++) {
|
||||
const p = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(p);
|
||||
game = act(game,p.id,{type:'truthPass'});
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage,'ask');
|
||||
game = act(game,f.asker,{type:'truthAsk',question:{kind:'bribe',target:f.target,
|
||||
turn:game.turn,recipient:f.asker,minimum:5}});
|
||||
assert.ok(!viewGame(game,f.target).truthBribeAnswers?.includes('yes'));
|
||||
assert.ok(viewGame(game,f.target).truthBribeAnswers?.includes('no'));
|
||||
reject(game,f.target,{type:'truthAnswer',answer:'yes'});
|
||||
assert.deepEqual(game.phaseBidPromises![0],phasePromise);
|
||||
game = answerTruthBid(game,f.target,'no');
|
||||
assert.equal(game.truthtrance,null);
|
||||
assert.deepEqual(game.phaseBidPromises![0],phasePromise);
|
||||
game = advance(game,g => clean(g) && g.phase === 3 && g.auction?.active === f.target);
|
||||
game = act(game,f.target,{type:'bid',amount:6,allyPayment:0});
|
||||
assert.ok(game.phaseBidPromises?.some(p => p.player === f.target && p.fulfilled));
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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 { truthBidInventory } from './fixture-truth-bid';
|
||||
import { actTruthTurnBid as act, advanceTruthTurnBid as advance, bindTruthTurnBid,
|
||||
createTruthTurnBidFixture, finishTruthTurnBidLot, purchaseTruthTurnBidLot,
|
||||
truthTurnBidClean as clean, truthTurnBidPlayer as player, type TruthTurnBidStep } from './fixture-truth-turn-bid';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game,actor,action));
|
||||
assert.deepEqual(game,before);
|
||||
}
|
||||
function firstContested(game: Game, asker: string, rival: string, target: string, amount: number | null) {
|
||||
assert.equal(game.auction?.index,0); assert.equal(game.auction?.active,asker);
|
||||
const actions: TruthTurnBidStep[] = [];
|
||||
game = act(game,asker,{type:'passBid'},actions);
|
||||
assert.equal(game.auction?.active,rival);
|
||||
game = act(game,rival,{type:'bid',amount:1,allyPayment:0},actions);
|
||||
assert.equal(game.auction?.active,target);
|
||||
game = act(game,target,amount === null ? {type:'passBid'} : {type:'bid',amount,allyPayment:0},actions);
|
||||
return {game,actions};
|
||||
}
|
||||
for (const advanced of [false,true]) {
|
||||
const prefix = advanced ? 'Advanced' : 'Basic';
|
||||
void test(`${prefix} phase bid Yes survives a real first-lot pass then fulfills on a later accepted bid`, () => {
|
||||
const f = createTruthTurnBidFixture({advanced});
|
||||
const bound = bindTruthTurnBid(f,'yes',4);
|
||||
const first = firstContested(bound.game,f.asker,f.opponent,f.target,null);
|
||||
let game = finishTruthTurnBidLot(first.game,first.actions);
|
||||
assert.equal(game.auction?.index,1);
|
||||
assert.ok(player(game,f.opponent).hand.some(c => c.id === f.lotCards[0]));
|
||||
const promise = viewGame(game,f.target).phaseBidPromises.find(p => p.minimum === 4)!;
|
||||
assert.ok(promise); assert.equal(promise.fulfilled ?? false,false); assert.equal(promise.released ?? false,false);
|
||||
game = advance(game,state => clean(state) && state.auction?.active === f.target);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game,f.target);
|
||||
view.players.find(p => p.id === f.target)!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.ok(action);
|
||||
const accepted = act(game,f.target,action);
|
||||
assert.ok(accepted,`${difficulty} supplies a legal current action`);
|
||||
}
|
||||
game = act(game,f.target,{type:'bid',amount:4,allyPayment:0});
|
||||
assert.ok(viewGame(game,f.target).phaseBidPromises.some(p => p.minimum === 4 && p.fulfilled));
|
||||
assert.deepEqual(truthBidInventory(game),f.originalCards);
|
||||
});
|
||||
void test(`${prefix} phase bid No forbids qualifying bids across both original lots`, () => {
|
||||
const f = createTruthTurnBidFixture({advanced});
|
||||
const bound = bindTruthTurnBid(f,'no',4);
|
||||
let game = firstContested(bound.game,f.asker,f.opponent,f.target,3).game;
|
||||
game = finishTruthTurnBidLot(game);
|
||||
assert.equal(game.auction?.index,1);
|
||||
assert.ok(player(game,f.target).hand.some(c => c.id === f.lotCards[0]));
|
||||
game = advance(game,state => clean(state) && state.auction?.active === f.target);
|
||||
assert.ok(viewGame(game,f.target).phaseBidPromises.some(p => p.minimum === 4 && !p.fulfilled && !p.released));
|
||||
reject(game,f.target,{type:'bid',amount:4,allyPayment:0});
|
||||
game = act(game,f.target,{type:'bid',amount:3,allyPayment:0});
|
||||
assert.deepEqual(truthBidInventory(game),f.originalCards);
|
||||
});
|
||||
void test(`${prefix} a live phase bid Yes cannot be abandoned on the final original lot`, () => {
|
||||
const f = createTruthTurnBidFixture({advanced}); let game = f.game;
|
||||
for (let n=0;n<2;n++) game = purchaseTruthTurnBidLot(game,f.opponent).game;
|
||||
assert.equal(game.auction?.index,2);
|
||||
game = advance(game,state => clean(state) && state.auction?.active === f.target);
|
||||
game = bindTruthTurnBid(f,'yes',4,game).game;
|
||||
reject(game,f.target,{type:'passBid'});
|
||||
reject(game,f.target,{type:'bid',amount:3,allyPayment:0});
|
||||
game = act(game,f.target,{type:'bid',amount:4,allyPayment:0});
|
||||
assert.ok(viewGame(game,f.target).phaseBidPromises.some(p => p.minimum === 4 && p.fulfilled));
|
||||
assert.deepEqual(truthBidInventory(game),f.originalCards);
|
||||
});
|
||||
void test(`${prefix} an actual opposing last-lot raise releases an unaffordable phase bid Yes`, () => {
|
||||
const f = createTruthTurnBidFixture({advanced,extraGuild:true}); let game = f.game;
|
||||
// Two real E purchases cost him two; a real A purchase pays E one. The
|
||||
// original fourth lot remains, E has one hand slot and nine spice, and H
|
||||
// has its real ten spice. This is no staged budget or future rival bid.
|
||||
for (const buyer of [f.target,f.target,f.opponent]) game = purchaseTruthTurnBidLot(game,buyer).game;
|
||||
assert.equal(game.auction?.index,3); assert.equal(player(game,f.target).spice,9);
|
||||
game = advance(game,state => clean(state) && state.auction?.active === f.asker);
|
||||
assert.ok(!game.auction!.passed.includes(f.target));
|
||||
game = bindTruthTurnBid(f,'yes',8,game).game;
|
||||
game = act(game,f.asker,{type:'bid',amount:10,allyPayment:0});
|
||||
assert.ok(viewGame(game,f.target).phaseBidPromises.some(p => p.minimum === 8 && p.released));
|
||||
assert.deepEqual(truthBidInventory(game),f.originalCards);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
PhaseBidPromiseError,
|
||||
finishPhaseBidPhase,
|
||||
livePhaseBidPromises,
|
||||
parsePhaseBidClaim,
|
||||
phaseBidAllowedRange,
|
||||
phaseBidClaimText,
|
||||
phaseBidCompletionRange,
|
||||
reconcilePhaseBidPromises,
|
||||
recordPhaseBid,
|
||||
validatePhaseBidPromises,
|
||||
type PhaseBidOpportunity,
|
||||
type PhaseBidPromise,
|
||||
} from '../game/turn-bid-promises';
|
||||
|
||||
const seats = ['respondent', 'asker', 'rival'];
|
||||
const promise = (event: string, minimum: number, answer = true,
|
||||
extra: Partial<PhaseBidPromise> = {}): PhaseBidPromise =>
|
||||
({ event, turn: 2, minimum, player: 'respondent', asker: 'asker', answer, ...extra });
|
||||
const opportunity = (extra: Partial<PhaseBidOpportunity> = {}): PhaseBidOpportunity =>
|
||||
({ turn: 2, player: 'respondent', kind: 'current', minimum: 1, maximum: 9, ...extra });
|
||||
|
||||
void test('phase bid claim is one positive safe turn and threshold, with no lot or cumulative payment', () => {
|
||||
const claim = parsePhaseBidClaim({ turn: 2, minimum: 6 });
|
||||
assert.deepEqual(claim, { turn: 2, minimum: 6 });
|
||||
assert.match(phaseBidClaimText(claim), /bid of at least 6 spice/);
|
||||
assert.match(phaseBidClaimText(claim), /Bidding phase closes/);
|
||||
for (const field of ['turn', 'minimum'] as const)
|
||||
for (const value of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '6', null, undefined])
|
||||
assert.throws(() => parsePhaseBidClaim({ turn: 2, minimum: 6, [field]: value }), PhaseBidPromiseError);
|
||||
for (const value of [null, undefined, [], '6', 6])
|
||||
assert.throws(() => parsePhaseBidClaim(value), PhaseBidPromiseError);
|
||||
});
|
||||
|
||||
void test('Yes survives a smaller accepted bid and lot close when a funded later lot is genuinely witnessed', () => {
|
||||
const start = Object.freeze([Object.freeze(promise('yes-six', 6))]);
|
||||
let rows = recordPhaseBid(start, 'respondent', 2, 3);
|
||||
assert.equal(rows[0].fulfilled, undefined);
|
||||
const future = opportunity({ kind: 'future', minimum: 1, maximum: 7 });
|
||||
rows = reconcilePhaseBidPromises(rows, 'respondent', 2, [future], 'voluntary');
|
||||
assert.equal(rows[0].released, undefined);
|
||||
rows = recordPhaseBid(rows, 'respondent', 2, 5);
|
||||
assert.equal(rows[0].fulfilled, undefined, 'Separate bids are not summed');
|
||||
rows = recordPhaseBid(rows, 'respondent', 2, 6);
|
||||
assert.equal(rows[0].fulfilled, true, 'An accepted declaration suffices even without winning the card');
|
||||
assert.equal(start[0].fulfilled, undefined);
|
||||
});
|
||||
|
||||
void test('No caps all later native lots and remains binding after Yes is fulfilled', () => {
|
||||
const yes = promise('yes-six', 6), no = promise('no-eight', 8, false);
|
||||
const source = Object.freeze([Object.freeze(yes), Object.freeze(no)]);
|
||||
const before = structuredClone(source);
|
||||
assert.deepEqual(phaseBidAllowedRange(source, opportunity()), { minimum: 1, maximum: 7 });
|
||||
assert.deepEqual(phaseBidCompletionRange(source, opportunity()), { minimum: 6, maximum: 7 });
|
||||
const rows = recordPhaseBid(source, 'respondent', 2, 7);
|
||||
assert.equal(rows[0].fulfilled, true); assert.equal(rows[1].fulfilled, undefined);
|
||||
assert.throws(() => recordPhaseBid(rows, 'respondent', 2, 8), PhaseBidPromiseError);
|
||||
assert.deepEqual(source, before);
|
||||
assert.equal(recordPhaseBid(rows, 'rival', 2, 8)[1], no);
|
||||
assert.equal(recordPhaseBid(rows, 'respondent', 3, 8)[1], no);
|
||||
const finished = finishPhaseBidPhase(rows, 'respondent', 2, 'external');
|
||||
assert.equal(finished[1].fulfilled, true);
|
||||
});
|
||||
|
||||
void test('funded witnesses and other No caps constrain joint completion without forcing each bid to qualify', () => {
|
||||
const rows = [promise('yes-six', 6), promise('yes-seven', 7), promise('no-eight', 8, false)];
|
||||
assert.deepEqual(phaseBidAllowedRange(rows, opportunity()), { minimum: 1, maximum: 7 });
|
||||
assert.deepEqual(phaseBidCompletionRange(rows, opportunity()), { minimum: 7, maximum: 7 });
|
||||
assert.equal(phaseBidCompletionRange(rows, opportunity({ maximum: 6 })), null);
|
||||
assert.equal(phaseBidCompletionRange(rows, opportunity({ minimum: 8 })), null);
|
||||
assert.equal(phaseBidCompletionRange([promise('yes-eight', 8), promise('no-eight', 8, false)], opportunity()), null);
|
||||
assert.deepEqual(phaseBidAllowedRange([promise('no-eight', 8, false)], opportunity()), { minimum: 1, maximum: 7 });
|
||||
});
|
||||
|
||||
void test('own action cannot abandon the last real witness, while outside impossibility releases only affected Yes rows', () => {
|
||||
const rows = Object.freeze([Object.freeze(promise('yes-five', 5)), Object.freeze(promise('yes-eight', 8)),
|
||||
Object.freeze(promise('no-ten', 10, false))]);
|
||||
const before = structuredClone(rows);
|
||||
const limited = [opportunity({ maximum: 5 })];
|
||||
assert.throws(() => reconcilePhaseBidPromises(rows, 'respondent', 2, limited, 'voluntary'), PhaseBidPromiseError);
|
||||
assert.throws(() => reconcilePhaseBidPromises(rows, 'respondent', 2, [], 'voluntary'), PhaseBidPromiseError);
|
||||
const after = reconcilePhaseBidPromises(rows, 'respondent', 2, limited, 'external');
|
||||
assert.equal(after[0].released, undefined); assert.equal(after[1].released, true);
|
||||
assert.equal(after[2].released, undefined, 'An impossible Yes does not erase a No restriction');
|
||||
assert.deepEqual(rows, before);
|
||||
assert.throws(() => finishPhaseBidPhase(rows, 'respondent', 2, 'voluntary'), PhaseBidPromiseError);
|
||||
const closed = finishPhaseBidPhase(rows, 'respondent', 2, 'external');
|
||||
assert.equal(closed[0].released, true); assert.equal(closed[1].released, true); assert.equal(closed[2].fulfilled, true);
|
||||
});
|
||||
|
||||
void test('only actual verified intervals for the respondent and turn can witness a promise', () => {
|
||||
const rows = [promise('yes-six', 6)];
|
||||
for (const wrong of [opportunity({ turn: 3 }), opportunity({ player: 'rival' })])
|
||||
assert.throws(() => reconcilePhaseBidPromises(rows, 'respondent', 2, [wrong], 'voluntary'), PhaseBidPromiseError);
|
||||
for (const invalid of [opportunity({ maximum: 0 }), opportunity({ minimum: 4, maximum: 3 }),
|
||||
opportunity({ maximum: Infinity }), opportunity({ kind: 'invented' as 'future' })])
|
||||
assert.throws(() => phaseBidAllowedRange(rows, invalid), PhaseBidPromiseError);
|
||||
assert.throws(() => recordPhaseBid(rows, 'respondent', 2, 0), PhaseBidPromiseError);
|
||||
assert.deepEqual(livePhaseBidPromises([rows[0], promise('old', 8, true, { turn: 1 }),
|
||||
promise('someone-else', 9, true, { player: 'rival' })], 'respondent', 2), [rows[0]]);
|
||||
});
|
||||
|
||||
void test('phase records preserve event identities, actual participants and mutually exclusive terminal status', () => {
|
||||
const valid = [promise('live', 6), promise('fulfilled', 7, true, { fulfilled: true }),
|
||||
promise('released', 8, true, { released: true })];
|
||||
assert.doesNotThrow(() => validatePhaseBidPromises(valid, seats));
|
||||
const invalid: unknown[] = [null, [null], [valid[0], { ...valid[1], event: 'live' }],
|
||||
[{ ...valid[0], event: '' }], [{ ...valid[0], asker: 'outsider' }],
|
||||
[{ ...valid[0], player: 'outsider' }], [{ ...valid[0], asker: 'respondent' }],
|
||||
[{ ...valid[0], minimum: 0 }], [{ ...valid[0], turn: 0 }], [{ ...valid[0], answer: 'yes' }],
|
||||
[{ ...valid[0], fulfilled: 'true' }], [{ ...valid[0], released: 1 }],
|
||||
[{ ...valid[0], fulfilled: true, released: true }]];
|
||||
for (const input of invalid)
|
||||
assert.throws(() => validatePhaseBidPromises(input as PhaseBidPromise[], seats), PhaseBidPromiseError);
|
||||
});
|
||||
Reference in new issue
Block a user