Add named Truthtrance leader-revival commitments
This commit is contained in:
1 parent
5627512d16
commit
a76effb932
20 files changed
+1681
-180
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user