Add named Truthtrance leader-revival commitments

This commit is contained in:
Quintine committed 2026-10-26 14:52:17 +11:00
1 parent 5627512d16
commit a76effb932
20 files changed
+1681 -180

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { leaderRevivalOptions } from '../game/revival';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import { actTruthBid, truthBidInventory, truthBidPlayer } from './fixture-truth-bid';
export type TruthLeaderRevivalStep = { actor: string; action: Action };
export type TruthLeaderRevivalTransition = { before: Game; steps: TruthLeaderRevivalStep[]; after: Game };
export const actTruthLeaderRevival = actTruthBid;
export const truthLeaderRevivalInventory = truthBidInventory;
export const truthLeaderRevivalPlayer = truthBidPlayer;
export const truthLeaderRevivalClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
export function nextTruthLeaderRevival(game: Game): TruthLeaderRevivalStep | null {
if (game.truthtrance) return null;
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
if (game.decision?.kind === 'faceDance') return { actor: game.decision.player, action: { type: 'decision', reveal: false } };
if (game.decision?.kind === 'revivalStop') return { actor: game.decision.player, action: { type: 'decision', decline: true } };
if (game.battle?.preparation) return { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } };
}
for (const player of game.players) {
const view = viewGame(game, player.id);
if (view.leaderRevivalPromiseGuide?.phase === game.phase)
return { actor: player.id, action: view.leaderRevivalPromiseGuide.action };
if (view.revivalPromiseGuide?.phase === game.phase) return { actor: player.id, action: view.revivalPromiseGuide.action };
if (view.bidPromiseGuide) return { actor: player.id, action: view.bidPromiseGuide.action };
}
return nextRicheseStrongholdsNativeStep(game);
}
export function advanceTruthLeaderRevival(game: Game, until: (state: Game) => boolean,
actions: TruthLeaderRevivalStep[] = []): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The real named-leader source ended before its owned control');
const next = nextTruthLeaderRevival(game); assert.ok(next, 'The original named-leader question and answer belong to their callers');
game = actTruthLeaderRevival(game, next.actor, next.action, actions);
}
throw Error('The real named-leader source did not reach its finite boundary');
}
export function settleTruthLeaderRevival(game: Game, actions: TruthLeaderRevivalStep[] = []): Game {
return advanceTruthLeaderRevival(game, truthLeaderRevivalClean, actions);
}
export function finishTruthLeaderRevival(game: Game, actions: TruthLeaderRevivalStep[] = []): Game {
const turn = game.turn;
return advanceTruthLeaderRevival(game, state => state.turn === turn && state.phase === 5 && truthLeaderRevivalClean(state), actions);
}
/** Public original H/A/T +Ix47 with no optional modules. Harkonnen really
* receives both Truthtrances, A a projectile, T Worthless/Ghola or the optional
* actually played Tooth that also kills A's named native disc.
* T pays1 to enter original Arrakeen10, loses the counter and Hidar in an
* actual projectile battle, then reaches genuine next-turn Revival. This is
* native T own early permission, not an ordinary five-disc cohort opening. */
export function createTruthLeaderRevivalFixture(options: {
advanced?: boolean; window?: 'active' | 'waiting'; ghola?: boolean; bothDead?: boolean;
} = {}) {
const advanced = options.advanced ?? false, window = options.window ?? 'active';
assert.ok(!options.ghola || !options.bothDead, 'One actual starting card supplies either Ghola or the native Tooth');
const asker = 'harkonnen', opponent = 'atreides', target = 'tleilaxu', leader = 'tleilaxu-1';
let game = createGame('TRUTHLEADERREVIVALSOURCE', newPlayer(asker, 'Harkonnen', 'harkonnen'), advanced, ['ix']);
joinGame(game, newPlayer(opponent, 'Atreides', 'atreides'));
joinGame(game, newPlayer(target, 'Tleilaxu', 'tleilaxu'));
const initial = structuredClone(game), actions: TruthLeaderRevivalStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthLeaderRevival(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthLeaderRevival(game, until, actions); };
const transition = (run: () => void): TruthLeaderRevivalTransition => {
const before = structuredClone(game), from = actions.length; run();
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(asker, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game); assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => !player.hand.length));
const originalCards = truthLeaderRevivalInventory(game); assert.equal(originalCards.length, 47);
const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
const weapon = game.deck.find(card => card.kind === 'projectile');
const targetCard = game.deck.find(card => options.bothDead ? card.kind === 'poisonTooth' :
options.ghola ? card.effect === 'ghola' : card.kind === 'worthless');
assert.ok(weapon && targetCard);
const deal = [truths[0].id, truths[1].id, weapon.id, targetCard.id];
const dealOrder = deal.map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game);
assert.deepEqual(truthLeaderRevivalPlayer(game, asker).hand.map(card => card.id), truths.map(card => card.id));
assert.ok(truthLeaderRevivalPlayer(game, opponent).hand.some(card => card.id === weapon.id));
assert.ok(truthLeaderRevivalPlayer(game, target).hand.some(card => card.id === targetCard.id));
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1, 2, 3].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery && card.sector >= 7);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
});
reach(state => state.turn === 1 && state.phase === 5 && state.active === target && truthLeaderRevivalClean(state));
const shipmentAction: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 };
const shipment = transition(() => { act(target, shipmentAction); reach(truthLeaderRevivalClean); });
assert.equal(truthLeaderRevivalPlayer(game, target).spice, 4);
reach(state => state.phase === 6 && truthLeaderRevivalClean(state) && !!state.active);
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(choice => choice.territory === 'arrakeen'); assert.ok(choice);
const battleAction: Action = { type: 'chooseBattle', territory: 'arrakeen',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker };
act(choice.chooser, battleAction);
reach(state => !!state.battle && truthLeaderRevivalClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false);
const beforePlans = structuredClone(game);
const winnerLeader = truthLeaderRevivalPlayer(game, opponent).leaders.find(leader => leader.id === 'atreides-4' && !leader.dead); assert.ok(winnerLeader);
const plans: TruthLeaderRevivalStep[] = [
{ actor: target, action: { type: 'battlePlan', leader, dial: 0, support: 0, weapon: options.bothDead ? targetCard.id : null, defense: null } },
{ actor: opponent, action: { type: 'battlePlan', leader: winnerLeader.id, dial: options.bothDead ? 1 : 0,
support: advanced && options.bothDead ? 1 : 0, weapon: weapon.id, defense: null } },
];
for (const plan of plans) act(plan.actor, plan.action);
const revealed = structuredClone(game);
const battle = transition(() => { reach(state => !state.battle && truthLeaderRevivalClean(state)); });
const afterBattle = structuredClone(game), dead = truthLeaderRevivalPlayer(game, target).leaders.find(disc => disc.id === leader)!;
assert.equal(dead.dead, true); assert.equal(dead.deaths, 1);
assert.equal(truthLeaderRevivalPlayer(game, target).leaders.filter(disc => !disc.dead).length, 4);
assert.equal(game.lastBattleContext?.winner, opponent);
assert.equal(truthLeaderRevivalPlayer(game, target).tanks, 1);
assert.equal(truthLeaderRevivalPlayer(game, target).reserves, 19);
if (options.bothDead) assert.equal(truthLeaderRevivalPlayer(game, opponent).leaders.find(disc => disc.id === winnerLeader.id)!.dead, true);
const storm = transition(() => { reach(state => state.turn === 2 && state.phase === 1 && truthLeaderRevivalClean(state)); });
const postStormActionCount = actions.length, postStorm = structuredClone(game);
const bidding = transition(() => { reach(state => state.turn === 2 && state.phase === 3 && truthLeaderRevivalClean(state)); });
const phase3ActionCount = actions.length, phase3 = structuredClone(game);
const arrival = transition(() => { reach(state => state.turn === 2 && state.phase === 4 && truthLeaderRevivalClean(state)); });
const opened = structuredClone(game);
if (window === 'active' && game.revivalOrder) {
assert.ok(game.revivalOrder.remaining.includes(target));
reach(state => state.phase === 4 && truthLeaderRevivalClean(state) && state.revivalOrder?.remaining[0] === target);
}
const own = truthLeaderRevivalPlayer(game, target), quote = leaderRevivalOptions(game, own).leaders.find(option => option.id === leader); assert.ok(quote);
assert.equal(quote.early, true); assert.equal(quote.cost, 2); assert.equal(quote.affordable, true);
assert.equal(own.spice, 4); assert.equal(own.revivalCycle, 0); assert.equal(own.leaderRevived, false);
assert.deepEqual(truthLeaderRevivalInventory(game), originalCards);
const question = { kind: 'leaderRevival' as const, target, turn: game.turn, leader };
return { game, sourceGame: structuredClone(game), initial, lobby, setup, beforeStartingDeal, afterSetup,
advanced, window, bothDead: options.bothDead ?? false, asker, opponent, target, opponentLeader: winnerLeader.id,
actor: asker, leader, leaderName: dead.name,
truths: truths.map(card => card.id), truth: truths[0].id, card: truths[0].id,
ghola: options.ghola ? targetCard.id : null, weapon: weapon.id,
shipmentAction, shipment, beforeBattle, battleAction, beforePlans, plans, revealed, battle, afterBattle,
storm, postStorm, postStormActionCount, bidding, phase3, phase3ActionCount, arrival, opened, quote,
question, declarationAction: { type: 'card', card: truths[0].id } as Action,
questionAction: { type: 'truthAsk', question } as Action, revivalAction: { type: 'reviveLeader', leader } as Action,
originalCards, originalInventory: originalCards, originalSpice, deal, dealOrder, spiceOrder,
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length, sourceCount: actions.length,
staging: [] as string[], sourceDisclosure: 'Original public H/A/T Ix47 Basic/Advanced setup deals H both Truthtrances, A projectile and T Worthless/Ghola/PoisonTooth through unopened positions only. Real T1 shipment pays1; actual Hidar dies to the original A projectile and loses its one counter. The optional actual Tooth kills A leader too; A then dials1 with original Advanced support1 to retain a real win. Native no-Traitor calls and any real FaceDance decline preserve the result. Actual next Storm/phases reach T4spice and native own early leader cost2 with four living discs; this does not open the ordinary five-disc cohort. No played hand, army, wallet, death, clock or promise is installed.' };
}
export type TruthLeaderRevivalFixture = ReturnType<typeof createTruthLeaderRevivalFixture>;
export function declareTruthLeaderRevival(f: TruthLeaderRevivalFixture, state = f.game, options: { copies?: 1 | 2 } = {}) {
let game = state; const before = structuredClone(game), actions: TruthLeaderRevivalStep[] = [];
const declaration: Action = { type: 'card', card: f.truths[0], ...(options.copies === 2 ? { cards: [f.truths[1]] } : {}) };
game = actTruthLeaderRevival(game, f.asker, declaration, actions);
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthLeaderRevival(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return { game, before, declaration, actions, actionCount: actions.length };
}
export function askTruthLeaderRevival(game: Game, asker: string, target: string, leader: string,
actions: TruthLeaderRevivalStep[] = []): Game {
return actTruthLeaderRevival(game, asker, { type: 'truthAsk', question: { kind: 'leaderRevival', target, turn: game.turn, leader } }, actions);
}
export function answerTruthLeaderRevival(game: Game, target: string, answer: 'yes' | 'no',
actions: TruthLeaderRevivalStep[] = []): Game {
return actTruthLeaderRevival(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthLeaderRevival(f: TruthLeaderRevivalFixture, answer: 'yes' | 'no' = 'yes', leader = f.leader, state = f.game) {
const declared = declareTruthLeaderRevival(f, state), actions = [...declared.actions];
const asked = askTruthLeaderRevival(declared.game, f.asker, f.target, leader, actions);
const game = answerTruthLeaderRevival(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length };
}
+159
View File
@@ -0,0 +1,159 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
LeaderRevivalPromiseError,
leaderRevivalClaimText,
leaderRevivalPromiseModeSupported,
leaderRevivalPromisesSatisfied,
leaderRevivalRequirements,
liveLeaderRevivalPromises,
parseLeaderRevivalClaim,
recordPromisedLeaderRevival,
validateLeaderRevivalPromises,
type LeaderRevivalPromise,
} from '../game/leader-revival-promises';
import { revivalPromiseModeSupported } from '../game/revival-promises';
const seats = ['respondent', 'asker', 'other-player'] as const;
function promise(event: string, leader = 'leader-a', extra: Partial<LeaderRevivalPromise> = {}): LeaderRevivalPromise {
return { event, turn: 2, leader, player: 'respondent', asker: 'asker', answer: true, ...extra };
}
void test('named leader claims canonicalize current turn and exact identity including the reserved native KH token', () => {
const input = { turn: 2, leader: 'leader-a', currentDeath: 1, privateCost: 3 };
assert.deepEqual(parseLeaderRevivalClaim(input), { turn: 2, leader: 'leader-a' });
assert.deepEqual(parseLeaderRevivalClaim({ turn: Number.MAX_SAFE_INTEGER, leader: 'kwisatz' }),
{ turn: Number.MAX_SAFE_INTEGER, leader: 'kwisatz' });
for (const turn of [0, -1, 1.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '2', null, undefined])
assert.throws(() => parseLeaderRevivalClaim({ ...input, turn }), LeaderRevivalPromiseError);
for (const leader of ['', ' ', ' leader-a', 'leader-a ', 1, null, undefined])
assert.throws(() => parseLeaderRevivalClaim({ ...input, leader }), LeaderRevivalPromiseError);
for (const value of [null, undefined, [], 'leader-a', 2])
assert.throws(() => parseLeaderRevivalClaim(value), LeaderRevivalPromiseError);
assert.match(leaderRevivalClaimText(parseLeaderRevivalClaim(input), () => 'Named Leader'), /revive Named Leader through ordinary leader revival/);
assert.match(leaderRevivalClaimText({ turn: 2, leader: 'kwisatz' }), /revive Kwisatz Haderach/);
assert.match(leaderRevivalClaimText(parseLeaderRevivalClaim(input)), /from now until your Revival opportunity ends this turn/);
});
void test('named leader scope is the existing force-revival mode alias and introduces no optional-module permission', () => {
assert.equal(leaderRevivalPromiseModeSupported, revivalPromiseModeSupported);
const classic = { advanced: false, expansions: [], players: [{ faction: 'atreides' }, { faction: 'emperor' }] };
const native = { advanced: true, expansions: ['ix', 'choam', 'ecaz'],
players: ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].map(faction => ({ faction })) };
assert.equal(leaderRevivalPromiseModeSupported(classic), true);
assert.equal(leaderRevivalPromiseModeSupported({ ...classic, advanced: true }), true);
assert.equal(leaderRevivalPromiseModeSupported(native), true);
for (const option of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery',
'semutaPreview', 'kullPreview', 'nexusKullPreview', 'richeseBetrayalPreview'] as const)
assert.equal(leaderRevivalPromiseModeSupported({ ...native, [option]: true }), false, option);
assert.equal(leaderRevivalPromiseModeSupported({ ...native, expansions: ['ix', 'choam'] }), false);
});
void test('joint requirements deduplicate the same disc and retain distinct leaders for native allowance quotation', () => {
const records = [promise('yes-a'), promise('yes-a-again', 'leader-a', { asker: 'other-player' }),
promise('yes-b', 'leader-b'), promise('no-c', 'leader-c', { answer: false }),
promise('no-c-again', 'leader-c', { answer: false })];
assert.deepEqual(leaderRevivalRequirements(records, 'respondent', 2),
{ required: ['leader-a', 'leader-b'], forbidden: ['leader-c'] });
assert.equal(leaderRevivalPromisesSatisfied(records, 'respondent', 2, ['leader-a', 'leader-b']), true);
assert.equal(leaderRevivalPromisesSatisfied(records, 'respondent', 2, ['leader-a']), false);
assert.equal(leaderRevivalPromisesSatisfied(records, 'respondent', 2, ['leader-a', 'leader-b', 'leader-c']), false);
assert.equal(leaderRevivalRequirements([...records, promise('no-a', 'leader-a', { answer: false })], 'respondent', 2), null);
assert.equal(leaderRevivalPromisesSatisfied([promise('yes-a'), promise('no-a', 'leader-a', { answer: false })],
'respondent', 2, ['leader-a']), false);
});
void test('hypothetical completion matches exact future return identities and No allows other leaders or no return', () => {
const yes = [promise('yes-a')], no = [promise('no-a', 'leader-a', { answer: false })];
assert.equal(leaderRevivalPromisesSatisfied(yes, 'respondent', 2, []), false);
assert.equal(leaderRevivalPromisesSatisfied(yes, 'respondent', 2, ['leader-b']), false);
assert.equal(leaderRevivalPromisesSatisfied(yes, 'respondent', 2, ['leader-a']), true);
assert.equal(leaderRevivalPromisesSatisfied(yes, 'respondent', 2, ['leader-a', 'leader-a']), true);
assert.equal(leaderRevivalPromisesSatisfied(no, 'respondent', 2, []), true);
assert.equal(leaderRevivalPromisesSatisfied(no, 'respondent', 2, ['leader-b']), true);
assert.equal(leaderRevivalPromisesSatisfied(no, 'respondent', 2, ['leader-a']), false);
assert.deepEqual(leaderRevivalRequirements(no, 'respondent', 2), { required: [], forbidden: ['leader-a'] });
assert.equal(leaderRevivalPromisesSatisfied([], 'respondent', 2, []), true);
});
void test('one actual ordinary return fulfills every matching Yes while retaining other live clauses', () => {
const records = [promise('yes-a'), promise('yes-a-again'), promise('yes-b', 'leader-b'),
promise('no-c', 'leader-c', { answer: false })];
const before = structuredClone(records); records.forEach(Object.freeze); Object.freeze(records);
const returned = recordPromisedLeaderRevival(records, 'respondent', 2, 'leader-a');
assert.notEqual(returned, records); assert.notEqual(returned[0], records[0]); assert.notEqual(returned[1], records[1]);
assert.equal(returned[0].fulfilled, true); assert.equal(returned[1].fulfilled, true);
assert.equal(returned[2], records[2]); assert.equal(returned[3], records[3]);
assert.deepEqual(leaderRevivalRequirements(returned, 'respondent', 2), { required: ['leader-b'], forbidden: ['leader-c'] });
assert.deepEqual(records, before);
});
void test('a conflicting No rejects before any matching Yes can change and remains binding after unrelated returns', () => {
const records = [promise('yes-a'), promise('no-a', 'leader-a', { answer: false })];
const before = structuredClone(records); records.forEach(Object.freeze); Object.freeze(records);
assert.throws(() => recordPromisedLeaderRevival(records, 'respondent', 2, 'leader-a'), LeaderRevivalPromiseError);
assert.deepEqual(records, before);
const unrelated = recordPromisedLeaderRevival(records, 'respondent', 2, 'leader-b');
assert.equal(unrelated[0], records[0]); assert.equal(unrelated[1], records[1]);
assert.equal(unrelated[1].fulfilled, undefined, 'An unrelated return does not close a No answer');
for (const leader of ['', ' ', ' leader-a', 'leader-a '])
assert.throws(() => recordPromisedLeaderRevival(records, 'respondent', 2, leader), LeaderRevivalPromiseError);
});
void test('earlier completed returns never satisfy a newly answered named-leader commitment', () => {
const first = recordPromisedLeaderRevival([promise('earlier-answer')], 'respondent', 2, 'leader-a');
const later = [...first, promise('new-answer')];
assert.equal(first[0].fulfilled, true); assert.equal(later[1].fulfilled, undefined);
assert.deepEqual(leaderRevivalRequirements(later, 'respondent', 2), { required: ['leader-a'], forbidden: [] });
assert.equal(leaderRevivalPromisesSatisfied(later, 'respondent', 2, []), false);
const completed = recordPromisedLeaderRevival(later, 'respondent', 2, 'leader-a');
assert.equal(completed[0], first[0]); assert.equal(completed[1].fulfilled, true);
assert.equal(later[1].fulfilled, undefined, 'Only a new actual return event changes the later ledger entry');
});
void test('native KH is a distinct named identity and the ledger does not turn a physical leader return into a KH return', () => {
const records = [promise('yes-kh', 'kwisatz'), promise('no-leader-a', 'leader-a', { answer: false })];
assert.deepEqual(leaderRevivalRequirements(records, 'respondent', 2), { required: ['kwisatz'], forbidden: ['leader-a'] });
assert.equal(leaderRevivalPromisesSatisfied(records, 'respondent', 2, ['leader-b']), false);
const other = recordPromisedLeaderRevival(records, 'respondent', 2, 'leader-b');
assert.equal(other[0], records[0]);
const returned = recordPromisedLeaderRevival(records, 'respondent', 2, 'kwisatz');
assert.equal(returned[0].fulfilled, true); assert.equal(returned[1], records[1]);
assert.equal(leaderRevivalPromisesSatisfied(returned, 'respondent', 2, []), true);
});
void test('other actors, old turns and terminal records preserve their exact identities and do not constrain the current return', () => {
const live = promise('live');
const records = [live, promise('other-player', 'leader-a', { player: 'other-player', answer: false }),
promise('old-turn', 'leader-a', { turn: 1, answer: false }),
promise('fulfilled', 'leader-a', { fulfilled: true }), promise('released', 'leader-a', { answer: false, released: true }),
promise('other-disc', 'leader-b')];
assert.deepEqual(liveLeaderRevivalPromises(records, 'respondent', 2), [live, records[5]]);
const returned = recordPromisedLeaderRevival(records, 'respondent', 2, 'leader-a');
assert.equal(returned[0].fulfilled, true);
for (let index = 1; index < records.length; index++) assert.equal(returned[index], records[index]);
assert.deepEqual(leaderRevivalRequirements(records, 'absent', 2), { required: [], forbidden: [] });
const absent = recordPromisedLeaderRevival(records, 'absent', 2, 'leader-a');
records.forEach((record, index) => assert.equal(absent[index], record));
});
void test('record validation binds unique events, actual participants, exact named identity and compatible terminal flags', () => {
const valid = [promise('live'), promise('kh', 'kwisatz'), promise('fulfilled-yes', 'leader-b', { fulfilled: true }),
promise('released-yes', 'leader-a', { released: true }),
promise('fulfilled-no', 'leader-b', { answer: false, fulfilled: true }),
promise('released-no', 'leader-a', { answer: false, released: true })];
const before = structuredClone(valid);
assert.doesNotThrow(() => validateLeaderRevivalPromises(valid, seats)); assert.deepEqual(valid, before);
assert.doesNotThrow(() => validateLeaderRevivalPromises(JSON.parse(JSON.stringify(valid)) as LeaderRevivalPromise[], seats));
assert.doesNotThrow(() => validateLeaderRevivalPromises([], seats));
const base = promise('event');
const invalid: unknown[] = [null, {}, [null], [base, { ...base }],
...[{ event: '' }, { event: ' ' }, { player: 'outsider' }, { asker: 'outsider' }, { asker: 'respondent' },
{ leader: '' }, { leader: ' leader-a' }, { leader: null }, { answer: 'yes' },
{ fulfilled: 1 }, { released: 'true' }, { fulfilled: true, released: true }, { turn: 0 }]
.map(extra => [{ ...base, ...extra }])];
for (const records of invalid)
assert.throws(() => validateLeaderRevivalPromises(records as LeaderRevivalPromise[], seats), LeaderRevivalPromiseError);
assert.deepEqual(valid, before);
});
@@ -0,0 +1,206 @@
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 { LeaderRevivalClaimFields, leaderRevivalClaimInputError } from '../components/leader-revival-claim-fields';
import { LeaderRevivalPromises, LeaderRevivalPromiseGuideButton } from '../components/leader-revival-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import type { LeaderRevivalClaim } from '../game/leader-revival-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>;
type Props = Parameters<typeof LeaderRevivalPromiseGuideButton>[0];
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 choose(props: Parameters<typeof LeaderRevivalClaimFields>[0], value: string) {
const select = elements(LeaderRevivalClaimFields(props)).find(element => element.props.id === 'truth-leader-revival-leader');
assert.ok(select);
(select.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
}
function guide(props: Props) { const button = LeaderRevivalPromiseGuideButton(props); assert.ok(button); return button; }
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
const markup = (game: GameView) => renderToStaticMarkup(createElement(LeaderRevivalPromises, { game, busy: false, send() {} }));
/** Controlled personalized projections test UI guards and exact callbacks.
* These are not native acquisition, casualty, eligibility or payment evidence. */
function projected(phase = 4, type = 'reviveLeader', advanced = false): GameView {
const source = createGame('TRUTHLEADERREVIVALCONTROLS', 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.leaderRevivalPromises = [{ event: 'controlled-leader-revival-event', player: 'p', asker: 'a',
turn: 2, leader: type === 'reviveKwisatz' ? 'kwisatz' : 'named-leader', answer: type !== 'ready' }];
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.leaderRevivalPromiseGuide = { phase, action: { type, leaderRevivalPromiseEvent: 'controlled-leader-revival-event',
...(type === 'reviveLeader' ? { leader: 'named-leader' } : {}),
...(type === 'revive' ? { amount: 1, elite: 0, freeElite: 0 } : {}),
...(type === 'acceptLeaderRevival' ? { leaderRevivalPrice: game.revivalRequests.p.price } : {}),
...(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-native-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.leaderRevivalPromiseGuide = { phase: 3, action: { type: 'richeseBid', event: native.event,
amount: method === 'silent' ? 0 : null, allyPayment: 0, leaderRevivalPromiseEvent: 'controlled-leader-revival-event' } };
return game;
}
void test('named-leader fields preserve the fixed turn and emit only parser-valid current name choices', () => {
const value: LeaderRevivalClaim = { turn: 2, leader: 'named-leader' }, changes: LeaderRevivalClaim[] = [];
const leaders = [{ id: 'named-leader', name: 'Named Commander' }, { id: 'kwisatz', name: 'Kwisatz Haderach' }];
const props = { value, leaders, onChange(next: LeaderRevivalClaim) { changes.push(next); } };
const html = renderToStaticMarkup(createElement(LeaderRevivalClaimFields, props));
assert.match(html, /Turn 2/); assert.match(html, /Named Commander/); assert.match(html, /Kwisatz Haderach/);
assert.match(html, /A request alone is not a return/); assert.match(html, /Native revival cycles, costs and turn order still apply/);
assert.equal(elements(LeaderRevivalClaimFields(props)).filter(element => typeof element.props.onChange === 'function').length, 1);
choose(props, 'kwisatz'); assert.deepEqual(changes, [{ turn: 2, leader: 'kwisatz' }]);
for (const value of ['', 'missing', ' named-leader ']) choose(props, value);
choose({ ...props, disabled: true }, 'kwisatz'); choose({ ...props, leaders: [] }, 'kwisatz');
choose({ ...props, value: { ...props.value, turn: 0 } }, 'kwisatz'); assert.equal(changes.length, 1);
for (const value of [null, {}, { turn: 0, leader: 'named-leader' }, { turn: 2, leader: '' }]) assert.ok(leaderRevivalClaimInputError(value));
assert.ok(leaderRevivalClaimInputError(value, []));
assert.match(renderToStaticMarkup(createElement(LeaderRevivalClaimFields, { ...props, leaders: [] })), /role="alert"/);
});
void test('own leader promises persist with public names, actual-return meaning, statuses and earlier-phase waiting', () => {
const game = projected(); game.leaderRevivalPromiseGuide = null;
game.leaderRevivalPromises.push(
{ event: 'no-event', player: 'p', asker: 'a', turn: 2, leader: 'kwisatz', answer: false },
{ event: 'completed-event', player: 'p', asker: 'a', turn: 1, leader: 'named-leader', answer: true, fulfilled: true },
{ event: 'released-event', player: 'p', asker: 'a', turn: 2, leader: 'missing', answer: true, released: true },
{ event: 'foreign-event', player: 'a', asker: 'p', turn: 2, leader: 'foreign-hidden-leader-id', answer: true },
);
const before = structuredClone(game);
for (let render = 0; render < 2; render++) {
const html = markup(game);
assert.match(html, /Your leader Revival promises/); assert.match(html, /Named Commander/); assert.match(html, /Kwisatz Haderach/);
assert.match(html, /Yes requires the named leader’s future actual ordinary native return/);
assert.match(html, /No forbids that named ordinary return until your Revival opportunity ends this turn/);
assert.match(html, /A request alone is not a return/); assert.match(html, /Prior returns, Ghola, foreign leaders and special card returns do not count/);
assert.match(html, /Original revival cycles, costs, limits and turn order still apply/);
assert.match(html, /accept an existing, current Tleilaxu quote/); assert.match(html, /does not arrange a new agreement or promise future consent/);
assert.match(html, /Fulfilled: this commitment is complete/); assert.match(html, /Released: this commitment is no longer possible/);
assert.match(html, /href="\/rules#truthtrance-leader-revival"/);
assert.doesNotMatch(html, /foreign-hidden-leader-id|controlled-leader-revival-event|released-event/);
}
assert.deepEqual(game, before);
for (const phase of [0, 1, 2, 3]) {
game.phase = phase;
assert.match(markup(game), /Your leader Revival opportunity is still ahead/);
assert.equal(LeaderRevivalPromiseGuideButton({ game, busy: false, send() {} }), null);
}
game.phase = 4; game.revivalOrder = { version: 1, turn: 2, original: ['a', 'p'], remaining: ['a', 'p'], completed: [], started: false };
assert.match(markup(game), /this promise remains visible while the current actor finishes/);
assert.equal(LeaderRevivalPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
});
void test('actual Basic and ordered Advanced guide callbacks preserve named leader, KH, priced negotiation, joint-force and Ready packets', () => {
for (const advanced of [false, true]) for (const type of ['reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival', 'revive', '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.leaderRevivalPromiseGuide!.action]); assert.equal(sent[0], game.leaderRevivalPromiseGuide!.action);
assert.match(markup(game), type === 'reviveLeader' ? /Revive Named Commander while honoring your answers/
: type === 'reviveKwisatz' ? /Revive Kwisatz Haderach while honoring your answers/
: type === 'acceptLeaderRevival' ? /Accept the current quoted leader revival while honoring your answers/
: type === 'revive' ? /Revive 1 force to prepare your leader Revival/ : /Finish Revival while honoring your named-leader answers/);
press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
if (type === 'acceptLeaderRevival') {
assert.deepEqual(sent[0], { type: 'acceptLeaderRevival', leaderRevivalPromiseEvent: 'controlled-leader-revival-event', 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);
delete game.revivalRequests.p; press(button); assert.equal(sent.length, 1);
}
}
});
void test('actual preparation callbacks preserve Charity, owned ordinary payment, and current Richese 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.leaderRevivalPromiseGuide!.action]);
if (type === 'decision') {
assert.equal(game.auction!.active, 'a'); assert.equal(sent[0].card, 'projected-original-karama'); assert.equal(sent[0].karama, true);
assert.match(markup(game), /Use the quoted Karama auction payment for your later leader Revival/);
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.leaderRevivalPromiseGuide!.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-native-auction-event';
if (method === 'silent') game.richeseAuction!.submitted.push('p'); else game.richeseAuction!.active = 'a';
press(button); assert.equal(sent.length, 1);
}
});
void test('leader guide callbacks reject stale actor, event, guide, order and interrupted windows without changing native actions', () => {
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.leaderRevivalPromiseGuide = { phase: 4, action: { ...game.leaderRevivalPromiseGuide!.action } }; },
game => { game.leaderRevivalPromiseGuide = null; }, game => { game.leaderRevivalPromiseGuide!.phase = 3; },
game => { game.leaderRevivalPromiseGuide!.action.leader = 'kwisatz'; },
game => { game.leaderRevivalPromises[0].event = 'expired'; }, game => { game.leaderRevivalPromises[0].player = 'a'; },
game => { game.leaderRevivalPromises[0].turn = 1; }, game => { game.leaderRevivalPromises[0].fulfilled = true; },
game => { game.leaderRevivalPromises[0].released = true; }, game => { game.leaderRevivalPromises = []; },
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, 'reviveLeader', true), sent: Action[] = [];
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
const button = guide(props); change(game); press(button); assert.deepEqual(sent, []);
}
const props = { game: projected(), busy: false, send() { assert.fail('A now-busy leader guide cannot dispatch'); } };
const button = guide(props); props.busy = true; press(button);
for (const [phase, type] of [[0, 'ready'], [1, 'ready'], [2, 'reviveLeader'], [3, 'reviveLeader'], [4, 'charity']] as const) {
const game = projected(phase, type), sent: Action[] = [];
press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.deepEqual(sent, []);
}
});
void test('named-leader guidance uses only public names and the projected guide without private eligibility inspection', () => {
for (const type of ['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 leader Revival promises/);
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
assert.deepEqual(sent, [game.leaderRevivalPromiseGuide!.action]);
}
});
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import {
createTruthLeaderRevivalFixture as fixture, declareTruthLeaderRevival as declare,
askTruthLeaderRevival as ask, answerTruthLeaderRevival as answer,
actTruthLeaderRevival as act, settleTruthLeaderRevival as settle,
finishTruthLeaderRevival as finish, truthLeaderRevivalPlayer as player,
truthLeaderRevivalInventory as inventory,
} from './fixture-truth-leader-revival';
void test('an earlier funded Bribe Yes retains its purse before a named leader answer', () => {
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.leader);
assert.deepEqual(viewGame(game, f.target).truthLeaderRevivalAnswers, ['no']);
const before = structuredClone(game);
assert.throws(() => answer(game, f.target, 'yes')); assert.deepEqual(game, before);
game = answer(game, f.target, 'no');
const payment = viewGame(game, f.target).bribePromiseGuide; assert.ok(payment);
game = act(game, f.target, payment.action);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.opponent).bribes, 4);
game = finish(game);
assert.equal(game.bribePromises![0].fulfilled, true);
assert.equal(game.leaderRevivalPromises![0].fulfilled, true);
assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('earlier named Yes limits a later bribe and preserves the actual self-income preparation', () => {
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
game = answer(ask(game, f.asker, f.target, f.leader), 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 });
assert.equal(player(game, f.target).spice, 1);
let guide = viewGame(game, f.target).leaderRevivalPromiseGuide; 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.ok(!game.leaderRevivalPromises![0].fulfilled);
guide = viewGame(game, f.target).leaderRevivalPromiseGuide; 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.leaderRevivalPromises![0].fulfilled, true);
assert.equal(player(game, f.opponent).bribes, 3);
game = finish(game);
assert.ok(!game.bribePromises![0].fulfilled, 'The turn-wide bribe No outlasts Revival');
assert.equal(game.bribePromises![0].progress, 3);
assert.deepEqual(inventory(game), f.originalCards);
});
+206
View File
@@ -0,0 +1,206 @@
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 {
actTruthLeaderRevival as act, advanceTruthLeaderRevival as advance, answerTruthLeaderRevival as answer,
askTruthLeaderRevival as ask, bindTruthLeaderRevival as bind, createTruthLeaderRevivalFixture as fixture,
declareTruthLeaderRevival as declare, finishTruthLeaderRevival as finish, settleTruthLeaderRevival as settle,
truthLeaderRevivalClean as clean, truthLeaderRevivalInventory as inventory, truthLeaderRevivalPlayer as player,
} from './fixture-truth-leader-revival';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
const promise = (game: Game, target: string) => game.leaderRevivalPromises!.find(promise => promise.player === target)!;
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} named Tleilaxu Revival Yes follows real projectile death and original early return`, () => {
const f = fixture({ advanced }); assert.equal(f.initial.status, 'lobby'); 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.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCount, f.sourceActions.length);
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(card => card.id), f.deal);
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(leader => leader.id === f.leader)!.dead, true);
assert.equal(player(f.game, f.target).leaders.filter(leader => !leader.dead).length, 4);
assert.equal(player(f.game, f.target).revivalCycle, 0); assert.equal(f.quote.early, true); assert.equal(f.quote.cost, 2);
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.ok(!promise(bound.game, f.target).fulfilled); rejected(bound.game, f.target, { type: 'ready' });
const guide = viewGame(bound.game, f.target).leaderRevivalPromiseGuide; assert.ok(guide); assert.equal(guide.phase, 4);
assert.equal(guide.action.type, 'reviveLeader'); assert.equal(guide.action.leader, f.leader);
let game = act(bound.game, f.target, guide.action);
if (game.pendingRevival) {
assert.ok(!promise(game, f.target).fulfilled);
assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead, true);
}
game = settle(game);
assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead, false);
assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.deaths, 1);
assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).leaderRevived, true);
assert.equal(player(game, f.target).tanks, 1); assert.equal(player(game, f.target).revived, 0);
assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native named No rejects the exact ordinary leader return while an independent free force return stays legal', () => {
const f = fixture(); let game = bind(f, 'no').game;
rejected(game, f.target, f.revivalAction);
game = settle(act(game, f.target, { type: 'revive', amount: 1, elite: 0 }));
assert.ok(!promise(game, f.target).fulfilled); assert.equal(player(game, f.target).revived, 1);
assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead, true);
assert.equal(player(game, f.target).spice, 5, 'Original self free-revival bank1 stays separate');
game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native Ghola can return the named leader without fulfilling or violating an ordinary-return No', () => {
const f = fixture({ ghola: true }); let game = bind(f, 'no').game; assert.ok(f.ghola);
game = settle(act(game, f.target, { type: 'card', card: f.ghola, leader: f.leader }));
assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead, false);
assert.equal(player(game, f.target).leaderRevived, false); assert.ok(!promise(game, f.target).fulfilled);
assert.equal(player(game, f.target).spice, 5, 'Printed Ghola bank1 is distinct from an ordinary leader invoice');
assert.equal(game.discard.filter(card => card.id === f.ghola).length, 1);
game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native Ghola cannot erase the only named ordinary-return Yes witness', () => {
const f = fixture({ ghola: true }), game = bind(f).game; assert.ok(f.ghola);
rejected(game, f.target, { type: 'card', card: f.ghola, leader: f.leader });
assert.ok(player(game, f.target).hand.some(card => card.id === f.ghola));
assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead, true);
});
void test('native prior named ordinary return cannot satisfy a newly answered question about future returns', () => {
const f = fixture(); let game = settle(act(f.game, f.target, f.revivalAction));
assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).leaderRevived, true);
game = declare(f, game).game; game = ask(game, f.asker, f.target, f.leader);
assert.deepEqual(viewGame(game, f.target).truthLeaderRevivalAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no');
assert.ok(!promise(game, f.target).fulfilled); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native waiting Advanced named return keeps original Revival order and exposes its guide only when current', () => {
const f = fixture({ advanced: true, window: 'waiting' }); assert.notEqual(f.game.revivalOrder!.remaining[0], f.target);
let game = bind(f).game; assert.equal(viewGame(game, f.target).leaderRevivalPromiseGuide, null);
game = advance(game, state => clean(state) && state.revivalOrder?.remaining[0] === f.target);
const guide = viewGame(game, f.target).leaderRevivalPromiseGuide; assert.ok(guide);
game = settle(act(game, f.target, guide.action)); assert.equal(promise(game, f.target).fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('two original Truth copies require the same physical leader once at its native price', () => {
const f = fixture(), declared = declare(f, f.game, { copies: 2 });
let game = ask(declared.game, f.asker, f.target, f.leader); game = answer(game, f.target, 'yes');
game = ask(game, f.asker, f.target, f.leader); assert.deepEqual(viewGame(game, f.target).truthLeaderRevivalAnswers, ['yes']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'no' }); game = answer(game, f.target, 'yes');
const guide = viewGame(game, f.target).leaderRevivalPromiseGuide; assert.ok(guide); game = settle(act(game, f.target, guide.action));
assert.ok(game.leaderRevivalPromises!.every(promise => promise.fulfilled)); assert.equal(player(game, f.target).spice, 2);
assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.deaths, 1);
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 Truth copies cannot contradict a matching named ordinary-return No', () => {
const f = fixture(), declared = declare(f, f.game, { copies: 2 });
let game = ask(declared.game, f.asker, f.target, f.leader); game = answer(game, f.target, 'no');
game = ask(game, f.asker, f.target, f.leader); assert.deepEqual(viewGame(game, f.target).truthLeaderRevivalAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no');
rejected(game, f.target, f.revivalAction); assert.equal(player(game, f.target).spice, 4);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native joint force and named leader Yes preserve actual separate prices allowances and bank income', () => {
const f = fixture(), declared = declare(f, f.game, { copies: 2 });
let game = ask(declared.game, f.asker, f.target, f.leader); game = answer(game, f.target, 'yes');
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: 'yes' });
for (let count = 0; count < 4 && (!promise(game, f.target).fulfilled || !game.revivalPromises![0].fulfilled); count++) {
const own = viewGame(game, f.target), guide = own.leaderRevivalPromiseGuide ?? own.revivalPromiseGuide; assert.ok(guide);
game = settle(act(game, f.target, guide.action));
}
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(game.revivalPromises![0].progress, 1);
assert.equal(game.revivalPromises![0].fulfilled, true); assert.equal(player(game, f.target).leaderRevived, true);
assert.equal(player(game, f.target).tanks, 0); assert.equal(player(game, f.target).revived, 1);
assert.equal(player(game, f.target).freeForcesRevived, 1); assert.equal(player(game, f.target).spice, 3);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native named Yes may prepare its price through an actual owned free-force return and Tleilaxu bank income', () => {
const f = fixture(); let game = act(f.game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
assert.equal(player(game, f.target).spice, 1); game = bind(f, 'yes', f.leader, game).game;
const preparation = viewGame(game, f.target).leaderRevivalPromiseGuide; assert.ok(preparation);
assert.equal(preparation.action.type, 'revive'); assert.equal(preparation.action.amount, 1);
game = settle(act(game, f.target, preparation.action));
assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).freeForcesRevived, 1);
assert.equal(player(game, f.target).tanks, 0); assert.ok(!promise(game, f.target).fulfilled);
assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead, true);
const returned = viewGame(game, f.target).leaderRevivalPromiseGuide; assert.ok(returned); assert.equal(returned.action.type, 'reviveLeader');
game = settle(act(game, f.target, returned.action));
assert.equal(player(game, f.target).spice, 0); assert.equal(promise(game, f.target).fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native force No prevents using its forbidden free return to fund a named leader Yes', () => {
const f = fixture(); let game = act(f.game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
game = declare(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, f.leader);
assert.deepEqual(viewGame(game, f.target).truthLeaderRevivalAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no');
assert.equal(player(game, f.target).spice, 1); assert.equal(player(game, f.target).tanks, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
for (const permitted of [false, true]) void test(`native actually priced personal early leader quote ${permitted ? 'completes the named Yes' : 'stays blocked by a named No'}`, () => {
const f = fixture({ advanced: true, window: 'waiting', bothDead: true });
assert.equal(player(f.afterBattle, f.opponent).leaders.find(leader => leader.id === f.opponentLeader)!.dead, true);
assert.equal(player(f.game, f.opponent).leaders.filter(leader => !leader.dead).length, 4);
assert.equal(f.game.revivalOrder!.remaining[0], f.opponent);
let game = act(f.game, f.opponent, { type: 'requestLeaderRevival', leader: f.opponentLeader });
game = act(game, f.target, { type: 'quoteLeaderRevival', target: f.opponent, amount: 3 });
const before = player(game, f.opponent).spice, payee = player(game, f.target).spice;
game = declare(f, game).game; game = ask(game, f.asker, f.opponent, f.opponentLeader);
game = answer(game, f.opponent, permitted ? 'yes' : 'no');
if (permitted) {
const guide = viewGame(game, f.opponent).leaderRevivalPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'acceptLeaderRevival'); assert.equal(guide.action.leaderRevivalPrice, 3);
game = settle(act(game, f.opponent, guide.action));
assert.equal(player(game, f.opponent).leaders.find(leader => leader.id === f.opponentLeader)!.dead, false);
assert.equal(player(game, f.opponent).spice, before - 3); assert.equal(player(game, f.target).spice, payee + 3);
assert.equal(player(game, f.opponent).leaderRevived, true); assert.equal(promise(game, f.opponent).fulfilled, true);
} else {
rejected(game, f.opponent, { type: 'acceptLeaderRevival', leaderRevivalPrice: 3 });
assert.equal(player(game, f.opponent).spice, before);
assert.equal(player(game, f.opponent).leaders.find(leader => leader.id === f.opponentLeader)!.dead, true);
}
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native named question ownership exact identity and guided event reject stale or foreign packets', () => {
const f = fixture({ advanced: true }), 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 } });
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, leader: 'atreides-0' } });
let game = ask(declared.game, f.asker, f.target, f.leader);
assert.deepEqual(viewGame(game, f.target).truthLeaderRevivalAnswers, ['yes', 'no']);
assert.equal(viewGame(game, f.asker).truthLeaderRevivalAnswers, null);
rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes');
const guide = viewGame(game, f.target).leaderRevivalPromiseGuide; assert.ok(guide); assert.ok(guide.action.leaderRevivalPromiseEvent);
rejected(game, f.target, { ...guide.action, leaderRevivalPromiseEvent: 'stale-owned-leader-event' });
rejected(game, f.opponent, guide.action);
game = settle(act(game, f.target, guide.action)); rejected(game, f.target, guide.action);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('all four minimal policies offer a legal real named leader return at the current native opportunity', () => {
const f = fixture(), game = bind(f).game;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.target); view.players.find(player => player.id === f.target)!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'reviveLeader' && action.leader === f.leader); assert.ok(action, difficulty);
const returned = settle(act(structuredClone(game), f.target, action));
assert.equal(promise(returned, f.target).fulfilled, true); assert.equal(player(returned, f.target).spice, 2);
assert.deepEqual(inventory(returned), f.originalCards);
}
});