Files
Dune/tests/truth-mixed-bid-runtime.test.ts

248 lines
18 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { truthFactAnswer } from '../game/truthtrance';
import { truthKnowledgeOf } from '../game/truthtrance-knowledge';
import {
actTruthMixedBid as act, advanceTruthMixedBid as advance, answerTruthMixedBid as answer,
askTruthMixedBid as ask, bindTruthMixedBid as bind, continueTruthMixedBidLot as closeLot,
createTruthMixedBidFixture as fixture, declareTruthMixedBid as declare, finishTruthMixedBid as finish,
truthMixedBidClean as clean, truthMixedBidFact as fact, truthMixedBidGroup as group,
truthMixedBidInventory as inventory, truthMixedBidMinimum as bid, truthMixedBidPlayer as player,
truthMixedBidPromise as promise,
} from './fixture-truth-mixed-bid';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function current(game: Game, actor: string): Game {
return advance(game, state => clean(state) && state.auction?.active === actor);
}
function truthOnce(game: Game, truth: string) {
assert.equal(game.discard.filter(card => card.id === truth).length, 1);
}
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} mixed bid freezes ten spice before the actual declaration and original four-spice purchase`, () => {
const f = fixture({ advanced }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.equal(f.sourceActionCount, advanced ? 25 : 22); assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.deepEqual(f.staging, []); assert.deepEqual(f.game.expansions, []); assert.equal(f.originalCards.length, 33);
assert.equal(f.truths.length, 2); assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(card => card.id), f.deal);
assert.deepEqual(player(f.afterSetup, f.asker).hand.map(card => card.id), f.truths);
assert.equal(truthFactAnswer(player(f.game, f.target), f.spiceFact), 'yes');
const source = structuredClone(f.game); let game = bind(f).game;
assert.deepEqual(f.game, source); assert.equal(game.truthtrance, null); truthOnce(game, f.truth);
assert.deepEqual(promise(game).mixed, f.mixed); assert.equal(promise(game).fulfilled, false);
rejected(game, f.target, { type: 'passBid' }); rejected(game, f.target, { type: 'bid', amount: 3 });
const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 4);
assert.equal(guide.action.bidPromiseEvent, promise(game).event);
game = act(game, f.target, guide.action); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, 10, 'The accepted declaration fulfills before the original winning invoice');
game = closeLot(game); assert.equal(player(game, f.target).spice, 6);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
assert.equal(player(game, f.target).hand.filter(card => card.id === f.lotCard).length, 1);
truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native true held Shield OR a future bid gives only Yes with no binding and leaves the actual bid legal', () => {
const f = fixture(), mixed = group('or', fact(f.shieldFact), bid());
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedBidAnswers, ['yes']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'no' }); let game = answer(asked, f.target, 'yes');
assert.equal(promise(game).fulfilled, true); assert.equal(game.bidPromises?.length ?? 0, 0);
assert.equal(viewGame(game, f.target).bidPromiseGuide, null); assert.deepEqual(promise(game).mixed, mixed);
game = act(game, f.target, { type: 'bid', amount: 4 }); game = closeLot(game);
assert.equal(player(game, f.target).spice, 6); assert.equal(promise(game).fulfilled, true);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native false held Hajr AND a future bid gives only No while the later qualifying declaration remains legal', () => {
const f = fixture(), mixed = group('and', fact(f.hajrFact), bid());
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedBidAnswers, ['no']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' }); let game = answer(asked, f.target, 'no');
assert.equal(promise(game).fulfilled, true); assert.equal(game.bidPromises?.length ?? 0, 0);
game = act(game, f.target, { type: 'bid', amount: 4 }); game = closeLot(game);
assert.equal(player(game, f.target).spice, 6); assert.equal(promise(game).fulfilled, true);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native false Hajr OR future bid Yes requires a new accepted bid', () => {
const f = fixture(); let game = bind(f, 'yes', group('or', fact(f.hajrFact), bid())).game;
assert.equal(promise(game).fulfilled, false); rejected(game, f.target, { type: 'passBid' });
rejected(game, f.target, { type: 'bid', amount: 3 }); game = act(game, f.target, { type: 'bid', amount: 4 });
assert.equal(promise(game).fulfilled, true); game = closeLot(game); assert.equal(player(game, f.target).spice, 6);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native true Shield AND future bid No allows a lower bid and forbids every later qualifying declaration in that lot', () => {
const f = fixture({ window: 'waiting' }); let game = act(f.game, f.asker, { type: 'bid', amount: 1 });
game = act(game, f.opponent, { type: 'bid', amount: 2 });
game = bind(f, 'no', group('and', fact(f.shieldFact), bid(6)), game).game;
rejected(game, f.target, { type: 'bid', amount: 6 }); game = act(game, f.target, { type: 'bid', amount: 4 });
assert.equal(promise(game).fulfilled, false); game = act(game, f.asker, { type: 'bid', amount: 5 });
game = act(game, f.opponent, { type: 'passBid' }); rejected(game, f.target, { type: 'bid', amount: 6 });
game = act(game, f.target, { type: 'passBid' }); game = advance(game, clean);
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
assert.equal(player(game, f.target).spice, 15); assert.equal(player(game, f.asker).spice, 5);
assert.deepEqual(inventory(game), f.originalCards);
});
for (const answerValue of ['yes', 'no'] as const) void test(`native whole nested mixed bid ${answerValue} collapses AND maxima and OR minima to the same threshold`, () => {
const f = fixture(), mixed = group('and', fact(f.shieldFact), group('or',
group('and', bid(4), bid(7)), group('and', bid(3), bid(5))));
let game = bind(f, answerValue, mixed).game; assert.deepEqual(promise(game).mixed, mixed);
const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide);
if (answerValue === 'yes') {
assert.equal(guide.action.amount, 5); rejected(game, f.target, { type: 'bid', amount: 4 });
game = act(game, f.target, guide.action); assert.equal(promise(game).fulfilled, true);
} else {
rejected(game, f.target, { type: 'bid', amount: 5 }); game = act(game, f.target, { type: 'bid', amount: 4 });
}
game = closeLot(game); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, answerValue === 'yes' ? 5 : 6); assert.deepEqual(inventory(game), f.originalCards);
});
for (const answerValue of ['yes', 'no'] as const) void test(`native unknown forecast uses the conservative whole ${answerValue} mixed bid constraint`, () => {
const f = fixture(), p = player(f.game, f.target); assert.equal(truthKnowledgeOf(f.game, p).stormForecast, null);
assert.equal(truthFactAnswer(p, f.forecastFact, truthKnowledgeOf(f.game, p)), 'unknown');
const mixed = group(answerValue === 'yes' ? 'or' : 'and', fact(f.forecastFact), bid());
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedBidAnswers, [answerValue]);
rejected(asked, f.target, { type: 'truthAnswer', answer: answerValue === 'yes' ? 'no' : 'yes' });
let game = answer(asked, f.target, answerValue); assert.equal(promise(game).fulfilled, false);
if (answerValue === 'yes') {
rejected(game, f.target, { type: 'passBid' }); game = act(game, f.target, { type: 'bid', amount: 4 });
assert.equal(promise(game).fulfilled, true); game = closeLot(game);
} else {
rejected(game, f.target, { type: 'bid', amount: 4 }); game = act(game, f.target, { type: 'bid', amount: 2 });
if (game.auction?.index === 0) game = closeLot(game);
}
assert.equal(promise(game).fulfilled, true); truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native prior qualifying bid before a real outbid remains outside the newly answered mixed Yes', () => {
const f = fixture({ window: 'waiting' }); let game = f.game;
for (const [actor, amount] of [[f.asker, 1], [f.opponent, 2], [f.target, 4], [f.asker, 5], [f.opponent, 6]] as const)
game = act(game, actor, { type: 'bid', amount });
game = bind(f, 'yes', f.mixed, game).game; assert.equal(promise(game).fulfilled, false);
rejected(game, f.target, { type: 'passBid' }); const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide);
assert.equal(guide.action.amount, 7); game = act(game, f.target, guide.action); assert.equal(promise(game).fulfilled, true);
game = closeLot(game); assert.equal(player(game, f.target).spice, 3); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed bid question and guide retain exact owner turn lot event and JSON boundaries', () => {
const f = fixture(), declared = declare(f);
assert.ok(viewGame(declared.game, f.asker).truthMixedBidTargets.includes(f.target));
rejected(declared.game, f.opponent, f.questionAction);
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, lot: f.question.lot + 1 } });
let game = ask(declared.game, f.asker, f.target, f.mixed);
assert.deepEqual(viewGame(game, f.target).truthMixedBidAnswers, ['yes', 'no']);
assert.equal(viewGame(game, f.asker).truthMixedBidAnswers, null); assert.equal(viewGame(game, f.opponent).truthMixedBidAnswers, null);
rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes');
const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide);
assert.equal(guide.action.bidPromiseEvent, promise(game).event); rejected(game, f.opponent, guide.action);
rejected(game, f.target, { ...guide.action, bidPromiseEvent: 'stale-mixed-bid-event' });
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, wire);
assert.equal(promise(game).fulfilled, true); rejected(game, f.target, wire);
game = closeLot(game); game = current(game, f.target); rejected(game, f.target, wire);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed bid projection retains only owned raw aggregate and filters its native residual', () => {
const f = fixture(), game = bind(f).game;
for (const actor of [f.target, f.asker, f.opponent]) {
const view = viewGame(game, actor); assert.deepEqual(view.bidPromises, []); assert.equal(view.truthMixedBidAnswers, null);
assert.deepEqual(view.truthHistory.at(-1)?.question, f.question); assert.equal(view.truthHistory.at(-1)?.answer, 'yes');
if (actor !== f.target) {
assert.deepEqual(view.mixedBidPromises, []); assert.equal(view.bidPromiseGuide, null);
} else {
const row = promise(game);
assert.deepEqual(Object.keys(row).sort(), ['answer', 'asker', 'event', 'fulfilled', 'lot', 'mixed', 'player', 'released', 'turn']);
assert.equal(row.player, f.target); assert.equal(row.asker, f.asker); assert.equal(row.turn, f.game.turn);
assert.equal(row.lot, f.question.lot); assert.equal(row.answer, true); assert.deepEqual(row.mixed, f.mixed);
assert.equal(row.fulfilled, false); assert.equal(row.released, false);
assert.equal(view.bidPromiseGuide?.action.bidPromiseEvent, row.event);
}
}
assert.equal(game.bidPromises?.length, 1); assert.equal(game.bidPromises![0].event, promise(game).event);
});
void test('native waiting mixed Yes releases after a real opposing ten-spice raise and retains original Emperor income', () => {
const f = fixture({ window: 'waiting' }); assert.equal(f.sourceActionCount, 20);
let game = bind(f).game; assert.equal(game.auction!.active, f.asker); assert.equal(viewGame(game, f.target).bidPromiseGuide, null);
game = act(game, f.asker, { type: 'passBid' }); game = act(game, f.opponent, { type: 'bid', amount: 10 });
assert.equal(game.auction!.active, f.target); assert.equal(promise(game).released, true); assert.equal(promise(game).fulfilled, false);
game = act(game, f.target, { type: 'passBid' }); game = closeLot(game);
assert.equal(player(game, f.target).spice, 20); assert.equal(player(game, f.opponent).spice, 0);
assert.ok(player(game, f.opponent).hand.some(card => card.id === f.lotCard)); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed No allows an actual lower purchase and does not reactivate in the next ordinary lot', () => {
const f = fixture(); let game = bind(f, 'no').game;
rejected(game, f.target, { type: 'bid', amount: 4 }); game = act(game, f.target, { type: 'bid', amount: 2 });
if (game.auction?.index === 0) game = closeLot(game);
assert.equal(game.auction!.index, 1); assert.equal(player(game, f.target).spice, 8);
assert.equal(promise(game).fulfilled, true); assert.equal(viewGame(game, f.target).bidPromiseGuide, null);
game = current(game, f.target); game = act(game, f.target, { type: 'bid', amount: 4 });
assert.equal(game.auction!.bid, 4); assert.equal(game.auction!.bidder, f.target); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native accepted mixed Yes is fulfilled even when a later opposing bidder wins and pays', () => {
const f = fixture({ window: 'waiting' }); let game = act(f.game, f.asker, { type: 'bid', amount: 1 });
game = act(game, f.opponent, { type: 'bid', amount: 2 }); game = bind(f, 'yes', f.mixed, game).game;
game = act(game, f.target, { type: 'bid', amount: 4 }); assert.equal(promise(game).fulfilled, true);
game = act(game, f.asker, { type: 'bid', amount: 5 }); game = act(game, f.opponent, { type: 'passBid' });
game = act(game, f.target, { type: 'passBid' }); game = advance(game, clean);
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.lotCard));
assert.ok(!player(game, f.target).hand.some(card => card.id === f.lotCard));
assert.equal(player(game, f.asker).spice, 5); assert.equal(player(game, f.target).spice, 15);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native original Karama funds mixed eleven-spice Yes and settles its original free-purchase invoice', () => {
const f = fixture({ targetKarama: true }); let game = bind(f, 'yes', group('and', fact(f.spiceFact), bid(11))).game;
const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 11);
game = act(game, f.target, guide.action); assert.equal(promise(game).fulfilled, true);
game = advance(game, state => !state.response && state.decision?.kind === 'auctionPayment');
assert.equal(game.auction!.bid, 11); assert.equal(player(game, f.target).spice, 10);
game = act(game, f.target, { type: 'decision', karama: true, card: f.karama });
assert.equal(player(game, f.target).spice, 10); assert.equal(promise(game).fulfilled, true);
assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard)); truthOnce(game, f.karama!);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed action targets exclude already-passed and current-highest bidders', () => {
const passed = fixture(), declared = declare(passed);
assert.ok(passed.game.auction!.passed.includes(passed.opponent));
assert.ok(!viewGame(declared.game, passed.asker).truthMixedBidTargets.includes(passed.opponent));
rejected(declared.game, passed.asker, { type: 'truthAsk', question: { ...passed.question, target: passed.opponent } });
const highest = fixture({ window: 'waiting' }); let game = highest.game;
for (const [actor, amount] of [[highest.asker, 1], [highest.opponent, 2], [highest.target, 3]] as const)
game = act(game, actor, { type: 'bid', amount });
game = declare(highest, game).game; assert.ok(!viewGame(game, highest.asker).truthMixedBidTargets.includes(highest.target));
rejected(game, highest.asker, highest.questionAction); assert.deepEqual(inventory(game), highest.originalCards);
});
void test('all four minimal policies answer and execute an exact legal mixed bid Yes guide', () => {
const f = fixture(), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const answering = viewGame(game, f.target);
answering.players.find(seat => seat.id === f.target)!.bot = difficulty;
const answerAction = botActions(answering).find(action => action.type === 'truthAnswer'); assert.ok(answerAction, difficulty);
assert.equal(answerAction.answer, 'yes', difficulty); game = act(game, f.target, answerAction);
const view = viewGame(game, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
const guide = view.bidPromiseGuide; assert.ok(guide, difficulty);
const offered = botActions(view).find(action => action.type === 'bid' && action.bidPromiseEvent === guide.action.bidPromiseEvent);
assert.ok(offered, difficulty); assert.deepEqual(offered, guide.action); game = act(game, f.target, offered);
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).spice, 10);
game = closeLot(game); assert.equal(player(game, f.target).spice, 6); truthOnce(game, f.truth);
assert.deepEqual(inventory(game), f.originalCards);
}
});