Extend Truthtrance revival promises through earlier turn phases

This commit is contained in:
Quintine committed 2026-10-26 13:09:17 +11:00
1 parent 24653799cf
commit 7fc21113e3
14 files changed
+810 -52

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { RevivalClaim } from '../game/revival-promises';
import {
actTruthRevival, answerTruthRevival, askTruthRevival, bindTruthRevival, createTruthRevivalFixture,
declareTruthRevival, nextTruthRevival, settleTruthRevival,
truthRevivalClean, truthRevivalInventory, truthRevivalPlayer, type TruthRevivalStep, type TruthRevivalTransition,
} from './fixture-truth-revival';
export type TruthRevivalEarlyStep = TruthRevivalStep;
export const actTruthRevivalEarly = actTruthRevival;
export const truthRevivalEarlyClean = truthRevivalClean;
export const truthRevivalEarlyInventory = truthRevivalInventory;
export const truthRevivalEarlyPlayer = truthRevivalPlayer;
export const askTruthRevivalEarly = askTruthRevival;
export const answerTruthRevivalEarly = answerTruthRevival;
export const settleTruthRevivalEarly = settleTruthRevival;
export function nextTruthRevivalEarly(game: Game): TruthRevivalEarlyStep | null {
if (game.truthtrance) return null;
for (const player of game.players) {
const view = viewGame(game, player.id);
if (game.phase === 3 && view.bidPromiseGuide) return { actor: player.id, action: view.bidPromiseGuide.action };
const guide = view.revivalPromiseGuide;
if (guide && guide.phase === game.phase) return { actor: player.id, action: guide.action };
}
return nextTruthRevival(game);
}
export function advanceTruthRevivalEarly(game: Game, until: (state: Game) => boolean,
actions: TruthRevivalEarlyStep[] = []): Game {
for (let count = 0; count < 1500; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The earned early Revival source ended before its owned control');
const next = nextTruthRevivalEarly(game); assert.ok(next, 'Answer the actual original Truthtrance before continuing');
game = actTruthRevivalEarly(game, next.actor, next.action, actions);
}
throw Error('The earned early Revival source did not reach its finite boundary');
}
export function atTruthRevivalEarlyOpportunity(game: Game, target: string) {
return game.phase === 4 && truthRevivalEarlyClean(game) &&
(!game.revivalOrder || game.revivalOrder.remaining[0] === target);
}
export function reachTruthRevivalEarlyOpportunity(game: Game, target = 'emperor', actions: TruthRevivalEarlyStep[] = []) {
return advanceTruthRevivalEarly(game, state => atTruthRevivalEarlyOpportunity(state, target), actions);
}
export function finishTruthRevivalEarly(game: Game, actions: TruthRevivalEarlyStep[] = []): Game {
const turn = game.turn;
return advanceTruthRevivalEarly(game, state => state.turn === turn && state.phase === 5 && truthRevivalEarlyClean(state), actions);
}
/** Reuse only the actual post-Storm snapshot and its exact original action
* prefix. The already constructed later Revival suffix is excluded. Phase0
* additionally completes a genuine turn; its optional prior return uses a real
* free request and deliberately leaves that old ledger visible until next4. */
export function createTruthRevivalEarlyFixture(options: {
advanced?: boolean; phase?: 0 | 1 | 2 | 3; poor?: boolean; stopper?: boolean; phase0PriorReturn?: boolean; targetKarama?: boolean;
} = {}) {
const advanced = options.advanced ?? false, phase = options.phase ?? 1;
const base = createTruthRevivalFixture({ advanced, stopper: options.stopper, targetKarama: options.targetKarama, window: 'waiting' });
const prefixCount = base.sourceActionCount - base.arrival.steps.length;
const actions = structuredClone(base.sourceActions.slice(0, prefixCount));
assert.deepEqual(actions.at(-1), base.storm.steps.at(-1));
let game = structuredClone(base.storm.after);
const postStorm = structuredClone(game);
const act = (actor: string, action: Action) => { game = actTruthRevivalEarly(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthRevivalEarly(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) };
};
assert.equal(game.turn, 2); assert.equal(game.phase, 1); assert.equal(truthRevivalEarlyPlayer(game, base.target).tanks, 3);
let priorReturn: TruthRevivalTransition | null = null;
if (phase === 0) {
if (options.phase0PriorReturn) {
reach(state => atTruthRevivalEarlyOpportunity(state, base.target));
priorReturn = transition(() => {
act(base.target, { type: 'revive', amount: 1, elite: 0 });
game = settleTruthRevivalEarly(game, actions);
});
}
reach(state => state.turn === 3 && state.phase === 0 && truthRevivalEarlyClean(state));
} else if (phase !== 1) reach(state => state.turn === 2 && state.phase === phase && truthRevivalEarlyClean(state));
assert.equal(game.phase, phase);
// These still-unopened ordinary cards keep the following early-phase path
// separate from an unrelated worm/alliance or Sandtrout programme.
const futureSpiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
if (phase === 0) for (const position of [0, 1]) {
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);
futureSpiceOrder.push({ position, from, card: structuredClone(card) });
}
let funding: TruthRevivalTransition | null = null;
if (options.poor) {
assert.equal(phase, 2, 'The earned Charity witness begins at quiet actual Charity');
assert.equal(truthRevivalEarlyPlayer(game, base.target).spice, 4);
funding = transition(() => { act(base.target, { type: 'bribe', target: base.asker, amount: 4 }); });
assert.equal(truthRevivalEarlyPlayer(game, base.target).spice, 0);
}
assert.deepEqual(truthRevivalEarlyInventory(game), base.originalCards);
const question = { kind: 'revival' as const, target: base.target, turn: game.turn,
counter: 'total' as const, minimum: options.poor || options.phase0PriorReturn ? 2 : 3 };
return { ...base, game, sourceGame: structuredClone(game), base, advanced, phase,
originalPostStorm: postStorm, postStormActionCount: prefixCount, excludedArrivalSteps: base.arrival.steps,
priorReturn, futureSpiceOrder, funding, poor: options.poor ?? false,
question, questionAction: { type: 'truthAsk', question } as Action,
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
staging: [] as string[], sourceDisclosure: 'Only the original earned Storm snapshot and its actual action prefix are reused; the later Revival suffix is removed from the source log. Phase0 completes a real extra turn and can earn a prior free return. Charity poverty uses an actual four-spice bribe before Truth. Questions, guides, bids, invoices, Charity and ordinary returns remain actual caller actions; no played state or promise is installed.' };
}
export type TruthRevivalEarlyFixture = ReturnType<typeof createTruthRevivalEarlyFixture>;
export function declareTruthRevivalEarly(f: TruthRevivalEarlyFixture, state = f.game, options: { copies?: 1 | 2 } = {}) {
return declareTruthRevival(f, state, options);
}
export function bindTruthRevivalEarly(f: TruthRevivalEarlyFixture, answer: 'yes' | 'no' = 'yes',
minimum = f.question.minimum, counter: RevivalClaim['counter'] = 'total', state = f.game) {
return bindTruthRevival(f, answer, minimum, counter, state);
}
/** Every intervening phase follows native controls or its own exact projected
* commitment guide. Stop before the actual current ordinary return, not after
* an invented future action. */
export function completeTruthRevivalEarly(f: TruthRevivalEarlyFixture, state = f.game) {
const actions: TruthRevivalEarlyStep[] = [];
const game = reachTruthRevivalEarlyOpportunity(state, f.target, actions);
return { game, before: structuredClone(state), actions, actionCount: actions.length };
}
+4 -3
View File
@@ -50,7 +50,7 @@ export function finishTruthRevival(game: Game, actions: TruthRevivalStep[] = [])
* 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;
advanced?: boolean; window?: 'active' | 'waiting'; targetGhola?: boolean; targetKarama?: 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');
@@ -73,8 +73,8 @@ export function createTruthRevivalFixture(options: {
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;
const targetCard = game.deck.find(card => options.targetGhola ? card.effect === 'ghola' : options.targetKarama ? card.effect === 'karama' : card.kind === 'shield');
const karama = stopper ? game.deck.find(card => card.effect === 'karama' && card.id !== targetCard?.id) : 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) => {
@@ -126,6 +126,7 @@ export function createTruthRevivalFixture(options: {
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,
auctionKarama: options.targetKarama ? targetCard.id : null,
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,
+2 -2
View File
@@ -61,7 +61,7 @@ function projected(action: 'revive' | 'ready' = 'revive', advanced = false): Gam
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'
game.revivalPromiseGuide = { phase: 4, action: action === 'revive'
? { type: 'revive', amount: 2, elite: 0, freeElite: 0, revivalPromiseEvent: 'controlled-revival-event' }
: { type: 'ready', revivalPromiseEvent: 'controlled-revival-event' } };
return game;
@@ -129,7 +129,7 @@ void test('stale Revival guides guard readiness, current ordered actor, event, q
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 = { phase: 4, 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; },
+178
View File
@@ -0,0 +1,178 @@
import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { RevivalPromises, RevivalPromiseGuideButton } from '../components/revival-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { createTechTokens } from '../game/tech-tokens';
import {
actTruthRevivalEarly, advanceTruthRevivalEarly, bindTruthRevivalEarly, completeTruthRevivalEarly,
createTruthRevivalEarlyFixture, settleTruthRevivalEarly, truthRevivalEarlyClean, truthRevivalEarlyInventory,
} from './fixture-truth-revival-early';
type Props = Parameters<typeof RevivalPromiseGuideButton>[0];
function guide(props: Props) { 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 personalized preparation quotes only. The native source below
* separately earns its Tanks, Truthtrance answer and preparation journey. */
function projected(phase: number, type: 'charity' | 'ready' | 'passBid' | 'decision' = 'charity'): GameView {
const source = createGame('EARLYREVIVALCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
const game = viewGame(source, 'p');
game.status = 'playing'; game.turn = 2; game.phase = phase; game.ready = [];
game.revivalPromises = [{ event: 'early-revival-event', player: 'p', asker: 'a', turn: 2,
counter: 'total', minimum: 3, answer: true, progress: 0 }];
game.auction = phase === 3 ? { bid: 7, bidder: type === 'decision' ? 'p' : 'a', active: type === 'decision' ? 'a' : 'p',
remaining: 2, inspectionBlocked: null, card: null } : null;
game.decision = type === 'decision' ? { kind: 'auctionPayment', player: 'p' } : null;
game.revivalPromiseGuide = { phase, action: { type, revivalPromiseEvent: 'early-revival-event',
...(type === 'decision' ? { karama: true, card: 'original-projected-karama' } : {}) } };
return game;
}
void test('early Revival guidance stays visible in quiet Storm and Spice without inventing an immediate action', () => {
for (const phase of [0, 1]) {
const game = projected(phase); game.revivalPromiseGuide = null;
const html = markup(game);
assert.match(html, /Your Revival promises/); assert.match(html, /Your Revival opportunity is still ahead/);
assert.match(html, /continue the current phase and keep the required spice and forces available/);
assert.match(html, /These steps preserve your later Revival; they do not count as returned forces/);
assert.equal(RevivalPromiseGuideButton({ game, busy: false, send() {} }), null);
// Even a stale supplied preparation cannot create a Storm/Spice action.
game.revivalPromiseGuide = { phase, action: { type: 'ready', revivalPromiseEvent: 'early-revival-event' } };
const sent: Action[] = []; press(guide({ game, busy: false, send(action) { sent.push(action); } }));
assert.deepEqual(sent, []);
}
});
void test('actual phase-two Charity and Ready callbacks forward the current preparation quote and preserve zero return progress', () => {
for (const type of ['charity', 'ready'] as const) {
const game = projected(2, type), sent: Action[] = [];
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
assert.deepEqual(sent, [game.revivalPromiseGuide!.action]); assert.equal(sent[0], game.revivalPromiseGuide!.action);
assert.equal(game.revivalPromises[0].progress, 0);
assert.match(markup(game), type === 'charity' ? /Claim Charity for your later Revival/ : /Finish Charity while preserving your Revival answers/);
press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
game.ready = ['p']; press(button); assert.equal(sent.length, 1);
}
});
void test('actual phase-three pass and owned auction-payment callbacks retain original funding and Karama packets', () => {
for (const type of ['passBid', 'decision'] as const) {
const game = projected(3, type), sent: Action[] = [];
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
assert.deepEqual(sent, [game.revivalPromiseGuide!.action]); assert.equal(sent[0], game.revivalPromiseGuide!.action);
assert.equal(game.revivalPromises[0].progress, 0);
if (type === 'decision') {
assert.equal(game.auction!.active, 'a', 'The owned payment decision, rather than the next bidder, owns this action.');
assert.equal(sent[0].card, 'original-projected-karama'); assert.equal(sent[0].karama, true);
assert.match(markup(game), /Use the quoted Karama auction payment for your later Revival/);
assert.doesNotMatch(markup(game), /original-projected-karama/);
} else assert.match(markup(game), /Pass this bid while preserving your Revival answers/);
press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
}
const paid = projected(3, 'decision'); paid.revivalPromiseGuide!.action = {
type: 'decision', karama: false, revivalPromiseEvent: 'early-revival-event',
};
assert.match(markup(paid), /Pay the current auction price while preserving your Revival answers/);
});
void test('early guide callbacks reject changed phase, actor, parent payment, event, quote and interrupted windows', () => {
const common: ((game: GameView) => void)[] = [
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 4; },
game => { game.revivalPromiseGuide!.phase = 4; },
game => { game.revivalPromiseGuide = { phase: game.phase, action: { ...game.revivalPromiseGuide!.action } }; },
game => { game.revivalPromiseGuide = null; }, game => { game.revivalPromises[0].event = 'expired'; },
game => { game.revivalPromises[0].fulfilled = true; }, game => { game.revivalPromises[0].released = true; },
game => { game.phaseOpening = { passed: [] }; },
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
game => { game.automaticContinuationPending = true; },
game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.techTokens = createTechTokens(); },
];
for (const type of ['passBid', 'decision'] as const) {
const specific: ((game: GameView) => void)[] = type === 'decision' ? [
game => { game.decision = null; }, game => { game.decision = { kind: 'auctionPayment', player: 'a' }; },
game => { game.decision = { kind: 'revivalStop', player: 'p', recipient: 'a', revival: 'forces' }; },
game => { game.revivalPromiseGuide!.action.card = 'another-card'; },
] : [game => { game.auction!.active = 'a'; }, game => { game.decision = { kind: 'auctionPayment', player: 'p' }; }];
for (const change of [...common, ...specific]) {
const game = projected(3, type), 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(2), busy: false, send() { assert.fail('A now-busy preparation cannot dispatch'); } };
const button = guide(props); props.busy = true; press(button);
for (const [phase, type] of [[2, 'passBid'], [2, 'decision'], [3, 'ready']] as const) {
const game = projected(phase, type), sent: Action[] = [];
press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.deepEqual(sent, []);
}
});
void test('preparation guidance never opens private holdings or the unrevealed original auction card', () => {
for (const type of ['passBid', 'decision'] as const) {
const game = projected(3, type), sent: Action[] = [];
for (const seat of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'tanks', 'reserves'])
Object.defineProperty(seat, key, { get() { throw Error(`No unneeded private ${key}`); }, configurable: true });
Object.defineProperty(game.auction!, 'card', { get() { throw Error('No unrevealed lot inspection'); } });
assert.match(markup(game), /Your Revival promises/);
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
assert.deepEqual(sent, [game.revivalPromiseGuide!.action]);
}
});
void test('genuine Basic and Advanced Charity-funded early answers follow actual Charity, pass and native-return callbacks', () => {
for (const advanced of [false, true]) {
const f = createTruthRevivalEarlyFixture({ advanced, phase: 2, poor: true });
let game = bindTruthRevivalEarly(f).game;
const dispatch = (phase: number, type: string) => {
const view = viewGame(game, f.target), sent: Action[] = [], next = view.revivalPromiseGuide;
assert.ok(next); assert.equal(next.phase, phase); assert.equal(next.action.type, type);
press(guide({ game: view, busy: false, send(action) { sent.push(action); } }));
assert.deepEqual(sent, [next.action]); game = actTruthRevivalEarly(game, f.target, sent[0]);
};
assert.match(markup(viewGame(game, f.target)), /Claim Charity for your later Revival/);
dispatch(2, 'charity');
assert.equal(game.players.find(player => player.id === f.target)!.spice, 2);
assert.equal(game.revivalPromises!.find(promise => promise.player === f.target)!.progress, 0);
dispatch(2, 'ready');
game = advanceTruthRevivalEarly(game, state => state.phase === 3 && truthRevivalEarlyClean(state) && state.auction?.active === f.target);
assert.match(markup(viewGame(game, f.target)), /Pass this bid while preserving your Revival answers/);
dispatch(3, 'passBid');
assert.equal(game.revivalPromises!.find(promise => promise.player === f.target)!.progress, 0);
game = completeTruthRevivalEarly(f, game).game;
dispatch(4, 'revive'); game = settleTruthRevivalEarly(game);
const promise = game.revivalPromises!.find(promise => promise.player === f.target)!;
assert.equal(promise.progress, 2); assert.equal(promise.fulfilled, true);
assert.deepEqual(truthRevivalEarlyInventory(game), f.originalCards);
}
});
void test('controlled native Richese zero/pass guides retain their event and owned opportunity without reading sealed bids', () => {
for (const method of ['silent', 'onceAround'] as const) {
const game = projected(3, 'passBid');
const native = createRicheseAuction({ event: 'native-controlled-lot', cardId: 'controlled-card', owner: 'a',
method, source: 'cache', eligible: ['p', 'a'], order: ['p', 'a'], tieOrder: ['p', 'a'] });
game.richeseAuction = { ...projectRicheseAuction(native, 'p'), card: null, claim: null,
ownAllyPayment: 0, ownAvailable: 4, allyAvailable: 0, peekKnown: false, inspectionBlocked: null };
game.revivalPromiseGuide = { phase: 3, action: { type: 'richeseBid', event: native.event,
amount: method === 'silent' ? 0 : null, allyPayment: 0, revivalPromiseEvent: 'early-revival-event' } };
const sent: Action[] = [], props = { game, busy: false, send(action: Action) { sent.push(action); } };
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
assert.deepEqual(sent, [game.revivalPromiseGuide.action]);
assert.match(markup(game), method === 'silent' ? /Submit zero in this Silent auction/ : /Pass this Richese bid/);
game.richeseAuction.event = 'later-lot'; press(button); assert.equal(sent.length, 1);
game.richeseAuction.event = native.event;
if (method === 'silent') game.richeseAuction.submitted.push('p'); else game.richeseAuction.active = 'a';
press(button); assert.equal(sent.length, 1);
}
});
+209
View File
@@ -0,0 +1,209 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
actTruthRevivalEarly as act, advanceTruthRevivalEarly as advance, bindTruthRevivalEarly as bind,
answerTruthRevivalEarly as answer, askTruthRevivalEarly as ask, declareTruthRevivalEarly as declare,
completeTruthRevivalEarly as complete, createTruthRevivalEarlyFixture as fixture,
finishTruthRevivalEarly as finish, settleTruthRevivalEarly as settle,
truthRevivalEarlyClean as clean, truthRevivalEarlyInventory as inventory, truthRevivalEarlyPlayer as player,
} from './fixture-truth-revival-early';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
const promise = (game: Game, target: string) => game.revivalPromises!.find(promise => promise.player === target)!;
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced typed' : 'Basic'} post-Storm Truth Revival Yes survives actual Spice Charity and Bidding`, () => {
const f = fixture({ advanced }); assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 1);
assert.deepEqual(f.originalPostStorm, f.base.storm.after);
assert.deepEqual(f.sourceActions, f.base.sourceActions.slice(0, f.postStormActionCount));
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.deepEqual(f.staging, []);
assert.equal(player(f.game, f.target).tanks, 3); assert.equal(player(f.game, f.target).spice, 4);
let game = bind(f).game; assert.equal(promise(game, f.target).progress, 0);
assert.equal(viewGame(game, f.target).revivalPromiseGuide, null, 'Quiet Spice is not a fabricated owned guide opportunity');
const actual = complete(f, game); game = actual.game;
assert.ok(actual.actions.some(step => step.action.type === 'passBid'));
assert.equal(game.phase, 4); assert.equal(promise(game, f.target).progress, 0);
if (advanced) assert.equal(game.revivalOrder!.remaining[0], f.target);
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); assert.equal(guide.phase, 4);
game = settle(act(game, f.target, guide.action));
assert.equal(promise(game, f.target).progress, 3); assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).tanks, 0);
assert.equal(player(game, f.target).freeForcesRevived, 1);
assert.equal(player(game, f.target).elites?.revived ?? 0, advanced ? 1 : 0);
assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native Storm-phase promise uses the next real Revival reset and excludes a prior-turn ordinary return', () => {
const f = fixture({ phase: 0, phase0PriorReturn: true });
assert.equal(f.game.turn, 3); assert.equal(f.game.phase, 0); assert.ok(f.priorReturn);
assert.equal(player(f.game, f.target).tanks, 2); assert.equal(player(f.game, f.target).revived, 1);
assert.equal(player(f.game, f.target).freeForcesRevived, 1);
let game = bind(f).game; assert.equal(promise(game, f.target).progress, 0);
game = complete(f, game).game;
assert.equal(player(game, f.target).revived, 0); assert.equal(player(game, f.target).freeForcesRevived, 0);
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 2);
game = act(game, f.target, guide.action);
assert.equal(promise(game, f.target).progress, 2); assert.equal(player(game, f.target).spice, 2);
assert.equal(player(game, f.target).freeForcesRevived, 1); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native Charity-funded early Yes requires its actual two-spice claim before owned readiness or spending', () => {
const f = fixture({ phase: 2, poor: true }); assert.ok(f.funding);
assert.equal(f.funding.steps[0].action.type, 'bribe'); assert.equal(player(f.game, f.target).spice, 0);
assert.equal(player(f.game, f.asker).bribes, 4);
let game = bind(f).game;
rejected(game, f.target, { type: 'ready' });
const charity = viewGame(game, f.target).revivalPromiseGuide; assert.ok(charity); assert.equal(charity.phase, 2);
assert.equal(charity.action.type, 'charity'); game = act(game, f.target, charity.action);
assert.equal(player(game, f.target).spice, 2); assert.equal(promise(game, f.target).progress, 0);
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 1 });
game = advance(game, state => state.phase === 3 && clean(state) && state.auction?.active === f.target);
rejected(game, f.target, { type: 'bid', amount: 1 });
const bidGuide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(bidGuide); assert.equal(bidGuide.phase, 3);
assert.equal(bidGuide.action.type, 'passBid'); game = act(game, f.target, bidGuide.action);
game = complete(f, game).game;
const returned = viewGame(game, f.target).revivalPromiseGuide; assert.ok(returned); assert.equal(returned.phase, 4);
game = act(game, f.target, returned.action);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).tanks, 1);
assert.equal(promise(game, f.target).progress, 2); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native early No remains binding through Bidding and limits the actual later ordinary return', () => {
const f = fixture(); let game = bind(f, 'no', 2).game;
game = complete(f, game).game;
game = act(game, f.target, { type: 'revive', amount: 1, elite: 0 });
assert.equal(promise(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(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native early Yes reserves its paid return funding against a new owned auction bid', () => {
const f = fixture({ phase: 3 }); let game = bind(f).game;
game = advance(game, state => clean(state) && state.auction?.active === f.target);
assert.equal(player(game, f.target).spice, 4);
rejected(game, f.target, { type: 'bid', amount: 1 });
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide);
assert.equal(guide.phase, 3); assert.equal(guide.action.type, 'passBid');
game = act(game, f.target, guide.action); game = complete(f, game).game;
assert.equal(player(game, f.target).spice, 4); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native existing winning bid and its real payment reserve the early Revival invoice once', () => {
const f = fixture({ phase: 3 }); let game = f.game;
game = advance(game, state => clean(state) && state.auction?.active === f.target);
const card = game.auction!.cards[game.auction!.index].id;
game = act(game, f.target, { type: 'bid', amount: 2 });
assert.equal(game.auction!.bidder, f.target); assert.equal(player(game, f.target).spice, 4);
game = bind(f, 'yes', 2, 'total', game).game;
assert.equal(promise(game, f.target).progress, 0);
game = complete(f, game).game;
assert.equal(player(game, f.target).spice, 2); assert.ok(player(game, f.target).hand.some(held => held.id === card));
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 2);
game = act(game, f.target, guide.action);
assert.equal(player(game, f.target).spice, 0); assert.equal(promise(game, f.target).progress, 2);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native owned Karama auction-payment guide preserves the actual funds for the earlier Revival Yes', () => {
const f = fixture({ phase: 3, targetKarama: true }); assert.ok(f.auctionKarama);
let game = bind(f).game;
game = advance(game, state => clean(state) && state.auction?.active === f.target);
game = act(game, f.target, { type: 'bid', amount: 5 });
assert.equal(player(game, f.target).spice, 4);
game = advance(game, state => !state.response && !state.phaseOpening && state.decision?.kind === 'auctionPayment' && state.decision.player === f.target);
const payment = viewGame(game, f.target).revivalPromiseGuide; assert.ok(payment); assert.equal(payment.phase, 3);
assert.equal(payment.action.type, 'decision'); assert.equal(payment.action.karama, true);
assert.equal(payment.action.card, f.auctionKarama);
game = act(game, f.target, payment.action);
assert.equal(player(game, f.target).spice, 4); assert.equal(promise(game, f.target).progress, 0);
assert.equal(game.discard.filter(card => card.id === f.auctionKarama).length, 1);
game = complete(f, game).game;
const revival = viewGame(game, f.target).revivalPromiseGuide; assert.ok(revival); assert.equal(revival.phase, 4);
game = act(game, f.target, revival.action);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).tanks, 0);
assert.equal(promise(game, f.target).progress, 3); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native earlier-phase typed Yes releases only after a real later Tleilaxu prevention', () => {
const f = fixture({ advanced: true, stopper: true }); let game = bind(f).game; assert.ok(f.stopper && f.special);
game = complete(f, game).game;
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide);
game = act(game, f.target, guide.action); assert.equal(game.decision?.kind, 'revivalStop');
assert.equal(promise(game, f.target).progress, 0);
game = settle(act(game, f.stopper, f.special));
assert.equal(promise(game, f.target).released, true); assert.equal(promise(game, f.target).progress, 0);
assert.equal(player(game, f.target).tanks, 3); assert.equal(player(game, f.target).spice, 4);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('two genuine early Truth answers preserve both a funded bid and the later ordinary return', () => {
const f = fixture({ phase: 3 }), declared = declare(f, f.game, { copies: 2 });
let game = ask(declared.game, f.asker, f.target, 2); game = answer(game, f.target, 'yes');
game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'bid', target: f.target,
turn: game.turn, lot: game.auction!.index + 1, minimum: 1 } });
assert.ok(viewGame(game, f.target).truthBidAnswers!.includes('yes'));
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = advance(game, state => clean(state) && state.auction?.active === f.target);
assert.notEqual(viewGame(game, f.target).revivalPromiseGuide?.action.type, 'passBid');
const bid = viewGame(game, f.target).bidPromiseGuide; assert.ok(bid); assert.equal(bid.action.amount, 1);
game = act(game, f.target, bid.action); game = complete(f, game).game;
assert.equal(player(game, f.target).spice, 3); assert.equal(promise(game, f.target).progress, 0);
const revival = viewGame(game, f.target).revivalPromiseGuide; assert.ok(revival); assert.equal(revival.action.amount, 2);
game = act(game, f.target, revival.action);
assert.equal(player(game, f.target).spice, 1); assert.equal(promise(game, f.target).progress, 2);
assert.ok(game.bidPromises!.every(promise => promise.fulfilled || promise.released));
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('a genuine early Revival Yes cannot also promise an unfunded ordinary bid', () => {
const f = fixture({ phase: 3 }), declared = declare(f, f.game, { copies: 2 });
let game = ask(declared.game, f.asker, f.target, 3); game = answer(game, f.target, 'yes');
game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'bid', target: f.target,
turn: game.turn, lot: game.auction!.index + 1, minimum: 1 } });
assert.deepEqual(viewGame(game, f.target).truthBidAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
assert.equal(promise(game, f.target).answer, true); assert.equal(game.bidPromises![0].answer, false);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('a genuine earlier bid Yes excludes an ordinary Revival Yes that would consume its last bid funding', () => {
const f = fixture({ phase: 3 }), declared = declare(f, f.game, { copies: 2 });
let game = act(declared.game, f.asker, { type: 'truthAsk', question: { kind: 'bid', target: f.target,
turn: f.game.turn, lot: f.game.auction!.index + 1, minimum: 1 } });
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = ask(game, f.asker, f.target, 3);
assert.deepEqual(viewGame(game, f.target).truthRevivalAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = answer(game, f.target, 'no');
assert.equal(promise(game, f.target).answer, false); assert.equal(game.bidPromises![0].answer, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native early guide acts only for its real Charity owner and cannot replay in a later phase', () => {
const f = fixture({ phase: 2, poor: true }); let game = bind(f).game;
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); assert.equal(guide.phase, 2);
assert.equal(viewGame(game, f.asker).revivalPromiseGuide, null);
rejected(game, f.asker, guide.action);
game = act(game, f.target, guide.action); game = complete(f, game).game;
rejected(game, f.target, guide.action); assert.deepEqual(inventory(game), f.originalCards);
});
void test('all four minimal policies submit the actual needed Charity before the early Revival return', () => {
const f = fixture({ phase: 2, poor: true }), 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 === 'charity'); assert.ok(action, difficulty);
const accepted = act(structuredClone(game), f.target, action);
assert.equal(player(accepted, f.target).spice, 2); assert.equal(promise(accepted, f.target).progress, 0);
assert.deepEqual(inventory(accepted), f.originalCards);
}
});