Add current-lot Truthtrance bid commitments
This commit is contained in:
1 parent
4ab20a2838
commit
bd6e5bbf7f
18 files changed
+1346
-27
No files matched your search
@@ -0,0 +1,183 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
BidPromiseError,
|
||||
bidClaimText,
|
||||
bidPromiseModeSupported,
|
||||
bidPromiseRange,
|
||||
bidRequired,
|
||||
liveBidPromises,
|
||||
parseBidClaim,
|
||||
recordPromisedBid,
|
||||
validateBidPromises,
|
||||
type BidOpportunity,
|
||||
type BidPromise,
|
||||
} from '../game/bid-promises';
|
||||
|
||||
const seats = ['respondent', 'asker', 'rival'] as const;
|
||||
function opportunity(extra: Partial<BidOpportunity> = {}): BidOpportunity {
|
||||
return { turn: 2, lot: 3, source: 'original-physical-lot', bid: 4,
|
||||
bidder: 'rival', passed: [], eligible: seats, ...extra };
|
||||
}
|
||||
function promise(event: string, minimum: number, answer = true,
|
||||
extra: Partial<BidPromise> = {}): BidPromise {
|
||||
return { event, turn: 2, lot: 3, source: 'original-physical-lot',
|
||||
player: 'respondent', asker: 'asker', answer, minimum, ...extra };
|
||||
}
|
||||
|
||||
void test('bid claims normalize one-based safe-integer turn, lot and minimum without adding a purchase or cumulative amount', () => {
|
||||
const input = { turn: 2, lot: 3, minimum: 6 };
|
||||
const parsed = parseBidClaim(input);
|
||||
assert.deepEqual(parsed, input); assert.notEqual(parsed, input);
|
||||
assert.deepEqual(parseBidClaim({ turn: 1, lot: 1, minimum: 1 }), { turn: 1, lot: 1, minimum: 1 });
|
||||
assert.deepEqual(parseBidClaim({ turn: Number.MAX_SAFE_INTEGER, lot: Number.MAX_SAFE_INTEGER,
|
||||
minimum: Number.MAX_SAFE_INTEGER }), { turn: Number.MAX_SAFE_INTEGER, lot: Number.MAX_SAFE_INTEGER,
|
||||
minimum: Number.MAX_SAFE_INTEGER });
|
||||
for (const field of ['turn', 'lot', 'minimum'] as const)
|
||||
for (const value of [0, -1, 1.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '6', null, undefined])
|
||||
assert.throws(() => parseBidClaim({ ...input, [field]: value }), BidPromiseError, `${field}/${String(value)}`);
|
||||
for (const value of [null, undefined, [], '6', 6]) assert.throws(() => parseBidClaim(value), BidPromiseError);
|
||||
const text = bidClaimText(parsed);
|
||||
assert.match(text, /place a bid of at least 6 spice/); assert.match(text, /from now until ordinary lot 3 closes/);
|
||||
assert.doesNotMatch(text, /win|purchase|sum|original-physical-lot/);
|
||||
});
|
||||
|
||||
void test('bid commitment scope retains original bare Basic and Advanced native rosters without admitting optional modules', () => {
|
||||
const classic = { advanced: false, expansions: [], players: [{ faction: 'atreides' }, { faction: 'emperor' }] };
|
||||
const native = { advanced: true, expansions: ['ix', 'choam', 'ecaz'],
|
||||
players: ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].map(faction => ({ faction })) };
|
||||
for (const game of [classic, { ...classic, advanced: true }, native, { ...native, advanced: false },
|
||||
{ ...classic, expansions: ['ix', 'choam', 'ecaz'] },
|
||||
{ ...classic, advanced: true, expansions: ['ecaz'], players: [{ faction: 'moritani' }, { faction: 'harkonnen' }] },
|
||||
]) assert.equal(bidPromiseModeSupported(game), true);
|
||||
for (const option of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
|
||||
'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery',
|
||||
'semutaPreview', 'kullPreview', 'nexusKullPreview', 'richeseBetrayalPreview'] as const)
|
||||
assert.equal(bidPromiseModeSupported({ ...native, [option]: true }), false, option);
|
||||
for (const game of [
|
||||
{ ...native, expansions: ['ix', 'ix'] }, { ...native, expansions: ['ix', 'choam'] },
|
||||
{ ...classic, expansions: ['unknown'] }, { ...classic, players: [{ faction: 'atreides' }, { faction: 'unknown' }] },
|
||||
{ ...classic, players: [{ faction: 'atreides' }] },
|
||||
{ ...classic, players: [...native.players, { faction: 'atreides' }] },
|
||||
{ ...classic, advanced: 'basic' as unknown as boolean },
|
||||
]) assert.equal(bidPromiseModeSupported(game), false);
|
||||
});
|
||||
|
||||
void test('one new accepted bid fulfills Yes while smaller bids, an old high bid and a purchase do not substitute', () => {
|
||||
const original = [promise('at-least-six', 6)];
|
||||
let records = recordPromisedBid(original, 'respondent', opportunity({ bid: 2 }), 3);
|
||||
records = recordPromisedBid(records, 'respondent', opportunity({ bid: 4 }), 5);
|
||||
assert.equal(records[0].fulfilled, undefined, 'Accepted bids 3 and 5 are not a cumulative bid of 8');
|
||||
assert.equal(bidRequired(records, 'respondent', opportunity()), true);
|
||||
assert.deepEqual(bidPromiseRange(records, 'respondent', opportunity({ bid: 6 }), 10), { minimum: 7, maximum: 10 });
|
||||
const alreadyHighest = opportunity({ bid: 9, bidder: 'respondent' });
|
||||
assert.equal(bidPromiseRange(records, 'respondent', alreadyHighest, 10), null);
|
||||
assert.equal(bidRequired(records, 'respondent', alreadyHighest), true, 'A pre-existing winning bid does not answer a new promise');
|
||||
assert.equal(records[0].fulfilled, undefined, 'Holding the high bid or later paying its invoice is not a new accepted declaration');
|
||||
const accepted = recordPromisedBid(records, 'respondent', opportunity({ bid: 6 }), 7);
|
||||
assert.equal(accepted[0].fulfilled, true, 'A qualifying accepted bid is sufficient without winning the card');
|
||||
assert.equal(bidRequired(accepted, 'respondent', opportunity({ bid: 8, bidder: 'rival' })), false);
|
||||
assert.equal(original[0].fulfilled, undefined);
|
||||
});
|
||||
|
||||
void test('joint Yes lower bounds and No caps preserve every qualifying single-bid amount', () => {
|
||||
const records = [promise('yes-six', 6), promise('yes-seven', 7),
|
||||
promise('no-ten', 10, false), promise('no-eight', 8, false)];
|
||||
const source = opportunity();
|
||||
assert.deepEqual(bidPromiseRange(records, 'respondent', source, 20), { minimum: 7, maximum: 7 });
|
||||
assert.deepEqual(bidPromiseRange(records.slice(0, 1).concat(records.slice(2)), 'respondent', source, 20),
|
||||
{ minimum: 6, maximum: 7 });
|
||||
assert.deepEqual(bidPromiseRange(records, 'respondent', source, 7), { minimum: 7, maximum: 7 });
|
||||
assert.equal(bidPromiseRange(records, 'respondent', source, 6), null, 'Funding cannot be invented to meet Yes');
|
||||
assert.equal(bidPromiseRange(records, 'respondent', opportunity({ bid: 7 }), 20), null,
|
||||
'A later competing bid can remove the entire permissible interval');
|
||||
});
|
||||
|
||||
void test('conflicting promises have no completion while a No-only cap never requires placing a bid', () => {
|
||||
const conflict = [promise('yes-eight', 8), promise('no-eight', 8, false)];
|
||||
assert.equal(bidPromiseRange(conflict, 'respondent', opportunity(), 20), null);
|
||||
assert.equal(bidRequired(conflict, 'respondent', opportunity()), true);
|
||||
const no = [promise('stay-below-six', 6, false), promise('stay-below-nine', 9, false)];
|
||||
assert.deepEqual(bidPromiseRange(no, 'respondent', opportunity(), 20), { minimum: 5, maximum: 5 });
|
||||
assert.equal(bidRequired(no, 'respondent', opportunity()), false);
|
||||
assert.equal(bidPromiseRange(no, 'respondent', opportunity({ bid: 5 }), 20), null);
|
||||
assert.equal(bidRequired(no, 'respondent', opportunity({ bid: 5 })), false,
|
||||
'An unavailable permitted bid does not turn a No answer into a required action');
|
||||
});
|
||||
|
||||
void test('passed, current-highest and ineligible players have no future own declaration route in this opportunity', () => {
|
||||
const records = [promise('yes-six', 6)];
|
||||
for (const source of [opportunity({ passed: ['respondent'] }),
|
||||
opportunity({ bidder: 'respondent', bid: 6 }), opportunity({ eligible: ['asker', 'rival'] })]) {
|
||||
assert.equal(bidPromiseRange(records, 'respondent', source, 20), null);
|
||||
assert.equal(records[0].fulfilled, undefined, 'Eligibility and current high bids cannot fulfill a promise');
|
||||
}
|
||||
assert.deepEqual(bidPromiseRange(records, 'respondent', opportunity({ passed: ['asker'] }), 20),
|
||||
{ minimum: 6, maximum: 20 });
|
||||
});
|
||||
|
||||
void test('live obligations retain their original turn, physical lot and respondent while independent askers combine', () => {
|
||||
const own = promise('own', 6), secondAsker = promise('second-asker', 8, false, { asker: 'rival' });
|
||||
const records = [own, secondAsker,
|
||||
promise('other-turn', 99, true, { turn: 1 }),
|
||||
promise('other-physical-lot', 99, true, { source: 'another-original-lot', lot: 4 }),
|
||||
promise('other-respondent', 99, true, { player: 'rival' }),
|
||||
promise('already-fulfilled', 99, true, { fulfilled: true }),
|
||||
promise('already-released', 99, true, { released: true })];
|
||||
assert.deepEqual(liveBidPromises(records, 'respondent', opportunity()), [own, secondAsker]);
|
||||
assert.deepEqual(bidPromiseRange(records, 'respondent', opportunity(), 20), { minimum: 6, maximum: 7 });
|
||||
const accepted = recordPromisedBid(records, 'respondent', opportunity(), 6);
|
||||
assert.equal(accepted[0].fulfilled, true); assert.equal(accepted[1], secondAsker);
|
||||
for (let index = 2; index < records.length; index++) assert.equal(accepted[index], records[index]);
|
||||
assert.deepEqual(liveBidPromises(records, 'absent', opportunity()), []);
|
||||
});
|
||||
|
||||
void test('maximum safe bids keep exact inclusive bounds and never overflow into an invented next bid', () => {
|
||||
const maximum = Number.MAX_SAFE_INTEGER;
|
||||
const source = opportunity({ bid: maximum - 1 });
|
||||
const yes = [promise('largest-yes', maximum)];
|
||||
assert.deepEqual(bidPromiseRange(yes, 'respondent', source, maximum), { minimum: maximum, maximum });
|
||||
assert.equal(recordPromisedBid(yes, 'respondent', source, maximum)[0].fulfilled, true);
|
||||
assert.equal(bidPromiseRange(yes, 'respondent', opportunity({ bid: maximum }), maximum), null);
|
||||
assert.equal(bidPromiseRange([promise('largest-no', maximum, false)], 'respondent', source, maximum), null);
|
||||
for (const invalid of [0, -1, 1.5, Number.NaN, Infinity, maximum + 1])
|
||||
assert.equal(bidPromiseRange([], 'respondent', opportunity(), invalid), null);
|
||||
for (const invalid of [-1, 1.5, Number.NaN, Infinity, maximum + 1])
|
||||
assert.equal(bidPromiseRange([], 'respondent', opportunity({ bid: invalid }), maximum), null);
|
||||
});
|
||||
|
||||
void test('a rejected No bid is immutable and fulfilled Yes leaves No binding for later accepted declarations', () => {
|
||||
const yes = Object.freeze(promise('yes-six', 6)), no = Object.freeze(promise('no-eight', 8, false));
|
||||
const records = Object.freeze([yes, no]); const before = structuredClone(records);
|
||||
assert.throws(() => recordPromisedBid(records, 'respondent', opportunity(), 8), BidPromiseError);
|
||||
assert.deepEqual(records, before);
|
||||
const accepted = recordPromisedBid(records, 'respondent', opportunity(), 7);
|
||||
assert.notEqual(accepted, records); assert.notEqual(accepted[0], yes); assert.equal(accepted[0].fulfilled, true);
|
||||
assert.equal(accepted[1], no); assert.equal(accepted[1].fulfilled, undefined);
|
||||
assert.equal(bidRequired(accepted, 'respondent', opportunity()), false);
|
||||
assert.deepEqual(liveBidPromises(accepted, 'respondent', opportunity()), [no]);
|
||||
assert.throws(() => recordPromisedBid(accepted, 'respondent', opportunity({ bid: 7 }), 8), BidPromiseError);
|
||||
const repeated = recordPromisedBid(accepted, 'respondent', opportunity(), 7);
|
||||
assert.equal(repeated[0], accepted[0]); assert.equal(repeated[1], no);
|
||||
assert.deepEqual(records, before);
|
||||
});
|
||||
|
||||
void test('persisted bid records require distinct events, physical sources and two actual participants with valid status', () => {
|
||||
const valid = [promise('live', 6), promise('fulfilled', 7, true, { fulfilled: true }),
|
||||
promise('released', 8, false, { released: true })];
|
||||
const before = structuredClone(valid);
|
||||
assert.doesNotThrow(() => validateBidPromises(valid, seats)); assert.deepEqual(valid, before);
|
||||
assert.doesNotThrow(() => validateBidPromises([], seats));
|
||||
const invalid: unknown[] = [
|
||||
null, {}, [null], [valid[0], { ...valid[1], event: 'live' }],
|
||||
[{ ...valid[0], event: '' }], [{ ...valid[0], source: '' }],
|
||||
[{ ...valid[0], player: 'outsider' }], [{ ...valid[0], asker: 'outsider' }],
|
||||
[{ ...valid[0], asker: 'respondent' }], [{ ...valid[0], answer: 'yes' }],
|
||||
[{ ...valid[0], fulfilled: 'true' }], [{ ...valid[0], released: 1 }],
|
||||
[{ ...valid[0], fulfilled: true, released: true }],
|
||||
[{ ...valid[0], turn: 0 }], [{ ...valid[0], lot: 0 }], [{ ...valid[0], minimum: 0 }],
|
||||
];
|
||||
for (const records of invalid)
|
||||
assert.throws(() => validateBidPromises(records as BidPromise[], seats), BidPromiseError);
|
||||
assert.deepEqual(valid, before);
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { actTruthMovement, type TruthMovementStep } from './fixture-truth-movement';
|
||||
|
||||
export type TruthBidStep = TruthMovementStep;
|
||||
export const actTruthBid = actTruthMovement;
|
||||
export const truthBidPlayer = (game: Game, actor: string) => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const truthBidClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function truthBidInventory(game: Game) {
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])];
|
||||
const ids = cards.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Each original Treachery Card keeps one physical slot');
|
||||
return ids;
|
||||
}
|
||||
export function nextTruthBid(game: Game): TruthBidStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
if (truthBidClean(game) && game.phase === 3 && game.auction) {
|
||||
const actor = game.auction.active, guide = viewGame(game, actor).bidPromiseGuide;
|
||||
return { actor, action: guide?.action ?? { type: 'passBid' } };
|
||||
}
|
||||
if (!game.phaseOpening && !game.response && game.decision?.kind === 'auctionPayment' && game.auction &&
|
||||
game.auction.bid > truthBidPlayer(game, game.decision.player).spice)
|
||||
return null; // Caller owns the actual unfunded Karama payment choice.
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceTruthBid(game: Game, until: (state: Game) => boolean, actions: TruthBidStep[] = []): Game {
|
||||
for (let count = 0; count < 900; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original bid source ended before its owned control');
|
||||
const next = nextTruthBid(game); assert.ok(next, 'The original Truthtrance or unfunded Karama choice belongs to its caller');
|
||||
game = actTruthBid(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original bid source did not reach its finite boundary');
|
||||
}
|
||||
export function continueTruthBidLot(game: Game, actions: TruthBidStep[] = []): Game {
|
||||
assert.ok(game.auction); const turn = game.turn, lot = game.auction.index + 1;
|
||||
return advanceTruthBid(game, state => state.turn !== turn || state.phase !== 3 ||
|
||||
!state.auction || state.auction.index + 1 !== lot, actions);
|
||||
}
|
||||
export function finishTruthBid(game: Game, actions: TruthBidStep[] = []): Game {
|
||||
return advanceTruthBid(game, state => state.phase === 4 && truthBidClean(state), actions);
|
||||
}
|
||||
|
||||
/** Public original H/A/E base33, Basic or Advanced preview. Only original
|
||||
* unopened starting-deal/lot/Spice positions and scalar lotteries are selected.
|
||||
* Harkonnen really receives both original consecutive Truthtrances, Atreides
|
||||
* Baliset and Emperor Shield (or original Karama). No dealt hand, purse, force,
|
||||
* auction, question, answer or promise is installed. */
|
||||
export function createTruthBidFixture(options: {
|
||||
advanced?: boolean; window?: 'waiting' | 'active'; targetKarama?: boolean;
|
||||
} = {}) {
|
||||
const advanced = options.advanced ?? false, window = options.window ?? 'waiting';
|
||||
const asker = 'harkonnen', opponent = 'atreides', target = 'emperor';
|
||||
let game = createGame('TRUTHBIDSOURCE', newPlayer(asker, 'Harkonnen', 'harkonnen'), advanced);
|
||||
joinGame(game, newPlayer(opponent, 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer(target, 'Emperor', 'emperor'));
|
||||
const initial = structuredClone(game), actions: TruthBidStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actTruthBid(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceTruthBid(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(asker, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game); assert.equal(game.status, 'setup');
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
const originalCards = truthBidInventory(game); assert.equal(originalCards.length, 33);
|
||||
const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
|
||||
const filler = game.deck.find(card => card.name === 'Baliset');
|
||||
const targetCard = game.deck.find(card => options.targetKarama ? card.effect === 'karama' : card.kind === 'shield');
|
||||
const lotCards = [game.deck.find(card => card.kind === 'projectile'), game.deck.find(card => card.kind === 'poison'),
|
||||
game.deck.find(card => card.kind === 'snooper')];
|
||||
assert.ok(filler && targetCard && lotCards.every(Boolean));
|
||||
const deal = [truths[0].id, truths[1].id, filler.id, targetCard.id];
|
||||
const prefix = [...deal, ...lotCards.map(card => card!.id)];
|
||||
const dealOrder = prefix.map((id, position) => {
|
||||
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(truthBidPlayer(game, asker).hand.map(card => card.id), truths.map(card => card.id));
|
||||
assert.deepEqual(truthBidPlayer(game, opponent).hand.map(card => card.id), [filler.id]);
|
||||
assert.deepEqual(truthBidPlayer(game, target).hand.map(card => card.id), [targetCard.id]);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
reach(state => state.turn === 1 && state.phase === 3 && !!state.auction && truthBidClean(state));
|
||||
const beforePasses = structuredClone(game);
|
||||
if (window === 'active') reach(state => state.auction?.active === target && truthBidClean(state));
|
||||
assert.equal(game.auction!.index, 0); assert.equal(game.auction!.bid, 0);
|
||||
assert.equal(game.auction!.active, window === 'active' ? target : asker);
|
||||
assert.equal(truthBidPlayer(game, target).spice, 10); assert.equal(truthBidPlayer(game, opponent).spice, 10);
|
||||
assert.equal(viewGame(game, target).bidPromiseLot?.lot, 1);
|
||||
assert.deepEqual(truthBidInventory(game), originalCards);
|
||||
const question = { kind: 'bid' as const, target, turn: game.turn, lot: game.auction!.index + 1, minimum: 4 };
|
||||
return { game, sourceGame: structuredClone(game), initial, lobby, setup, beforeStartingDeal, afterSetup, beforePasses,
|
||||
advanced, window, asker, opponent, target, actor: asker, truth: truths[0].id, card: truths[0].id,
|
||||
truths: truths.map(card => card.id), targetCard: targetCard.id, karama: options.targetKarama ? targetCard.id : null,
|
||||
question, declarationAction: { type: 'card', card: truths[0].id } as Action,
|
||||
questionAction: { type: 'truthAsk', question } as Action,
|
||||
lotCard: game.auction!.cards[game.auction!.index].id, lotCards: lotCards.map(card => card!.id),
|
||||
deal, dealOrder, spiceOrder, originalSpice, originalCards, originalInventory: originalCards,
|
||||
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
|
||||
staging: [] as string[], sourceDisclosure: 'Genuine public H/A/E base33 setup. Only original unopened four-card starting deal, three ordinary lot positions, two ordinary Spice positions and scalar lotteries are selected. Harkonnen owns both physical Truthtrances; active Emperor additionally follows real H/A passes. No played hand, wallet, force, lot, Truthtrance or promise is installed.' };
|
||||
}
|
||||
export type TruthBidFixture = ReturnType<typeof createTruthBidFixture>;
|
||||
|
||||
export function declareTruthBid(f: TruthBidFixture, state = f.game, options: { copies?: 1 | 2 } = {}) {
|
||||
let game = state; const before = structuredClone(game), actions: TruthBidStep[] = [];
|
||||
const declaration: Action = { type: 'card', card: f.truths[0], ...(options.copies === 2 ? { cards: [f.truths[1]] } : {}) };
|
||||
game = actTruthBid(game, f.asker, declaration, actions);
|
||||
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actTruthBid(game, actor.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return { game, before, declaration, actions, actionCount: actions.length };
|
||||
}
|
||||
export function askTruthBid(game: Game, asker: string, target: string, minimum = 4, actions: TruthBidStep[] = []): Game {
|
||||
assert.ok(game.auction);
|
||||
return actTruthBid(game, asker, { type: 'truthAsk', question: {
|
||||
kind: 'bid', target, turn: game.turn, lot: game.auction.index + 1, minimum,
|
||||
} }, actions);
|
||||
}
|
||||
export function answerTruthBid(game: Game, target: string, answer: 'yes' | 'no', actions: TruthBidStep[] = []): Game {
|
||||
return actTruthBid(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
export function bindTruthBid(f: TruthBidFixture, answer: 'yes' | 'no' = 'yes', minimum = 4, state = f.game) {
|
||||
const declared = declareTruthBid(f, state), actions = [...declared.actions];
|
||||
const asked = askTruthBid(declared.game, f.asker, f.target, minimum, actions);
|
||||
const game = answerTruthBid(asked, f.target, answer, actions);
|
||||
return { game, declared, asked, actions, actionCount: actions.length };
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { BidClaimFields, bidClaimInputError } from '../components/bid-claim-fields';
|
||||
import { BidPromises, BidPromiseGuideButton } from '../components/bid-promises';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import type { BidClaim } from '../game/bid-promises';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { createTruthBidFixture, bindTruthBid, actTruthBid, truthBidInventory } from './fixture-truth-bid';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
||||
},
|
||||
});
|
||||
const { Truthtrance } = await import('../components/truthtrance');
|
||||
aliases.deregister();
|
||||
|
||||
type ElementProps = { children?: ReactNode; [key: string]: unknown };
|
||||
type Element = ReactElement<ElementProps>;
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<ElementProps>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
function edit(props: Parameters<typeof BidClaimFields>[0], minimum: number) {
|
||||
const input = elements(BidClaimFields(props)).find(element => element.props.id === 'truth-bid-minimum' && typeof element.props.onChange === 'function');
|
||||
assert.ok(input);
|
||||
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber: minimum } });
|
||||
}
|
||||
function guide(props: Parameters<typeof BidPromiseGuideButton>[0]) {
|
||||
const button = BidPromiseGuideButton(props); assert.ok(button);
|
||||
return button;
|
||||
}
|
||||
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
|
||||
const markup = (game: GameView, busy = false) => renderToStaticMarkup(createElement(BidPromises, { game, busy, send() {} }));
|
||||
|
||||
/** Controlled personalized projections exercise UI guards only. The native
|
||||
* producer below supplies earned questions, answers and ordinary lot actions. */
|
||||
function projected(action: 'bid' | 'passBid' = 'bid'): GameView {
|
||||
const source = createGame('TRUTHBIDCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
|
||||
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
|
||||
const game = viewGame(source, 'p');
|
||||
game.status = 'playing'; game.phase = 3; game.turn = 2;
|
||||
game.auction = { bid: 1, bidder: 'a', active: 'p', remaining: 2, inspectionBlocked: null, card: null };
|
||||
game.bidPromiseLot = { turn: 2, lot: 1 };
|
||||
game.bidPromises = [{ event: 'controlled-bid-event', player: 'p', asker: 'a', turn: 2, lot: 1,
|
||||
minimum: 4, answer: action === 'bid' }];
|
||||
game.bidPromiseGuide = { action: action === 'bid'
|
||||
? { type: 'bid', amount: 4, allyPayment: 1, bidPromiseEvent: 'controlled-bid-event' }
|
||||
: { type: 'passBid', bidPromiseEvent: 'controlled-bid-event' } };
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('bid claim fields keep the displayed turn and lot fixed and accept only parser-valid whole minimum edits', () => {
|
||||
const value: BidClaim = { turn: 2, lot: 3, minimum: 4 }, changes: BidClaim[] = [];
|
||||
const props = { value, onChange(next: BidClaim) { changes.push(next); } };
|
||||
const html = renderToStaticMarkup(createElement(BidClaimFields, props));
|
||||
assert.match(html, /Turn 2 · Ordinary lot 3/); assert.match(html, /Minimum bid in spice/);
|
||||
assert.match(html, /min="1"/); assert.match(html, /max="9007199254740991"/);
|
||||
assert.match(html, /If you win, the original payment rules still apply/);
|
||||
assert.match(html, /optional modules and native special auctions are excluded/);
|
||||
assert.equal(elements(BidClaimFields(props)).filter(element => typeof element.props.onChange === 'function').length, 1);
|
||||
for (const minimum of [1, 7, Number.MAX_SAFE_INTEGER]) edit(props, minimum);
|
||||
assert.deepEqual(changes, [1, 7, Number.MAX_SAFE_INTEGER].map(minimum => ({ ...value, minimum })));
|
||||
for (const minimum of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
assert.ok(bidClaimInputError({ ...value, minimum })); edit(props, minimum);
|
||||
}
|
||||
assert.equal(changes.length, 3);
|
||||
edit({ ...props, disabled: true }, 8); assert.equal(changes.length, 3);
|
||||
edit({ ...props, value: { ...value, lot: 0 } }, 8); assert.equal(changes.length, 3);
|
||||
assert.match(renderToStaticMarkup(createElement(BidClaimFields, { ...props, value: { ...value, minimum: 0 } })), /role="alert"/);
|
||||
});
|
||||
|
||||
void test('own bid commitments remain visible with exact Yes/No meaning, completion and release statuses', () => {
|
||||
const game = projected();
|
||||
game.bidPromises.push(
|
||||
{ event: 'no-event', player: 'p', asker: 'a', turn: 2, lot: 1, minimum: 8, answer: false },
|
||||
{ event: 'fulfilled-event', player: 'p', asker: 'a', turn: 2, lot: 1, minimum: 2, answer: true, fulfilled: true },
|
||||
{ event: 'released-event', player: 'p', asker: 'a', turn: 2, lot: 1, minimum: 12, answer: true, released: true },
|
||||
{ event: 'foreign-event', player: 'a', asker: 'p', turn: 2, lot: 1, minimum: 99, answer: true },
|
||||
);
|
||||
game.auction!.active = 'a'; game.bidPromiseGuide = null;
|
||||
const before = structuredClone(game);
|
||||
for (let render = 0; render < 2; render++) {
|
||||
const html = markup(game);
|
||||
assert.match(html, /Your bid promises/); assert.match(html, /ordinary lot 1/);
|
||||
assert.match(html, /Yes requires one qualifying bid/); assert.match(html, /No forbids every later bid at or above/);
|
||||
assert.match(html, /If you win, the original payment rules still apply/);
|
||||
assert.match(html, /Fulfilled: this commitment is complete/); assert.match(html, /Released: this commitment is no longer possible/);
|
||||
assert.match(html, /Your next bid opportunity is still ahead/);
|
||||
assert.doesNotMatch(html, /99 spice|foreign-event|controlled-bid-event/);
|
||||
}
|
||||
assert.deepEqual(game, before);
|
||||
assert.equal(BidPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
|
||||
});
|
||||
|
||||
void test('the actual guide callback forwards bid or pass with the authoritative funding and event fields unchanged', () => {
|
||||
for (const type of ['bid', 'passBid'] as const) {
|
||||
const game = projected(type), sent: Action[] = [];
|
||||
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
|
||||
const button = guide(props); press(button);
|
||||
assert.deepEqual(sent, [game.bidPromiseGuide!.action]); assert.equal(sent[0], game.bidPromiseGuide!.action);
|
||||
assert.equal(button.props.disabled, false);
|
||||
press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('busy and stale guide callbacks cannot cross current owner, lot, event, stage or continuation boundaries', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.me = 'a'; }, game => { game.auction!.active = 'a'; },
|
||||
game => { game.turn = 3; }, game => { game.phase = 5; }, game => { game.status = 'setup'; },
|
||||
game => { game.bidPromiseLot = { turn: 2, lot: 2 }; }, game => { game.bidPromiseLot = null; },
|
||||
game => { game.bidPromiseGuide = { action: { ...game.bidPromiseGuide!.action } }; },
|
||||
game => { game.bidPromiseGuide = null; }, game => { game.bidPromiseGuide!.action.amount = 5; },
|
||||
game => { game.bidPromises[0].event = 'another-event'; }, game => { game.bidPromises[0].player = 'a'; },
|
||||
game => { game.bidPromises[0].fulfilled = true; }, game => { game.bidPromises[0].released = true; },
|
||||
game => { game.bidPromises = []; }, game => { game.auction = null; },
|
||||
game => { game.phaseOpening = { passed: [] }; },
|
||||
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||||
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||||
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||||
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||||
game => { game.techTokens = createTechTokens(); },
|
||||
game => { game.automaticContinuationPending = true; },
|
||||
game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
|
||||
game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(), sent: Action[] = [];
|
||||
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
|
||||
const button = guide(props); change(game); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
const props = { game: projected(), busy: false, send() { assert.fail('A now-busy guide cannot dispatch'); } };
|
||||
const button = guide(props); props.busy = true; press(button);
|
||||
});
|
||||
|
||||
void test('bid controls use the public lot and own guide without reading card faces, source fingerprints or rival holdings', () => {
|
||||
const game = projected(), sent: Action[] = [];
|
||||
for (const player of game.players) {
|
||||
for (const key of ['hand', 'traitors', 'faceDancers']) Object.defineProperty(player, key, {
|
||||
get() { throw Error(`Bid controls cannot inspect ${key}`); }, configurable: true,
|
||||
});
|
||||
}
|
||||
Object.defineProperty(game.auction!, 'card', { get() { throw Error('No ordinary-lot face inspection'); } });
|
||||
Object.defineProperty(game.bidPromises[0], 'source', { get() { throw Error('No internal bid source inspection'); } });
|
||||
assert.match(markup(game), /Bid 4 spice while honoring your answers/);
|
||||
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
|
||||
assert.deepEqual(sent, [game.bidPromiseGuide!.action]);
|
||||
});
|
||||
|
||||
void test('source-earned Basic and Advanced current-lot answers connect persistent guidance to the actual qualifying bid callback', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = createTruthBidFixture({ advanced, window: 'active' }), bound = bindTruthBid(f, 'yes', 4);
|
||||
const respondent = viewGame(bound.asked, f.target);
|
||||
assert.ok(respondent.truthBidAnswers?.includes('yes'));
|
||||
assert.equal(viewGame(bound.asked, f.asker).truthBidAnswers, null);
|
||||
const answerHtml = renderToStaticMarkup(createElement(Truthtrance, { game: respondent, busy: false, act() {} }));
|
||||
assert.match(answerHtml, /Answer Yes/); assert.match(answerHtml, /Answer No/);
|
||||
const game = viewGame(bound.game, f.target), sent: Action[] = [];
|
||||
assert.deepEqual(game.bidPromiseLot, { turn: bound.game.turn, lot: 1 });
|
||||
assert.match(markup(game), /Your bid promises/); assert.match(markup(game), /Bid 4 spice/);
|
||||
const next = game.bidPromiseGuide; assert.ok(next);
|
||||
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
|
||||
assert.deepEqual(sent, [next.action]);
|
||||
const after = actTruthBid(bound.game, f.target, sent[0]);
|
||||
const event = next.action.bidPromiseEvent; assert.equal(typeof event, 'string');
|
||||
assert.equal(after.bidPromises?.find(promise => promise.event === event)?.fulfilled, true);
|
||||
assert.equal(after.players.find(player => player.id === f.target)!.spice, 10,
|
||||
'The qualifying bid fulfills Yes before any later winning invoice is paid.');
|
||||
assert.deepEqual(truthBidInventory(after), f.originalCards);
|
||||
assert.equal(BidPromises({ game: viewGame(after, f.asker), busy: false, send() {} }), null);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user