Support grouped Truthtrance revival commitments

This commit is contained in:
Quintine committed 2026-10-26 15:16:32 +11:00
1 parent a76effb932
commit f5ae61f382
20 files changed
+1730 -30

No files matched your search

+82
View File
@@ -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 };
}
+226
View File
@@ -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);
}
});