Support earlier combined shipment and movement promises
This commit is contained in:
1 parent
d4e82c74b1
commit
8dbec05844
18 files changed
+1321
-46
No files matched your search
@@ -0,0 +1,188 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { createTruthMovementEarlyFixture } from './fixture-truth-movement-early';
|
||||
import {
|
||||
createTruthMovementNativeEarlyFixture, nextTruthMovementNativeEarlyStep,
|
||||
type TruthMovementNativeEarlyAuction, type TruthMovementNativeEarlyRoster,
|
||||
} from './fixture-truth-movement-native-early';
|
||||
import { createTruthMixedMovementFixture } from './fixture-truth-mixed-movement';
|
||||
import { createTruthRevivalEarlyFixture } from './fixture-truth-revival-early';
|
||||
import {
|
||||
actTruthTravel, truthTravelClean, truthTravelFact, truthTravelGroup,
|
||||
truthTravelInventory, truthTravelMove, truthTravelPlayer, truthTravelPromise,
|
||||
truthTravelShip, type TruthTravelExpression, type TruthTravelStep,
|
||||
} from './fixture-truth-travel';
|
||||
|
||||
export type TruthTravelEarlyStep = TruthTravelStep;
|
||||
export const actTruthTravelEarly = actTruthTravel;
|
||||
export const truthTravelEarlyClean = truthTravelClean;
|
||||
export const truthTravelEarlyInventory = truthTravelInventory;
|
||||
export const truthTravelEarlyPlayer = truthTravelPlayer;
|
||||
export const truthTravelEarlyPromise = truthTravelPromise;
|
||||
export { truthTravelFact, truthTravelGroup, truthTravelMove, truthTravelShip };
|
||||
export type { TruthTravelExpression };
|
||||
|
||||
/** Replay current native controls. A private guide always belongs to this
|
||||
* phase and event; the source's real auction and response controls do the rest. */
|
||||
export function nextTruthTravelEarly(game: Game,
|
||||
auction: TruthMovementNativeEarlyAuction = {}): TruthTravelEarlyStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
for (const player of game.players) {
|
||||
const guide = viewGame(game, player.id).travelPromiseGuide;
|
||||
if (guide) return { actor: player.id, action: guide.action };
|
||||
}
|
||||
if (game.players.some(player => ['richese', 'choam', 'ecaz', 'moritani', 'ixians', 'tleilaxu'].includes(player.faction)))
|
||||
return nextTruthMovementNativeEarlyStep(game, auction);
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'revivalStop')
|
||||
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceTruthTravelEarly(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthTravelEarlyStep[] = [], auction: TruthMovementNativeEarlyAuction = {}): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original early travel source ended before its native boundary');
|
||||
const next = nextTruthTravelEarly(game, auction);
|
||||
assert.ok(next, 'Supply the actual Truthtrance question, answer or owned movement before continuing');
|
||||
game = actTruthTravelEarly(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original early travel source did not reach its finite boundary');
|
||||
}
|
||||
export function settleTruthTravelEarly(game: Game, actions: TruthTravelEarlyStep[] = [],
|
||||
auction: TruthMovementNativeEarlyAuction = {}): Game {
|
||||
return advanceTruthTravelEarly(game, state => truthTravelEarlyClean(state) && !state.pendingShipment &&
|
||||
!state.pendingRevival, actions, auction);
|
||||
}
|
||||
|
||||
/** Original sources only. Earlier snapshots exclude the later factory suffix;
|
||||
* unopened Spice selections are reapplied before their real phase controls.
|
||||
* The earned Revival source includes a real paid shipment and Storm casualties,
|
||||
* so its previous-turn shipment flag needs no controlled installation. */
|
||||
export function createTruthTravelEarlyFixture(options: {
|
||||
source?: 'classic' | 'hajr' | 'native' | 'revival'; phase?: 0 | 1 | 2 | 3 | 4;
|
||||
advanced?: boolean; hajr?: boolean; poor?: boolean; roster?: TruthMovementNativeEarlyRoster;
|
||||
targetKarama?: boolean; minimum?: number; mixed?: TruthTravelExpression;
|
||||
} = {}) {
|
||||
const kind = options.source ?? 'classic', phase = options.phase ?? 2;
|
||||
const classicSource = kind === 'classic'
|
||||
? createTruthMovementEarlyFixture({ phase: phase < 2 ? 0 : 2 }) : null;
|
||||
const hajrSource = kind === 'hajr'
|
||||
? createTruthMixedMovementFixture({ advanced: options.advanced, hajr: options.hajr, window: 'charity' }) : null;
|
||||
const nativeSource = kind === 'native' ? createTruthMovementNativeEarlyFixture({ roster: options.roster }) : null;
|
||||
const revivalSource = kind === 'revival' ? createTruthRevivalEarlyFixture({
|
||||
advanced: options.advanced, phase: phase === 4 ? 3 : phase, poor: options.poor,
|
||||
targetKarama: options.targetKarama,
|
||||
}) : null;
|
||||
const original = classicSource ?? hajrSource ?? nativeSource ?? revivalSource!;
|
||||
let game = structuredClone(original.game), actions: TruthTravelEarlyStep[] = structuredClone(original.actions);
|
||||
const initialPrefixCount = actions.length;
|
||||
const selectedSpice: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
if (phase < 2 && (hajrSource || nativeSource)) {
|
||||
const base = hajrSource ?? nativeSource!;
|
||||
assert.ok(base.afterSetupActionCount !== null);
|
||||
game = structuredClone(base.afterSetup);
|
||||
actions = structuredClone(base.actions.slice(0, base.afterSetupActionCount));
|
||||
for (const { position, card } of base.spiceOrder) {
|
||||
const from = game.spiceDeck.findIndex((candidate, index) => index >= position &&
|
||||
JSON.stringify(candidate) === JSON.stringify(card));
|
||||
assert.ok(from >= position, 'Select the same original still-unopened Spice card');
|
||||
const selected = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, selected);
|
||||
selectedSpice.push({ position, from, card: structuredClone(selected) });
|
||||
}
|
||||
}
|
||||
if (game.phase !== phase) game = advanceTruthTravelEarly(game,
|
||||
state => state.phase === phase && truthTravelEarlyClean(state), actions);
|
||||
const asker = original.asker, target = original.target;
|
||||
const truth = classicSource?.truth ?? hajrSource?.truth ?? nativeSource?.truth ?? revivalSource!.truth;
|
||||
const originalCards = original.originalCards;
|
||||
const minimum = options.minimum ?? (hajrSource ? 1 : revivalSource ? 2 : 8);
|
||||
const shipmentAction: Action = hajrSource
|
||||
? { type: 'ship', territory: 'polar_sink', sector: 0, amount: minimum, elite: 0, allyPayment: 0 }
|
||||
: { type: 'ship', territory: 'arrakeen', sector: 10, amount: minimum, elite: 0, allyPayment: 0 };
|
||||
const firstMove: Action = hajrSource
|
||||
? { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: minimum }
|
||||
: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: minimum };
|
||||
const secondMove: Action = { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: minimum };
|
||||
const mixed = options.mixed ?? truthTravelGroup('and',
|
||||
truthTravelShip(String(shipmentAction.territory), minimum), truthTravelMove(String(firstMove.territory), minimum));
|
||||
const question = { kind: 'travel' as const, target, turn: game.turn, mixed };
|
||||
assert.equal(game.status, 'playing'); assert.equal(game.phase, phase);
|
||||
assert.ok(truthTravelEarlyClean(game));
|
||||
assert.equal(game.travelPromises?.length ?? 0, 0);
|
||||
assert.deepEqual(truthTravelEarlyInventory(game), originalCards);
|
||||
assert.ok(truthTravelEarlyPlayer(game, asker).hand.some(card => card.id === truth));
|
||||
const sourceDisclosure = 'sourceDisclosure' in original ? original.sourceDisclosure : original.staging.join(' ');
|
||||
return {
|
||||
game, source: structuredClone(game), kind, phase, base: original,
|
||||
classicSource, hajrSource, nativeSource, revivalSource,
|
||||
asker, target, truth, card: truth, hajr: hajrSource?.hajr ?? null,
|
||||
setup: original.setup, lobby: original.lobby, beforeStartingDeal: original.beforeStartingDeal,
|
||||
afterSetup: original.afterSetup, originalCards, originalSpice: original.originalSpice,
|
||||
dealOrder: original.dealOrder, spiceOrder: original.spiceOrder, selectedSpice,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { inherited: initialPrefixCount, selected: actions.length,
|
||||
setup: actions.findIndex(step => step.action.type === 'start') + 1,
|
||||
priorShipments: actions.filter(step => step.actor === target && step.action.type === 'ship').length,
|
||||
priorRevivals: actions.filter(step => step.actor === target && step.action.type === 'revive').length },
|
||||
minimum, mixed, question, shipmentAction, firstMove, secondMove,
|
||||
shipmentCost: minimum * (hajrSource ? 2 : 1),
|
||||
declaration: { type: 'card', card: truth } satisfies Action,
|
||||
questionAction: { type: 'truthAsk', question } satisfies Action,
|
||||
hajrAction: hajrSource?.hajr ? { type: 'card', card: hajrSource.hajr } satisfies Action : null,
|
||||
currentEnd: { type: 'endMovement' } satisfies Action,
|
||||
sourceDisclosure: `${sourceDisclosure} This early travel adapter retains only the recorded ${actions.length}-action ` +
|
||||
`phase${phase} prefix. The caller supplies the whole travel question, answer and all later real actions. ` +
|
||||
'No card, dealt hand, wallet, army, clock, ready list, old shipment flag, promise or receipt is installed.',
|
||||
};
|
||||
}
|
||||
export type TruthTravelEarlyFixture = ReturnType<typeof createTruthTravelEarlyFixture>;
|
||||
|
||||
export function declareTruthTravelEarly(f: TruthTravelEarlyFixture, source = f.game,
|
||||
actions: TruthTravelEarlyStep[] = []) {
|
||||
let game = actTruthTravelEarly(source, f.asker, f.declaration, actions);
|
||||
const opened = structuredClone(game);
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const player = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(player);
|
||||
game = actTruthTravelEarly(game, player.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return { game, opened, actions };
|
||||
}
|
||||
export function askTruthTravelEarly(f: TruthTravelEarlyFixture, source = f.game,
|
||||
mixed = f.mixed, actions: TruthTravelEarlyStep[] = []) {
|
||||
const declared = declareTruthTravelEarly(f, source, actions);
|
||||
assert.ok(viewGame(declared.game, f.asker).truthTravelTargets.includes(f.target));
|
||||
const game = actTruthTravelEarly(declared.game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'travel', target: f.target, turn: source.turn, mixed: structuredClone(mixed),
|
||||
} }, actions);
|
||||
return { game, declared, actions };
|
||||
}
|
||||
export function bindTruthTravelEarly(f: TruthTravelEarlyFixture, answer: 'yes' | 'no' = 'yes',
|
||||
mixed = f.mixed, source = f.game, actions: TruthTravelEarlyStep[] = []) {
|
||||
const asked = askTruthTravelEarly(f, source, mixed, actions);
|
||||
assert.ok(viewGame(asked.game, f.target).truthTravelAnswers?.includes(answer));
|
||||
const game = settleTruthTravelEarly(actTruthTravelEarly(asked.game, f.target,
|
||||
{ type: 'truthAnswer', answer }, actions), actions);
|
||||
return { game, asked, actions, actionCount: actions.length };
|
||||
}
|
||||
export function reachTruthTravelEarlyMovement(f: TruthTravelEarlyFixture, game: Game,
|
||||
actions: TruthTravelEarlyStep[] = [], auction: TruthMovementNativeEarlyAuction = {}) {
|
||||
return advanceTruthTravelEarly(game, state => state.phase === 5 && state.active === f.target &&
|
||||
truthTravelEarlyClean(state) && !state.pendingShipment, actions, auction);
|
||||
}
|
||||
export function finishTruthTravelEarly(f: TruthTravelEarlyFixture, game: Game,
|
||||
actions: TruthTravelEarlyStep[] = []): Game {
|
||||
game = settleTruthTravelEarly(game, actions);
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, f.target);
|
||||
game = settleTruthTravelEarly(actTruthTravelEarly(game, f.target, f.currentEnd, actions), actions);
|
||||
assert.ok(!game.movementRemaining?.includes(f.target));
|
||||
return game;
|
||||
}
|
||||
export function completeTruthTravelEarly(f: TruthTravelEarlyFixture, game: Game,
|
||||
actions: TruthTravelEarlyStep[] = []): Game {
|
||||
return advanceTruthTravelEarly(game, state => {
|
||||
const row = truthTravelEarlyPromise(state, f.target);
|
||||
return !!row.fulfilled || !!row.released;
|
||||
}, actions);
|
||||
}
|
||||
@@ -54,7 +54,8 @@ function projected(next: Action = { type: 'move', forces: { 'arrakeen:10': 3, 'a
|
||||
game.status = 'playing'; game.turn = 2; game.phase = 5; game.active = 'p'; game.ready = [];
|
||||
game.travelPromises = [{ event: 'controlled-travel-event', player: 'p', asker: 'a', turn: 2,
|
||||
mixed: claim(), answer: true, fulfilled: false, released: false }];
|
||||
game.travelPromiseGuide = { event: 'controlled-travel-event', action: { ...next, travelPromiseEvent: 'controlled-travel-event' } };
|
||||
game.travelPromiseGuide = { event: 'controlled-travel-event', action: { ...next,
|
||||
travelPromiseEvent: 'controlled-travel-event', travelPromisePhase: game.phase } };
|
||||
game.truthTravelAnswers = null;
|
||||
return game;
|
||||
}
|
||||
@@ -206,6 +207,7 @@ void test('travel guide rejects stale view, actor, source, turn, phase, native o
|
||||
game => { game.travelPromiseGuide = { ...game.travelPromiseGuide!, action: { ...game.travelPromiseGuide!.action } }; },
|
||||
game => { game.travelPromiseGuide!.event = 'changed'; },
|
||||
game => { game.travelPromiseGuide!.action.travelPromiseEvent = 'changed'; },
|
||||
game => { game.travelPromiseGuide!.action.travelPromisePhase = 4; },
|
||||
game => { (game.travelPromiseGuide!.action.forces as Record<string, number>)['arrakeen:10'] = 2; },
|
||||
game => { (game.travelPromiseGuide!.action.eliteForces as Record<string, number>)['arrakeen:10'] = 0; },
|
||||
game => { game.travelPromises[0].event = 'expired'; }, game => { game.travelPromises[0].player = 'a'; },
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createTruthBidFixture, truthBidInventory } from './fixture-truth-bid';
|
||||
|
||||
/** Genuine Harkonnen two-Truth opening at the original current ordinary lot.
|
||||
* Both questions use distinct actually dealt cards; no ledger is installed. */
|
||||
function ask(game: Game, asker: string, card: string, question: unknown) {
|
||||
game = applyAction(game, asker, { type: 'card', card });
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = applyAction(game, actor.id, { type: 'truthPass' });
|
||||
}
|
||||
return applyAction(game, asker, { type: 'truthAsk', question });
|
||||
}
|
||||
function questions(f: ReturnType<typeof createTruthBidFixture>, minimum: number) {
|
||||
return {
|
||||
bid: { kind: 'bid', target: f.target, turn: f.game.turn, lot: f.game.auction!.index + 1, minimum: 4 },
|
||||
travel: { kind: 'travel', target: f.target, turn: f.game.turn, mixed: { kind: 'and', terms: [
|
||||
{ kind: 'action', action: { kind: 'ship', territory: 'arrakeen', minimum } },
|
||||
{ kind: 'action', action: { kind: 'move', territory: 'carthag', minimum } },
|
||||
] } },
|
||||
};
|
||||
}
|
||||
void test('native compatible bid and travel answers in either order offer the required declaration before any pass', () => {
|
||||
for (const bidFirst of [true, false]) {
|
||||
const f = createTruthBidFixture({ window: 'active' }), q = questions(f, 1);
|
||||
let game = ask(f.game, f.asker, f.truths[0], bidFirst ? q.bid : q.travel);
|
||||
game = applyAction(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = ask(game, f.asker, f.truths[1], bidFirst ? q.travel : q.bid);
|
||||
game = applyAction(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const guide = viewGame(game, f.target).travelPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'bid'); assert.equal(guide.action.amount, 4);
|
||||
assert.equal(guide.action.travelPromisePhase, 3); assert.ok(!('bidPromiseEvent' in guide.action));
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, f.target); view.players.find(player => player.id === f.target)!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.deepEqual(action, view.travelPromiseGuide!.action);
|
||||
const done = applyAction(game, f.target, action);
|
||||
assert.equal(done.bidPromises?.[0].fulfilled, true); assert.equal(done.travelPromises?.[0].released, false);
|
||||
assert.equal(done.auction?.bid, 4); assert.deepEqual(truthBidInventory(done), f.originalCards);
|
||||
}
|
||||
assert.equal(game.truthHistory?.filter(row => row.answer === 'yes').length, 2);
|
||||
}
|
||||
});
|
||||
void test('native pending bid invoice and future travel cannot reserve the same original spice in either answer order', () => {
|
||||
for (const bidFirst of [true, false]) {
|
||||
const f = createTruthBidFixture({ window: 'active' }), q = questions(f, 8);
|
||||
let game = ask(f.game, f.asker, f.truths[0], bidFirst ? q.bid : q.travel);
|
||||
game = applyAction(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = ask(game, f.asker, f.truths[1], bidFirst ? q.travel : q.bid);
|
||||
const view = viewGame(game, f.target);
|
||||
assert.deepEqual(bidFirst ? view.truthTravelAnswers : view.truthBidAnswers, ['no']);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, f.target, { type: 'truthAnswer', answer: 'yes' })); assert.deepEqual(game, before);
|
||||
game = applyAction(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
assert.equal(game.truthHistory?.length, 2); assert.deepEqual(truthBidInventory(game), f.originalCards);
|
||||
assert.equal(game.discard.filter(card => f.truths.includes(card.id)).length, 2);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { TravelAnswers, TravelPromiseGuideButton, TravelPromises } from '../components/travel-promises';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { createRevivalOrder } from '../game/revival-order';
|
||||
import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
|
||||
|
||||
type Props = Parameters<typeof TravelPromiseGuideButton>[0];
|
||||
type ElementProps = { children?: ReactNode; [key: string]: unknown };
|
||||
type Element = ReactElement<ElementProps>;
|
||||
const event = 'controlled-early-travel';
|
||||
|
||||
/** Controlled personalized quotes test current UI ownership and dispatch only.
|
||||
* They do not establish native acquisition, answer feasibility or progress. */
|
||||
function projected(phase: number, next: Action = { type: 'ready' }): GameView {
|
||||
const source = createGame('EARLYTRAVELCONTROLS', 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.active = 'a'; game.ready = [];
|
||||
game.travelPromises = [{ event, player: 'p', asker: 'a', turn: 2,
|
||||
mixed: { kind: 'or', terms: [
|
||||
{ kind: 'action', action: { kind: 'ship', territory: 'carthag', minimum: 3 } },
|
||||
{ kind: 'action', action: { kind: 'move', territory: 'arrakeen', minimum: 4 } },
|
||||
] }, answer: true, fulfilled: false, released: false }];
|
||||
game.travelPromiseGuide = { event, action: { ...next, travelPromiseEvent: event, travelPromisePhase: phase } };
|
||||
game.truthTravelAnswers = null;
|
||||
return game;
|
||||
}
|
||||
function auction(next: Action = { type: 'passBid' }): GameView {
|
||||
const game = projected(3, next);
|
||||
game.auction = { bid: 7, bidder: next.type === 'decision' ? 'p' : 'a', active: 'p',
|
||||
remaining: 2, inspectionBlocked: null, card: null };
|
||||
if (next.type === 'decision') {
|
||||
game.auction.active = 'a';
|
||||
game.decision = { kind: 'auctionPayment', player: 'p' };
|
||||
}
|
||||
return game;
|
||||
}
|
||||
function revival(next: Action = { type: 'revive', amount: 3, elite: 1 }): GameView {
|
||||
const game = projected(4, next);
|
||||
game.active = 'p';
|
||||
game.revivalOrder = createRevivalOrder({ turn: 2, order: ['p', 'a'], ready: [] });
|
||||
return game;
|
||||
}
|
||||
function richese(method: 'silent' | 'onceAround'): GameView {
|
||||
const native = createRicheseAuction({ event: 'controlled-native-lot', cardId: 'controlled-native-card',
|
||||
owner: 'a', source: 'cache', method, eligible: ['p', 'a'], order: ['p', 'a'], tieOrder: ['p', 'a'] });
|
||||
const game = projected(3, { type: 'richeseBid', event: native.event, amount: method === 'silent' ? 0 : null, allyPayment: 0 });
|
||||
game.richeseAuction = { ...projectRicheseAuction(native, 'p'), card: null, claim: null,
|
||||
ownAllyPayment: 0, ownAvailable: 4, allyAvailable: 0, peekKnown: false, inspectionBlocked: null };
|
||||
return game;
|
||||
}
|
||||
function guide(props: Props) { const button = TravelPromiseGuideButton(props); assert.ok(button); return button; }
|
||||
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
|
||||
const markup = (game: GameView) => renderToStaticMarkup(createElement(TravelPromises, { game, busy: false, act() {} }));
|
||||
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 answer(game: GameView, send: (action: Action) => void) {
|
||||
const tree = TravelAnswers({ game, busy: false, act: send });
|
||||
const button = elements(tree).find(element => element.props['data-travel-answer'] === 'yes');
|
||||
assert.ok(button); return button;
|
||||
}
|
||||
const click = (button: Element) => (button.props.onClick as () => void)();
|
||||
function answering(phase: number): GameView {
|
||||
const game = projected(phase); game.travelPromiseGuide = null;
|
||||
game.truthtrance = { stage: 'answer', queue: [{ player: 'a', card: 'controlled-truth' }], passed: [],
|
||||
question: { kind: 'travel', target: 'p', turn: 2, mixed: game.travelPromises[0].mixed } };
|
||||
game.truthTravelAnswers = ['yes', 'no'];
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('early travel guide forwards a required native bid and rejects a changed ordinary opportunity', () => {
|
||||
const game = auction({ type: 'bid', amount: 8, allyPayment: 0 }), sent: Action[] = [];
|
||||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, false); press(button);
|
||||
assert.deepEqual(sent, [game.travelPromiseGuide!.action]);
|
||||
assert.match(markup(game), /Place the required bid of 8/);
|
||||
game.auction!.active = 'a'; press(button); assert.equal(sent.length, 1);
|
||||
});
|
||||
|
||||
void test('early whole-travel history stays readable before Movement and forwards only current Charity, Ready or card preparation', () => {
|
||||
for (const [phase, action, label] of [
|
||||
[2, { type: 'charity' }, /Claim Charity for your later shipment and movement/],
|
||||
[2, { type: 'ready' }, /Finish this preparation phase while preserving your whole answer/],
|
||||
[4, { type: 'ready' }, /Finish this preparation phase while preserving your whole answer/],
|
||||
[2, { type: 'card', card: 'controlled-preparation', amount: 3 }, /Use the offered native card preparation/],
|
||||
[4, { type: 'card', card: 'controlled-preparation', amount: 3 }, /Use the offered native card preparation/],
|
||||
] satisfies [number, Action, RegExp][]) {
|
||||
const game = projected(phase, action), sent: Action[] = [];
|
||||
const props = { game, busy: false, act(value: Action) { sent.push(value); } };
|
||||
const html = markup(game);
|
||||
assert.match(html, /Your Movement turn is still ahead/);
|
||||
assert.match(html, /Complete any offered current preparation below before confirming readiness/);
|
||||
assert.match(html, label); assert.doesNotMatch(html, />Ship \d|>Move \d/);
|
||||
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
|
||||
assert.deepEqual(sent, [game.travelPromiseGuide!.action]); assert.equal(sent[0], game.travelPromiseGuide!.action);
|
||||
props.busy = true; press(button); assert.equal(sent.length, 1);
|
||||
props.busy = false; game.ready.push('p'); press(button); assert.equal(sent.length, 1);
|
||||
}
|
||||
for (const phase of [0, 1]) {
|
||||
const game = projected(phase); game.travelPromiseGuide = null;
|
||||
assert.match(markup(game), /Your Movement turn is still ahead/); assert.doesNotMatch(markup(game), /<button/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('early native ordinary force, leader and Kwisatz revival packets retain the projected current Revival opportunity', () => {
|
||||
for (const [action, label] of [
|
||||
[{ type: 'revive', amount: 3, elite: 1, allyPayment: 2 }, /Revive 3 forces/],
|
||||
[{ type: 'reviveLeader', leader: 'controlled-leader', early: true, allyPayment: 2 }, /Revive the quoted leader/],
|
||||
[{ type: 'acceptLeaderRevival', leaderRevivalPrice: 3 }, /Accept the quoted leader revival/],
|
||||
[{ type: 'reviveKwisatz' }, /Revive your Kwisatz Haderach/],
|
||||
] satisfies [Action, RegExp][]) {
|
||||
const game = revival(action), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, false); assert.match(markup(game), label);
|
||||
press(button); assert.equal(sent[0], game.travelPromiseGuide!.action);
|
||||
assert.deepEqual(sent[0], { ...action, travelPromiseEvent: event, travelPromisePhase: 4 });
|
||||
}
|
||||
});
|
||||
|
||||
void test('current normal-auction pass and owned cash or physical Karama invoices preserve their exact native packets', () => {
|
||||
const card = baseDeck().find(card => card.effect === 'karama'); assert.ok(card);
|
||||
for (const next of [{ type: 'passBid' }, { type: 'decision', karama: false },
|
||||
{ type: 'decision', karama: true, card: card.id }] satisfies Action[]) {
|
||||
const game = auction(next), sent: Action[] = [];
|
||||
game.players.find(player => player.id === 'p')!.hand = [card];
|
||||
const button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, false); press(button);
|
||||
assert.equal(sent[0], game.travelPromiseGuide!.action);
|
||||
assert.match(markup(game), next.type === 'passBid'
|
||||
? /Pass this bid and preserve later shipment and movement/
|
||||
: next.karama ? /Use Karama for the current auction payment/ : /Pay the current auction price while preserving your whole answer/);
|
||||
if (next.type === 'decision') assert.equal(game.auction!.active, 'a', 'The owned invoice may belong to a different player than the next bid.');
|
||||
}
|
||||
});
|
||||
|
||||
void test('an already-paid owned Box search forwards its exact native card choice in phases zero through five', () => {
|
||||
for (const phase of [0, 1, 2, 3, 4, 5]) {
|
||||
const game = projected(phase, { type: 'decision', event: 'controlled-paid-search', card: 'controlled-recovered-hajr' });
|
||||
game.decision = { kind: 'nullentropy', player: 'p' };
|
||||
game.nullentropy = { card: { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special', effect: 'nullentropyBox' }, blocked: null,
|
||||
search: { event: 'controlled-paid-search', cards: [{ id: 'controlled-recovered-hajr', name: 'Hajr', kind: 'special', effect: 'hajr' }] } };
|
||||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.travelPromiseGuide!.action);
|
||||
assert.match(markup(game), /Complete your paid Box search for your whole answer/);
|
||||
game.nullentropy.search = { ...game.nullentropy.search! }; press(button); assert.equal(sent.length, 1);
|
||||
const current = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
game.decision = { kind: 'nullentropy', player: 'a' }; press(current); assert.equal(sent.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('current Richese Silent zero and Once Around pass guides retain their native lot without reading hidden bids', () => {
|
||||
for (const method of ['silent', 'onceAround'] as const) {
|
||||
const game = richese(method), sent: Action[] = [];
|
||||
Object.defineProperty(game.richeseAuction!, 'sealed', { get() { throw Error('No hidden bid access'); } });
|
||||
Object.defineProperty(game.richeseAuction!, 'card', { get() { throw Error('No hidden lot face access'); } });
|
||||
const button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.travelPromiseGuide!.action);
|
||||
assert.match(markup(game), method === 'silent' ? /Submit zero in this Silent auction/ : /Pass this Richese bid/);
|
||||
if (method === 'silent') game.richeseAuction!.submitted.push('p'); else game.richeseAuction!.active = 'a';
|
||||
press(button); assert.equal(sent.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('early guides refuse stale phase metadata, current source identity and changed native invoice or Revival ownership', () => {
|
||||
const cases: [() => GameView, ((game: GameView) => void)[]][] = [
|
||||
[() => projected(2), [
|
||||
game => { game.phase = 4; }, game => { game.travelPromiseGuide!.action.travelPromisePhase = 4; },
|
||||
game => { delete game.travelPromiseGuide!.action.travelPromisePhase; },
|
||||
game => { game.travelPromiseGuide = { ...game.travelPromiseGuide! }; },
|
||||
game => { game.travelPromiseGuide!.action = { ...game.travelPromiseGuide!.action }; },
|
||||
game => { game.travelPromises[0].event = 'another-answer'; }, game => { game.me = 'a'; }, game => { game.turn = 3; },
|
||||
]],
|
||||
[() => auction(), [
|
||||
game => { game.auction = { ...game.auction! }; }, game => { game.auction!.active = 'a'; },
|
||||
game => { game.auction!.bid = 8; }, game => { game.auction!.remaining = 1; },
|
||||
]],
|
||||
[() => auction({ type: 'decision', karama: false }), [
|
||||
game => { game.decision = { ...game.decision! }; }, game => { game.decision = null; },
|
||||
game => { game.decision = { kind: 'auctionPayment', player: 'a' }; },
|
||||
game => { game.auction!.bidder = 'a'; }, game => { game.auction!.bid = 8; },
|
||||
game => { game.auction!.allyPayment = 1; },
|
||||
game => { game.travelPromiseGuide!.action.karama = true; },
|
||||
]],
|
||||
[() => richese('silent'), [
|
||||
game => { game.richeseAuction = { ...game.richeseAuction! }; }, game => { game.richeseAuction!.event = 'another-lot'; },
|
||||
game => { game.richeseAuction!.eligible = ['a']; }, game => { game.richeseAuction!.outcome = { kind: 'unbid' }; },
|
||||
game => { game.travelPromiseGuide!.action.amount = 1; }, game => { game.travelPromiseGuide!.action.allyPayment = 1; },
|
||||
]],
|
||||
[() => revival(), [
|
||||
game => { game.revivalOrder = { ...game.revivalOrder! }; }, game => { game.revivalOrder!.turn = 3; },
|
||||
game => { game.revivalOrder!.remaining = ['a', 'p']; }, game => { game.active = 'a'; },
|
||||
game => { game.revival = { ...game.revival }; }, game => { game.revival.orderBlocked = 'Wait for your Revival opportunity.'; },
|
||||
game => { game.revival.pending = true; }, game => { game.ready.push('p'); },
|
||||
]],
|
||||
];
|
||||
for (const [build, changes] of cases) for (const change of changes) {
|
||||
const game = build(), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
const props = { game: projected(2), busy: false, act() { assert.fail('A replaced view cannot dispatch'); } };
|
||||
const button = guide(props); props.game = { ...props.game }; press(button);
|
||||
const retained = projected(2); retained.phase = 4;
|
||||
const refreshed = guide({ game: retained, busy: false, act() { assert.fail('An old Charity Ready packet cannot become a Revival Ready packet'); } });
|
||||
assert.equal(refreshed.props.disabled, true); press(refreshed);
|
||||
});
|
||||
|
||||
void test('early whole-travel guides refuse foreign or absent invoices, foreign Revival turns, wrong phases and intervening modals', () => {
|
||||
const invalid: GameView[] = [];
|
||||
for (const phase of [0, 1, 2, 3, 4, 6]) {
|
||||
for (const next of [{ type: 'ship', amount: 3, territory: 'carthag', sector: 11 },
|
||||
{ type: 'move', forces: { 'arrakeen:10': 4 }, territory: 'carthag', sector: 11 }, { type: 'endMovement' }] satisfies Action[])
|
||||
invalid.push(projected(phase, next));
|
||||
}
|
||||
for (const phase of [0, 1, 3]) invalid.push(projected(phase, { type: 'ready' }));
|
||||
invalid.push(projected(2, { type: 'revive', amount: 3 }), projected(4, { type: 'charity' }),
|
||||
projected(3, { type: 'decision', karama: false }), auction({ type: 'decision', karama: true }));
|
||||
const foreignInvoice = auction({ type: 'decision', karama: false }); foreignInvoice.decision = { kind: 'auctionPayment', player: 'a' };
|
||||
const foreignRevival = revival(); foreignRevival.revivalOrder!.remaining = ['a', 'p']; foreignRevival.active = 'a';
|
||||
const blockedRevival = revival({ type: 'ready' }); blockedRevival.revival.orderBlocked = 'Wait for your Revival opportunity.';
|
||||
invalid.push(foreignInvoice, foreignRevival, blockedRevival);
|
||||
for (const game of invalid) {
|
||||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
for (const change of [
|
||||
(game: GameView) => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||||
(game: GameView) => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||||
(game: GameView) => { game.truthtrance = answering(2).truthtrance; },
|
||||
(game: GameView) => { game.phaseOpening = { passed: [] }; },
|
||||
(game: GameView) => { game.automaticContinuationPending = true; },
|
||||
(game: GameView) => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||||
]) {
|
||||
const game = projected(2), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
change(game); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('whole travel answers accept only the exact current phase-zero-through-five answer window without opening private eligibility', () => {
|
||||
for (const phase of [0, 1, 2, 3, 4, 5]) {
|
||||
const game = answering(phase), sent: Action[] = [];
|
||||
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves'])
|
||||
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||||
const button = answer(game, value => sent.push(value)); assert.equal(button.props.disabled, false); click(button);
|
||||
assert.deepEqual(sent, [{ type: 'truthAnswer', answer: 'yes' }]);
|
||||
game.phase = phase === 5 ? 4 : phase + 1; click(button); assert.equal(sent.length, 1);
|
||||
}
|
||||
for (const phase of [-1, 6, 1.5]) {
|
||||
const game = answering(phase), sent: Action[] = [], button = answer(game, value => sent.push(value));
|
||||
assert.equal(button.props.disabled, true); click(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('early answer callbacks retain target, source, phase and modal ownership after rendering', () => {
|
||||
for (const change of [
|
||||
(game: GameView) => { game.truthtrance!.queue[0] = { ...game.truthtrance!.queue[0] }; },
|
||||
(game: GameView) => { game.truthtrance!.queue[0].card = 'another-truth'; },
|
||||
(game: GameView) => { game.truthtrance!.question!.target = 'a'; },
|
||||
(game: GameView) => { game.truthtrance!.question = { ...game.truthtrance!.question! }; },
|
||||
(game: GameView) => { game.truthTravelAnswers = ['yes']; },
|
||||
(game: GameView) => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||||
(game: GameView) => { game.phaseOpening = { passed: [] }; },
|
||||
(game: GameView) => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||||
]) {
|
||||
const game = answering(2), sent: Action[] = [], button = answer(game, value => sent.push(value));
|
||||
change(game); click(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
|
||||
/** Controlled conserved native resources isolate joint quote compatibility.
|
||||
* Both original physical Truths are present; every promise is made through
|
||||
* actual declare/priority/Ask/answer actions. No setup/casualty history claimed. */
|
||||
function controlled(spice = 2) {
|
||||
const game = createGame('EARLYTRAVELREVIVAL', newPlayer('a', 'Asker', 'harkonnen'), false);
|
||||
game.players.push(newPlayer('p', 'Emperor', 'emperor'), newPlayer('o', 'Other', 'atreides'));
|
||||
Object.assign(game, { status: 'playing', phase: 4, turn: 2, storm: 18, active: 'p',
|
||||
order: ['p', 'a', 'o'], movementRemaining: ['p', 'a', 'o'], ready: [], deck: baseDeck(), discard: [] });
|
||||
for (const player of game.players) {
|
||||
player.hand = []; player.forces = {}; player.reserves = 20; player.spice = 10; player.traitorChoices = [];
|
||||
}
|
||||
const target = game.players[1]; target.reserves = 17; target.tanks = 3; target.spice = spice;
|
||||
const cards = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(cards.length, 2);
|
||||
game.deck = game.deck.filter(card => card.effect !== 'truthtrance'); game.players[0].hand = cards;
|
||||
const inventory = (g: Game) => [...g.deck, ...g.discard, ...g.players.flatMap(player => player.hand)].map(card => card.id).sort();
|
||||
return { game, truths: cards.map(card => card.id), inventory, original: inventory(game) };
|
||||
}
|
||||
function ask(game: Game, card: string, question: unknown) {
|
||||
game = applyAction(game, 'a', { type: 'card', card });
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const player = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(player);
|
||||
game = applyAction(game, player.id, { type: 'truthPass' });
|
||||
}
|
||||
return applyAction(game, 'a', { type: 'truthAsk', question });
|
||||
}
|
||||
const revival = { kind: 'revival', target: 'p', turn: 2, counter: 'total', minimum: 2 };
|
||||
const travel = { kind: 'travel', target: 'p', turn: 2, mixed: { kind: 'and', terms: [
|
||||
{ kind: 'action', action: { kind: 'ship', territory: 'arrakeen', minimum: 1 } },
|
||||
{ kind: 'action', action: { kind: 'move', territory: 'carthag', minimum: 1 } },
|
||||
] } };
|
||||
void test('controlled earlier Revival Yes prevents a later travel Yes from spending the same last spice', () => {
|
||||
const f = controlled(); let game = ask(f.game, f.truths[0], revival);
|
||||
assert.ok(viewGame(game, 'p').truthRevivalAnswers?.includes('yes'));
|
||||
game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' });
|
||||
game = ask(game, f.truths[1], travel);
|
||||
assert.deepEqual(viewGame(game, 'p').truthTravelAnswers, ['no']);
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' }));
|
||||
assert.deepEqual(game, before);
|
||||
game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'no' });
|
||||
game = applyAction(game, 'p', { type: 'revive', amount: 2 });
|
||||
assert.equal(game.players[1].spice, 0); assert.equal(game.players[1].reserves, 19);
|
||||
assert.equal(game.revivalPromises?.[0].fulfilled, true); assert.equal(game.travelPromises?.[0].released, false);
|
||||
game = applyAction(game, 'p', { type: 'ready' }); assert.ok(game.ready.includes('p'));
|
||||
assert.deepEqual(f.inventory(game), f.original);
|
||||
});
|
||||
void test('controlled earlier travel Yes prevents a later paid Revival Yes while leaving the compatible No legal', () => {
|
||||
const f = controlled(); let game = ask(f.game, f.truths[0], travel);
|
||||
assert.ok(viewGame(game, 'p').truthTravelAnswers?.includes('yes'));
|
||||
game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' });
|
||||
game = ask(game, f.truths[1], revival);
|
||||
assert.deepEqual(viewGame(game, 'p').truthRevivalAnswers, ['no']);
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' }));
|
||||
assert.deepEqual(game, before);
|
||||
game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'no' });
|
||||
game = applyAction(game, 'p', { type: 'ready' }); assert.ok(game.ready.includes('p'));
|
||||
assert.equal(game.travelPromises?.[0].released, false); assert.equal(game.travelPromises?.[0].fulfilled, false);
|
||||
assert.deepEqual(f.inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('controlled compatible Revival and travel Yes use the required real return before offering Revival Ready', () => {
|
||||
const f = controlled(3); let game = ask(f.game, f.truths[0], revival);
|
||||
game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' });
|
||||
game = ask(game, f.truths[1], travel);
|
||||
game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' });
|
||||
const guide = viewGame(game, 'p').travelPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'revive'); assert.equal(guide.action.amount, 2);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, 'p'); view.players.find(player => player.id === 'p')!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.deepEqual(action, view.travelPromiseGuide!.action);
|
||||
const done = applyAction(game, 'p', action); assert.equal(done.revivalPromises?.[0].fulfilled, true);
|
||||
}
|
||||
game = applyAction(game, 'p', guide.action);
|
||||
assert.equal(game.revivalPromises?.[0].fulfilled, true); assert.equal(game.players[1].spice, 1);
|
||||
const ready = viewGame(game, 'p').travelPromiseGuide; assert.ok(ready); assert.equal(ready.action.type, 'ready');
|
||||
game = applyAction(game, 'p', ready.action); assert.ok(game.ready.includes('p'));
|
||||
assert.equal(game.travelPromises?.[0].released, false); assert.deepEqual(f.inventory(game), f.original);
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { truthFactAnswer, type TruthFact } from '../game/truthtrance';
|
||||
import {
|
||||
actTruthTravelEarly as act, advanceTruthTravelEarly as advance,
|
||||
askTruthTravelEarly as ask, bindTruthTravelEarly as bind,
|
||||
completeTruthTravelEarly as complete, createTruthTravelEarlyFixture as fixture,
|
||||
declareTruthTravelEarly as declare, finishTruthTravelEarly as finish,
|
||||
reachTruthTravelEarlyMovement as reach, settleTruthTravelEarly as settle,
|
||||
truthTravelEarlyClean as clean, truthTravelEarlyInventory as inventory,
|
||||
truthTravelEarlyPlayer as player, truthTravelEarlyPromise as promise,
|
||||
truthTravelFact as fact, truthTravelGroup as group, truthTravelMove as move,
|
||||
truthTravelShip as ship, type TruthTravelEarlyStep,
|
||||
} from './fixture-truth-travel-early';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
const pending = (game: Game, target: string) => game.travelPromises!.find(row => row.player === target)!;
|
||||
function guide(game: Game, target: string) {
|
||||
const next = viewGame(game, target).travelPromiseGuide; assert.ok(next);
|
||||
assert.equal(next.event, promise(game, target).event);
|
||||
assert.equal(next.action.travelPromiseEvent, next.event);
|
||||
assert.equal(next.action.travelPromisePhase, game.phase);
|
||||
return next;
|
||||
}
|
||||
function finishPaidRoute(f: ReturnType<typeof fixture>, source: Game, actions: TruthTravelEarlyStep[] = []) {
|
||||
let game = reach(f, source, actions);
|
||||
const spice = player(game, f.target).spice;
|
||||
rejected(game, f.target, f.currentEnd);
|
||||
game = settle(act(game, f.target, f.shipmentAction, actions), actions);
|
||||
assert.equal(player(game, f.target).spice, spice - f.shipmentCost);
|
||||
assert.deepEqual(pending(game, f.target).progress, []);
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = settle(act(game, f.target, f.firstMove, actions), actions);
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.ok(!promise(game, f.target).released);
|
||||
game = finish(f, game, actions); assert.deepEqual(inventory(game), f.originalCards);
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('native early travel source records only original setup and phase actions with no installed future event', () => {
|
||||
for (const source of ['classic', 'hajr', 'native', 'revival'] as const) {
|
||||
const f = fixture({ source });
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.equal(f.sourceCounts.selected, f.sourceActions.length);
|
||||
assert.equal(f.sourceCounts.priorShipments,
|
||||
f.actions.filter(step => step.actor === f.target && step.action.type === 'ship').length);
|
||||
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
||||
assert.equal(f.game.phase, 2); assert.equal(f.game.truthtrance ?? null, null);
|
||||
assert.equal(f.game.travelPromises?.length ?? 0, 0);
|
||||
assert.deepEqual(inventory(f.game), f.originalCards);
|
||||
if (source === 'hajr') {
|
||||
assert.equal(f.sourceActionCount, 13);
|
||||
assert.deepEqual(f.sourceActions, f.hajrSource!.sourceActions);
|
||||
assert.equal(player(f.game, f.target).hand.find(card => card.id === f.hajr)?.effect, 'hajr');
|
||||
assert.deepEqual(player(f.game, f.target).forces, { 'arrakeen:10': 10 });
|
||||
}
|
||||
if (source === 'classic' || source === 'native') {
|
||||
assert.equal(player(f.game, f.target).spice, 10);
|
||||
assert.equal(player(f.game, f.target).reserves, 20);
|
||||
assert.deepEqual(player(f.game, f.target).forces, {});
|
||||
}
|
||||
if (source === 'revival') {
|
||||
assert.equal(f.sourceCounts.priorShipments, 1);
|
||||
assert.equal(player(f.game, f.target).tanks, 3);
|
||||
assert.equal(player(f.game, f.target).spice, 4);
|
||||
assert.deepEqual(f.revivalSource!.base.shipment.steps[0].action, f.revivalSource!.base.shipmentAction);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Storm Spice Charity Bidding and Revival travel Yes all retain one future paid shipment and distinct ground event', () => {
|
||||
for (const phase of [0, 1, 2, 3, 4] as const) {
|
||||
const f = fixture({ phase }), actions: TruthTravelEarlyStep[] = [];
|
||||
let game = bind(f, 'yes', f.mixed, f.game, actions).game;
|
||||
assert.equal(game.phase, phase); assert.equal(pending(game, f.target).shipment.pending, true);
|
||||
assert.equal(pending(game, f.target).shipment.event, null);
|
||||
assert.deepEqual(pending(game, f.target).progress, []);
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
rejected(game, f.target, f.shipmentAction); rejected(game, f.target, f.firstMove);
|
||||
const early = viewGame(game, f.target).travelPromiseGuide;
|
||||
assert.ok(!early || !['ship', 'move', 'endMovement'].includes(early.action.type));
|
||||
game = finishPaidRoute(f, game, actions);
|
||||
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
||||
assert.ok((phase < 4 ? actions : f.actions).some(step => step.actor === f.target && step.action.type === 'passBid'));
|
||||
}
|
||||
});
|
||||
|
||||
void test('native concurrent Charity readiness reopens after either definite whole travel answer', () => {
|
||||
const f = fixture();
|
||||
const ready = act(f.game, f.target, { type: 'ready' }); assert.ok(ready.ready.includes(f.target));
|
||||
for (const answer of ['yes', 'no'] as const) {
|
||||
const game = bind(f, answer, f.mixed, ready).game;
|
||||
assert.equal(game.ready.includes(f.target), false);
|
||||
const next = viewGame(game, f.target).travelPromiseGuide;
|
||||
if (next) { assert.equal(guide(game, f.target).action.type, 'ready'); }
|
||||
assert.equal(pending(game, f.target).shipment.pending, true);
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
assert.ok(act(game, f.target, next?.action ?? { type: 'ready' }).ready.includes(f.target));
|
||||
}
|
||||
});
|
||||
|
||||
void test('native current-phase guide has exact event and phase authority through Charity bid invoice and Revival', () => {
|
||||
const f = fixture({ source: 'revival', phase: 2, targetKarama: true }); let game = bind(f).game;
|
||||
const initialSpice = player(game, f.target).spice;
|
||||
const charity = guide(game, f.target); assert.equal(charity.action.type, 'ready');
|
||||
rejected(game, f.asker, charity.action);
|
||||
rejected(game, f.target, { ...charity.action, travelPromiseEvent: 'stale-travel-answer' });
|
||||
rejected(game, f.target, { ...charity.action, travelPromisePhase: 3 });
|
||||
game = act(game, f.target, charity.action);
|
||||
rejected(game, f.target, charity.action);
|
||||
game = advance(game, state => state.phase === 3 && clean(state) && state.auction?.active === f.target);
|
||||
const pass = guide(game, f.target); assert.equal(pass.action.type, 'passBid');
|
||||
rejected(game, f.target, charity.action);
|
||||
game = act(game, f.target, { type: 'bid', amount: 2, allyPayment: 0 });
|
||||
const purchased = game.auction!.cards[game.auction!.index].id;
|
||||
game = advance(game, state => !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'auctionPayment' && state.decision.player === f.target);
|
||||
const invoice = guide(game, f.target); assert.equal(invoice.action.type, 'decision');
|
||||
assert.equal(invoice.action.karama, false);
|
||||
rejected(game, f.target, { ...invoice.action, karama: true });
|
||||
game = act(game, f.target, JSON.parse(JSON.stringify(invoice.action)) as Action);
|
||||
game = advance(game, state => state.phase === 4 && clean(state));
|
||||
assert.equal(player(game, f.target).spice, initialSpice - 2);
|
||||
assert.ok(player(game, f.target).hand.some(card => card.id === purchased));
|
||||
const revival = guide(game, f.target); assert.equal(revival.action.type, 'ready');
|
||||
rejected(game, f.target, invoice.action);
|
||||
finishPaidRoute(f, game);
|
||||
});
|
||||
|
||||
void test('native early voluntary bribe and excess bid cannot consume the last original eight-spice paid route', () => {
|
||||
const f = fixture(); let game = bind(f).game;
|
||||
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
|
||||
game = advance(game, state => state.phase === 3 && clean(state) && state.auction?.active === f.target);
|
||||
rejected(game, f.target, { type: 'bid', amount: 3, allyPayment: 0 });
|
||||
game = act(game, f.target, { type: 'bid', amount: 2, allyPayment: 0 });
|
||||
game = advance(game, state => state.phase === 4 && clean(state));
|
||||
assert.equal(player(game, f.target).spice, 8);
|
||||
finishPaidRoute(f, game);
|
||||
});
|
||||
|
||||
void test('native standing bid reserves its unpaid invoice before offering an early travel Yes', () => {
|
||||
const f = fixture({ phase: 3 });
|
||||
for (const amount of [2, 3]) {
|
||||
let game = advance(f.game, state => clean(state) && state.auction?.active === f.target);
|
||||
game = act(game, f.target, { type: 'bid', amount, allyPayment: 0 });
|
||||
const asked = ask(f, game);
|
||||
assert.equal(viewGame(asked.game, f.target).truthTravelAnswers?.includes('yes'), amount === 2);
|
||||
assert.equal(player(asked.game, f.target).spice, 10, 'The standing bid has not paid its real invoice');
|
||||
if (amount === 3) rejected(asked.game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
}
|
||||
});
|
||||
|
||||
void test('native whole No to AND survives all early preparation and the matching shipment until actual own end', () => {
|
||||
const f = fixture(); let game = bind(f, 'no').game;
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = reach(f, game);
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = settle(act(game, f.target, f.shipmentAction));
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
rejected(game, f.target, f.firstMove);
|
||||
game = finish(f, game); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.ok(!promise(game, f.target).released); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native early OR retains both manual routes without choosing a branch at answer time', () => {
|
||||
const f = fixture({ source: 'hajr' }), mixed = group('or', ship(), move());
|
||||
const bound = bind(f, 'yes', mixed).game;
|
||||
for (const action of [f.shipmentAction, f.firstMove]) {
|
||||
let game = reach(f, bound); assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = settle(act(game, f.target, action));
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.deepEqual(promise(game, f.target).mixed, mixed);
|
||||
game = finish(f, game); assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native early Hajr travel freezes its current spice fact and later performs both original physical ground groups', () => {
|
||||
const f = fixture({ source: 'hajr' }), actions: TruthTravelEarlyStep[] = [];
|
||||
const spiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: player(f.game, f.target).spice };
|
||||
const mixed = group('and', fact(spiceFact), ship(), move(), move('carthag'));
|
||||
let game = bind(f, 'yes', mixed, f.game, actions).game;
|
||||
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 3 }, actions);
|
||||
assert.equal(truthFactAnswer(player(game, f.target), spiceFact), 'no');
|
||||
game = reach(f, game, actions);
|
||||
game = settle(act(game, f.target, f.shipmentAction, actions), actions);
|
||||
game = settle(act(game, f.target, f.firstMove, actions), actions);
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
rejected(game, f.target, f.currentEnd);
|
||||
const hajr = guide(game, f.target); assert.equal(hajr.action.type, 'card'); assert.equal(hajr.action.card, f.hajr);
|
||||
game = settle(act(game, f.target, hajr.action, actions), actions);
|
||||
game = settle(act(game, f.target, f.secondMove, actions), actions);
|
||||
assert.equal(player(game, f.target).moved, 2);
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.deepEqual(pending(game, f.target).progress,
|
||||
[{ territory: 'imperial_basin', maximum: 1 }, { territory: 'carthag', maximum: 1 }]);
|
||||
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
|
||||
assert.deepEqual(inventory(finish(f, game, actions)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native early optional current facts settle constants without future receipts and reopen readiness uniformly', () => {
|
||||
const f = fixture({ source: 'hajr' });
|
||||
const held = f.setup.deck.find(card => card.id === f.hajr); assert.ok(held);
|
||||
const heldFact: TruthFact = { kind: 'hand', name: held.name };
|
||||
const projectile = f.setup.deck.find(card => card.kind === 'projectile'); assert.ok(projectile);
|
||||
const absentFact: TruthFact = { kind: 'hand', name: projectile.name };
|
||||
const ready = act(f.game, f.target, { type: 'ready' }); assert.ok(ready.ready.includes(f.target));
|
||||
for (const [mixed, answer] of [
|
||||
[group('or', fact(heldFact), ship(), move()), 'yes'],
|
||||
[group('and', fact(absentFact), ship(), move()), 'no'],
|
||||
] as const) {
|
||||
const game = bind(f, answer, mixed, ready).game;
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.equal(pending(game, f.target).shipment.event, null);
|
||||
assert.deepEqual(pending(game, f.target).progress, []);
|
||||
assert.equal(viewGame(game, f.target).travelPromiseGuide, null);
|
||||
assert.ok(!game.ready.includes(f.target));
|
||||
}
|
||||
});
|
||||
|
||||
void test('native earned prior-turn shipment is not current travel progress and still leaves the next original shipment pending', () => {
|
||||
const f = fixture({ source: 'revival', phase: 1 });
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.sourceCounts.priorShipments, 1);
|
||||
assert.equal(player(f.game, f.target).shipped, true,
|
||||
'This flag is earned by the recorded turn1 paid shipment, never installed');
|
||||
const actions: TruthTravelEarlyStep[] = []; let game = bind(f, 'yes', f.mixed, f.game, actions).game;
|
||||
assert.equal(pending(game, f.target).shipment.pending, true);
|
||||
assert.equal(pending(game, f.target).shipment.event, null);
|
||||
assert.deepEqual(pending(game, f.target).progress, []);
|
||||
assert.equal(viewGame(game, f.target).travelPromiseGuide, null);
|
||||
game = finishPaidRoute(f, game, actions);
|
||||
assert.equal(pending(game, f.target).shipment.event?.amount, 2);
|
||||
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'ship'));
|
||||
});
|
||||
|
||||
void test('native Charity poverty uses the original paid bribe and protects the two-spice route against Ready and paid Revival', () => {
|
||||
const f = fixture({ source: 'revival', phase: 2, poor: true });
|
||||
assert.equal(f.revivalSource!.funding?.steps[0].action.type, 'bribe');
|
||||
assert.equal(player(f.game, f.target).spice, 0); assert.equal(player(f.game, f.target).tanks, 3);
|
||||
let game = bind(f).game;
|
||||
rejected(game, f.target, { type: 'ready' });
|
||||
const charity = guide(game, f.target); assert.equal(charity.action.type, 'charity');
|
||||
game = act(game, f.target, charity.action); assert.equal(player(game, f.target).spice, 2);
|
||||
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 1 });
|
||||
game = advance(game, state => state.phase === 4 && clean(state));
|
||||
rejected(game, f.target, { type: 'revive', amount: 2, elite: 0 });
|
||||
game = settle(act(game, f.target, { type: 'revive', amount: 1, elite: 0 }));
|
||||
assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).tanks, 2);
|
||||
finishPaidRoute(f, game);
|
||||
});
|
||||
|
||||
void test('native Basic Revival readiness reopens while Advanced completed owned turns stay closed', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = fixture({ source: 'revival', phase: 4, advanced });
|
||||
let game = f.game;
|
||||
if (advanced) game = advance(game, state => state.phase === 4 && clean(state) &&
|
||||
state.revivalOrder?.remaining[0] === f.target);
|
||||
game = act(game, f.target, { type: 'ready' });
|
||||
if (advanced) {
|
||||
assert.ok(!game.revivalOrder?.remaining.includes(f.target));
|
||||
const completed = structuredClone(game.revivalOrder);
|
||||
game = bind(f, 'yes', f.mixed, game).game;
|
||||
assert.deepEqual(game.revivalOrder, completed);
|
||||
assert.equal(viewGame(game, f.target).travelPromiseGuide, null);
|
||||
rejected(game, f.target, { type: 'revive', amount: 1, elite: 0 });
|
||||
} else {
|
||||
assert.ok(game.ready.includes(f.target)); game = bind(f, 'yes', f.mixed, game).game;
|
||||
assert.equal(game.ready.includes(f.target), false);
|
||||
assert.equal(guide(game, f.target).action.type, 'ready');
|
||||
}
|
||||
finishPaidRoute(f, game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Richese first and last Once Around or Silent controls keep the exact legal current auction guide', () => {
|
||||
for (const position of ['first', 'last'] as const) for (const method of ['onceAround', 'silent'] as const) {
|
||||
const f = fixture({ source: 'native' }), auction = { position, method };
|
||||
let game = advance(bind(f).game, state => state.phase === 3 && clean(state) && !!state.richeseAuction &&
|
||||
!state.richeseAuction.outcome && (method === 'silent'
|
||||
? !Object.hasOwn(state.richeseAuction.sealed, f.target) : state.richeseAuction.active === f.target), [], auction);
|
||||
const next = guide(game, f.target);
|
||||
assert.equal(next.action.type, 'richeseBid'); assert.equal(next.action.event, game.richeseAuction!.event);
|
||||
assert.equal(next.action.amount, method === 'silent' ? 0 : null);
|
||||
game = act(game, f.target, next.action);
|
||||
assert.ok(!promise(game, f.target).released);
|
||||
assert.equal(viewGame(game, f.target).travelPromiseGuide, null);
|
||||
game = reach(f, game, [], auction); finishPaidRoute(f, game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native E1 and E3 future travel answers survive their original setup and real independent phase controls', () => {
|
||||
for (const roster of ['ixians-tleilaxu', 'ecaz-moritani'] as const) {
|
||||
const f = fixture({ source: 'native', roster });
|
||||
const game = bind(f).game; assert.equal(guide(game, f.target).action.type, 'ready');
|
||||
finishPaidRoute(f, game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled alliance-only escrow uses original forces funds and actual Harkonnen Karama then releases only at the foreign outcome', () => {
|
||||
for (const canceled of [false, true]) {
|
||||
const f = fixture(), source = structuredClone(f.game);
|
||||
// Only this alliance relation is controlled. Every card, counter and purse
|
||||
// is the source's original native one; no forecast or receipt is installed.
|
||||
player(source, f.target).ally = 'harkonnen'; player(source, 'harkonnen').ally = f.target;
|
||||
const mixed = group('and', ship('arrakeen', 8), move('imperial_basin', 8));
|
||||
let game = bind(f, 'yes', mixed, source).game;
|
||||
game = advance(game, state => state.phase === 3 && clean(state) && state.auction?.active === f.target);
|
||||
game = act(game, f.target, { type: 'pledgeAid', amount: 8 });
|
||||
game = advance(game, state => state.phase === 3 && clean(state) && state.auction?.active === 'harkonnen');
|
||||
game = act(game, 'harkonnen', { type: 'bid', amount: 3, allyPayment: 3 });
|
||||
assert.ok(!promise(game, f.target).released);
|
||||
game = advance(game, state => state.response?.kind === 'emperorIncome');
|
||||
assert.ok(!promise(game, f.target).released);
|
||||
if (canceled) {
|
||||
const karama = player(game, 'harkonnen').hand.find(card => card.effect === 'karama'); assert.ok(karama);
|
||||
game = act(game, 'harkonnen', { type: 'card', card: karama.id, mode: 'cancel' });
|
||||
}
|
||||
game = advance(game, state => state.phase === 4 && clean(state));
|
||||
assert.equal(player(game, f.target).spice, canceled ? 7 : 10);
|
||||
assert.equal(promise(game, f.target).released, canceled);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles answer an original Charity travel question and legally consume exact early and phase5 guides', () => {
|
||||
const f = fixture(), asked = ask(f).game;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(asked); const actions: TruthTravelEarlyStep[] = [], phases = new Set<number>();
|
||||
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const answer = botActions(answering).find(action => action.type === 'truthAnswer'); assert.ok(answer, difficulty);
|
||||
assert.equal(answer.answer, 'yes'); game = settle(act(game, f.target, answer, actions), actions);
|
||||
for (let count = 0; count < 160 && !promise(game, f.target).fulfilled; count++) {
|
||||
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const current = view.travelPromiseGuide;
|
||||
if (current) {
|
||||
assert.equal(current.action.travelPromisePhase, game.phase); phases.add(game.phase);
|
||||
const before = structuredClone(view), action = botActions(view)
|
||||
.find(action => action.travelPromiseEvent === current.event);
|
||||
assert.ok(action, `${difficulty} phase${game.phase}`); assert.deepEqual(action, current.action);
|
||||
assert.deepEqual(view, before); game = settle(act(game, f.target, action, actions), actions);
|
||||
} else {
|
||||
const next = advance(game, state => !!viewGame(state, f.target).travelPromiseGuide ||
|
||||
!!promise(state, f.target).fulfilled, actions);
|
||||
assert.notEqual(next, game); game = next;
|
||||
}
|
||||
}
|
||||
assert.equal(promise(game, f.target).fulfilled, true, difficulty);
|
||||
assert.ok(phases.has(2) && phases.has(3) && phases.has(4) && phases.has(5), difficulty);
|
||||
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'ship'));
|
||||
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'move'));
|
||||
assert.deepEqual(inventory(finish(f, game, actions)), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('early authoring still rejects a stale turn and a target whose actual phase5 interval ended', () => {
|
||||
const f = fixture(), declared = declare(f);
|
||||
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
|
||||
let game = reach(f, f.game); game = finish(f, game);
|
||||
const after = declare(f, game).game;
|
||||
assert.ok(!viewGame(after, f.asker).truthTravelTargets.includes(f.target));
|
||||
rejected(after, f.asker, f.questionAction);
|
||||
assert.ok(player(after, f.asker).hand.some(card => card.id === f.truth));
|
||||
assert.deepEqual(inventory(after), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native early exact guide completes through the unchanged original phase5 shipment movement and own-end boundary', () => {
|
||||
const f = fixture({ source: 'hajr' }), actions: TruthTravelEarlyStep[] = [];
|
||||
let game = bind(f, 'yes', f.mixed, f.game, actions).game;
|
||||
game = complete(f, game, actions);
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, f.target);
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
const route = actions.filter(step => step.actor === f.target && ['ship', 'move'].includes(step.action.type));
|
||||
assert.deepEqual(route.map(step => step.action.type), ['ship', 'move']);
|
||||
assert.ok(route.every(step => step.action.travelPromisePhase === 5));
|
||||
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
||||
assert.deepEqual(inventory(finish(f, game, actions)), f.originalCards);
|
||||
});
|
||||
@@ -225,11 +225,12 @@ void test('native waiting travel answer preserves the current owner queue until
|
||||
game = finish(game, f.target); physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native travel authoring excludes earlier phases and a respondent whose own movement interval has closed', () => {
|
||||
void test('native travel authoring admits upcoming Charity and excludes a respondent whose own movement interval has closed', () => {
|
||||
const charity = fixture({ window: 'charity' }); assert.equal(charity.game.phase, 2);
|
||||
const declaredCharity = declare(charity);
|
||||
assert.ok(!viewGame(declaredCharity.game, charity.asker).truthTravelTargets.includes(charity.target));
|
||||
rejected(declaredCharity.game, charity.asker, charity.questionAction);
|
||||
assert.ok(viewGame(declaredCharity.game, charity.asker).truthTravelTargets.includes(charity.target));
|
||||
const askedCharity = act(declaredCharity.game, charity.asker, charity.questionAction);
|
||||
assert.equal(askedCharity.truthtrance?.question?.kind, 'travel'); assert.equal(askedCharity.phase, 2);
|
||||
assert.equal(declaredCharity.game.discard.filter(card => card.id === charity.truth).length, 0);
|
||||
const f = fixture(); let game = finish(f.game, f.target);
|
||||
assert.equal(game.active, f.asker); assert.ok(!game.movementRemaining?.includes(f.target));
|
||||
@@ -257,8 +258,10 @@ void test('native travel projection keeps only the owner raw aggregate while exa
|
||||
assert.deepEqual(own.travelPromises, [row]);
|
||||
const guide = own.travelPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.event, row.event); assert.equal(guide.action.travelPromiseEvent, row.event);
|
||||
assert.equal(guide.action.travelPromisePhase, 5);
|
||||
rejected(game, f.asker, guide.action);
|
||||
rejected(game, f.target, { ...guide.action, travelPromiseEvent: 'stale-native-travel-event' });
|
||||
rejected(game, f.target, { ...guide.action, travelPromisePhase: 4 });
|
||||
rejected(game, f.target, { ...guide.action, amount: Number(guide.action.amount) + 1 });
|
||||
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, wire));
|
||||
assert.equal(promise(game, f.target).event, row.event); rejected(game, f.target, wire);
|
||||
|
||||
Reference in new issue
Block a user