Add cumulative Truthtrance force-revival commitments
This commit is contained in:
1 parent
bd6e5bbf7f
commit
24653799cf
21 files changed
+1540
-56
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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)!;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user