216 lines
13 KiB
TypeScript
216 lines
13 KiB
TypeScript
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 {
|
|
actTruthBid as act, advanceTruthBid as advance, answerTruthBid as answer,
|
|
askTruthBid as ask, bindTruthBid as bind, continueTruthBidLot as closeLot,
|
|
createTruthBidFixture as fixture, declareTruthBid as declare, finishTruthBid as finish,
|
|
truthBidClean as clean, truthBidInventory as inventory, truthBidPlayer as player,
|
|
} from './fixture-truth-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);
|
|
}
|
|
|
|
void test('two original Truth cards keep a prior shipment commitment funded when answering a later bid question', () => {
|
|
const f = fixture();
|
|
let game = declare(f, f.game, { copies: 2 }).game;
|
|
game = act(game, f.asker, { type: 'truthAsk', question: {
|
|
kind: 'shipment', target: f.target, claim: { territory: 'arrakeen', minimum: 9 },
|
|
} });
|
|
assert.ok(viewGame(game, f.target).truthShipmentAnswers?.includes('yes'));
|
|
game = answer(game, f.target, 'yes');
|
|
assert.equal(game.truthtrance?.stage, 'ask');
|
|
game = ask(game, f.asker, f.target, 4);
|
|
assert.deepEqual(viewGame(game, f.target).truthBidAnswers, ['no']);
|
|
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
|
game = answer(game, f.target, 'no');
|
|
game = current(game, f.target);
|
|
game = act(game, f.target, { type: 'bid', amount: 1, allyPayment: 0 });
|
|
game = closeLot(game);
|
|
assert.equal(player(game, f.target).spice, 9);
|
|
game = finish(game);
|
|
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
|
|
game = act(game, f.target, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 9, elite: 0, allyPayment: 0 });
|
|
game = advance(game, clean);
|
|
assert.equal(player(game, f.target).forces['arrakeen:10'], 9);
|
|
assert.ok(game.shipmentPromises?.some(promise => promise.player === f.target && promise.fulfilled));
|
|
assert.ok(game.bidPromises?.every(promise => promise.fulfilled));
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
});
|
|
|
|
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} current-lot Truth Yes requires a real new bid and preserves original payment`, () => {
|
|
const f = fixture({ advanced, window: 'active' });
|
|
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
|
assert.deepEqual(f.game.expansions, []); assert.deepEqual(f.staging, []);
|
|
assert.equal(f.originalCards.length, 33); assert.equal(f.truths.length, 2);
|
|
assert.equal(f.sourceActionCount, f.sourceActions.length);
|
|
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(card => card.id), f.deal);
|
|
const before = structuredClone(f.game), bound = bind(f);
|
|
assert.deepEqual(f.game, before);
|
|
assert.equal(bound.game.truthtrance, null);
|
|
assert.equal(bound.game.discard.filter(card => card.id === f.truth).length, 1);
|
|
assert.equal(player(bound.game, f.asker).hand.filter(card => card.effect === 'truthtrance').length, 1);
|
|
rejected(bound.game, f.target, { type: 'passBid' });
|
|
rejected(bound.game, f.target, { type: 'bid', amount: 3 });
|
|
const guide = viewGame(bound.game, f.target).bidPromiseGuide; assert.ok(guide);
|
|
assert.equal(guide.action.type, 'bid'); assert.equal(guide.action.amount, 4);
|
|
const bid = act(bound.game, f.target, guide.action);
|
|
assert.equal(bid.bidPromises!.find(promise => promise.player === f.target)!.fulfilled, true);
|
|
assert.equal(player(bid, f.target).spice, 10, 'Declaring the bid does not pay its later invoice');
|
|
const paid = closeLot(bid);
|
|
assert.equal(player(paid, f.target).spice, 6);
|
|
assert.equal(player(paid, f.target).hand.filter(card => card.id === f.lotCard).length, 1);
|
|
assert.ok((paid.bidPromises ?? []).every(promise => promise.fulfilled || promise.released));
|
|
assert.deepEqual(inventory(paid), f.originalCards);
|
|
const done = finish(paid); assert.equal(done.phase, 4); assert.deepEqual(inventory(done), f.originalCards);
|
|
});
|
|
|
|
for (const lowerBid of [false, true]) void test(`native current-lot Truth No permits ${lowerBid ? 'a lower bid' : 'a pass'} and expires before the next ordinary lot`, () => {
|
|
const f = fixture({ window: lowerBid ? 'active' : 'waiting' });
|
|
let source = f.game;
|
|
if (!lowerBid) {
|
|
source = act(source, f.asker, { type: 'bid', amount: 1 });
|
|
source = act(source, f.opponent, { type: 'passBid' });
|
|
}
|
|
const bound = bind(f, 'no', 4, source);
|
|
rejected(bound.game, f.target, { type: 'bid', amount: 4 });
|
|
const chosen = act(bound.game, f.target, lowerBid ? { type: 'bid', amount: 2 } : { type: 'passBid' });
|
|
const next = chosen.auction?.index === 0 ? closeLot(chosen) : chosen;
|
|
assert.equal(next.auction!.index, 1);
|
|
assert.ok((next.bidPromises ?? []).every(promise => promise.fulfilled || promise.released));
|
|
const ready = current(next, f.target);
|
|
const accepted = act(ready, f.target, { type: 'bid', amount: 4 });
|
|
assert.equal(accepted.auction!.bid, 4); assert.equal(accepted.auction!.bidder, f.target);
|
|
assert.deepEqual(inventory(accepted), f.originalCards);
|
|
});
|
|
|
|
void test('native waiting bidder Yes is released by a real opposing ten-spice bid, with ordinary Emperor income later', () => {
|
|
const f = fixture(), bound = bind(f);
|
|
assert.equal(bound.game.auction!.active, f.asker);
|
|
assert.equal(viewGame(bound.game, f.target).bidPromiseGuide, null);
|
|
let game = act(bound.game, f.asker, { type: 'passBid' });
|
|
assert.equal(game.auction!.active, f.opponent);
|
|
game = act(game, f.opponent, { type: 'bid', amount: 10 });
|
|
assert.equal(game.auction!.active, f.target);
|
|
assert.equal(game.bidPromises!.find(promise => promise.player === f.target)!.released, true);
|
|
game = act(game, f.target, { type: 'passBid' }); game = closeLot(game);
|
|
assert.equal(player(game, f.opponent).spice, 0);
|
|
assert.equal(player(game, f.target).spice, 20, 'Original Emperor auction income remains separate from its released declaration promise');
|
|
assert.ok(player(game, f.opponent).hand.some(card => card.id === f.lotCard));
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
});
|
|
|
|
void test('native bidder cannot bribe away the last Yes funding but may keep its real four-spice witness', () => {
|
|
const f = fixture(), bound = bind(f);
|
|
rejected(bound.game, f.target, { type: 'bribe', target: f.asker, amount: 7 });
|
|
let game = act(bound.game, f.target, { type: 'bribe', target: f.asker, amount: 6 });
|
|
assert.equal(player(game, f.target).spice, 4); assert.equal(player(game, f.asker).bribes, 6);
|
|
game = current(game, f.target);
|
|
const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide);
|
|
game = act(game, f.target, guide.action); game = closeLot(game);
|
|
assert.equal(player(game, f.target).spice, 0); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
});
|
|
|
|
void test('native earlier qualifying bid does not fulfill a Truth promise answered after a real outbid', () => {
|
|
const f = fixture(); 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 });
|
|
assert.equal(game.auction!.active, f.target);
|
|
game = bind(f, 'yes', 4, game).game;
|
|
assert.equal(game.bidPromises!.find(promise => promise.player === f.target)!.fulfilled ?? false, 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(game.bidPromises![0].fulfilled, true);
|
|
game = closeLot(game); assert.equal(player(game, f.target).spice, 3);
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
});
|
|
|
|
void test('native bid declaration fulfills Yes even when a different real bidder later wins and pays', () => {
|
|
const f = fixture(); let game = act(f.game, f.asker, { type: 'bid', amount: 1 });
|
|
game = act(game, f.opponent, { type: 'bid', amount: 2 });
|
|
game = bind(f, 'yes', 4, game).game;
|
|
game = act(game, f.target, { type: 'bid', amount: 4 }); assert.equal(game.bidPromises![0].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.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('two original queued Truthtrances combine No six with Yes four and each discard only once', () => {
|
|
const f = fixture({ window: 'active' }), declared = declare(f, f.game, { copies: 2 });
|
|
let game = ask(declared.game, f.asker, f.target, 6); game = answer(game, f.target, 'no');
|
|
assert.equal(game.truthtrance!.stage, 'ask');
|
|
game = ask(game, f.asker, f.target, 4); game = answer(game, f.target, 'yes');
|
|
assert.equal(game.bidPromises!.length, 2);
|
|
for (const card of f.truths) assert.equal(game.discard.filter(held => held.id === card).length, 1);
|
|
rejected(game, f.target, { type: 'passBid' }); rejected(game, f.target, { type: 'bid', amount: 6 });
|
|
game = act(game, f.target, { type: 'bid', amount: 5 }); game = closeLot(game);
|
|
assert.equal(player(game, f.target).spice, 5);
|
|
assert.ok((game.bidPromises ?? []).every(promise => promise.fulfilled || promise.released));
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
});
|
|
|
|
void test('native question ownership, exact current-lot binding and stale guided callbacks retain original action boundaries', () => {
|
|
const f = fixture({ window: 'active' }), declared = declare(f), question = f.question;
|
|
rejected(declared.game, f.opponent, { type: 'truthAsk', question });
|
|
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...question, lot: question.lot + 1 } });
|
|
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...question, turn: question.turn + 1 } });
|
|
let game = ask(declared.game, f.asker, f.target);
|
|
assert.deepEqual(viewGame(game, f.target).truthBidAnswers, ['yes', 'no']);
|
|
assert.equal(viewGame(game, f.opponent).truthBidAnswers, 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.ok(guide.action.bidPromiseEvent);
|
|
assert.ok(viewGame(game, f.target).bidPromises.every(promise => !Object.hasOwn(promise, 'source')));
|
|
game = act(game, f.target, guide.action); game = closeLot(game); game = current(game, f.target);
|
|
rejected(game, f.target, guide.action);
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
});
|
|
|
|
void test('native already-passed and current-highest bidders cannot supply a new Truth bid opportunity', () => {
|
|
const passed = fixture({ window: 'active' }), first = declare(passed);
|
|
assert.ok(passed.game.auction!.passed.includes(passed.opponent));
|
|
rejected(first.game, passed.asker, { type: 'truthAsk', question: { ...passed.question, target: passed.opponent } });
|
|
const highest = fixture(); 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 });
|
|
assert.equal(game.auction!.bidder, highest.target);
|
|
game = declare(highest, game).game;
|
|
rejected(game, highest.asker, { type: 'truthAsk', question: highest.question });
|
|
assert.deepEqual(inventory(game), highest.originalCards);
|
|
});
|
|
|
|
void test('native actual Karama funds the promised overbid and its original free purchase consumes one card', () => {
|
|
const f = fixture({ window: 'active', targetKarama: true }); let game = bind(f, 'yes', 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);
|
|
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.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
|
|
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
});
|
|
|
|
void test('all four minimal policies offer a legal native bid for the current answered Yes', () => {
|
|
const f = fixture({ window: 'active' }), bound = bind(f).game;
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(bound, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
|
|
const offered = botActions(view).find(action => action.type === 'bid' && Number(action.amount) >= 4); assert.ok(offered, difficulty);
|
|
const accepted = act(structuredClone(bound), f.target, offered);
|
|
assert.equal(accepted.bidPromises![0].fulfilled, true); assert.deepEqual(inventory(accepted), f.originalCards);
|
|
}
|
|
});
|