184 lines
12 KiB
TypeScript
184 lines
12 KiB
TypeScript
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);
|
|
});
|