Files
Dune/tests/distrans-prebid-engine.test.ts

219 lines
12 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { handLimit, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
actDistransPrebid, advanceDistransPrebid, buyDistransPrebidLot, createDistransPrebidFixture,
distransPrebidClean, distransPrebidInventory, distransPrebidPlayer as player,
distransPrebidPosition, distransPrebidUntouched, finishDistransPrebidLot,
playDistransPrebid, type DistransPrebidFixture, type DistransPrebidStep,
} from './fixture-distrans-prebid';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => actDistransPrebid(game, actor, action));
assert.deepEqual(game, before, 'The rejected public action leaves the native table unchanged');
}
function source(fixture: DistransPrebidFixture) {
const f = fixture;
assert.equal(f.initial.status, 'lobby');
assert.equal(f.setup.status, 'setup');
assert.ok(f.setup.players.every(seat => seat.hand.length === 0));
assert.equal(f.afterSetup.status, 'playing');
assert.equal(f.afterSetup.turn, 1);
assert.ok(f.afterSetup.players.every(seat => seat.hand.length === 1));
for (const { actor, card } of f.startingDealSelection)
assert.ok(player(f.afterSetup, actor).hand.some(held => held.id === card));
assert.ok(player(f.afterSetup, f.holder).hand.some(held => held.id === f.give));
assert.ok(!player(f.afterSetup, f.holder).hand.some(held => held.id === f.card));
assert.ok(f.acquisition.before.richeseCache!.some(card => card.id === f.card));
assert.ok(f.acquisition.actions.some(step => step.actor === f.holder &&
step.action.type === 'richeseBid' && step.action.amount === 1));
assert.equal(player(f.acquired, f.holder).spice, player(f.acquisition.before, f.holder).spice - 1);
assert.ok(player(f.acquired, f.holder).hand.some(card => card.id === f.card));
assert.equal(f.acquired.richeseCache!.some(card => card.id === f.card), false);
assert.equal(f.firstLot.auction!.cards.length, 3);
assert.ok(distransPrebidUntouched(f.firstLot));
assert.ok(distransPrebidUntouched(f.game));
assert.ok(distransPrebidClean(f.game));
assert.equal(f.originalInventory.length, 45);
assert.deepEqual(distransPrebidInventory(f.game), f.originalInventory);
assert.equal(f.actions.length, f.sourceActionCount);
assert.equal(f.actions[4].action.type, 'start');
assert.equal(f.actions[4].action.advancedPreview, f.advanced ? true : undefined);
assert.ok(f.game.players.every(seat => seat.ally === null));
for (const purchase of f.purchases) {
assert.ok(purchase.actions.some(step => step.actor === purchase.winner &&
step.action.type === 'bid' && step.action.amount === 1));
assert.equal(player(purchase.after, purchase.winner).hand.filter(card => card.id === purchase.card).length, 1);
assert.equal(player(purchase.after, purchase.winner).spice,
player(purchase.before, purchase.winner).spice - 1);
}
}
function transferred(f: DistransPrebidFixture, before: Game, after: Game, actions: DistransPrebidStep[] = []) {
assert.deepEqual(distransPrebidPosition(after), distransPrebidPosition(before),
'Transfer retains the original physical pool, index, opener, active owner, order and every wallet');
assert.deepEqual(player(after, f.holder).hand,
player(before, f.holder).hand.filter(card => card.id !== f.card && card.id !== f.give));
const original = player(before, f.holder).hand.find(card => card.id === f.give)!;
assert.deepEqual(player(after, f.recipient).hand, [...player(before, f.recipient).hand, original]);
assert.deepEqual(after.discard, [...before.discard, player(before, f.holder).hand.find(card => card.id === f.card)!]);
assert.equal(player(after, f.holder).hand.length, player(before, f.holder).hand.length - 2);
assert.equal(player(after, f.recipient).hand.length, player(before, f.recipient).hand.length + 1);
assert.equal(after.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(distransPrebidInventory(after), f.originalInventory);
const from = actions.length;
after = advanceDistransPrebid(after, distransPrebidClean, actions);
assert.ok(actions.slice(from).every(step => step.action.type === 'semutaPass'),
'Complete only the actual neutral fresh-discard controls before resuming this lot');
assert.deepEqual(distransPrebidPosition(after), distransPrebidPosition(before));
assert.ok(distransPrebidUntouched(after), 'The transfer does not count as the first bid or pass');
return after;
}
function completeBid(game: Game, bidder: string, actions: DistransPrebidStep[] = []) {
assert.ok(game.auction);
const before = structuredClone(game), index = game.auction.index, turn = game.turn;
const card = game.auction.cards[index].id;
const done = finishDistransPrebidLot(game, turn, index, actions);
assert.equal(player(done, bidder).hand.filter(held => held.id === card).length, 1);
assert.equal(player(done, bidder).spice, player(before, bidder).spice - 1);
assert.ok(!done.auction || done.auction.index !== index || done.turn !== turn || done.phase !== 3);
assert.equal(done.currentAuctionSale ?? null, null);
assert.ok(distransPrebidClean(done));
return done;
}
for (const advanced of [false, true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode} genuine paid-cache Distrans transfers its original starting card before the first ordinary bid and completes that exact lot`, () => {
const f = createDistransPrebidFixture({ advanced });
source(f);
const offered = viewGame(f.game, f.holder).distrans!;
assert.equal(offered.blocked, null);
assert.ok(offered.choices.find(choice => choice.recipient === f.recipient)!.cards.some(card => card.id === f.give));
const before = structuredClone(f.game), actions: DistransPrebidStep[] = [];
let game = playDistransPrebid(f, f.game, actions);
assert.deepEqual(f.game, before);
game = transferred(f, before, game, actions);
rejected(game, f.holder, f.action);
const bidder = game.auction!.active;
game = actDistransPrebid(game, bidder, { type: 'bid', amount: 1, allyPayment: 0 }, actions);
assert.equal(game.auction!.bid, 1);
assert.equal(game.auction!.bidder, bidder);
game = completeBid(game, bidder, actions);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(distransPrebidInventory(game), f.originalInventory);
assert.ok(actions.some(step => step.action.type === 'passBid'));
});
for (const opening of ['bid', 'passBid'] as const) {
void test(`${mode} first real ${opening} closes Distrans until the next untouched ordinary lot`, () => {
const f = createDistransPrebidFixture({ advanced });
const actor = f.game.auction!.active;
let game = actDistransPrebid(f.game, actor,
opening === 'bid' ? { type: 'bid', amount: 1, allyPayment: 0 } : { type: 'passBid' });
assert.equal(distransPrebidUntouched(game), false);
assert.ok(viewGame(game, f.holder).distrans!.blocked);
rejected(game, f.holder, f.action);
let bidder = actor;
if (opening === 'passBid') {
assert.equal(game.auction!.bid, 0);
assert.deepEqual(game.auction!.passed, [actor]);
bidder = game.auction!.active;
game = actDistransPrebid(game, bidder, { type: 'bid', amount: 1, allyPayment: 0 });
rejected(game, f.holder, f.action);
}
game = completeBid(game, bidder);
assert.equal(game.auction!.index, 1);
assert.ok(distransPrebidUntouched(game));
assert.equal(viewGame(game, f.holder).distrans!.blocked, null);
const before = structuredClone(game);
game = playDistransPrebid(f, game);
game = transferred(f, before, game);
rejected(game, f.holder, f.action);
assert.deepEqual(distransPrebidInventory(game), f.originalInventory);
});
}
void test(`${mode} two genuine ordinary purchases fill the donor, whose two-card Distrans removal reopens real bidding capacity`, () => {
const f = createDistransPrebidFixture({ advanced, capacity: 'donorFull' });
source(f);
assert.equal(f.purchases.length, 2);
assert.equal(f.game.auction!.index, 2);
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
const before = structuredClone(f.game);
let game = playDistransPrebid(f);
game = transferred(f, before, game);
assert.equal(player(game, f.holder).hand.length, 2);
const purchased = buyDistransPrebidLot(game, f.holder);
game = purchased.game;
assert.equal(player(game, f.holder).hand.length, 3);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(distransPrebidInventory(game), f.originalInventory);
});
void test(`${mode} a native last-slot active recipient keeps its opener, cannot bid when filled, can Pass, and all profiles finish the original lot`, () => {
const f = createDistransPrebidFixture({ advanced, capacity: 'recipientLastSlot' });
source(f);
assert.equal(f.purchases.length, 2);
assert.equal(f.game.auction!.index, 2);
assert.equal(f.game.auction!.active, f.recipient);
const before = structuredClone(f.game);
const filled = transferred(f, before, playDistransPrebid(f));
assert.equal(filled.auction!.active, f.recipient);
assert.equal(filled.active, f.recipient);
assert.equal(player(filled, f.recipient).hand.length, handLimit(player(filled, f.recipient)));
rejected(filled, f.recipient, { type: 'bid', amount: 1, allyPayment: 0 });
for (const difficulty of DIFFICULTIES) {
const view = viewGame(filled, f.recipient);
view.players.find(seat => seat.id === f.recipient)!.bot = difficulty;
const choices = botActions(view);
assert.ok(choices.length, `${difficulty} has an actual legal continuation`);
assert.ok(choices.every(action => action.type !== 'bid'), `${difficulty} cannot raise with a full hand`);
assert.equal(choices[0].type, 'passBid', `${difficulty} chooses the live Pass`);
let game = actDistransPrebid(filled, f.recipient, choices[0]);
assert.notEqual(game.auction!.active, f.recipient);
assert.equal(game.auction!.bid, 0);
assert.deepEqual(game.auction!.passed, [f.recipient]);
const bidder = game.auction!.active, recipientSpice = player(game, f.recipient).spice;
game = actDistransPrebid(game, bidder, { type: 'bid', amount: 1, allyPayment: 0 });
game = completeBid(game, bidder);
assert.equal(player(game, f.recipient).hand.length, handLimit(player(game, f.recipient)));
// Emperor may receive the other buyer's ordinary payment, but the full
// recipient never pays or gains a fifth physical card.
assert.equal(player(game, f.recipient).spice,
recipientSpice + Number(player(game, f.recipient).faction === 'emperor' && bidder !== f.recipient));
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(distransPrebidInventory(game), f.originalInventory);
}
});
void test(`${mode} an opponent's genuinely purchased full hand rejects Distrans at the native next-turn untouched lot`, () => {
const f = createDistransPrebidFixture({ advanced, capacity: 'recipientFull' });
source(f);
assert.equal(f.purchases.length, 3);
assert.equal(f.game.turn, 2);
assert.equal(player(f.game, f.recipient).hand.length, handLimit(player(f.game, f.recipient)));
const choice = viewGame(f.game, f.holder).distrans!.choices.find(choice => choice.recipient === f.recipient)!;
assert.equal(choice.cards.length, 0);
assert.ok(choice.blocked);
rejected(f.game, f.holder, f.action);
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.card));
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.give));
assert.equal(f.game.discard.some(card => card.id === f.card), false);
assert.deepEqual(distransPrebidInventory(f.game), f.originalInventory);
});
void test(`${mode} genuine prebid source rejects self, absent targets and stale repeated custody atomically`, () => {
const f = createDistransPrebidFixture({ advanced });
rejected(f.game, f.holder, { ...f.action, target: f.holder });
rejected(f.game, f.holder, { ...f.action, give: f.card });
rejected(f.game, f.holder, { ...f.action, target: 'absent-seat' });
rejected(f.game, f.holder, { ...f.action, give: player(f.game, f.recipient).hand[0].id });
const game = transferred(f, f.game, playDistransPrebid(f));
rejected(game, f.holder, f.action);
assert.deepEqual(distransPrebidInventory(game), f.originalInventory);
});
}