Support grouped Truthtrance revival commitments
This commit is contained in:
1 parent
a76effb932
commit
f5ae61f382
20 files changed
+1730
-30
No files matched your search
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { parseRevivalExpression, type RevivalExpression } from '../game/revival-expression-promises';
|
||||
import {
|
||||
actTruthLeaderRevival, createTruthLeaderRevivalFixture, declareTruthLeaderRevival,
|
||||
nextTruthLeaderRevival, truthLeaderRevivalClean, truthLeaderRevivalInventory,
|
||||
truthLeaderRevivalPlayer, type TruthLeaderRevivalStep,
|
||||
} from './fixture-truth-leader-revival';
|
||||
|
||||
export type TruthRevivalExpressionStep = TruthLeaderRevivalStep;
|
||||
export const actTruthRevivalExpression = actTruthLeaderRevival;
|
||||
export const truthRevivalExpressionClean = truthLeaderRevivalClean;
|
||||
export const truthRevivalExpressionInventory = truthLeaderRevivalInventory;
|
||||
export const truthRevivalExpressionPlayer = truthLeaderRevivalPlayer;
|
||||
|
||||
export function nextTruthRevivalExpression(game: Game): TruthRevivalExpressionStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
for (const player of game.players) {
|
||||
const guide = viewGame(game, player.id).revivalExpressionPromiseGuide;
|
||||
if (guide?.phase === game.phase) return { actor: player.id, action: guide.action };
|
||||
}
|
||||
return nextTruthLeaderRevival(game);
|
||||
}
|
||||
export function advanceTruthRevivalExpression(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthRevivalExpressionStep[] = []): Game {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextTruthRevivalExpression(game); assert.ok(step, 'The original grouped Revival source needs a legal native step');
|
||||
game = actTruthRevivalExpression(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original grouped Revival source did not reach its finite boundary');
|
||||
}
|
||||
export function settleTruthRevivalExpression(game: Game, actions: TruthRevivalExpressionStep[] = []): Game {
|
||||
return advanceTruthRevivalExpression(game, truthRevivalExpressionClean, actions);
|
||||
}
|
||||
export function finishTruthRevivalExpression(game: Game, actions: TruthRevivalExpressionStep[] = []): Game {
|
||||
const turn = game.turn;
|
||||
return advanceTruthRevivalExpression(game,
|
||||
state => state.turn === turn && state.phase === 5 && truthRevivalExpressionClean(state), actions);
|
||||
}
|
||||
|
||||
/** Thin source adapter: the complete public H/A/T Ix47 setup, original deals,
|
||||
* real paid shipment/projectile death and next Storm are inherited unchanged.
|
||||
* The question alone changes; no physical state or promise is installed. */
|
||||
export function createTruthRevivalExpressionFixture(options: {
|
||||
advanced?: boolean; window?: 'active' | 'waiting'; ghola?: boolean; bothDead?: boolean;
|
||||
claim?: RevivalExpression;
|
||||
} = {}) {
|
||||
const { claim: suppliedClaim, ...sourceOptions } = options;
|
||||
const base = createTruthLeaderRevivalFixture(sourceOptions);
|
||||
const claim = parseRevivalExpression(suppliedClaim ?? { kind: 'and', terms: [
|
||||
{ kind: 'forces', counter: 'total', minimum: 1 }, { kind: 'leader', leader: base.leader },
|
||||
] });
|
||||
const question = { kind: 'revivalExpression' as const, target: base.target, turn: base.game.turn, claim };
|
||||
return { ...base, base, claim, question, questionAction: { type: 'truthAsk', question } as Action,
|
||||
postStormSourceActions: structuredClone(base.sourceActions.slice(0, base.postStormActionCount)),
|
||||
phase3SourceActions: structuredClone(base.sourceActions.slice(0, base.phase3ActionCount)),
|
||||
sourceDisclosure: `${base.sourceDisclosure} This adapter inherits the exact original action prefix and changes only the grouped ordinary Revival question; its progress starts after the actual answer.` };
|
||||
}
|
||||
export type TruthRevivalExpressionFixture = ReturnType<typeof createTruthRevivalExpressionFixture>;
|
||||
export function declareTruthRevivalExpression(f: TruthRevivalExpressionFixture, state = f.game,
|
||||
options: { copies?: 1 | 2 } = {}) {
|
||||
return declareTruthLeaderRevival(f.base, state, options);
|
||||
}
|
||||
export function askTruthRevivalExpression(game: Game, asker: string, target: string, claim: RevivalExpression,
|
||||
actions: TruthRevivalExpressionStep[] = []): Game {
|
||||
return actTruthRevivalExpression(game, asker, { type: 'truthAsk', question: {
|
||||
kind: 'revivalExpression', target, turn: game.turn, claim: parseRevivalExpression(claim),
|
||||
} }, actions);
|
||||
}
|
||||
export function answerTruthRevivalExpression(game: Game, target: string, answer: 'yes' | 'no',
|
||||
actions: TruthRevivalExpressionStep[] = []): Game {
|
||||
return actTruthRevivalExpression(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
export function bindTruthRevivalExpression(f: TruthRevivalExpressionFixture, answer: 'yes' | 'no' = 'yes',
|
||||
claim = f.claim, state = f.game) {
|
||||
const declared = declareTruthRevivalExpression(f, state), actions = [...declared.actions];
|
||||
const asked = askTruthRevivalExpression(declared.game, f.asker, f.target, claim, actions);
|
||||
const game = answerTruthRevivalExpression(asked, f.target, answer, actions);
|
||||
return { game, declared, asked, actions, actionCount: actions.length };
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
REVIVAL_EXPRESSION_MAX_DEPTH,
|
||||
REVIVAL_EXPRESSION_MAX_LEAVES,
|
||||
RevivalExpressionPromiseError,
|
||||
evaluateRevivalExpression,
|
||||
liveRevivalExpressionPromises,
|
||||
parseRevivalExpression,
|
||||
parseRevivalExpressionClaim,
|
||||
recordRevivalExpressionReturn,
|
||||
revivalExpressionBranches,
|
||||
revivalExpressionPromiseModeSupported,
|
||||
revivalExpressionText,
|
||||
validateRevivalExpressionPromises,
|
||||
type RevivalExpression,
|
||||
type RevivalExpressionProgress,
|
||||
type RevivalExpressionPromise,
|
||||
} from '../game/revival-expression-promises';
|
||||
import { revivalPromiseModeSupported } from '../game/revival-promises';
|
||||
|
||||
const seats = ['respondent', 'asker', 'other-player'];
|
||||
const forces = (minimum = 3, counter: 'total' | 'normal' | 'elite' = 'total'): RevivalExpression =>
|
||||
({ kind: 'forces', counter, minimum });
|
||||
const leader = (id = 'leader-a'): RevivalExpression => ({ kind: 'leader', leader: id });
|
||||
const group = (kind: 'and' | 'or', ...terms: RevivalExpression[]): RevivalExpression => ({ kind, terms });
|
||||
const zero = (): RevivalExpressionProgress => ({ total: 0, normal: 0, elite: 0, leaders: [] });
|
||||
const promise = (event: string, claim: RevivalExpression = forces(), extra: Partial<RevivalExpressionPromise> = {}): RevivalExpressionPromise =>
|
||||
({ event, turn: 2, claim, player: 'respondent', asker: 'asker', answer: true, progress: zero(), ...extra });
|
||||
|
||||
void test('revival expressions accept all sixteen leaves and four group levels with independent canonical copies', () => {
|
||||
assert.equal(REVIVAL_EXPRESSION_MAX_DEPTH, 4); assert.equal(REVIVAL_EXPRESSION_MAX_LEAVES, 16);
|
||||
const sixteen = group('and', ...Array.from({ length: 16 }, (_, index) => leader(`leader-${index}`)));
|
||||
const parsed = parseRevivalExpression(sixteen);
|
||||
assert.deepEqual(parsed, sixteen); assert.notEqual(parsed, sixteen);
|
||||
if ('terms' in parsed && 'terms' in sixteen) {
|
||||
assert.notEqual(parsed.terms, sixteen.terms); assert.notEqual(parsed.terms[0], sixteen.terms[0]);
|
||||
}
|
||||
const nested = (depth: number): RevivalExpression => depth === 0 ? forces(1) : group('or', leader(`level-${depth}`), nested(depth - 1));
|
||||
assert.deepEqual(parseRevivalExpression(nested(4)), nested(4));
|
||||
assert.throws(() => parseRevivalExpression(nested(5)), RevivalExpressionPromiseError);
|
||||
assert.throws(() => parseRevivalExpression(group('or', ...Array.from({ length: 17 }, (_, index) => leader(`leader-${index}`)))),
|
||||
RevivalExpressionPromiseError);
|
||||
assert.deepEqual(parseRevivalExpressionClaim({ turn: Number.MAX_SAFE_INTEGER, claim: leader('kwisatz') }),
|
||||
{ turn: Number.MAX_SAFE_INTEGER, claim: leader('kwisatz') });
|
||||
});
|
||||
|
||||
void test('strict parsing checks malformed decisive branches, exact fields and every threshold before evaluation', () => {
|
||||
const invalid: unknown[] = [null, [], {}, { kind: 'and', terms: [] }, { kind: 'or', terms: [leader()] },
|
||||
{ kind: 'and', terms: [leader(), forces()], extra: true }, { kind: 'leader', leader: ' leader-a' },
|
||||
{ kind: 'leader', leader: '' }, { kind: 'leader', leader: 'leader-a', cost: 2 },
|
||||
{ kind: 'forces', counter: 'total', minimum: 1, progress: 0 },
|
||||
...[0, 21, 1.5, Number.NaN, Infinity, '3'].map(minimum => ({ kind: 'forces', counter: 'total', minimum })),
|
||||
{ kind: 'forces', counter: 'sardaukar', minimum: 1 }, { kind: 'not', terms: [leader(), forces()] }];
|
||||
for (const input of invalid) assert.throws(() => parseRevivalExpression(input), RevivalExpressionPromiseError);
|
||||
const decisive = { kind: 'or', terms: [leader(), { kind: 'forces', counter: 'total', minimum: 0 }] };
|
||||
assert.throws(() => evaluateRevivalExpression(decisive as RevivalExpression,
|
||||
{ ...zero(), leaders: ['leader-a'] }), RevivalExpressionPromiseError,
|
||||
'A true first branch does not hide a malformed later branch');
|
||||
for (const input of [{ turn: 0, claim: forces() }, { turn: '2', claim: forces() },
|
||||
{ turn: 2, claim: forces(), target: 'respondent' }, { turn: 2 }, { claim: forces() }])
|
||||
assert.throws(() => parseRevivalExpressionClaim(input), RevivalExpressionPromiseError);
|
||||
});
|
||||
|
||||
void test('evaluation and public text keep nested Boolean meaning and count each elite as one physical force', () => {
|
||||
const claim = group('and', forces(3), group('or', forces(2, 'normal'), leader('kwisatz')));
|
||||
const twoElites = { total: 3, normal: 1, elite: 2, leaders: [] };
|
||||
assert.equal(evaluateRevivalExpression(claim, twoElites), false);
|
||||
assert.equal(evaluateRevivalExpression(claim, { ...twoElites, leaders: ['kwisatz'] }), true);
|
||||
assert.equal(evaluateRevivalExpression(claim, { total: 3, normal: 2, elite: 1, leaders: [] }), true);
|
||||
assert.equal(evaluateRevivalExpression(forces(2, 'elite'), twoElites), true);
|
||||
assert.equal(evaluateRevivalExpression(forces(2, 'normal'), twoElites), false);
|
||||
assert.equal(revivalExpressionText(claim),
|
||||
'(revive at least 3 physical forces through ordinary force revival AND (revive at least 2 normal forces through ordinary force revival OR revive Kwisatz Haderach through ordinary leader revival))');
|
||||
assert.equal(revivalExpressionText(leader(), () => 'Named Leader'), 'revive Named Leader through ordinary leader revival');
|
||||
});
|
||||
|
||||
void test('whole Yes keeps OR alternatives after partial force progress and fulfills on either actual return route', () => {
|
||||
const original = promise('choice', group('or', forces(3, 'normal'), leader()));
|
||||
const initial = [...revivalExpressionBranches(original)];
|
||||
assert.deepEqual(initial, [[{ kind: 'forces', counter: 'normal', minimum: 3, progress: 0, answer: true }],
|
||||
[{ kind: 'leader', leader: 'leader-a', answer: true }]]);
|
||||
const partial = recordRevivalExpressionReturn([original], 'respondent', 2, { kind: 'forces', amount: 2, elite: 1 });
|
||||
assert.deepEqual(partial[0].progress, { total: 2, normal: 1, elite: 1, leaders: [] });
|
||||
assert.equal(partial[0].fulfilled, undefined);
|
||||
assert.deepEqual([...revivalExpressionBranches(partial[0])],
|
||||
[[{ kind: 'forces', counter: 'normal', minimum: 3, progress: 1, answer: true }],
|
||||
[{ kind: 'leader', leader: 'leader-a', answer: true }]], 'Partial play never pins the force alternative');
|
||||
const byLeader = recordRevivalExpressionReturn(partial, 'respondent', 2, { kind: 'leader', leader: 'leader-a' });
|
||||
assert.equal(byLeader[0].fulfilled, true);
|
||||
const byForces = recordRevivalExpressionReturn(partial, 'respondent', 2, { kind: 'forces', amount: 2, elite: 0 });
|
||||
assert.equal(byForces[0].fulfilled, true);
|
||||
assert.deepEqual([...revivalExpressionBranches(byLeader[0])], [[]]);
|
||||
assert.deepEqual(original.progress, zero());
|
||||
});
|
||||
|
||||
void test('whole No to AND permits one completed conjunct and only prevents completing the whole statement', () => {
|
||||
const original = promise('not-both', group('and', forces(2, 'elite'), leader()), { answer: false });
|
||||
assert.deepEqual([...revivalExpressionBranches(original)],
|
||||
[[{ kind: 'forces', counter: 'elite', minimum: 2, progress: 0, answer: false }],
|
||||
[{ kind: 'leader', leader: 'leader-a', answer: false }]]);
|
||||
const first = recordRevivalExpressionReturn([original], 'respondent', 2, { kind: 'forces', amount: 2, elite: 2 });
|
||||
assert.equal(first[0].fulfilled, undefined);
|
||||
assert.deepEqual([...revivalExpressionBranches(first[0])], [[{ kind: 'leader', leader: 'leader-a', answer: false }]]);
|
||||
const before = structuredClone(first); Object.freeze(first[0].progress.leaders); Object.freeze(first[0].progress); Object.freeze(first[0]); Object.freeze(first);
|
||||
assert.throws(() => recordRevivalExpressionReturn(first, 'respondent', 2, { kind: 'leader', leader: 'leader-a' }), RevivalExpressionPromiseError);
|
||||
assert.deepEqual(first, before);
|
||||
const other = recordRevivalExpressionReturn(first, 'respondent', 2, { kind: 'leader', leader: 'leader-b' });
|
||||
assert.deepEqual(other[0].progress.leaders, ['leader-b']); assert.equal(other[0].fulfilled, undefined);
|
||||
});
|
||||
|
||||
void test('whole No to OR retains every cap across split requests and rejects before another Yes can change', () => {
|
||||
const no = promise('neither', group('or', forces(3), leader()), { answer: false });
|
||||
assert.deepEqual([...revivalExpressionBranches(no)], [[
|
||||
{ kind: 'forces', counter: 'total', minimum: 3, progress: 0, answer: false },
|
||||
{ kind: 'leader', leader: 'leader-a', answer: false },
|
||||
]]);
|
||||
const first = recordRevivalExpressionReturn([no], 'respondent', 2, { kind: 'forces', amount: 1, elite: 0 });
|
||||
const second = recordRevivalExpressionReturn(first, 'respondent', 2, { kind: 'forces', amount: 1, elite: 1 });
|
||||
const together = [promise('yes-third-force', forces(1)), second[0]], before = structuredClone(together);
|
||||
assert.throws(() => recordRevivalExpressionReturn(together, 'respondent', 2, { kind: 'forces', amount: 1, elite: 0 }), RevivalExpressionPromiseError);
|
||||
assert.deepEqual(together, before);
|
||||
assert.throws(() => recordRevivalExpressionReturn(second, 'respondent', 2, { kind: 'leader', leader: 'leader-a' }), RevivalExpressionPromiseError);
|
||||
assert.equal(second[0].progress.total, 2); assert.equal(second[0].fulfilled, undefined);
|
||||
});
|
||||
|
||||
void test('lazy branch products retain sixteen-leaf choices and prune redundant same-counter bounds', () => {
|
||||
const sixteen = group('and', ...Array.from({ length: 8 }, (_, index) =>
|
||||
group('or', leader(`left-${index}`), leader(`right-${index}`))));
|
||||
const iterator = revivalExpressionBranches(promise('sixteen', sixteen));
|
||||
assert.equal(iterator[Symbol.iterator](), iterator);
|
||||
const first = iterator.next(); assert.equal(first.done, false); assert.equal(first.value?.length, 8);
|
||||
const firstSnapshot = structuredClone(first.value);
|
||||
const second = iterator.next(); assert.equal(second.done, false); assert.equal(second.value?.length, 8);
|
||||
assert.deepEqual(first.value, firstSnapshot, 'Later products cannot mutate previously returned alternatives');
|
||||
assert.notDeepEqual(first.value, second.value);
|
||||
assert.equal([...revivalExpressionBranches(promise('all', sixteen))].length, 256);
|
||||
assert.deepEqual([...revivalExpressionBranches(promise('strongest-yes', group('and', forces(2), forces(5), forces(3))))],
|
||||
[[{ kind: 'forces', counter: 'total', minimum: 5, progress: 0, answer: true }]]);
|
||||
assert.deepEqual([...revivalExpressionBranches(promise('strongest-no', group('or', forces(2), forces(5)), { answer: false }))],
|
||||
[[{ kind: 'forces', counter: 'total', minimum: 2, progress: 0, answer: false }]]);
|
||||
assert.deepEqual([...revivalExpressionBranches(promise('same-disc', group('and', leader(), leader())))],
|
||||
[[{ kind: 'leader', leader: 'leader-a', answer: true }]]);
|
||||
});
|
||||
|
||||
void test('candidate branches simplify completed leaves without allowing a currently true whole No', () => {
|
||||
const progress = { total: 3, normal: 2, elite: 1, leaders: ['leader-a'] };
|
||||
const yes = promise('already-true', group('or', forces(3), leader('leader-b')), { progress });
|
||||
assert.deepEqual([...revivalExpressionBranches(yes)], [[]]);
|
||||
const no = promise('impossible-no', yes.claim, { progress, answer: false });
|
||||
assert.deepEqual([...revivalExpressionBranches(no)], []);
|
||||
const and = promise('remaining-leader', group('and', forces(3), leader('leader-b')), { progress });
|
||||
assert.deepEqual([...revivalExpressionBranches(and)], [[{ kind: 'leader', leader: 'leader-b', answer: true }]]);
|
||||
assert.deepEqual([...revivalExpressionBranches({ ...and, answer: false })],
|
||||
[[{ kind: 'leader', leader: 'leader-b', answer: false }]]);
|
||||
});
|
||||
|
||||
void test('new answer intervals, terminal entries, players and turns isolate immutable physical progress', () => {
|
||||
const completed = recordRevivalExpressionReturn([promise('first', leader())], 'respondent', 2,
|
||||
{ kind: 'leader', leader: 'leader-a' })[0];
|
||||
const released = promise('released', forces(1), { released: true });
|
||||
const otherTurn = promise('other-turn', forces(1), { turn: 1 });
|
||||
const otherPlayer = promise('other-player', forces(1), { player: 'other-player' });
|
||||
const later = promise('later', leader());
|
||||
const records = [completed, released, otherTurn, otherPlayer, later];
|
||||
assert.deepEqual(liveRevivalExpressionPromises(records, 'respondent', 2), [later]);
|
||||
assert.deepEqual([...revivalExpressionBranches(later)], [[{ kind: 'leader', leader: 'leader-a', answer: true }]],
|
||||
'An earlier physical return is excluded from a later answer');
|
||||
const returned = recordRevivalExpressionReturn(records, 'respondent', 2, { kind: 'leader', leader: 'leader-a' });
|
||||
for (let index = 0; index < 4; index++) assert.equal(returned[index], records[index]);
|
||||
assert.equal(returned[4].fulfilled, true); assert.deepEqual(later.progress, zero());
|
||||
const duplicate = recordRevivalExpressionReturn([promise('same-id', forces(20), { progress: { ...zero(), leaders: ['leader-a'] } })],
|
||||
'respondent', 2, { kind: 'leader', leader: 'leader-a' });
|
||||
assert.deepEqual(duplicate[0].progress.leaders, ['leader-a']);
|
||||
const absent = recordRevivalExpressionReturn(records, 'absent', 2, { kind: 'forces', amount: 1, elite: 0 });
|
||||
records.forEach((record, index) => assert.equal(absent[index], record));
|
||||
});
|
||||
|
||||
void test('physical progress validates typed totals, the twenty-counter bound, exact events and unique leader identities', () => {
|
||||
const invalidProgress: unknown[] = [{ ...zero(), total: 1 }, { ...zero(), normal: -1 },
|
||||
{ total: 21, normal: 20, elite: 1, leaders: [] }, { total: 1, normal: 0.5, elite: 0.5, leaders: [] },
|
||||
{ ...zero(), leaders: ['leader-a', 'leader-a'] }, { ...zero(), leaders: [' leader-a'] },
|
||||
{ ...zero(), extra: 1 }];
|
||||
for (const progress of invalidProgress)
|
||||
assert.throws(() => evaluateRevivalExpression(forces(), progress as RevivalExpressionProgress), RevivalExpressionPromiseError);
|
||||
const nearLimit = promise('twenty', forces(20, 'elite'), { progress: { total: 20, normal: 20, elite: 0, leaders: [] } });
|
||||
assert.throws(() => recordRevivalExpressionReturn([nearLimit], 'respondent', 2,
|
||||
{ kind: 'forces', amount: 1, elite: 1 }), RevivalExpressionPromiseError);
|
||||
for (const event of [{ kind: 'forces', amount: 1, elite: 2 }, { kind: 'forces', amount: 21, elite: 0 },
|
||||
{ kind: 'forces', amount: 1, elite: -1 }, { kind: 'forces', amount: 1.5, elite: 0 },
|
||||
{ kind: 'leader', leader: ' leader-a' }, { kind: 'leader', leader: 'leader-a', special: true }])
|
||||
assert.throws(() => recordRevivalExpressionReturn([], 'respondent', 2, event as Parameters<typeof recordRevivalExpressionReturn>[3]),
|
||||
RevivalExpressionPromiseError);
|
||||
const original = promise('zero');
|
||||
assert.equal(recordRevivalExpressionReturn([original], 'respondent', 2, { kind: 'forces', amount: 0, elite: 0 })[0], original);
|
||||
});
|
||||
|
||||
void test('ledger validation binds event participants and whole-answer terminal consistency including JSON records', () => {
|
||||
const valid = [promise('live'), promise('fulfilled', forces(2),
|
||||
{ progress: { total: 2, normal: 1, elite: 1, leaders: [] }, fulfilled: true }),
|
||||
promise('closed-no', forces(2), { answer: false, fulfilled: true }),
|
||||
promise('released', leader(), { released: true })];
|
||||
const before = structuredClone(valid);
|
||||
assert.doesNotThrow(() => validateRevivalExpressionPromises(valid, seats));
|
||||
assert.doesNotThrow(() => validateRevivalExpressionPromises(JSON.parse(JSON.stringify(valid)) as RevivalExpressionPromise[], seats));
|
||||
const base = promise('event');
|
||||
const invalid: unknown[] = [null, {}, [null], [base, { ...base }],
|
||||
...[{ event: '' }, { player: 'absent' }, { asker: 'absent' }, { asker: 'respondent' }, { answer: 'yes' },
|
||||
{ fulfilled: 1 }, { fulfilled: true }, { fulfilled: true, released: true },
|
||||
{ progress: { total: 3, normal: 3, elite: 0, leaders: [] } },
|
||||
{ answer: false, progress: { total: 3, normal: 3, elite: 0, leaders: [] } }, { turn: 0 }]
|
||||
.map(extra => [{ ...base, ...extra }])];
|
||||
for (const records of invalid)
|
||||
assert.throws(() => validateRevivalExpressionPromises(records as RevivalExpressionPromise[], seats), RevivalExpressionPromiseError);
|
||||
assert.deepEqual(valid, before);
|
||||
});
|
||||
|
||||
void test('expression mode is exactly the existing native revival mode with no new module or start permission', () => {
|
||||
assert.equal(revivalExpressionPromiseModeSupported, revivalPromiseModeSupported);
|
||||
const classic = { advanced: false, expansions: [], players: [{ faction: 'atreides' }, { faction: 'emperor' }] };
|
||||
assert.equal(revivalExpressionPromiseModeSupported(classic), true);
|
||||
assert.equal(revivalExpressionPromiseModeSupported({ ...classic, advanced: true }), true);
|
||||
for (const option of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
|
||||
'discoveryEnabled', 'ecazTreachery', 'semutaPreview', 'kullPreview', 'nexusKullPreview', 'richeseBetrayalPreview'] as const)
|
||||
assert.equal(revivalExpressionPromiseModeSupported({ ...classic, [option]: true }), false, option);
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { RevivalExpressionFields, revivalExpressionInputError } from '../components/revival-expression-fields';
|
||||
import { RevivalExpressionPromises, RevivalExpressionPromiseGuideButton } from '../components/revival-expression-promises';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { parseRevivalExpression, type RevivalExpression } from '../game/revival-expression-promises';
|
||||
import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
|
||||
type ElementProps = { children?: ReactNode; [key: string]: unknown };
|
||||
type Element = ReactElement<ElementProps>;
|
||||
const leaders = [{ id: 'named-leader', name: 'Named Commander' }, { id: 'kwisatz', name: 'Kwisatz Haderach' }];
|
||||
const force = (minimum = 1, counter: 'total' | 'normal' | 'elite' = 'total'): RevivalExpression => ({ kind: 'forces', counter, minimum });
|
||||
const named = (leader = 'named-leader'): RevivalExpression => ({ kind: 'leader', leader });
|
||||
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 find(tree: ReactNode, key: string, value: string): Element {
|
||||
const found = elements(tree).find(element => element.props[key] === value); assert.ok(found, `${key}=${value}`); return found;
|
||||
}
|
||||
const control = (tree: ReactNode, key: string) => find(tree, 'data-revival-expression-control', key);
|
||||
const click = (button: Element) => (button.props.onClick as () => void)();
|
||||
function select(tree: ReactNode, path: string, field: string, value: string) {
|
||||
const input = find(tree, 'id', `truth-revival-expression-${path}-${field}`);
|
||||
(input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
|
||||
}
|
||||
function minimum(tree: ReactNode, path: string, valueAsNumber: number) {
|
||||
const input = find(tree, 'id', `truth-revival-expression-${path}-minimum`);
|
||||
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } });
|
||||
}
|
||||
function editor(initial: RevivalExpression, disabled = false, choices = leaders) {
|
||||
let value = initial, changes = 0;
|
||||
return {
|
||||
get value() { return value; }, get changes() { return changes; },
|
||||
tree: () => RevivalExpressionFields({ value, leaders: choices, disabled, onChange(next) { value = next; changes++; } }),
|
||||
html: () => renderToStaticMarkup(createElement(RevivalExpressionFields, { value, leaders: choices, disabled, onChange() {} })),
|
||||
};
|
||||
}
|
||||
function nested(depth: number): RevivalExpression {
|
||||
let value = force();
|
||||
for (let i = 0; i < depth; i++) value = { kind: 'and', terms: [value, named()] };
|
||||
return value;
|
||||
}
|
||||
function guide(props: Parameters<typeof RevivalExpressionPromiseGuideButton>[0]) {
|
||||
const button = RevivalExpressionPromiseGuideButton(props); assert.ok(button); return button;
|
||||
}
|
||||
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
|
||||
const markup = (game: GameView) => renderToStaticMarkup(createElement(RevivalExpressionPromises, { game, busy: false, send() {} }));
|
||||
|
||||
/** Controlled personalized expressions and quotes test UI callbacks only.
|
||||
* They do not represent source-earned cards, casualties, payments or returns. */
|
||||
function projected(phase = 4, type = 'revive', advanced = false): GameView {
|
||||
const source = createGame('TRUTHREVIVALEXPRESSIONCONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced);
|
||||
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
|
||||
const game = viewGame(source, 'p');
|
||||
game.status = 'playing'; game.turn = 2; game.phase = phase; game.ready = [];
|
||||
game.allLeaders = [{ id: 'named-leader', name: 'Named Commander', faction: 'emperor', strength: 3 }];
|
||||
game.revivalExpressionPromises = [{ event: 'controlled-revival-expression-event', player: 'p', asker: 'a', turn: 2,
|
||||
claim: { kind: 'or', terms: [force(3), { kind: 'and', terms: [named(), force(1, 'elite')] }] },
|
||||
answer: type !== 'ready', progress: { total: 1, normal: 1, elite: 0, leaders: [] } }];
|
||||
game.revivalOrder = advanced ? { version: 1, turn: 2, original: ['p', 'a'], remaining: ['p', 'a'], completed: [], started: false } : null;
|
||||
game.active = advanced ? 'p' : null;
|
||||
game.auction = phase === 3 ? { bid: 4, bidder: type === 'decision' ? 'p' : 'a', active: type === 'decision' ? 'a' : 'p',
|
||||
remaining: 2, inspectionBlocked: null, card: null } : null;
|
||||
game.decision = type === 'decision' ? { kind: 'auctionPayment', player: 'p' } : null;
|
||||
if (type === 'acceptLeaderRevival') game.revivalRequests.p = { leader: 'named-leader', price: 3 };
|
||||
game.revivalExpressionPromiseGuide = { phase, action: { type, revivalExpressionPromiseEvent: 'controlled-revival-expression-event',
|
||||
...(type === 'revive' ? { amount: 2, elite: 1, freeElite: 0 } : {}),
|
||||
...(type === 'reviveLeader' ? { leader: 'named-leader' } : {}),
|
||||
...(type === 'acceptLeaderRevival' ? { leaderRevivalPrice: 3 } : {}),
|
||||
...(type === 'decision' ? { karama: true, card: 'projected-original-karama' } : {}) } };
|
||||
return game;
|
||||
}
|
||||
function nativeProjected(method: 'silent' | 'onceAround') {
|
||||
const game = projected(3, 'richeseBid');
|
||||
const native = createRicheseAuction({ event: 'controlled-expression-auction-event', cardId: 'controlled-card', owner: 'a',
|
||||
method, source: 'cache', eligible: ['p', 'a'], order: ['p', 'a'], tieOrder: ['p', 'a'] });
|
||||
game.richeseAuction = { ...projectRicheseAuction(native, 'p'), card: null, claim: null,
|
||||
ownAllyPayment: 0, ownAvailable: 4, allyAvailable: 0, peekKnown: false, inspectionBlocked: null };
|
||||
game.revivalExpressionPromiseGuide = { phase: 3, action: { type: 'richeseBid', event: native.event,
|
||||
amount: method === 'silent' ? 0 : null, allyPayment: 0, revivalExpressionPromiseEvent: 'controlled-revival-expression-event' } };
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('whole-tree callbacks author sixteen leaves, nested AND or OR, named counters and singleton collapse without truncation', () => {
|
||||
const e = editor(force(2)); click(control(e.tree(), 'or-1'));
|
||||
for (let count = 3; count <= 16; count++) {
|
||||
click(control(e.tree(), 'add-1')); minimum(e.tree(), `1.${count}`, count);
|
||||
if (count % 3 === 0) select(e.tree(), `1.${count}`, 'counter', 'elite');
|
||||
}
|
||||
assert.ok(e.value.kind === 'or'); assert.equal(e.value.terms.length, 16); assert.deepEqual(parseRevivalExpression(e.value), e.value);
|
||||
select(e.tree(), '1.2', 'kind', 'leader'); select(e.tree(), '1.2', 'leader', 'kwisatz');
|
||||
assert.deepEqual(e.value.terms[1], named('kwisatz')); assert.match(e.html(), /16 \/ 16 conditions/);
|
||||
assert.equal((e.html().match(/Revival condition type/g) ?? []).length, 16);
|
||||
const before = structuredClone(e.value), changes = e.changes;
|
||||
for (const key of ['add-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'or-1']) {
|
||||
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
|
||||
}
|
||||
assert.equal(e.changes, changes); assert.deepEqual(e.value, before);
|
||||
const grouped = editor({ kind: 'and', terms: [force(3), named()] });
|
||||
click(control(grouped.tree(), 'or-1.2')); select(grouped.tree(), '1.2', 'meaning', 'and');
|
||||
click(control(grouped.tree(), 'nested-or-1')); click(control(grouped.tree(), 'nested-and-1'));
|
||||
assert.ok(grouped.value.kind === 'and'); assert.equal(grouped.value.terms.length, 4);
|
||||
click(control(grouped.tree(), 'remove-1.2.2')); assert.deepEqual(grouped.value.terms[1], named());
|
||||
click(control(grouped.tree(), 'remove-1.4')); click(control(grouped.tree(), 'remove-1.3'));
|
||||
click(control(grouped.tree(), 'remove-1.1')); assert.deepEqual(grouped.value, named());
|
||||
});
|
||||
|
||||
void test('depth-four guards and busy callbacks preserve the tree while invalid numeric drafts remain editable and parser-visible', () => {
|
||||
const deep = editor(nested(4)); assert.equal(revivalExpressionInputError(deep.value), null);
|
||||
assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||||
for (const key of ['and-1', 'or-1.1.1.1.1', 'nested-and-1.1.1.1']) {
|
||||
assert.equal(control(deep.tree(), key).props.disabled, true); click(control(deep.tree(), key));
|
||||
}
|
||||
assert.equal(deep.changes, 0); minimum(deep.tree(), '1.1.1.1.1', 20); assert.equal(deep.changes, 1);
|
||||
for (const invalid of [NaN, -1, 0, 0.5, 21, Infinity]) {
|
||||
const e = editor(force()); minimum(e.tree(), '1', invalid);
|
||||
const draft = e.value; assert.ok(draft.kind === 'forces'); assert.ok(Object.is(draft.minimum, invalid));
|
||||
assert.ok(revivalExpressionInputError(e.value)); assert.match(e.html(), /role="alert"/);
|
||||
click(control(e.tree(), 'or-1')); const wrapped = e.value; assert.ok(wrapped.kind === 'or');
|
||||
assert.deepEqual(wrapped.terms[0], force(invalid));
|
||||
minimum(e.tree(), '1.1', 2); assert.equal(revivalExpressionInputError(e.value), null);
|
||||
}
|
||||
for (const invalid of [nested(5), { kind: 'and', terms: [force()] }, { kind: 'or', terms: Array.from({ length: 17 }, () => force()) },
|
||||
{ kind: 'forces', counter: 'strength', minimum: 1 }, { kind: 'leader', leader: '' }]) assert.ok(revivalExpressionInputError(invalid));
|
||||
const frozen = editor({ kind: 'and', terms: [force(), named()] }, true), before = structuredClone(frozen.value);
|
||||
for (const key of ['add-1', 'nested-and-1', 'or-1', 'remove-1.2']) click(control(frozen.tree(), key));
|
||||
minimum(frozen.tree(), '1.1', 4); select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'leader', 'kwisatz');
|
||||
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before);
|
||||
const empty = editor(force(), false, []); select(empty.tree(), '1', 'kind', 'leader'); assert.equal(empty.changes, 0);
|
||||
});
|
||||
|
||||
void test('own panels show the entire unpinned expression, cumulative typed and named progress, whole-No meaning and waiting', () => {
|
||||
const game = projected(); game.revivalExpressionPromiseGuide = null;
|
||||
game.revivalExpressionPromises[0].progress = { total: 2, normal: 1, elite: 1, leaders: ['kwisatz'] };
|
||||
game.revivalExpressionPromises.push(
|
||||
{ ...game.revivalExpressionPromises[0], event: 'no-expression', answer: false, claim: { kind: 'and', terms: [force(3), named()] } },
|
||||
{ ...game.revivalExpressionPromises[0], event: 'fulfilled-expression', fulfilled: true },
|
||||
{ ...game.revivalExpressionPromises[0], event: 'released-expression', released: true },
|
||||
{ ...game.revivalExpressionPromises[0], event: 'foreign-expression', player: 'a', claim: named('hidden-foreign-name') },
|
||||
);
|
||||
const before = structuredClone(game), html = markup(game);
|
||||
assert.match(html, /Your Revival expression promises/); assert.match(html, / OR /); assert.match(html, / AND /);
|
||||
assert.match(html, /Named Commander/); assert.match(html, /Future ordinary progress: 2 physical forces \(1 normal, 1 elite\)/);
|
||||
assert.match(html, /returned leaders: Kwisatz Haderach/);
|
||||
assert.match(html, /No negates the whole expression, not each condition separately/);
|
||||
assert.match(html, /Legal alternatives remain available; no fixed branch is selected for you/);
|
||||
assert.match(html, /Prior returns, requests alone, Ghola, foreign leaders and special card returns do not count/);
|
||||
assert.match(html, /Fulfilled: this commitment is complete/); assert.match(html, /Released: this commitment is no longer possible/);
|
||||
assert.match(html, /href="\/rules#truthtrance-revival-expression"/);
|
||||
assert.doesNotMatch(html, /hidden-foreign-name|controlled-revival-expression-event/); assert.deepEqual(game, before);
|
||||
for (const phase of [0, 1, 2, 3]) { game.phase = phase; assert.match(markup(game), /Your Revival opportunity is still ahead/); }
|
||||
game.phase = 4; game.revivalOrder = { version: 1, turn: 2, original: ['a', 'p'], remaining: ['a', 'p'], completed: [], started: false };
|
||||
assert.match(markup(game), /while the current actor finishes/);
|
||||
assert.equal(RevivalExpressionPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
|
||||
});
|
||||
|
||||
void test('actual Basic and ordered Advanced whole-expression return guides preserve every original action field', () => {
|
||||
for (const advanced of [false, true]) for (const type of ['revive', 'reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival', 'ready']) {
|
||||
const game = projected(4, type, advanced), sent: Action[] = [];
|
||||
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
|
||||
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
|
||||
assert.deepEqual(sent, [game.revivalExpressionPromiseGuide!.action]); assert.equal(sent[0], game.revivalExpressionPromiseGuide!.action);
|
||||
assert.match(markup(game), /honoring the whole expression/); press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
|
||||
if (type === 'revive') assert.deepEqual(sent[0], { type: 'revive', amount: 2, elite: 1, freeElite: 0, revivalExpressionPromiseEvent: 'controlled-revival-expression-event' });
|
||||
if (type === 'acceptLeaderRevival') {
|
||||
assert.equal(sent[0].leaderRevivalPrice, 3); game.revivalRequests.p.price = 4; press(button); assert.equal(sent.length, 1);
|
||||
game.revivalRequests.p.price = 3; game.revivalRequests.p.declined = true; press(button); assert.equal(sent.length, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual preparation guides preserve Charity, ordinary pass or payment, and owned native silent-zero or pass packets', () => {
|
||||
for (const [phase, type] of [[2, 'charity'], [2, 'ready'], [3, 'passBid'], [3, 'decision']] as const) {
|
||||
const game = projected(phase, type), sent: Action[] = [];
|
||||
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.deepEqual(sent, [game.revivalExpressionPromiseGuide!.action]);
|
||||
if (type === 'decision') {
|
||||
assert.equal(sent[0].card, 'projected-original-karama'); assert.equal(sent[0].karama, true);
|
||||
assert.doesNotMatch(markup(game), /projected-original-karama/);
|
||||
game.decision!.player = 'a'; press(button); assert.equal(sent.length, 1);
|
||||
} else if (phase === 2) { game.ready.push('p'); press(button); assert.equal(sent.length, 1); }
|
||||
else { game.auction!.active = 'a'; press(button); assert.equal(sent.length, 1); }
|
||||
}
|
||||
for (const method of ['silent', 'onceAround'] as const) {
|
||||
const game = nativeProjected(method), sent: Action[] = [];
|
||||
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.deepEqual(sent, [game.revivalExpressionPromiseGuide!.action]);
|
||||
assert.match(markup(game), method === 'silent' ? /Submit zero in this Silent auction/ : /Pass this Richese bid/);
|
||||
game.richeseAuction!.event = 'later-event'; press(button); assert.equal(sent.length, 1);
|
||||
game.richeseAuction!.event = 'controlled-expression-auction-event';
|
||||
if (method === 'silent') game.richeseAuction!.submitted.push('p'); else game.richeseAuction!.active = 'a';
|
||||
press(button); assert.equal(sent.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('stale whole-expression callbacks guard current own event, quote, phase, frame and quiet windows using no private eligibility', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 5; }, game => { game.status = 'setup'; },
|
||||
game => { game.ready = ['p']; }, game => { game.revivalOrder!.remaining = ['a', 'p']; game.active = 'a'; },
|
||||
game => { game.revivalOrder!.turn = 1; }, game => { game.active = null; }, game => { game.revivalOrder = null; },
|
||||
game => { game.revivalOrder = { ...game.revivalOrder!, remaining: ['p', 'a'] }; },
|
||||
game => { game.revivalExpressionPromiseGuide = { phase: 4, action: { ...game.revivalExpressionPromiseGuide!.action } }; },
|
||||
game => { game.revivalExpressionPromiseGuide = null; }, game => { game.revivalExpressionPromiseGuide!.phase = 3; },
|
||||
game => { game.revivalExpressionPromiseGuide!.action.amount = 3; },
|
||||
game => { game.revivalExpressionPromises[0].event = 'expired'; }, game => { game.revivalExpressionPromises[0].player = 'a'; },
|
||||
game => { game.revivalExpressionPromises[0].turn = 1; }, game => { game.revivalExpressionPromises[0].fulfilled = true; },
|
||||
game => { game.revivalExpressionPromises[0].released = true; }, game => { game.revivalExpressionPromises = []; },
|
||||
game => { game.phaseOpening = { passed: [] }; }, game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||||
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||||
game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
|
||||
game => { game.automaticContinuationPending = true; }, game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
|
||||
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(); },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(4, 'revive', true), sent: Action[] = [];
|
||||
const button = guide({ game, busy: false, send(action) { sent.push(action); } }); change(game); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
const props = { game: projected(), busy: false, send() { assert.fail('A now-busy expression guide cannot dispatch'); } };
|
||||
const button = guide(props); props.busy = true; press(button);
|
||||
for (const [phase, type] of [[0, 'ready'], [1, 'ready'], [2, 'revive'], [3, 'revive'], [4, 'charity']] as const) {
|
||||
const game = projected(phase, type), sent: Action[] = [];
|
||||
press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.deepEqual(sent, []);
|
||||
}
|
||||
for (const type of ['revive', 'reviveLeader', 'reviveKwisatz', 'decision']) {
|
||||
const game = projected(type === 'decision' ? 3 : 4, type), sent: Action[] = [];
|
||||
for (const seat of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'leaders', 'kwisatz', 'spice', 'reserves', 'tanks', 'elites'])
|
||||
Object.defineProperty(seat, key, { get() { throw Error(`No private or unneeded ${key} inspection`); }, configurable: true });
|
||||
if (game.auction) Object.defineProperty(game.auction, 'card', { get() { throw Error('No unrevealed lot inspection'); } });
|
||||
for (const key of ['spiceDeck', 'deck', 'discard', 'revival']) Object.defineProperty(game, key, {
|
||||
get() { throw Error(`No private eligibility or ${key} inspection`); }, configurable: true,
|
||||
});
|
||||
assert.match(markup(game), /Your Revival expression promises/);
|
||||
press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.deepEqual(sent, [game.revivalExpressionPromiseGuide!.action]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame } from '../game/engine';
|
||||
import {
|
||||
createTruthRevivalExpressionFixture as fixture, declareTruthRevivalExpression as declare,
|
||||
askTruthRevivalExpression as ask, answerTruthRevivalExpression as answer,
|
||||
bindTruthRevivalExpression as bind, actTruthRevivalExpression as act,
|
||||
settleTruthRevivalExpression as settle, finishTruthRevivalExpression as finish,
|
||||
truthRevivalExpressionPlayer as player, truthRevivalExpressionInventory as inventory,
|
||||
} from './fixture-truth-revival-expression';
|
||||
|
||||
void test('grouped Yes retains whole progress through actual bribe and owned income preparation', () => {
|
||||
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
|
||||
game = answer(ask(game, f.asker, f.target, f.claim), f.target, 'yes');
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'bribe', target: f.target, turn: game.turn, recipient: f.opponent, minimum: 4,
|
||||
} });
|
||||
assert.deepEqual(viewGame(game, f.target).truthBribeAnswers, ['no']);
|
||||
game = answer(game, f.target, 'no');
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, f.target, { type: 'bribe', target: f.opponent, amount: 4 }));
|
||||
assert.deepEqual(game, before);
|
||||
game = act(game, f.target, { type: 'bribe', target: f.opponent, amount: 3 });
|
||||
let guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'revive');
|
||||
game = settle(act(game, f.target, guide.action));
|
||||
assert.equal(player(game, f.target).spice, 2);
|
||||
assert.equal(game.revivalExpressionPromises![0].progress.total, 1);
|
||||
assert.ok(!game.revivalExpressionPromises![0].fulfilled);
|
||||
guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'reviveLeader');
|
||||
game = settle(act(game, f.target, guide.action));
|
||||
assert.equal(player(game, f.target).spice, 0);
|
||||
assert.equal(game.revivalExpressionPromises![0].fulfilled, true);
|
||||
assert.equal(player(game, f.opponent).bribes, 3);
|
||||
game = finish(game); assert.ok(!game.bribePromises![0].fulfilled);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('prior funded Bribe Yes excludes an impossible later grouped AND Yes', () => {
|
||||
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'bribe', target: f.target, turn: game.turn, recipient: f.opponent, minimum: 4,
|
||||
} });
|
||||
game = answer(game, f.target, 'yes'); game = ask(game, f.asker, f.target, f.claim);
|
||||
assert.deepEqual(viewGame(game, f.target).truthRevivalExpressionAnswers, ['no']);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => answer(game, f.target, 'yes')); assert.deepEqual(game, before);
|
||||
game = answer(game, f.target, 'no');
|
||||
const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide);
|
||||
game = act(game, f.target, guide.action); assert.equal(player(game, f.target).spice, 0);
|
||||
game = finish(game); assert.equal(game.revivalExpressionPromises![0].fulfilled, true);
|
||||
assert.equal(game.bribePromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('partial ordinary forces preserve a grouped OR until the alternate real leader return', () => {
|
||||
const f = fixture(), claim = { kind: 'or' as const, terms: [
|
||||
{ kind: 'forces' as const, counter: 'normal' as const, minimum: 2 }, { kind: 'leader' as const, leader: f.leader },
|
||||
] };
|
||||
let game = bind(f, 'yes', claim).game;
|
||||
game = settle(act(game, f.target, { type: 'revive', amount: 1, elite: 0 }));
|
||||
assert.equal(game.revivalExpressionPromises![0].progress.normal, 1);
|
||||
assert.ok(!game.revivalExpressionPromises![0].fulfilled);
|
||||
const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'reviveLeader');
|
||||
game = settle(act(game, f.target, guide.action));
|
||||
assert.equal(game.revivalExpressionPromises![0].fulfilled, true);
|
||||
assert.deepEqual(game.revivalExpressionPromises![0].progress.leaders, [f.leader]);
|
||||
assert.deepEqual(inventory(finish(game)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('earlier native shipment funding excludes a later grouped AND beyond the shared purse', () => {
|
||||
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: 4 },
|
||||
} });
|
||||
game = answer(game, f.target, 'yes'); game = ask(game, f.asker, f.target, f.claim);
|
||||
assert.deepEqual(viewGame(game, f.target).truthRevivalExpressionAnswers, ['no']);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => answer(game, f.target, 'yes')); assert.deepEqual(game, before);
|
||||
game = answer(game, f.target, 'no');
|
||||
assert.equal(game.shipmentPromises![0].answer, true);
|
||||
assert.ok(!game.shipmentPromises![0].released);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('earlier grouped AND reserves its real native return budget before a later shipment answer', () => {
|
||||
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
|
||||
game = answer(ask(game, f.asker, f.target, f.claim), f.target, 'yes');
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'shipment', target: f.target, claim: { territory: 'arrakeen', minimum: 4 },
|
||||
} });
|
||||
assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['no']);
|
||||
game = answer(game, f.target, 'no');
|
||||
for (let count = 0; count < 3 && !game.revivalExpressionPromises![0].fulfilled; count++) {
|
||||
const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide);
|
||||
game = settle(act(game, f.target, guide.action));
|
||||
}
|
||||
assert.equal(game.revivalExpressionPromises![0].fulfilled, true);
|
||||
assert.equal(player(game, f.target).spice, 3);
|
||||
assert.ok(!game.shipmentPromises![0].fulfilled);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
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 type { RevivalExpression } from '../game/revival-expression-promises';
|
||||
import {
|
||||
actTruthRevivalExpression as act, advanceTruthRevivalExpression as advance,
|
||||
answerTruthRevivalExpression as answer, askTruthRevivalExpression as ask,
|
||||
bindTruthRevivalExpression as bind, createTruthRevivalExpressionFixture as fixture,
|
||||
declareTruthRevivalExpression as declare, finishTruthRevivalExpression as finish,
|
||||
settleTruthRevivalExpression as settle, truthRevivalExpressionClean as clean,
|
||||
truthRevivalExpressionInventory as inventory, truthRevivalExpressionPlayer as player,
|
||||
} from './fixture-truth-revival-expression';
|
||||
|
||||
const force = (minimum = 1): RevivalExpression => ({ kind: 'forces', counter: 'total', minimum });
|
||||
const leader = (id: string): RevivalExpression => ({ kind: 'leader', leader: id });
|
||||
const both = (id: string): RevivalExpression => ({ kind: 'and', terms: [force(), leader(id)] });
|
||||
const either = (id: string): RevivalExpression => ({ kind: 'or', terms: [force(), leader(id)] });
|
||||
const progress0 = { total: 0, normal: 0, elite: 0, leaders: [] };
|
||||
const promise = (game: Game, target: string) => game.revivalExpressionPromises!.find(row => row.player === target)!;
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function complete(game: Game, target: string): Game {
|
||||
for (let count = 0; count < 4 && !promise(game, target).fulfilled; count++) {
|
||||
const guide = viewGame(game, target).revivalExpressionPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.phase, 4); game = settle(act(game, target, guide.action));
|
||||
}
|
||||
assert.equal(promise(game, target).fulfilled, true); return game;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} grouped Revival AND follows actual force then named leader returns`, () => {
|
||||
const f = fixture({ advanced }); assert.equal(f.setup.status, 'setup'); assert.deepEqual(f.game.expansions, ['ix']);
|
||||
assert.equal(f.originalCards.length, 47); assert.deepEqual(f.staging, []);
|
||||
assert.equal(f.sourceCount, f.sourceActions.length); assert.equal(f.sourceActionCount, f.sourceActions.length);
|
||||
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(card => card.id), f.deal);
|
||||
assert.equal(f.base.question.kind, 'leaderRevival'); assert.equal(f.question.kind, 'revivalExpression');
|
||||
assert.equal(player(f.shipment.before, f.target).spice - player(f.shipment.after, f.target).spice, 1);
|
||||
assert.equal(player(f.afterBattle, f.target).leaders.find(disc => disc.id === f.leader)!.dead, true);
|
||||
assert.equal(player(f.game, f.target).tanks, 1); assert.equal(player(f.game, f.target).spice, 4);
|
||||
const before = structuredClone(f.game), bound = bind(f); assert.deepEqual(f.game, before);
|
||||
assert.deepEqual(promise(bound.game, f.target).progress, progress0);
|
||||
rejected(bound.game, f.target, { type: 'ready' });
|
||||
let game = act(bound.game, f.target, { type: 'revive', amount: 1, elite: 0 });
|
||||
if (game.pendingRevival) assert.deepEqual(promise(game, f.target).progress, progress0);
|
||||
game = settle(game);
|
||||
assert.deepEqual(promise(game, f.target).progress, { total: 1, normal: 1, elite: 0, leaders: [] });
|
||||
assert.ok(!promise(game, f.target).fulfilled); assert.equal(player(game, f.target).spice, 5);
|
||||
assert.equal(player(game, f.target).freeForcesRevived, 1); assert.equal(player(game, f.target).tanks, 0);
|
||||
rejected(game, f.target, { type: 'ready' });
|
||||
const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.phase, 4); assert.equal(guide.action.type, 'reviveLeader'); assert.equal(guide.action.leader, f.leader);
|
||||
game = settle(act(game, f.target, guide.action));
|
||||
assert.deepEqual(promise(game, f.target).progress, { total: 1, normal: 1, elite: 0, leaders: [f.leader] });
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.target).spice, 3);
|
||||
assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.deaths, 1);
|
||||
assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, false);
|
||||
if (advanced) assert.deepEqual(game.revivalOrder!.original, f.game.revivalOrder!.original);
|
||||
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
||||
assert.deepEqual(inventory(finish(game)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native grouped OR Yes accepts a manual ordinary route different from its guide', () => {
|
||||
const f = fixture(), claim = either(f.leader); let game = bind(f, 'yes', claim).game;
|
||||
const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide);
|
||||
assert.ok(guide.action.type === 'revive' || guide.action.type === 'reviveLeader');
|
||||
const manual: Action = guide.action.type === 'revive' ? f.revivalAction : { type: 'revive', amount: 1, elite: 0 };
|
||||
assert.notEqual(manual.type, guide.action.type); game = settle(act(game, f.target, manual));
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.equal(promise(game, f.target).progress.total, manual.type === 'revive' ? 1 : 0);
|
||||
assert.deepEqual(promise(game, f.target).progress.leaders, manual.type === 'reviveLeader' ? [f.leader] : []);
|
||||
assert.deepEqual(inventory(finish(game)), f.originalCards);
|
||||
});
|
||||
|
||||
for (const first of ['forces', 'leader'] as const) void test(`native grouped AND No permits ${first} first and rejects only completion of the whole expression`, () => {
|
||||
const f = fixture(); let game = bind(f, 'no').game;
|
||||
const forceAction: Action = { type: 'revive', amount: 1, elite: 0 };
|
||||
game = settle(act(game, f.target, first === 'forces' ? forceAction : f.revivalAction));
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
assert.equal(promise(game, f.target).progress.total, first === 'forces' ? 1 : 0);
|
||||
assert.deepEqual(promise(game, f.target).progress.leaders, first === 'leader' ? [f.leader] : []);
|
||||
rejected(game, f.target, first === 'forces' ? f.revivalAction : forceAction);
|
||||
game = finish(game); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native grouped OR No rejects either ordinary return while permitting native phase completion', () => {
|
||||
const f = fixture(); let game = bind(f, 'no', either(f.leader)).game;
|
||||
rejected(game, f.target, { type: 'revive', amount: 1, elite: 0 }); rejected(game, f.target, f.revivalAction);
|
||||
assert.deepEqual(promise(game, f.target).progress, progress0);
|
||||
game = finish(game); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.equal(player(game, f.target).spice, 4); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native nested whole No retains its outer AND after an inner OR becomes true', () => {
|
||||
const f = fixture(), claim: RevivalExpression = { kind: 'and', terms: [either(f.leader), leader(f.leader)] };
|
||||
let game = bind(f, 'no', claim).game;
|
||||
game = settle(act(game, f.target, { type: 'revive', amount: 1, elite: 0 }));
|
||||
assert.equal(promise(game, f.target).progress.total, 1); assert.ok(!promise(game, f.target).fulfilled);
|
||||
rejected(game, f.target, f.revivalAction);
|
||||
game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native prior ordinary force return is excluded from a newly answered grouped expression', () => {
|
||||
const f = fixture(); let game = settle(act(f.game, f.target, { type: 'revive', amount: 1, elite: 0 }));
|
||||
assert.equal(player(game, f.target).revived, 1); assert.equal(player(game, f.target).spice, 5);
|
||||
game = bind(f, 'yes', either(f.leader), game).game;
|
||||
assert.deepEqual(promise(game, f.target).progress, progress0); assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = settle(act(game, f.target, f.revivalAction));
|
||||
assert.deepEqual(promise(game, f.target).progress, { ...progress0, leaders: [f.leader] });
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native original Ghola return is excluded from grouped ordinary leader progress and No', () => {
|
||||
const f = fixture({ ghola: true }); assert.ok(f.ghola);
|
||||
let game = bind(f, 'no', either(f.leader)).game;
|
||||
game = settle(act(game, f.target, { type: 'card', card: f.ghola, leader: f.leader }));
|
||||
assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, false);
|
||||
assert.equal(player(game, f.target).leaderRevived, false); assert.equal(player(game, f.target).spice, 5);
|
||||
assert.deepEqual(promise(game, f.target).progress, progress0); assert.ok(!promise(game, f.target).fulfilled);
|
||||
rejected(game, f.target, { type: 'revive', amount: 1, elite: 0 });
|
||||
game = finish(game); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.equal(game.discard.filter(card => card.id === f.ghola).length, 1); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('two original Truths keep scalar force No and a dynamic grouped OR Yes jointly legal', () => {
|
||||
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'revival', target: f.target, turn: game.turn, counter: 'total', minimum: 1 } });
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
game = ask(game, f.asker, f.target, either(f.leader));
|
||||
assert.deepEqual(viewGame(game, f.target).truthRevivalExpressionAnswers, ['yes', 'no']);
|
||||
game = answer(game, f.target, 'yes');
|
||||
const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'reviveLeader'); game = settle(act(game, f.target, guide.action));
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(game.revivalPromises![0].progress, 0);
|
||||
assert.equal(player(game, f.target).tanks, 1); assert.equal(player(game, f.target).spice, 2);
|
||||
game = finish(game); assert.equal(game.revivalPromises![0].fulfilled, true);
|
||||
for (const card of f.truths) assert.equal(game.discard.filter(held => held.id === card).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('two original Truths reject a scalar No contradicting an existing grouped AND Yes', () => {
|
||||
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
|
||||
game = answer(ask(game, f.asker, f.target, both(f.leader)), f.target, 'yes');
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'revival', target: f.target, turn: game.turn, counter: 'total', minimum: 1 } });
|
||||
assert.deepEqual(viewGame(game, f.target).truthRevivalAnswers, ['yes']);
|
||||
rejected(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = complete(game, f.target);
|
||||
assert.equal(game.revivalPromises![0].fulfilled, true); assert.equal(game.revivalPromises![0].progress, 1);
|
||||
assert.equal(player(game, f.target).spice, 3); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native post-Storm grouped Yes persists through real Spice charity bidding and ordered Revival', () => {
|
||||
const f = fixture({ advanced: true, window: 'waiting' });
|
||||
assert.equal(f.postStorm.phase, 1); assert.equal(f.postStorm.turn, 2);
|
||||
assert.equal(f.postStormSourceActions.length, f.postStormActionCount); assert.deepEqual(f.postStorm, f.storm.after);
|
||||
assert.deepEqual(f.sourceActions.slice(f.postStormActionCount, f.phase3ActionCount), f.bidding.steps);
|
||||
let game = bind(f, 'yes', f.claim, f.postStorm).game;
|
||||
assert.deepEqual(promise(game, f.target).progress, progress0);
|
||||
game = advance(game, state => state.phase === 4 && clean(state) && state.revivalOrder?.remaining[0] === f.target);
|
||||
assert.deepEqual(promise(game, f.target).progress, progress0); assert.equal(player(game, f.target).spice, 4);
|
||||
game = complete(game, f.target); assert.equal(player(game, f.target).spice, 3);
|
||||
assert.deepEqual(inventory(finish(game)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native waiting grouped promise retains original order and rejects stale foreign question and guide packets', () => {
|
||||
const f = fixture({ advanced: true, window: 'waiting' }), declared = declare(f);
|
||||
rejected(declared.game, f.opponent, f.questionAction);
|
||||
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
|
||||
let game = ask(declared.game, f.asker, f.target, f.claim);
|
||||
assert.equal(viewGame(game, f.asker).truthRevivalExpressionAnswers, null);
|
||||
assert.deepEqual(viewGame(game, f.target).truthRevivalExpressionAnswers, ['yes', 'no']);
|
||||
rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes');
|
||||
assert.equal(viewGame(game, f.target).revivalExpressionPromiseGuide, null);
|
||||
game = advance(game, state => clean(state) && state.revivalOrder?.remaining[0] === f.target);
|
||||
assert.deepEqual(game.revivalOrder!.original, f.game.revivalOrder!.original);
|
||||
const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide);
|
||||
assert.ok(guide.action.revivalExpressionPromiseEvent);
|
||||
rejected(game, f.target, { ...guide.action, revivalExpressionPromiseEvent: 'stale-grouped-revival-event' });
|
||||
rejected(game, f.opponent, guide.action);
|
||||
game = complete(game, f.target); rejected(game, f.target, guide.action); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('all four minimal policies offer legal native grouped returns until the whole AND is fulfilled', () => {
|
||||
const f = fixture(), bound = bind(f).game;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(bound);
|
||||
for (let count = 0; count < 4 && !promise(game, f.target).fulfilled; count++) {
|
||||
const view = viewGame(game, f.target); view.players.find(player => player.id === f.target)!.bot = difficulty;
|
||||
const guide = view.revivalExpressionPromiseGuide; assert.ok(guide, difficulty);
|
||||
const action = botActions(view).find(action => action.revivalExpressionPromiseEvent === guide.action.revivalExpressionPromiseEvent);
|
||||
assert.ok(action, difficulty); game = settle(act(game, f.target, action));
|
||||
}
|
||||
assert.equal(promise(game, f.target).fulfilled, true, difficulty);
|
||||
assert.equal(player(game, f.target).spice, 3); assert.equal(player(game, f.target).tanks, 0);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user