Files
Dune/tests/truth-turn-bid-composition.test.ts

384 lines
22 KiB
TypeScript

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