Carry phase bid commitments through expansion auctions
This commit is contained in:
1 parent
b64b732e50
commit
974b7fd99f
10 files changed
+568
-13
No files matched your search
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { advanceAuctionClock, applyAction, hasAuctionClockOpportunity, synchronizeAuctionClock,
|
||||
viewGame } from '../game/engine';
|
||||
import { truthBidInventory } from './fixture-truth-bid';
|
||||
import { advanceTruthTurnBid, bindTruthTurnBid, createTruthTurnBidFixture,
|
||||
finishTruthTurnBidLot, purchaseTruthTurnBidLot, truthTurnBidClean,
|
||||
truthTurnBidPlayer } from './fixture-truth-turn-bid';
|
||||
|
||||
const epoch = 'phase-bid-test:0';
|
||||
|
||||
/** All three lots are original cards reached through the actual Basic auction.
|
||||
* Earlier cards are purchased through native bids, never staged into a hand. */
|
||||
function finalLot(answer: 'yes' | 'no') {
|
||||
const fixture = createTruthTurnBidFixture();
|
||||
let game = fixture.game;
|
||||
for (let n = 0; n < 2; n++) game = purchaseTruthTurnBidLot(game, fixture.opponent).game;
|
||||
assert.equal(game.auction?.index, 2);
|
||||
game = advanceTruthTurnBid(game, state => truthTurnBidClean(state) &&
|
||||
state.auction?.active === fixture.target);
|
||||
game = bindTruthTurnBid(fixture, answer, 4, game).game;
|
||||
assert.equal(game.auction?.active, fixture.target);
|
||||
assert.equal(game.auction?.clockVersion, 1);
|
||||
assert.ok(hasAuctionClockOpportunity(game));
|
||||
return { fixture, game, card: game.auction!.cards[game.auction!.index].id };
|
||||
}
|
||||
|
||||
void test('a real expiring last-lot clock submits the required phase Yes bid and keeps native payment', () => {
|
||||
const { fixture: f, game, card } = finalLot('yes');
|
||||
const original = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, f.target, { type: 'passBid' }));
|
||||
assert.deepEqual(game, original);
|
||||
const guide = viewGame(game, f.target).phaseBidPromiseGuide;
|
||||
assert.ok(guide); assert.equal(guide.action.type, 'bid'); assert.equal(guide.action.amount, 4);
|
||||
const armed = synchronizeAuctionClock(game, 1_000, epoch);
|
||||
assert.equal(advanceAuctionClock(armed, 10_999, epoch), armed);
|
||||
const expired = advanceAuctionClock(armed, 11_000, epoch);
|
||||
assert.equal(expired.auction?.bidder, f.target);
|
||||
assert.equal(expired.auction?.bid, 4);
|
||||
assert.equal(truthTurnBidPlayer(expired, f.target).spice, truthTurnBidPlayer(game, f.target).spice,
|
||||
'A deadline declaration does not prepay or waive the native invoice');
|
||||
assert.ok(expired.phaseBidPromises?.some(row => row.player === f.target && row.minimum === 4 && row.fulfilled));
|
||||
assert.match(expired.log.at(-1)!.text, /legal bid required by their binding Truthtrance answer was submitted/);
|
||||
assert.deepEqual(truthBidInventory(expired), f.originalCards);
|
||||
const settled = finishTruthTurnBidLot(expired);
|
||||
assert.equal(truthTurnBidPlayer(settled, f.target).spice, truthTurnBidPlayer(game, f.target).spice - 4);
|
||||
assert.ok(truthTurnBidPlayer(settled, f.target).hand.some(item => item.id === card));
|
||||
assert.deepEqual(truthBidInventory(settled), f.originalCards);
|
||||
});
|
||||
|
||||
void test('a real expiring last-lot clock preserves phase No and takes the legal pass', () => {
|
||||
const { fixture: f, game } = finalLot('no');
|
||||
assert.throws(() => applyAction(game, f.target, { type: 'bid', amount: 4, allyPayment: 0 }));
|
||||
const expected = applyAction(game, f.target, { type: 'passBid' });
|
||||
const armed = synchronizeAuctionClock(game, 1_000, epoch);
|
||||
const expired = advanceAuctionClock(armed, 11_000, epoch);
|
||||
assert.equal(expired.phase, expected.phase);
|
||||
assert.deepEqual(expired.auction, expected.auction);
|
||||
assert.deepEqual(expired.phaseBidPromises, expected.phaseBidPromises);
|
||||
assert.ok(expired.phaseBidPromises?.some(row => row.player === f.target && !row.answer && !row.fulfilled && !row.released),
|
||||
'A phase-wide No stays live while other bidders can still act');
|
||||
assert.match(expired.log.at(-1)!.text, /ten-second bidding time expired; they passed/);
|
||||
assert.deepEqual(truthBidInventory(expired), f.originalCards);
|
||||
const closed = advanceTruthTurnBid(expired, state => state.phase !== 3);
|
||||
assert.ok(closed.phaseBidPromises?.some(row => row.player === f.target && !row.answer && row.fulfilled),
|
||||
'The No is fulfilled only after the actual Bidding phase closes');
|
||||
assert.deepEqual(truthBidInventory(closed), f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
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 {
|
||||
actTruthMovementNativeEarly as act,
|
||||
advanceTruthMovementNativeEarly as advance,
|
||||
createTruthMovementNativeEarlyFixture as fixture,
|
||||
truthMovementNativeEarlyClean as clean,
|
||||
truthMovementNativeEarlyInventory as inventory,
|
||||
truthMovementNativeEarlyPlayer as player,
|
||||
} from './fixture-truth-movement-native-early';
|
||||
|
||||
/** Native Basic setup uses original unopened deal and Spice positions; the
|
||||
* shared fixture reaches clean Charity without a staged hand or promise. */
|
||||
function bind(answer: 'yes' | 'no', withCancel = false) {
|
||||
const f = fixture({ roster: 'ixians-tleilaxu' });
|
||||
let game = structuredClone(f.game);
|
||||
let karama: string | undefined;
|
||||
if (withCancel) {
|
||||
// A controlled physical supplement moves one still-unopened Karama to the
|
||||
// Tleilaxu hand. This is only the optional cancellation cost: no wallet,
|
||||
// response, auction, bidder, pool or promise is installed.
|
||||
const index = game.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(index >= 0);
|
||||
const [card] = game.deck.splice(index, 1);
|
||||
karama = card.id;
|
||||
player(game, 'tleilaxu').hand.push(card);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
game = act(game, f.asker, { type: 'card', card: f.truth });
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id));
|
||||
assert.ok(actor);
|
||||
game = act(game, actor.id, { type: 'truthPass' });
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'phaseBid', target: f.target, turn: game.turn, minimum: 4,
|
||||
} });
|
||||
assert.ok(viewGame(game, f.target).truthPhaseBidAnswers?.includes(answer));
|
||||
assert.equal(viewGame(game, f.asker).truthPhaseBidAnswers, null);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const target = viewGame(game, f.target);
|
||||
target.players.find(seat => seat.id === f.target)!.bot = difficulty;
|
||||
const action = botActions(target)[0];
|
||||
assert.equal(action?.type, 'truthAnswer');
|
||||
assert.ok(target.truthPhaseBidAnswers!.includes(action.answer as 'yes' | 'no' | 'unknown'));
|
||||
}
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer });
|
||||
assert.equal(game.truthtrance, null);
|
||||
assert.ok(game.phaseBidPromises?.some(row => row.player === f.target && row.answer === (answer === 'yes')));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
return { f, game, karama };
|
||||
}
|
||||
|
||||
function ordinaryLot(source: Game, karama?: string) {
|
||||
let game: Game;
|
||||
if (karama) {
|
||||
game = advance(source, state => state.response?.kind === 'ixAuction' && !!state.ixAuction);
|
||||
assert.deepEqual(game.ixAuction!.cards, [], 'The real response precedes drawing the Ixian pool');
|
||||
game = act(game, 'tleilaxu', { type: 'card', card: karama, mode: 'cancel' });
|
||||
} else {
|
||||
// With no eligible counter the native Ixian response auto-allows, so it
|
||||
// can proceed directly to the real inspection decision in one action.
|
||||
game = advance(source, state => state.decision?.kind === 'ixAuction' && !state.response);
|
||||
const pool = game.ixAuction!.cards;
|
||||
assert.ok(pool.length > 1);
|
||||
const returned = pool[0].id, retained = pool.slice(1).map(card => card.id).sort();
|
||||
game = act(game, game.decision!.player, { type: 'decision', card: returned, position: 'bottom' });
|
||||
game = advance(game, state => clean(state) && !!state.auction);
|
||||
assert.deepEqual(game.ixAuctionKnown!.cards.map(card => card.id).sort(), retained);
|
||||
assert.ok(game.deck.some(card => card.id === returned));
|
||||
return game;
|
||||
}
|
||||
game = advance(game, state => clean(state) && !!state.auction);
|
||||
assert.equal(game.ixAuction ?? null, null);
|
||||
assert.equal(game.ixAuctionKnown ?? null, null, 'A canceled inspection grants no Ixian private pool');
|
||||
assert.equal(game.discard.filter(card => card.id === karama).length, 1);
|
||||
return game;
|
||||
}
|
||||
|
||||
function toTarget(game: Game, target: string) {
|
||||
for (let n = 0; n < 20; n++) {
|
||||
if (clean(game) && game.auction?.active === target) return game;
|
||||
if (clean(game) && game.auction) game = act(game, game.auction.active, { type: 'passBid' });
|
||||
else game = advance(game, clean);
|
||||
}
|
||||
throw new Error('The original ordinary lot did not reach the promised bidder');
|
||||
}
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
for (const canceled of [false, true]) {
|
||||
const source = canceled ? 'canceled Ixian inspection' : 'actual Ixian pool selection';
|
||||
void test(`Basic early phase-bid Yes survives ${source} and completes through the real ordinary bid`, () => {
|
||||
const { f, game: bound, karama } = bind('yes', canceled);
|
||||
let game = toTarget(ordinaryLot(bound, karama), f.target);
|
||||
const outstanding = viewGame(game, f.target).phaseBidPromises.find(row => row.minimum === 4);
|
||||
assert.ok(outstanding && !outstanding.fulfilled && !outstanding.released);
|
||||
reject(game, f.target, { type: 'passBid' });
|
||||
// A smaller bid may remain legal when an actual funded sale opens a later
|
||||
// lot; the phase promise does not force the current lot's threshold.
|
||||
const target = viewGame(game, f.target), guide = target.phaseBidPromiseGuide;
|
||||
assert.ok(guide); assert.equal(guide.action.type, 'bid');
|
||||
assert.equal(guide.action.amount, 4);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const bot = viewGame(game, f.target);
|
||||
bot.players.find(seat => seat.id === f.target)!.bot = difficulty;
|
||||
const action = botActions(bot)[0]; assert.ok(action);
|
||||
const trial = act(game, f.target, action);
|
||||
assert.ok(trial.phaseBidPromises?.some(row => row.player === f.target && row.fulfilled));
|
||||
}
|
||||
game = act(game, f.target, guide.action);
|
||||
assert.ok(game.phaseBidPromises?.some(row => row.player === f.target && row.fulfilled));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test(`Basic early phase-bid No survives ${source} and constrains the real ordinary lot`, () => {
|
||||
const { f, game: bound, karama } = bind('no', canceled);
|
||||
let game = toTarget(ordinaryLot(bound, karama), f.target);
|
||||
reject(game, f.target, { type: 'bid', amount: 4, allyPayment: 0 });
|
||||
game = act(game, f.target, { type: 'passBid' });
|
||||
game = advance(game, state => state.phase > 3 && clean(state));
|
||||
assert.ok(game.phaseBidPromises?.some(row => row.player === f.target && !row.answer && row.fulfilled));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
actTruthtranceRichesePromises as act,
|
||||
advanceTruthtranceRichesePromises as advance,
|
||||
askTruthtranceRichesePromises as ask,
|
||||
createTruthtranceRichesePromisesFixture as fixture,
|
||||
passTruthRicheseFreshDiscard,
|
||||
truthtranceRichesePromisesClean as clean,
|
||||
truthtranceRichesePromisesInventory as inventory,
|
||||
truthtranceRichesePromisesPlayer as player,
|
||||
} from './fixture-truthtrance-richese-promises';
|
||||
|
||||
type Fixture = ReturnType<typeof fixture>;
|
||||
type Method = 'onceAround' | 'silent';
|
||||
|
||||
/** Supplemental setup moves real unopened cards only, retaining all physical
|
||||
* card identities; no funds, promise, auction result or board state is staged. */
|
||||
function fillHand(game: Game, f: Fixture, actor: string, size: number) {
|
||||
while (player(game, actor).hand.length < size) {
|
||||
const index = game.deck.findIndex(card => card.id !== f.ordinaryLot);
|
||||
assert.ok(index >= 0);
|
||||
player(game, actor).hand.push(game.deck.splice(index, 1)[0]);
|
||||
}
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
|
||||
function bind(f: Fixture, source: Game) {
|
||||
const asked = ask(source, f, { kind: 'phaseBid', target: f.emperor, turn: source.turn, minimum: 4 });
|
||||
assert.ok(viewGame(asked, f.emperor).truthPhaseBidAnswers?.includes('yes'));
|
||||
const bound = passTruthRicheseFreshDiscard(act(asked, f.emperor, { type: 'truthAnswer', answer: 'yes' }));
|
||||
assert.ok(bound.phaseBidPromises?.some(row => row.player === f.emperor && row.answer && !row.released));
|
||||
return bound;
|
||||
}
|
||||
|
||||
function cacheTurn(game: Game, target: string, method: Method) {
|
||||
const options = { position: 'first' as const, method };
|
||||
game = advance(game, state => clean(state) && state.richeseAuction?.source === 'cache' &&
|
||||
state.richeseAuction.method === method && !state.richeseAuction.outcome, [], options);
|
||||
if (method === 'onceAround') game = advance(game, state => clean(state) &&
|
||||
state.richeseAuction?.active === target, [], options);
|
||||
return game;
|
||||
}
|
||||
|
||||
function rejects(source: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(source);
|
||||
assert.throws(() => act(source, actor, action));
|
||||
assert.deepEqual(source, before, 'A rejected voluntary bid must not mutate its source');
|
||||
}
|
||||
|
||||
for (const method of ['onceAround', 'silent'] as const) {
|
||||
void test(`Basic ${method} cache bid cannot spend the last funds for a phase-wide Yes`, () => {
|
||||
const f = fixture({ advanced: false });
|
||||
const bound = bind(f, structuredClone(f.earlySource));
|
||||
assert.equal(player(bound, f.emperor).spice, 10);
|
||||
const current = cacheTurn(bound, f.emperor, method);
|
||||
const event = current.richeseAuction!.event;
|
||||
rejects(current, f.emperor, { type: 'richeseBid', event, amount: 7, allyPayment: 0 });
|
||||
const accepted = act(current, f.emperor, { type: 'richeseBid', event, amount: 6, allyPayment: 0 });
|
||||
assert.ok(accepted.phaseBidPromises?.some(row => row.player === f.emperor && row.answer &&
|
||||
!row.fulfilled && !row.released));
|
||||
assert.deepEqual(inventory(accepted), f.originalCards);
|
||||
});
|
||||
|
||||
void test(`Basic ${method} cache bid cannot consume the only hand slot needed for a phase-wide Yes`, () => {
|
||||
const f = fixture({ advanced: false });
|
||||
const source = structuredClone(f.earlySource);
|
||||
fillHand(source, f, f.emperor, 3);
|
||||
const bound = bind(f, source);
|
||||
const current = cacheTurn(bound, f.emperor, method);
|
||||
const event = current.richeseAuction!.event;
|
||||
rejects(current, f.emperor, { type: 'richeseBid', event, amount: 1, allyPayment: 0 });
|
||||
const passed = act(current, f.emperor, { type: 'richeseBid', event,
|
||||
amount: method === 'silent' ? 0 : null });
|
||||
assert.ok(passed.phaseBidPromises?.some(row => row.player === f.emperor && row.answer &&
|
||||
!row.fulfilled && !row.released));
|
||||
assert.deepEqual(inventory(passed), f.originalCards);
|
||||
});
|
||||
}
|
||||
|
||||
void test('a full Truthtrance holder creates a real ordinary slot by discarding the answered card', () => {
|
||||
const f = fixture({ advanced: false });
|
||||
const source = structuredClone(f.earlySource);
|
||||
// Immediately before answering only Emperor has an open slot. The definite
|
||||
// Truthtrance answer consumes Atreides' real held card, so Atreides also has
|
||||
// an open slot before the compulsory cache reduces the ordinary lot count.
|
||||
fillHand(source, f, f.asker, 4);
|
||||
fillHand(source, f, f.richese, 4);
|
||||
assert.equal(player(source, f.emperor).hand.length, 1);
|
||||
const bound = bind(f, source);
|
||||
assert.equal(player(bound, f.asker).hand.length, 3);
|
||||
assert.equal(player(bound, f.richese).hand.length, 4);
|
||||
const current = advance(bound, state => clean(state) && !!state.auction &&
|
||||
!state.richeseAuction && state.auction.active === f.emperor, [], { position: 'first' });
|
||||
assert.equal(current.richeseBidding?.normalCountBeforeCache, 2);
|
||||
assert.equal(current.richeseBidding?.normalCount, 1);
|
||||
const guide = viewGame(current, f.emperor).phaseBidPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.amount, 4);
|
||||
const fulfilled = act(current, f.emperor, guide.action);
|
||||
assert.ok(fulfilled.phaseBidPromises?.some(row => row.player === f.emperor && row.fulfilled));
|
||||
assert.deepEqual(inventory(fulfilled), f.originalCards);
|
||||
});
|
||||
|
||||
void test('an externally reduced one-eligible-seat state cannot retain a speculative ordinary Yes', () => {
|
||||
const f = fixture({ advanced: false });
|
||||
const bound = bind(f, structuredClone(f.earlySource));
|
||||
const staged = structuredClone(bound);
|
||||
// Controlled external capacity loss after the answered Truthtrance: move
|
||||
// unopened cards into the other two hands, leaving only Emperor eligible.
|
||||
fillHand(staged, f, f.asker, 4);
|
||||
fillHand(staged, f, f.richese, 4);
|
||||
assert.equal(player(staged, f.emperor).hand.length, 1);
|
||||
const settled = normalizeAutomaticGame(staged);
|
||||
assert.ok(settled.phaseBidPromises?.some(row => row.player === f.emperor && row.answer && row.released),
|
||||
'One eligible seat cannot guarantee an ordinary lot while the cache consumes that opportunity');
|
||||
assert.deepEqual(inventory(settled), f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
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 {
|
||||
actTruthtranceRichesePromises as act,
|
||||
advanceTruthtranceRichesePromises as advance,
|
||||
askTruthtranceRichesePromises as ask,
|
||||
createTruthtranceRichesePromisesFixture as fixture,
|
||||
passTruthRicheseFreshDiscard,
|
||||
truthtranceRichesePromisesClean as clean,
|
||||
truthtranceRichesePromisesInventory as inventory,
|
||||
truthtranceRichesePromisesPlayer as player,
|
||||
} from './fixture-truthtrance-richese-promises';
|
||||
|
||||
/** Real Basic Richese/Atreides/Emperor setup and Charity. The fixture orders
|
||||
* unopened original cards and Spice cards only: no hands, funds, cache,
|
||||
* commitments or phase state are staged. */
|
||||
function bind(answer: 'yes' | 'no') {
|
||||
const f = fixture({ advanced: false });
|
||||
const question = { kind: 'phaseBid' as const, target: f.emperor,
|
||||
turn: f.earlySource.turn, minimum: 4 };
|
||||
const asked = ask(structuredClone(f.earlySource), f, question);
|
||||
assert.ok(viewGame(asked, f.emperor).truthPhaseBidAnswers?.includes(answer));
|
||||
const answered = act(asked, f.emperor, { type: 'truthAnswer', answer });
|
||||
const bound = passTruthRicheseFreshDiscard(answered);
|
||||
assert.equal(bound.truthtrance, null);
|
||||
assert.equal(bound.discard.filter(card => card.id === f.truth).length, 1);
|
||||
assert.ok(bound.phaseBidPromises?.some(row => row.player === f.emperor &&
|
||||
row.minimum === 4 && row.answer === (answer === 'yes')));
|
||||
assert.deepEqual(inventory(bound), f.originalCards);
|
||||
return { f, asked, bound };
|
||||
}
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before, 'A rejected declaration must not mutate the game');
|
||||
}
|
||||
|
||||
type Method = 'onceAround' | 'silent';
|
||||
function cacheBidFirst(game: Game, actor: string, method: Method) {
|
||||
const options = { position: 'first' as const, method };
|
||||
game = advance(game, state => clean(state) && !!state.richeseAuction &&
|
||||
state.richeseAuction.source === 'cache' && !state.richeseAuction.outcome, [], options);
|
||||
if (method === 'onceAround') game = advance(game, state => clean(state) &&
|
||||
state.richeseAuction?.active === actor, [], options);
|
||||
const event = game.richeseAuction!.event;
|
||||
// This actual special-auction bid is not a phase-wide ordinary bid.
|
||||
game = act(game, actor, { type: 'richeseBid', event, amount: 4, allyPayment: 0 });
|
||||
const row = game.phaseBidPromises!.find(p => p.player === actor && p.minimum === 4)!;
|
||||
assert.ok(!row.fulfilled && !row.released,
|
||||
'A cache bid neither fulfills nor contradicts the ordinary-bid question');
|
||||
return game;
|
||||
}
|
||||
|
||||
function ordinaryTurn(game: Game, actor: string, position: 'first' | 'last', method: Method) {
|
||||
return advance(game, state => clean(state) && !!state.auction &&
|
||||
!state.richeseAuction && state.auction.active === actor, [], { position, method });
|
||||
}
|
||||
|
||||
for (const position of ['first', 'last'] as const) for (const method of ['onceAround', 'silent'] as const) {
|
||||
for (const answer of ['yes', 'no'] as const) {
|
||||
void test(`Basic phase bid ${answer} survives ${position} ${method} Richese cache and governs a real ordinary bid`, () => {
|
||||
const { f, bound } = bind(answer);
|
||||
const beforeOrdinary = position === 'first' ? cacheBidFirst(bound, f.emperor, method) : bound;
|
||||
const current = ordinaryTurn(beforeOrdinary, f.emperor, position, method);
|
||||
const promise = current.phaseBidPromises!.find(row => row.player === f.emperor && row.minimum === 4)!;
|
||||
assert.ok(promise && !promise.fulfilled && !promise.released);
|
||||
if (position === 'first') {
|
||||
assert.ok(player(current, f.emperor).hand.some(card => card.id === f.cacheCard));
|
||||
assert.equal(player(current, f.emperor).spice, 6);
|
||||
}
|
||||
let next: Game;
|
||||
if (answer === 'yes') {
|
||||
const guide = viewGame(current, f.emperor).phaseBidPromiseGuide;
|
||||
assert.ok(guide); assert.equal(guide.action.type, 'bid');
|
||||
assert.equal(guide.action.amount, 4);
|
||||
next = act(current, f.emperor, guide.action);
|
||||
assert.ok(next.phaseBidPromises!.some(row => row.player === f.emperor && row.fulfilled));
|
||||
} else {
|
||||
reject(current, f.emperor, { type: 'bid', amount: 4, allyPayment: 0 });
|
||||
next = act(current, f.emperor, { type: 'bid', amount: 3, allyPayment: 0 });
|
||||
assert.ok(next.phaseBidPromises!.some(row => row.player === f.emperor &&
|
||||
!row.answer && !row.fulfilled && !row.released));
|
||||
}
|
||||
assert.deepEqual(inventory(next), f.originalCards);
|
||||
if (position === 'last') {
|
||||
// The ordinary winner's invoice and any remaining auction controls
|
||||
// resolve before the original cache offer; do not invent another lot.
|
||||
const later = advance(next, state => clean(state) && !!state.richeseAuction &&
|
||||
state.richeseAuction.source === 'cache' && !state.richeseAuction.outcome,
|
||||
[], { position, method });
|
||||
assert.equal(later.richeseAuction!.method, method);
|
||||
assert.deepEqual(inventory(later), f.originalCards);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void test('all four profiles answer and follow a phase-bid guide after a native Richese cache auction', () => {
|
||||
const { f, asked } = bind('yes');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const answerView = viewGame(asked, f.emperor);
|
||||
answerView.players.find(row => row.id === f.emperor)!.bot = difficulty;
|
||||
const answer = botActions(answerView)[0];
|
||||
assert.equal(answer?.type, 'truthAnswer');
|
||||
assert.ok(answer.answer === 'yes' || answer.answer === 'no');
|
||||
const bound = passTruthRicheseFreshDiscard(act(structuredClone(asked), f.emperor, answer));
|
||||
const current = ordinaryTurn(bound, f.emperor, 'first', 'silent');
|
||||
const view = viewGame(current, f.emperor);
|
||||
view.players.find(row => row.id === f.emperor)!.bot = difficulty;
|
||||
const guide = view.phaseBidPromiseGuide;
|
||||
assert.ok(guide, 'The active seat has an actual legal phase-bid guide');
|
||||
const action = botActions(view)[0];
|
||||
assert.deepEqual(action, guide.action);
|
||||
const done = act(current, f.emperor, action);
|
||||
assert.deepEqual(inventory(done), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('an externally canceled cache preserves a pre-Bidding ordinary phase Yes', () => {
|
||||
const f = fixture({ advanced: false });
|
||||
const source = structuredClone(f.earlySource);
|
||||
// Controlled physical supplement: move one actual unopened Karama into
|
||||
// Atreides' hand for the real cache-cancellation response. All cards remain.
|
||||
const index = source.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
||||
const card = source.deck.splice(index, 1)[0];
|
||||
player(source, f.asker).hand.push(card);
|
||||
assert.deepEqual(inventory(source), f.originalCards);
|
||||
const asked = ask(source, f, { kind: 'phaseBid', target: f.emperor, turn: source.turn, minimum: 4 });
|
||||
assert.ok(viewGame(asked, f.emperor).truthPhaseBidAnswers?.includes('yes'));
|
||||
const bound = passTruthRicheseFreshDiscard(act(asked, f.emperor, { type: 'truthAnswer', answer: 'yes' }));
|
||||
const declaration = advance(bound, state => state.decision?.kind === 'richeseDeclaration');
|
||||
const opened = act(declaration, f.richese, { type: 'decision',
|
||||
event: declaration.richeseBidding!.event, position: 'first' });
|
||||
assert.equal(opened.response?.kind, 'richeseAuction');
|
||||
const canceled = passTruthRicheseFreshDiscard(act(opened, f.asker, { type: 'card', card: card.id, mode: 'cancel' }));
|
||||
assert.equal(canceled.richeseBidding?.cacheCanceled, true);
|
||||
assert.ok(canceled.phaseBidPromises?.some(row => row.player === f.emperor &&
|
||||
row.answer && !row.fulfilled && !row.released));
|
||||
const current = ordinaryTurn(canceled, f.emperor, 'first', 'onceAround');
|
||||
const guide = viewGame(current, f.emperor).phaseBidPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.amount, 4);
|
||||
const done = act(current, f.emperor, guide.action);
|
||||
assert.ok(done.phaseBidPromises?.some(row => row.player === f.emperor && row.fulfilled));
|
||||
assert.deepEqual(inventory(done), f.originalCards);
|
||||
});
|
||||
Reference in new issue
Block a user