Add cumulative Truthtrance force-revival commitments

This commit is contained in:
Quintine committed 2026-10-26 12:57:18 +11:00
1 parent bd6e5bbf7f
commit 24653799cf
21 files changed
+1540 -56

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { TERRITORIES } from '../game/board';
import { forceRevivalQuote } from '../game/revival';
import type { RevivalClaim } from '../game/revival-promises';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { actTruthBid, truthBidInventory, truthBidPlayer } from './fixture-truth-bid';
export type TruthRevivalStep = { actor: string; action: Action };
export type TruthRevivalTransition = { before: Game; steps: TruthRevivalStep[]; after: Game };
export const actTruthRevival = actTruthBid;
export const truthRevivalInventory = truthBidInventory;
export const truthRevivalPlayer = truthBidPlayer;
export const truthRevivalClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
export function nextTruthRevival(game: Game): TruthRevivalStep | null {
if (game.truthtrance) return null;
if (!game.response && !game.phaseOpening && game.decision?.kind === 'revivalStop')
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
if (game.phase === 4 && truthRevivalClean(game)) {
const actors = game.revivalOrder ? game.revivalOrder.remaining.slice(0, 1) : game.players.filter(player => !game.ready.includes(player.id)).map(player => player.id);
for (const actor of actors) {
const guide = viewGame(game, actor).revivalPromiseGuide;
if (guide) return { actor, action: guide.action };
}
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceTruthRevival(game: Game, until: (state: Game) => boolean, actions: TruthRevivalStep[] = []): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Revival source ended before its owned control');
const next = nextTruthRevival(game); assert.ok(next, 'The original Truthtrance answer belongs to its caller');
game = actTruthRevival(game, next.actor, next.action, actions);
}
throw Error('The original Revival source did not reach its finite boundary');
}
export function settleTruthRevival(game: Game, actions: TruthRevivalStep[] = []): Game {
return advanceTruthRevival(game, truthRevivalClean, actions);
}
export function finishTruthRevival(game: Game, actions: TruthRevivalStep[] = []): Game {
const turn = game.turn;
return advanceTruthRevival(game, state => state.turn === turn && state.phase === 5 && truthRevivalClean(state), actions);
}
/** Genuine public H/A/E base33; optional native Tleilaxu selects original Ix47
* without optional modules. H receives both original Truthtrances. E really
* pays6 to ship3 into next-Storm sector2, including one Sardaukar in Advanced.
* The real next Storm destroys those counters before original turn2 Revival.
* Only unopened deal/Spice positions and scalar lotteries are selected; no
* played hand, purse, army, Tanks, clock, question or promise is installed. */
export function createTruthRevivalFixture(options: {
advanced?: boolean; window?: 'active' | 'waiting'; targetGhola?: boolean; stopper?: boolean;
} = {}) {
const advanced = options.advanced ?? false, window = options.window ?? 'active', stopper = options.stopper ?? false;
assert.ok(!stopper || advanced, 'The native Tleilaxu special Karama witness uses its actual Advanced power');
const asker = 'harkonnen', opponent = 'atreides', target = 'emperor';
let game = createGame('TRUTHREVIVALSOURCE', newPlayer(asker, 'Harkonnen', 'harkonnen'), advanced, stopper ? ['ix'] : []);
for (const faction of [opponent, target, ...(stopper ? ['tleilaxu' as const] : [])] as const)
joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: TruthRevivalStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthRevival(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthRevival(game, until, actions); };
const transition = (run: () => void): TruthRevivalTransition => {
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 = truthRevivalInventory(game); assert.equal(originalCards.length, stopper ? 47 : 33);
const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
const filler = game.deck.find(card => card.name === 'Baliset');
const targetCard = game.deck.find(card => options.targetGhola ? card.effect === 'ghola' : card.kind === 'shield');
const karama = stopper ? game.deck.find(card => card.effect === 'karama') : null;
assert.ok(filler && targetCard && (!stopper || karama));
const deal = [truths[0].id, truths[1].id, filler.id, targetCard.id, ...(karama ? [karama.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(truthRevivalPlayer(game, asker).hand.map(card => card.id), truths.map(card => card.id));
assert.ok(truthRevivalPlayer(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 && truthRevivalClean(state));
assert.equal(game.storm, 1);
const sector = 2, destination = TERRITORIES.find(place => place.type === 'sand' && place.sectors.includes(sector) &&
game.players.every(player => !Object.keys(player.forces).some(key => key.startsWith(`${place.id}:`))));
assert.ok(destination);
const location = `${destination.id}:${sector}`, elite = advanced ? 1 : 0;
const shipmentAction: Action = { type: 'ship', territory: destination.id, sector, amount: 3, elite, allyPayment: 0 };
const shipment = transition(() => { act(target, shipmentAction); reach(truthRevivalClean); });
assert.equal(truthRevivalPlayer(game, target).spice, 4); assert.equal(truthRevivalPlayer(game, target).forces[location], 3);
assert.equal(truthRevivalPlayer(game, target).elites?.forces[location] ?? 0, elite);
const continuation = transition(() => {
reach(state => state.turn === 2 && state.phase === 0 && truthRevivalClean(state) && state.stormPending !== null);
});
const storm = transition(() => { reach(state => state.turn === 2 && state.phase === 1 && truthRevivalClean(state)); });
assert.equal(truthRevivalPlayer(game, target).forces[location], undefined);
assert.equal(truthRevivalPlayer(game, target).tanks, 3);
assert.equal(truthRevivalPlayer(game, target).elites?.tanks ?? 0, elite);
const arrival = transition(() => { reach(state => state.turn === 2 && state.phase === 4 && truthRevivalClean(state)); });
const opened = structuredClone(game);
if (window === 'active' && game.revivalOrder) {
assert.ok(game.revivalOrder.remaining.includes(target));
reach(state => state.phase === 4 && truthRevivalClean(state) && state.revivalOrder?.remaining[0] === target);
}
const source = truthRevivalPlayer(game, target), quote = forceRevivalQuote(game, source, 3, elite);
assert.equal(source.spice, 4); assert.equal(source.reserves, 17); assert.equal(source.revived, 0);
assert.equal(source.freeForcesRevived ?? 0, 0); assert.equal(quote.free, 1); assert.equal(quote.cost, 4);
assert.deepEqual(truthRevivalInventory(game), originalCards);
const question = { kind: 'revival' as const, target, turn: game.turn, counter: 'total' as const, minimum: 3 };
const special: Action | null = karama ? { type: 'card', card: karama.id, mode: 'special', target } : null;
return { game, sourceGame: structuredClone(game), initial, lobby, setup, beforeStartingDeal, afterSetup,
advanced, window, stopper: stopper ? 'tleilaxu' : null, asker, opponent, target, actor: asker,
truths: truths.map(card => card.id), truth: truths[0].id, card: truths[0].id,
ghola: options.targetGhola ? targetCard.id : null, karama: karama?.id ?? null, special,
destination: destination.id, sector, location, elite, shipmentAction, shipment, continuation, storm, arrival, opened,
quote, question, declarationAction: { type: 'card', card: truths[0].id } as Action,
questionAction: { type: 'truthAsk', question } as Action, revivalAction: { type: 'revive', amount: 3, elite } as Action,
originalCards, originalInventory: originalCards, originalSpice, deal, dealOrder, spiceOrder,
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
staging: [] as string[], sourceDisclosure: 'Original public H/A/E basic33 (or Advanced native Tleilaxu/Ix47) selects only unopened deals/ordinary Spice and scalar lotteries. Emperor actually ships3 for6, loses3 to the next native Storm and reaches original turn2 Revival with4 spice; Advanced casualties include1 Sardaukar. All ordinary returns, Truth declarations/answers and optional original Ghola/Tleilaxu Karama are caller actions, never installed state.' };
}
export type TruthRevivalFixture = ReturnType<typeof createTruthRevivalFixture>;
export function declareTruthRevival(f: TruthRevivalFixture, state = f.game, options: { copies?: 1 | 2 } = {}) {
let game = state; const before = structuredClone(game), actions: TruthRevivalStep[] = [];
const declaration: Action = { type: 'card', card: f.truths[0], ...(options.copies === 2 ? { cards: [f.truths[1]] } : {}) };
game = actTruthRevival(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 = actTruthRevival(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return { game, before, declaration, actions, actionCount: actions.length };
}
export function askTruthRevival(game: Game, asker: string, target: string, minimum = 3,
counter: RevivalClaim['counter'] = 'total', actions: TruthRevivalStep[] = []): Game {
return actTruthRevival(game, asker, { type: 'truthAsk', question: { kind: 'revival', target, turn: game.turn, counter, minimum } }, actions);
}
export function answerTruthRevival(game: Game, target: string, answer: 'yes' | 'no', actions: TruthRevivalStep[] = []): Game {
return actTruthRevival(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthRevival(f: TruthRevivalFixture, answer: 'yes' | 'no' = 'yes', minimum = 3,
counter: RevivalClaim['counter'] = 'total', state = f.game) {
const declared = declareTruthRevival(f, state), actions = [...declared.actions];
const asked = askTruthRevival(declared.game, f.asker, f.target, minimum, counter, actions);
const game = answerTruthRevival(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length };
}
+5 -3
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Game, type Player } from '../game/engine';
import { createNexusFremenFreeQuotaFixture } from './fixture-nexus-fremen-free-quota';
import { awaitSpiceBankerIncomeRevivalActor } from './fixture-spice-banker-income';
function pools(player: Player) {
return { reserves: player.reserves, tanks: player.tanks,
@@ -13,9 +14,10 @@ function pools(player: Player) {
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} saved exact-three physical receipt adopts its exhausted quota without replaying the actual return`, () => {
const fixture = createNexusFremenFreeQuotaFixture({ advanced });
const before = fixture.game.players.find(player => player.id === fixture.owner)!;
const offer = viewGame(fixture.game, fixture.owner).nexusFremenRevival!;
const granted = applyAction(fixture.game, fixture.owner,
const source = awaitSpiceBankerIncomeRevivalActor(fixture.game, fixture.owner);
const before = source.players.find(player => player.id === fixture.owner)!;
const offer = viewGame(source, fixture.owner).nexusFremenRevival!;
const granted = applyAction(source, fixture.owner,
{ type: 'nexusFremenRevival', event: offer.event });
const returned = applyAction(granted, fixture.owner, { type: 'revive', amount: 3, elite: 0 });
const after = returned.players.find(player => player.id === fixture.owner)!;
+195
View File
@@ -0,0 +1,195 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
RevivalPromiseError,
liveRevivalPromises,
parseRevivalClaim,
recordPromisedRevival,
revivalClaimText,
revivalPromiseCount,
revivalPromiseModeSupported,
revivalPromisesSatisfied,
validateRevivalPromises,
type RevivalClaim,
type RevivalPromise,
} from '../game/revival-promises';
const seats = ['respondent', 'asker', 'rival'] as const;
function promise(event: string, minimum: number, extra: Partial<RevivalPromise> = {}): RevivalPromise {
return { event, turn: 2, counter: 'total', minimum, player: 'respondent', asker: 'asker',
answer: true, progress: 0, ...extra };
}
void test('revival claims retain only the current-turn physical counter and positive bounded threshold', () => {
const input = { turn: 2, counter: 'normal', minimum: 4, privateTanks: 12, priorReturns: 3 };
assert.deepEqual(parseRevivalClaim(input), { turn: 2, counter: 'normal', minimum: 4 });
for (const counter of ['total', 'normal', 'elite'] as const) {
assert.deepEqual(parseRevivalClaim({ turn: 1, counter, minimum: 1 }), { turn: 1, counter, minimum: 1 });
assert.deepEqual(parseRevivalClaim({ turn: Number.MAX_SAFE_INTEGER, counter, minimum: 20 }),
{ turn: Number.MAX_SAFE_INTEGER, counter, minimum: 20 });
}
const valid = { turn: 2, counter: 'total', minimum: 4 };
for (const turn of [0, -1, 1.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '2', null, undefined])
assert.throws(() => parseRevivalClaim({ ...valid, turn }), RevivalPromiseError);
for (const minimum of [0, -1, 1.5, 21, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER, '4', null, undefined])
assert.throws(() => parseRevivalClaim({ ...valid, minimum }), RevivalPromiseError);
for (const counter of ['forces', 'sardaukar', '', 1, null, undefined])
assert.throws(() => parseRevivalClaim({ ...valid, counter }), RevivalPromiseError);
for (const value of [null, undefined, [], 'four', 4]) assert.throws(() => parseRevivalClaim(value), RevivalPromiseError);
assert.match(revivalClaimText(parseRevivalClaim(input)), /at least 4 normal forces/);
assert.match(revivalClaimText(parseRevivalClaim(input)), /ordinary force revival from now/);
assert.match(revivalClaimText(parseRevivalClaim(input)), /your Revival opportunity ends this turn/);
});
void test('revival promise scope preserves bare registered native Basic and Advanced selections and optional-module fences', () => {
const classic = { advanced: false, expansions: [], players: [{ faction: 'atreides' }, { faction: 'emperor' }] };
const native = { advanced: true, expansions: ['ix', 'choam', 'ecaz'],
players: ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].map(faction => ({ faction })) };
for (const game of [classic, { ...classic, advanced: true }, native, { ...native, advanced: false },
{ ...classic, expansions: ['ix', 'choam', 'ecaz'] },
{ ...classic, advanced: true, expansions: ['ecaz'], players: [{ faction: 'moritani' }, { faction: 'harkonnen' }] },
]) assert.equal(revivalPromiseModeSupported(game), true);
for (const option of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery',
'semutaPreview', 'kullPreview', 'nexusKullPreview', 'richeseBetrayalPreview'] as const)
assert.equal(revivalPromiseModeSupported({ ...native, [option]: true }), false, option);
for (const game of [
{ ...native, expansions: ['ix', 'choam'] }, { ...native, expansions: ['ix', 'ix', 'ecaz'] },
{ ...classic, expansions: ['unknown'] }, { ...classic, players: [{ faction: 'atreides' }, { faction: 'unknown' }] },
{ ...classic, players: [{ faction: 'atreides' }] }, { ...classic, players: [...native.players, { faction: 'atreides' }] },
{ ...classic, advanced: 'basic' as unknown as boolean },
]) assert.equal(revivalPromiseModeSupported(game), false);
});
void test('typed revival counts physical elites once and separates total, normal and elite quantities', () => {
const group = { amount: 3, elite: 2 };
const expected = { total: 3, normal: 1, elite: 2 };
for (const counter of ['total', 'normal', 'elite'] as const) {
const claim: RevivalClaim = { turn: 2, counter, minimum: 1 };
assert.equal(revivalPromiseCount(claim, group), expected[counter]);
assert.equal(revivalPromiseCount(claim, { amount: 0, elite: 0 }), 0);
}
assert.equal(revivalPromiseCount({ turn: 2, counter: 'normal', minimum: 20 }, { amount: 20, elite: 0 }), 20);
assert.equal(revivalPromiseCount({ turn: 2, counter: 'total', minimum: 2 }, { amount: 2, elite: 2 }), 2);
for (const group of [{ amount: -1, elite: 0 }, { amount: 21, elite: 0 }, { amount: 1.5, elite: 0 },
{ amount: Infinity, elite: 0 }, { amount: 2, elite: -1 }, { amount: 2, elite: 3 },
{ amount: 2, elite: 0.5 }, { amount: 2, elite: Number.NaN }]) {
assert.throws(() => revivalPromiseCount({ turn: 2, counter: 'total', minimum: 1 }, group), RevivalPromiseError);
assert.throws(() => recordPromisedRevival([], 'respondent', 2, group), RevivalPromiseError);
assert.throws(() => revivalPromisesSatisfied([], 'respondent', 2, group), RevivalPromiseError);
}
});
void test('future ordinary requests accumulate after each answer while prior completed returns do not satisfy a new promise', () => {
const original = [promise('first-answer', 5)];
const first = recordPromisedRevival(original, 'respondent', 2, { amount: 2, elite: 1 });
assert.equal(first[0].progress, 2); assert.equal(first[0].fulfilled, undefined);
const second = recordPromisedRevival(first, 'respondent', 2, { amount: 3, elite: 0 });
assert.equal(second[0].progress, 5); assert.equal(second[0].fulfilled, true);
const later = [...second, promise('later-answer', 2)];
assert.equal(revivalPromisesSatisfied(later, 'respondent', 2, { amount: 0, elite: 0 }), false,
'Five earlier returns are outside the new answer’s future progress');
const third = recordPromisedRevival(later, 'respondent', 2, { amount: 1, elite: 0 });
assert.equal(third[0], second[0]); assert.equal(third[1].progress, 1); assert.equal(third[1].fulfilled, undefined);
const fourth = recordPromisedRevival(third, 'respondent', 2, { amount: 1, elite: 0 });
assert.equal(fourth[1].progress, 2); assert.equal(fourth[1].fulfilled, true);
assert.equal(original[0].progress, 0); assert.equal(first[0].progress, 2);
});
void test('one hypothetical completion must satisfy total, normal, elite and No answers together', () => {
const records = [promise('total-four', 4), promise('normal-three', 3, { counter: 'normal' }),
promise('elite-one', 1, { counter: 'elite' }), promise('not-two-elites', 2, { counter: 'elite', answer: false })];
assert.equal(revivalPromisesSatisfied(records, 'respondent', 2, { amount: 4, elite: 1 }), true);
assert.equal(revivalPromisesSatisfied(records, 'respondent', 2, { amount: 4, elite: 2 }), false,
'Four total cannot compensate for too few normals or a breached elite cap');
assert.equal(revivalPromisesSatisfied(records, 'respondent', 2, { amount: 4, elite: 0 }), false);
const split = recordPromisedRevival(records, 'respondent', 2, { amount: 2, elite: 1 });
assert.deepEqual(split.map(record => record.progress), [2, 1, 1, 1]);
assert.equal(revivalPromisesSatisfied(split, 'respondent', 2, { amount: 2, elite: 0 }), true);
const completed = recordPromisedRevival(split, 'respondent', 2, { amount: 2, elite: 0 });
assert.deepEqual(completed.map(record => !!record.fulfilled), [true, true, true, false]);
assert.equal(completed[3].progress, 1);
const conflict = [promise('two-elites', 2, { counter: 'elite' }),
promise('below-two-elites', 2, { counter: 'elite', answer: false })];
assert.equal(revivalPromisesSatisfied(conflict, 'respondent', 2, { amount: 2, elite: 2 }), false);
assert.equal(revivalPromisesSatisfied(conflict, 'respondent', 2, { amount: 2, elite: 1 }), false);
});
void test('No forbids crossing a cumulative cap through smaller split requests and rejection leaves every record unchanged', () => {
const initial = [promise('need-three-normal', 3, { counter: 'normal' }), promise('below-four-total', 4, { answer: false })];
const first = recordPromisedRevival(initial, 'respondent', 2, { amount: 2, elite: 1 });
const second = recordPromisedRevival(first, 'respondent', 2, { amount: 1, elite: 0 });
assert.deepEqual(second.map(record => record.progress), [2, 3]);
const before = structuredClone(second); second.forEach(Object.freeze); Object.freeze(second);
assert.throws(() => recordPromisedRevival(second, 'respondent', 2, { amount: 1, elite: 0 }), RevivalPromiseError);
assert.deepEqual(second, before, 'A disallowed fourth total return cannot partly fulfill the normal Yes');
assert.equal(revivalPromisesSatisfied(second, 'respondent', 2, { amount: 1, elite: 0 }), false);
assert.equal(initial[0].progress, 0); assert.equal(initial[1].progress, 0);
});
void test('fulfilled and released answers retain their exact records while an unmatched typed counter is a no-op', () => {
const fulfilled = promise('fulfilled', 2, { progress: 2, fulfilled: true });
const released = promise('released', 2, { progress: 1, released: true });
const elite = promise('still-needs-elite', 1, { counter: 'elite' });
const no = promise('continuing-no', 4, { answer: false });
const records = Object.freeze([fulfilled, released, elite, no]); records.forEach(Object.freeze);
const next = recordPromisedRevival(records, 'respondent', 2, { amount: 2, elite: 0 });
assert.equal(next[0], fulfilled); assert.equal(next[1], released); assert.equal(next[2], elite);
assert.notEqual(next[3], no); assert.equal(next[3].progress, 2); assert.equal(next[3].fulfilled, undefined);
assert.deepEqual(liveRevivalPromises(next, 'respondent', 2), [elite, next[3]]);
});
void test('current turn and respondent isolate progress while different askers retain jointly binding answers', () => {
const own = promise('own', 3), secondAsker = promise('rival-asker', 5, { asker: 'rival', answer: false });
const otherTurn = promise('old-turn', 1, { turn: 1 });
const otherPlayer = promise('other-player', 1, { player: 'rival' });
const records = [own, secondAsker, otherTurn, otherPlayer];
assert.deepEqual(liveRevivalPromises(records, 'respondent', 2), [own, secondAsker]);
assert.equal(revivalPromisesSatisfied(records, 'respondent', 2, { amount: 3, elite: 0 }), true);
const next = recordPromisedRevival(records, 'respondent', 2, { amount: 3, elite: 0 });
assert.equal(next[0].fulfilled, true); assert.equal(next[1].progress, 3);
assert.equal(next[2], otherTurn); assert.equal(next[3], otherPlayer);
const unrelated = recordPromisedRevival(records, 'absent', 2, { amount: 1, elite: 0 });
records.forEach((record, index) => assert.equal(unrelated[index], record));
});
void test('zero final additions distinguish outstanding Yes from satisfied No and progress cannot exceed twenty physical returns', () => {
const no = [promise('below-two', 2, { answer: false, progress: 1 })];
assert.equal(revivalPromisesSatisfied(no, 'respondent', 2, { amount: 0, elite: 0 }), true);
const yes = [promise('one-remaining', 20, { progress: 19 })];
assert.equal(revivalPromisesSatisfied(yes, 'respondent', 2, { amount: 0, elite: 0 }), false);
const zero = recordPromisedRevival(yes, 'respondent', 2, { amount: 0, elite: 0 });
assert.equal(zero[0], yes[0]);
assert.equal(revivalPromisesSatisfied(yes, 'respondent', 2, { amount: 1, elite: 0 }), true);
const completed = recordPromisedRevival(yes, 'respondent', 2, { amount: 1, elite: 0 });
assert.equal(completed[0].progress, 20); assert.equal(completed[0].fulfilled, true);
assert.equal(revivalPromisesSatisfied(yes, 'respondent', 2, { amount: 2, elite: 0 }), false);
assert.throws(() => recordPromisedRevival(yes, 'respondent', 2, { amount: 2, elite: 0 }), RevivalPromiseError);
assert.equal(revivalPromisesSatisfied(completed, 'respondent', 2, { amount: 0, elite: 0 }), true);
assert.equal(revivalPromisesSatisfied([], 'respondent', 2, { amount: 0, elite: 0 }), true);
});
void test('record validation binds original events, participants and consistent cumulative terminal states', () => {
const valid = [promise('live-yes', 3, { progress: 2 }),
promise('fulfilled-yes', 3, { progress: 3, fulfilled: true }),
promise('released-yes', 3, { progress: 1, released: true }),
promise('live-no', 3, { progress: 2, answer: false }),
promise('completed-no', 3, { progress: 2, answer: false, fulfilled: true }),
promise('released-no', 3, { progress: 2, answer: false, released: true })];
const before = structuredClone(valid);
assert.doesNotThrow(() => validateRevivalPromises(valid, seats)); assert.deepEqual(valid, before);
assert.doesNotThrow(() => validateRevivalPromises(JSON.parse(JSON.stringify(valid)) as RevivalPromise[], seats));
assert.doesNotThrow(() => validateRevivalPromises([], seats));
const base = promise('event', 3);
const invalid: unknown[] = [null, {}, [null], [base, { ...base }],
...[{ event: '' }, { event: ' ' }, { player: 'outsider' }, { asker: 'outsider' }, { asker: 'respondent' },
{ answer: 'yes' }, { progress: -1 }, { progress: 21 }, { progress: 1.5 }, { progress: Number.NaN },
{ progress: Number.MAX_SAFE_INTEGER + 1 }, { fulfilled: 1 }, { released: 'true' },
{ fulfilled: true, progress: 2 }, { fulfilled: false, progress: 3 }, { progress: 3 },
{ fulfilled: true, released: true, progress: 3 }, { answer: false, progress: 3 },
{ answer: false, progress: 3, fulfilled: true }, { answer: false, progress: 3, released: true },
{ turn: 0 }, { minimum: 21 }, { counter: 'sardaukar' }].map(extra => [{ ...base, ...extra }])];
for (const records of invalid)
assert.throws(() => validateRevivalPromises(records as RevivalPromise[], seats), RevivalPromiseError);
assert.deepEqual(valid, before);
});
+188
View File
@@ -0,0 +1,188 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { RevivalClaimFields, revivalClaimInputError } from '../components/revival-claim-fields';
import { RevivalPromises, RevivalPromiseGuideButton } from '../components/revival-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import type { RevivalClaim } from '../game/revival-promises';
import { createTechTokens } from '../game/tech-tokens';
import { createTruthRevivalFixture, bindTruthRevival, actTruthRevival, settleTruthRevival } from './fixture-truth-revival';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { Truthtrance } = await import('../components/truthtrance');
aliases.deregister();
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
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 field(props: Parameters<typeof RevivalClaimFields>[0], id: string) {
const found = elements(RevivalClaimFields(props)).find(element => element.props.id === id && typeof element.props.onChange === 'function');
assert.ok(found); return found;
}
function minimum(props: Parameters<typeof RevivalClaimFields>[0], value: number) {
(field(props, 'truth-revival-minimum').props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)(
{ currentTarget: { valueAsNumber: value } });
}
function counter(props: Parameters<typeof RevivalClaimFields>[0], value: string) {
(field(props, 'truth-revival-counter').props.onChange as (event: { currentTarget: { value: string } }) => void)(
{ currentTarget: { value } });
}
function guide(props: Parameters<typeof RevivalPromiseGuideButton>[0]) {
const button = RevivalPromiseGuideButton(props); assert.ok(button); return button;
}
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
const markup = (game: GameView, busy = false) => renderToStaticMarkup(createElement(RevivalPromises, { game, busy, send() {} }));
/** Controlled entitled projections exercise UI guards only. The source-earned
* producer below supplies actual ordinary returns and cumulative progress. */
function projected(action: 'revive' | 'ready' = 'revive', advanced = false): GameView {
const source = createGame('TRUTHREVIVALCONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced);
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
const game = viewGame(source, 'p');
game.status = 'playing'; game.phase = 4; game.turn = 2; game.ready = [];
game.revivalOrder = advanced ? { version: 1, turn: 2, original: ['p', 'a'], remaining: ['p', 'a'], completed: [], started: false } : null;
game.active = advanced ? 'p' : null;
game.revivalPromises = [{ event: 'controlled-revival-event', player: 'p', asker: 'a', turn: 2,
counter: 'total', minimum: 3, answer: action === 'revive', progress: 1 }];
game.revivalPromiseGuide = { action: action === 'revive'
? { type: 'revive', amount: 2, elite: 0, freeElite: 0, revivalPromiseEvent: 'controlled-revival-event' }
: { type: 'ready', revivalPromiseEvent: 'controlled-revival-event' } };
return game;
}
void test('Revival claim fields preserve the turn and author only parser-valid physical counter types and whole minima1 through20', () => {
const value: RevivalClaim = { turn: 2, counter: 'total', minimum: 3 }, changes: RevivalClaim[] = [];
const props = { value, onChange(next: RevivalClaim) { changes.push(next); } };
const html = renderToStaticMarkup(createElement(RevivalClaimFields, props));
assert.match(html, /Turn 2/); assert.match(html, /min="1"/); assert.match(html, /max="20"/);
for (const type of ['total', 'normal', 'elite']) assert.match(html, new RegExp(`value="${type}"`));
assert.match(html, /each physical counter counts once/);
assert.match(html, /Earlier returns, card effects, Ghola and Emperor extra revivals do not count/);
minimum(props, 1); minimum(props, 20); counter(props, 'normal'); counter(props, 'elite');
assert.deepEqual(changes, [{ ...value, minimum: 1 }, { ...value, minimum: 20 },
{ ...value, counter: 'normal' }, { ...value, counter: 'elite' }]);
for (const next of [0, 21, -1, 1.5, NaN, Infinity]) {
assert.ok(revivalClaimInputError({ ...value, minimum: next })); minimum(props, next);
}
counter(props, 'strength'); counter(props, ''); assert.equal(changes.length, 4);
minimum({ ...props, disabled: true }, 2); counter({ ...props, disabled: true }, 'normal');
minimum({ ...props, value: { ...value, turn: 0 } }, 2); assert.equal(changes.length, 4);
assert.match(renderToStaticMarkup(createElement(RevivalClaimFields, { ...props, value: { ...value, minimum: 0 } })), /role="alert"/);
});
void test('own Revival obligations persist with cumulative typed progress, exclusions and completion/release status', () => {
const game = projected('revive', true);
game.revivalPromises.push(
{ event: 'no-event', player: 'p', asker: 'a', turn: 2, counter: 'elite', minimum: 2, answer: false, progress: 1 },
{ event: 'fulfilled-event', player: 'p', asker: 'a', turn: 2, counter: 'normal', minimum: 1, answer: true, progress: 1, fulfilled: true },
{ event: 'released-event', player: 'p', asker: 'a', turn: 2, counter: 'total', minimum: 9, answer: true, progress: 0, released: true },
{ event: 'foreign-event', player: 'a', asker: 'p', turn: 2, counter: 'normal', minimum: 19, answer: true, progress: 0 },
);
game.revivalOrder!.remaining = ['a', 'p']; game.active = 'a'; game.revivalPromiseGuide = null;
const before = structuredClone(game);
for (let render = 0; render < 2; render++) {
const html = markup(game);
assert.match(html, /Your Revival promises/); assert.match(html, /Recorded future ordinary returns: 1 physical force/);
assert.match(html, /Recorded future ordinary returns: 1 elite force/);
assert.match(html, /No keeps your cumulative future returns below that minimum, including across smaller split requests/);
assert.match(html, /Prior returns, card effects, Ghola and Emperor extra revivals do not count/);
assert.match(html, /Original revival costs, limits and turn order still apply/);
assert.match(html, /Fulfilled: this commitment is complete/); assert.match(html, /Released: this commitment is no longer possible/);
assert.match(html, /Your Revival opportunity is still ahead/); assert.match(html, /href="\/rules#truthtrance-revival"/);
assert.doesNotMatch(html, /at least 19|foreign-event|controlled-revival-event/);
}
assert.deepEqual(game, before);
assert.equal(RevivalPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
});
void test('actual Basic and ordered Advanced guide callbacks forward native revive or Ready packets unchanged', () => {
for (const advanced of [false, true]) for (const action of ['revive', 'ready'] as const) {
const game = projected(action, advanced), sent: Action[] = [];
const props = { game, busy: false, send(next: Action) { sent.push(next); } };
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
assert.deepEqual(sent, [game.revivalPromiseGuide!.action]); assert.equal(sent[0], game.revivalPromiseGuide!.action);
press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
}
});
void test('stale Revival guides guard readiness, current ordered actor, event, quote and native action windows', () => {
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.revivalPromiseGuide = { action: { ...game.revivalPromiseGuide!.action } }; },
game => { game.revivalPromiseGuide = null; }, game => { game.revivalPromiseGuide!.action.amount = 3; },
game => { game.revivalPromises[0].event = 'another-event'; }, game => { game.revivalPromises[0].player = 'a'; },
game => { game.revivalPromises[0].fulfilled = true; }, game => { game.revivalPromises[0].released = true; },
game => { game.revivalPromises = []; }, game => { game.phaseOpening = { passed: [] }; },
game => { game.decision = { kind: 'revivalStop', player: 'a', recipient: 'p', revival: 'forces' }; },
game => { game.response = { kind: 'revivalIncome', owner: 'a', amount: 1, passed: [] }; },
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(); },
game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
];
for (const change of changes) {
const game = projected('revive', 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 guide cannot dispatch'); } };
const button = guide(props); props.busy = true; press(button);
});
void test('Revival guidance never reconstructs eligibility from private hands or rival physical pools', () => {
const game = projected(), sent: Action[] = [];
for (const seat of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'reserves', 'tanks', 'elites'])
Object.defineProperty(seat, key, { get() { throw Error(`No private or unneeded ${key} inspection`); }, configurable: true });
assert.match(markup(game), /Revive 2 forces while honoring your answers/);
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
assert.deepEqual(sent, [game.revivalPromiseGuide!.action]);
});
void test('source-earned Basic and Advanced Revival views connect the actual guide callback to completed ordinary progress', () => {
for (const advanced of [false, true]) {
const f = createTruthRevivalFixture({ advanced, window: 'active' });
const bound = bindTruthRevival(f, 'yes', 3, 'total');
const respondent = viewGame(bound.asked, f.target);
assert.ok(respondent.truthRevivalAnswers?.includes('yes'));
assert.equal(viewGame(bound.asked, f.asker).truthRevivalAnswers, null);
const answers = renderToStaticMarkup(createElement(Truthtrance, { game: respondent, busy: false, act() {} }));
assert.match(answers, /Answer Yes/); assert.match(answers, /Answer No/);
const game = viewGame(bound.game, f.target), sent: Action[] = [];
if (advanced) assert.equal(game.revivalOrder?.remaining[0], f.target);
else assert.equal(game.revivalOrder, null);
assert.match(markup(game), /Your Revival promises/); assert.match(markup(game), /Recorded future ordinary returns: 0 physical forces/);
const next = game.revivalPromiseGuide; assert.ok(next);
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
assert.deepEqual(sent, [next.action]);
const after = settleTruthRevival(actTruthRevival(bound.game, f.target, sent[0]));
const promise = after.revivalPromises?.find(promise => promise.event === next.action.revivalPromiseEvent);
assert.ok(promise); assert.equal(promise.progress, 3); assert.equal(promise.fulfilled, true);
assert.equal(after.players.find(player => player.id === f.target)!.tanks, 0);
assert.match(markup(viewGame(after, f.target)), /Fulfilled: this commitment is complete/);
assert.equal(RevivalPromiseGuideButton({ game: viewGame(after, f.target), busy: false, send() {} }), null);
assert.equal(RevivalPromises({ game: viewGame(after, f.asker), busy: false, send() {} }), null);
}
});
+63
View File
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import {
createTruthRevivalFixture as fixture, declareTruthRevival as declare,
actTruthRevival as act, askTruthRevival as ask, answerTruthRevival as answer,
advanceTruthRevival as advance, finishTruthRevival as finish,
truthRevivalClean as clean, truthRevivalPlayer as player, truthRevivalInventory as inventory,
} from './fixture-truth-revival';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before);
}
const shipmentQuestion = (target: string) => ({ kind: 'shipment', target,
claim: { territory: 'arrakeen', minimum: 3 } });
void test('earlier actual shipment Yes reserves its three spice when a later ordinary Revival question is answered', () => {
const f = fixture();
let game = declare(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: shipmentQuestion(f.target) });
assert.ok(viewGame(game, f.target).truthShipmentAnswers?.includes('yes'));
game = answer(game, f.target, 'yes');
game = ask(game, f.asker, f.target, 3);
assert.deepEqual(viewGame(game, f.target).truthRevivalAnswers, ['no']);
reject(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = answer(game, f.target, 'no');
game = act(game, f.target, { type: 'revive', amount: 1, elite: 0 });
assert.equal(player(game, f.target).spice, 4);
assert.equal(game.revivalPromises![0].progress, 1);
reject(game, f.target, { type: 'revive', amount: 2, elite: 0 });
game = finish(game);
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
game = act(game, f.target, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 3, elite: 0, allyPayment: 0 });
game = advance(game, clean);
assert.equal(player(game, f.target).spice, 1);
assert.equal(player(game, f.target).forces['arrakeen:10'], 3);
assert.ok(game.shipmentPromises!.every(promise => promise.fulfilled));
assert.ok(game.revivalPromises!.every(promise => promise.fulfilled));
assert.deepEqual(inventory(game), f.originalCards);
});
void test('earlier ordinary Revival Yes excludes an unaffordable later shipment Yes before the public answer', () => {
const f = fixture();
let game = declare(f, f.game, { copies: 2 }).game;
game = ask(game, f.asker, f.target, 3);
game = answer(game, f.target, 'yes');
game = act(game, f.asker, { type: 'truthAsk', question: shipmentQuestion(f.target) });
assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['no']);
reject(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = answer(game, f.target, 'no');
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide);
game = act(game, f.target, guide.action);
assert.equal(player(game, f.target).spice, 0);
assert.equal(player(game, f.target).tanks, 0);
assert.ok(game.revivalPromises![0].fulfilled);
game = finish(game);
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
game = act(game, f.target, { type: 'endMovement' });
assert.ok(game.shipmentPromises!.every(promise => promise.fulfilled));
assert.deepEqual(inventory(game), f.originalCards);
});
+211
View File
@@ -0,0 +1,211 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { forceRevivalQuote } from '../game/revival';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
actTruthRevival as act, advanceTruthRevival as advance, answerTruthRevival as answer,
askTruthRevival as ask, bindTruthRevival as bind, createTruthRevivalFixture as fixture,
declareTruthRevival as declare, finishTruthRevival as finish, settleTruthRevival as settle,
truthRevivalClean as clean, truthRevivalInventory as inventory, truthRevivalPlayer as player,
} from './fixture-truth-revival';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
const progress = (game: Game, target: string) => game.revivalPromises!.find(promise => promise.player === target)!;
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced typed' : 'Basic'} ordinary Revival Yes earns original free and paid returns from real Storm casualties`, () => {
const f = fixture({ advanced });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); assert.deepEqual(f.staging, []);
assert.deepEqual(f.game.expansions, []); assert.equal(f.originalCards.length, 33);
assert.equal(f.sourceActionCount, 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, 6);
assert.equal(player(f.shipment.after, f.target).forces[f.location], 3);
assert.equal(player(f.storm.after, f.target).forces[f.location], undefined);
assert.equal(player(f.storm.after, f.target).tanks, 3);
assert.equal(player(f.storm.after, f.target).elites?.tanks ?? 0, advanced ? 1 : 0);
const before = structuredClone(f.game), bound = bind(f);
assert.deepEqual(f.game, before);
assert.equal(bound.game.discard.filter(card => card.id === f.truth).length, 1);
assert.equal(progress(bound.game, f.target).progress, 0);
rejected(bound.game, f.target, { type: 'ready' });
const guide = viewGame(bound.game, f.target).revivalPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'revive'); assert.equal(guide.action.amount, 3); assert.equal(guide.action.elite, advanced ? 1 : 0);
const quote = forceRevivalQuote(bound.game, player(bound.game, f.target), 3, advanced ? 1 : 0);
assert.equal(quote.free, 1); assert.equal(quote.cost, 4);
const returned = settle(act(bound.game, f.target, guide.action)), own = player(returned, f.target);
assert.equal(own.spice, 0); assert.equal(own.reserves, 20); assert.equal(own.tanks, 0);
assert.equal(own.revived, 3); assert.equal(own.freeForcesRevived, 1);
if (advanced) { assert.equal(own.elites!.tanks, 0); assert.equal(own.elites!.reserves, 5); assert.equal(own.elites!.revived, 1); }
assert.equal(progress(returned, f.target).progress, 3); assert.equal(progress(returned, f.target).fulfilled, true);
assert.deepEqual(inventory(returned), f.originalCards);
const done = finish(returned); assert.equal(done.phase, 5); assert.deepEqual(inventory(done), f.originalCards);
});
void test('native split ordinary returns fulfill cumulative Yes only after the second actual request', () => {
const f = fixture(), bound = bind(f); let game = act(bound.game, f.target, { type: 'revive', amount: 1, elite: 0 });
assert.equal(progress(game, f.target).progress, 1); assert.ok(!progress(game, f.target).fulfilled);
assert.equal(player(game, f.target).spice, 4); rejected(game, f.target, { type: 'ready' });
game = act(game, f.target, { type: 'revive', amount: 2, elite: 0 });
assert.equal(progress(game, f.target).progress, 3); assert.equal(progress(game, f.target).fulfilled, true);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).freeForcesRevived, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native No counts split ordinary requests together and permits an owned finish below its threshold', () => {
const f = fixture(); let game = bind(f, 'no', 2).game;
game = act(game, f.target, { type: 'revive', amount: 1, elite: 0 });
assert.equal(progress(game, f.target).progress, 1);
rejected(game, f.target, { type: 'revive', amount: 1, elite: 0 });
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'ready');
game = act(game, f.target, guide.action); game = finish(game);
assert.equal(player(game, f.target).revived, 1); assert.equal(player(game, f.target).tanks, 2);
assert.equal(progress(game, f.target).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native Basic No survives an early Ready and permits only below-cap ordinary returns before phase close', () => {
const f = fixture(); let game = bind(f, 'no', 3).game;
game = act(game, f.target, { type: 'ready' });
assert.equal(game.phase, 4); assert.ok(game.ready.includes(f.target));
assert.ok(!progress(game, f.target).fulfilled);
game = act(game, f.target, { type: 'revive', amount: 1, elite: 0 });
assert.equal(progress(game, f.target).progress, 1); assert.equal(player(game, f.target).revived, 1);
rejected(game, f.target, { type: 'revive', amount: 2, elite: 0 });
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native Advanced elite Yes counts one physical Sardaukar and ignores an earlier normal partial', () => {
const f = fixture({ advanced: true }); let game = bind(f, 'yes', 1, 'elite').game;
game = act(game, f.target, { type: 'revive', amount: 1, elite: 0 });
assert.equal(progress(game, f.target).progress, 0); assert.equal(player(game, f.target).freeForcesRevived, 1);
rejected(game, f.target, { type: 'ready' });
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); assert.equal(guide.action.elite, 1);
game = act(game, f.target, guide.action);
assert.equal(progress(game, f.target).progress, 1); assert.equal(progress(game, f.target).fulfilled, true);
assert.equal(player(game, f.target).elites!.reserves, 5); assert.equal(player(game, f.target).elites!.tanks, 0);
assert.equal(player(game, f.target).revived, 2); assert.equal(player(game, f.target).spice, 2);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native prior ordinary returns cannot fulfill a newly answered Revival Yes', () => {
const f = fixture(); let game = act(f.game, f.target, { type: 'revive', amount: 1, elite: 0 });
game = bind(f, 'yes', 2, 'total', game).game;
assert.equal(player(game, f.target).revived, 1); assert.equal(progress(game, f.target).progress, 0);
game = act(game, f.target, { type: 'revive', amount: 1, elite: 0 });
assert.equal(progress(game, f.target).progress, 1); rejected(game, f.target, { type: 'ready' });
game = act(game, f.target, { type: 'revive', amount: 1, elite: 0 });
assert.equal(progress(game, f.target).progress, 2); assert.equal(player(game, f.target).spice, 0);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('two genuine Truth copies bind compatible Advanced normal and elite ordinary returns', () => {
const f = fixture({ advanced: true }), declared = declare(f, f.game, { copies: 2 });
let game = ask(declared.game, f.asker, f.target, 2, 'normal'); game = answer(game, f.target, 'yes');
game = ask(game, f.asker, f.target, 1, 'elite'); game = answer(game, f.target, 'yes');
for (let count = 0; game.revivalPromises!.some(promise => promise.answer && !promise.fulfilled && !promise.released) && count < 3; count++) {
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); game = act(game, f.target, guide.action);
}
assert.ok(game.revivalPromises!.every(promise => promise.fulfilled));
assert.deepEqual(game.revivalPromises!.map(promise => promise.progress), [2, 1]);
assert.equal(player(game, f.target).tanks, 0); assert.equal(player(game, f.target).spice, 0);
for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('two genuine Truth copies reject an incompatible cumulative ordinary Revival answer', () => {
const f = fixture(), declared = declare(f, f.game, { copies: 2 });
let game = ask(declared.game, f.asker, f.target, 2); game = answer(game, f.target, 'no');
game = ask(game, f.asker, f.target, 2);
assert.deepEqual(viewGame(game, f.target).truthRevivalAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no');
assert.equal(game.revivalPromises!.length, 2); assert.equal(game.discard.filter(card => f.truths.includes(card.id)).length, 2);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native promised payer cannot spend the four spice needed by its only ordinary Revival witness', () => {
const f = fixture(), bound = bind(f);
rejected(bound.game, f.target, { type: 'bribe', target: f.asker, amount: 1 });
assert.equal(player(bound.game, f.target).spice, 4); assert.equal(progress(bound.game, f.target).progress, 0);
});
void test('original Ghola return does not advance an ordinary Revival promise or consume its free quota', () => {
const f = fixture({ targetGhola: true }); let game = bind(f, 'no', 2).game; assert.ok(f.ghola);
game = settle(act(game, f.target, { type: 'card', card: f.ghola, amount: 1, elite: 0 }));
assert.equal(progress(game, f.target).progress, 0); assert.equal(player(game, f.target).revived, 0);
assert.equal(player(game, f.target).freeForcesRevived ?? 0, 0); assert.equal(player(game, f.target).tanks, 2);
assert.equal(game.discard.filter(card => card.id === f.ghola).length, 1);
game = act(game, f.target, { type: 'revive', amount: 1, elite: 0 });
assert.equal(progress(game, f.target).progress, 1); assert.equal(player(game, f.target).freeForcesRevived, 1);
rejected(game, f.target, { type: 'revive', amount: 1, elite: 0 }); assert.deepEqual(inventory(game), f.originalCards);
});
void test('original Ghola cannot voluntarily withdraw the Tanks needed by a promised ordinary return', () => {
const f = fixture({ targetGhola: true }), bound = bind(f); assert.ok(f.ghola);
rejected(bound.game, f.target, { type: 'card', card: f.ghola, amount: 1, elite: 0 });
assert.ok(player(bound.game, f.target).hand.some(card => card.id === f.ghola));
assert.equal(player(bound.game, f.target).tanks, 3);
});
void test('native waiting Advanced respondent keeps its future Revival witness until its original current opportunity', () => {
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).revivalPromiseGuide, null);
game = advance(game, state => clean(state) && state.revivalOrder?.remaining[0] === f.target);
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide);
game = act(game, f.target, guide.action); assert.equal(progress(game, f.target).progress, 3);
assert.equal(player(game, f.target).tanks, 0); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native Tleilaxu response records no promised progress before the actual allowed typed return', () => {
const f = fixture({ advanced: true, stopper: true }); let game = bind(f).game;
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); const before = structuredClone(player(game, f.target));
game = act(game, f.target, guide.action);
assert.equal(game.decision?.kind, 'revivalStop'); assert.equal(game.decision!.player, f.stopper);
assert.equal(progress(game, f.target).progress, 0); assert.deepEqual(player(game, f.target), before);
game = settle(act(game, f.stopper!, { type: 'decision', decline: true }));
assert.equal(progress(game, f.target).progress, 3); assert.equal(progress(game, f.target).fulfilled, true);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).tanks, 0);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native opposing Tleilaxu special Karama prevents the pending return and releases its impossible Yes', () => {
const f = fixture({ advanced: true, stopper: true }); let game = bind(f).game; assert.ok(f.special && f.karama);
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide);
game = act(game, f.target, guide.action); assert.equal(progress(game, f.target).progress, 0);
game = settle(act(game, f.stopper!, f.special));
assert.equal(progress(game, f.target).progress, 0); assert.equal(progress(game, f.target).released, true);
assert.equal(player(game, f.target).spice, 4); assert.equal(player(game, f.target).tanks, 3);
assert.equal(player(game, f.target).revived, 0); assert.equal(player(game, f.target).freeForcesRevived ?? 0, 0);
assert.equal(player(game, f.stopper!).specialKaramaUsed, true);
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
game = act(game, f.target, { type: 'ready' }); assert.ok(game.revivalOrder!.completed.includes(f.target));
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native Revival question ownership and stale guided actions preserve the actual current turn', () => {
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 } });
let game = ask(declared.game, f.asker, f.target);
assert.deepEqual(viewGame(game, f.target).truthRevivalAnswers, ['yes', 'no']);
assert.equal(viewGame(game, f.asker).truthRevivalAnswers, null);
rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes');
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); assert.ok(guide.action.revivalPromiseEvent);
rejected(game, f.opponent, guide.action);
game = act(game, f.target, guide.action); rejected(game, f.target, guide.action);
game = act(game, f.target, { type: 'ready' }); assert.ok(game.revivalOrder!.completed.includes(f.target));
rejected(game, f.target, guide.action); assert.deepEqual(inventory(game), f.originalCards);
});
void test('all four minimal policies offer a legal ordinary return for the native Revival Yes', () => {
const f = fixture(), game = bind(f).game;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'revive' && action.amount === 3); assert.ok(action, difficulty);
const accepted = act(structuredClone(game), f.target, action);
assert.equal(progress(accepted, f.target).fulfilled, true); assert.equal(player(accepted, f.target).tanks, 0);
assert.deepEqual(inventory(accepted), f.originalCards);
}
});