Files
Dune/tests/revival-promises.test.ts

196 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import test from 'node:test';
import {
RevivalPromiseError,
liveRevivalPromises,
parseRevivalClaim,
recordPromisedRevival,
revivalClaimText,
revivalPromiseCount,
revivalPromiseModeSupported,
revivalPromisesSatisfied,
validateRevivalPromises,
type RevivalClaim,
type RevivalPromise,
} from '../game/revival-promises';
const seats = ['respondent', 'asker', 'rival'] as const;
function promise(event: string, minimum: number, extra: Partial<RevivalPromise> = {}): RevivalPromise {
return { event, turn: 2, counter: 'total', minimum, player: 'respondent', asker: 'asker',
answer: true, progress: 0, ...extra };
}
void test('revival claims retain only the current-turn physical counter and positive bounded threshold', () => {
const input = { turn: 2, counter: 'normal', minimum: 4, privateTanks: 12, priorReturns: 3 };
assert.deepEqual(parseRevivalClaim(input), { turn: 2, counter: 'normal', minimum: 4 });
for (const counter of ['total', 'normal', 'elite'] as const) {
assert.deepEqual(parseRevivalClaim({ turn: 1, counter, minimum: 1 }), { turn: 1, counter, minimum: 1 });
assert.deepEqual(parseRevivalClaim({ turn: Number.MAX_SAFE_INTEGER, counter, minimum: 20 }),
{ turn: Number.MAX_SAFE_INTEGER, counter, minimum: 20 });
}
const valid = { turn: 2, counter: 'total', minimum: 4 };
for (const turn of [0, -1, 1.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '2', null, undefined])
assert.throws(() => parseRevivalClaim({ ...valid, turn }), RevivalPromiseError);
for (const minimum of [0, -1, 1.5, 21, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER, '4', null, undefined])
assert.throws(() => parseRevivalClaim({ ...valid, minimum }), RevivalPromiseError);
for (const counter of ['forces', 'sardaukar', '', 1, null, undefined])
assert.throws(() => parseRevivalClaim({ ...valid, counter }), RevivalPromiseError);
for (const value of [null, undefined, [], 'four', 4]) assert.throws(() => parseRevivalClaim(value), RevivalPromiseError);
assert.match(revivalClaimText(parseRevivalClaim(input)), /at least 4 normal forces/);
assert.match(revivalClaimText(parseRevivalClaim(input)), /ordinary force revival from now/);
assert.match(revivalClaimText(parseRevivalClaim(input)), /your Revival opportunity ends this turn/);
});
void test('revival promise scope preserves bare registered native Basic and Advanced selections and optional-module fences', () => {
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(revivalPromiseModeSupported(game), true);
for (const option of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery',
'semutaPreview', 'kullPreview', 'nexusKullPreview', 'richeseBetrayalPreview'] as const)
assert.equal(revivalPromiseModeSupported({ ...native, [option]: true }), false, option);
for (const game of [
{ ...native, expansions: ['ix', 'choam'] }, { ...native, expansions: ['ix', 'ix', 'ecaz'] },
{ ...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(revivalPromiseModeSupported(game), false);
});
void test('typed revival counts physical elites once and separates total, normal and elite quantities', () => {
const group = { amount: 3, elite: 2 };
const expected = { total: 3, normal: 1, elite: 2 };
for (const counter of ['total', 'normal', 'elite'] as const) {
const claim: RevivalClaim = { turn: 2, counter, minimum: 1 };
assert.equal(revivalPromiseCount(claim, group), expected[counter]);
assert.equal(revivalPromiseCount(claim, { amount: 0, elite: 0 }), 0);
}
assert.equal(revivalPromiseCount({ turn: 2, counter: 'normal', minimum: 20 }, { amount: 20, elite: 0 }), 20);
assert.equal(revivalPromiseCount({ turn: 2, counter: 'total', minimum: 2 }, { amount: 2, elite: 2 }), 2);
for (const group of [{ amount: -1, elite: 0 }, { amount: 21, elite: 0 }, { amount: 1.5, elite: 0 },
{ amount: Infinity, elite: 0 }, { amount: 2, elite: -1 }, { amount: 2, elite: 3 },
{ amount: 2, elite: 0.5 }, { amount: 2, elite: Number.NaN }]) {
assert.throws(() => revivalPromiseCount({ turn: 2, counter: 'total', minimum: 1 }, group), RevivalPromiseError);
assert.throws(() => recordPromisedRevival([], 'respondent', 2, group), RevivalPromiseError);
assert.throws(() => revivalPromisesSatisfied([], 'respondent', 2, group), RevivalPromiseError);
}
});
void test('future ordinary requests accumulate after each answer while prior completed returns do not satisfy a new promise', () => {
const original = [promise('first-answer', 5)];
const first = recordPromisedRevival(original, 'respondent', 2, { amount: 2, elite: 1 });
assert.equal(first[0].progress, 2); assert.equal(first[0].fulfilled, undefined);
const second = recordPromisedRevival(first, 'respondent', 2, { amount: 3, elite: 0 });
assert.equal(second[0].progress, 5); assert.equal(second[0].fulfilled, true);
const later = [...second, promise('later-answer', 2)];
assert.equal(revivalPromisesSatisfied(later, 'respondent', 2, { amount: 0, elite: 0 }), false,
'Five earlier returns are outside the new answer’s future progress');
const third = recordPromisedRevival(later, 'respondent', 2, { amount: 1, elite: 0 });
assert.equal(third[0], second[0]); assert.equal(third[1].progress, 1); assert.equal(third[1].fulfilled, undefined);
const fourth = recordPromisedRevival(third, 'respondent', 2, { amount: 1, elite: 0 });
assert.equal(fourth[1].progress, 2); assert.equal(fourth[1].fulfilled, true);
assert.equal(original[0].progress, 0); assert.equal(first[0].progress, 2);
});
void test('one hypothetical completion must satisfy total, normal, elite and No answers together', () => {
const records = [promise('total-four', 4), promise('normal-three', 3, { counter: 'normal' }),
promise('elite-one', 1, { counter: 'elite' }), promise('not-two-elites', 2, { counter: 'elite', answer: false })];
assert.equal(revivalPromisesSatisfied(records, 'respondent', 2, { amount: 4, elite: 1 }), true);
assert.equal(revivalPromisesSatisfied(records, 'respondent', 2, { amount: 4, elite: 2 }), false,
'Four total cannot compensate for too few normals or a breached elite cap');
assert.equal(revivalPromisesSatisfied(records, 'respondent', 2, { amount: 4, elite: 0 }), false);
const split = recordPromisedRevival(records, 'respondent', 2, { amount: 2, elite: 1 });
assert.deepEqual(split.map(record => record.progress), [2, 1, 1, 1]);
assert.equal(revivalPromisesSatisfied(split, 'respondent', 2, { amount: 2, elite: 0 }), true);
const completed = recordPromisedRevival(split, 'respondent', 2, { amount: 2, elite: 0 });
assert.deepEqual(completed.map(record => !!record.fulfilled), [true, true, true, false]);
assert.equal(completed[3].progress, 1);
const conflict = [promise('two-elites', 2, { counter: 'elite' }),
promise('below-two-elites', 2, { counter: 'elite', answer: false })];
assert.equal(revivalPromisesSatisfied(conflict, 'respondent', 2, { amount: 2, elite: 2 }), false);
assert.equal(revivalPromisesSatisfied(conflict, 'respondent', 2, { amount: 2, elite: 1 }), false);
});
void test('No forbids crossing a cumulative cap through smaller split requests and rejection leaves every record unchanged', () => {
const initial = [promise('need-three-normal', 3, { counter: 'normal' }), promise('below-four-total', 4, { answer: false })];
const first = recordPromisedRevival(initial, 'respondent', 2, { amount: 2, elite: 1 });
const second = recordPromisedRevival(first, 'respondent', 2, { amount: 1, elite: 0 });
assert.deepEqual(second.map(record => record.progress), [2, 3]);
const before = structuredClone(second); second.forEach(Object.freeze); Object.freeze(second);
assert.throws(() => recordPromisedRevival(second, 'respondent', 2, { amount: 1, elite: 0 }), RevivalPromiseError);
assert.deepEqual(second, before, 'A disallowed fourth total return cannot partly fulfill the normal Yes');
assert.equal(revivalPromisesSatisfied(second, 'respondent', 2, { amount: 1, elite: 0 }), false);
assert.equal(initial[0].progress, 0); assert.equal(initial[1].progress, 0);
});
void test('fulfilled and released answers retain their exact records while an unmatched typed counter is a no-op', () => {
const fulfilled = promise('fulfilled', 2, { progress: 2, fulfilled: true });
const released = promise('released', 2, { progress: 1, released: true });
const elite = promise('still-needs-elite', 1, { counter: 'elite' });
const no = promise('continuing-no', 4, { answer: false });
const records = Object.freeze([fulfilled, released, elite, no]); records.forEach(Object.freeze);
const next = recordPromisedRevival(records, 'respondent', 2, { amount: 2, elite: 0 });
assert.equal(next[0], fulfilled); assert.equal(next[1], released); assert.equal(next[2], elite);
assert.notEqual(next[3], no); assert.equal(next[3].progress, 2); assert.equal(next[3].fulfilled, undefined);
assert.deepEqual(liveRevivalPromises(next, 'respondent', 2), [elite, next[3]]);
});
void test('current turn and respondent isolate progress while different askers retain jointly binding answers', () => {
const own = promise('own', 3), secondAsker = promise('rival-asker', 5, { asker: 'rival', answer: false });
const otherTurn = promise('old-turn', 1, { turn: 1 });
const otherPlayer = promise('other-player', 1, { player: 'rival' });
const records = [own, secondAsker, otherTurn, otherPlayer];
assert.deepEqual(liveRevivalPromises(records, 'respondent', 2), [own, secondAsker]);
assert.equal(revivalPromisesSatisfied(records, 'respondent', 2, { amount: 3, elite: 0 }), true);
const next = recordPromisedRevival(records, 'respondent', 2, { amount: 3, elite: 0 });
assert.equal(next[0].fulfilled, true); assert.equal(next[1].progress, 3);
assert.equal(next[2], otherTurn); assert.equal(next[3], otherPlayer);
const unrelated = recordPromisedRevival(records, 'absent', 2, { amount: 1, elite: 0 });
records.forEach((record, index) => assert.equal(unrelated[index], record));
});
void test('zero final additions distinguish outstanding Yes from satisfied No and progress cannot exceed twenty physical returns', () => {
const no = [promise('below-two', 2, { answer: false, progress: 1 })];
assert.equal(revivalPromisesSatisfied(no, 'respondent', 2, { amount: 0, elite: 0 }), true);
const yes = [promise('one-remaining', 20, { progress: 19 })];
assert.equal(revivalPromisesSatisfied(yes, 'respondent', 2, { amount: 0, elite: 0 }), false);
const zero = recordPromisedRevival(yes, 'respondent', 2, { amount: 0, elite: 0 });
assert.equal(zero[0], yes[0]);
assert.equal(revivalPromisesSatisfied(yes, 'respondent', 2, { amount: 1, elite: 0 }), true);
const completed = recordPromisedRevival(yes, 'respondent', 2, { amount: 1, elite: 0 });
assert.equal(completed[0].progress, 20); assert.equal(completed[0].fulfilled, true);
assert.equal(revivalPromisesSatisfied(yes, 'respondent', 2, { amount: 2, elite: 0 }), false);
assert.throws(() => recordPromisedRevival(yes, 'respondent', 2, { amount: 2, elite: 0 }), RevivalPromiseError);
assert.equal(revivalPromisesSatisfied(completed, 'respondent', 2, { amount: 0, elite: 0 }), true);
assert.equal(revivalPromisesSatisfied([], 'respondent', 2, { amount: 0, elite: 0 }), true);
});
void test('record validation binds original events, participants and consistent cumulative terminal states', () => {
const valid = [promise('live-yes', 3, { progress: 2 }),
promise('fulfilled-yes', 3, { progress: 3, fulfilled: true }),
promise('released-yes', 3, { progress: 1, released: true }),
promise('live-no', 3, { progress: 2, answer: false }),
promise('completed-no', 3, { progress: 2, answer: false, fulfilled: true }),
promise('released-no', 3, { progress: 2, answer: false, released: true })];
const before = structuredClone(valid);
assert.doesNotThrow(() => validateRevivalPromises(valid, seats)); assert.deepEqual(valid, before);
assert.doesNotThrow(() => validateRevivalPromises(JSON.parse(JSON.stringify(valid)) as RevivalPromise[], seats));
assert.doesNotThrow(() => validateRevivalPromises([], seats));
const base = promise('event', 3);
const invalid: unknown[] = [null, {}, [null], [base, { ...base }],
...[{ event: '' }, { event: ' ' }, { player: 'outsider' }, { asker: 'outsider' }, { asker: 'respondent' },
{ answer: 'yes' }, { progress: -1 }, { progress: 21 }, { progress: 1.5 }, { progress: Number.NaN },
{ progress: Number.MAX_SAFE_INTEGER + 1 }, { fulfilled: 1 }, { released: 'true' },
{ fulfilled: true, progress: 2 }, { fulfilled: false, progress: 3 }, { progress: 3 },
{ fulfilled: true, released: true, progress: 3 }, { answer: false, progress: 3 },
{ answer: false, progress: 3, fulfilled: true }, { answer: false, progress: 3, released: true },
{ turn: 0 }, { minimum: 21 }, { counter: 'sardaukar' }].map(extra => [{ ...base, ...extra }])];
for (const records of invalid)
assert.throws(() => validateRevivalPromises(records as RevivalPromise[], seats), RevivalPromiseError);
assert.deepEqual(valid, before);
});