Extend Truthtrance revival promises through earlier turn phases
This commit is contained in:
1 parent
24653799cf
commit
7fc21113e3
14 files changed
+810
-52
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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; },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user