Support earlier combined shipment and movement promises

This commit is contained in:
Quintine committed 2026-10-26 20:17:37 +11:00
1 parent d4e82c74b1
commit 8dbec05844
18 files changed
+1321 -46

No files matched your search

+188
View File
@@ -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);
}
+3 -1
View File
@@ -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'; },
+61
View File
@@ -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);
}
});
+273
View File
@@ -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, []);
}
});
+84
View File
@@ -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);
});
+381
View File
@@ -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);
});
+6 -3
View File
@@ -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);