Combine native shipment and ground movement promises
This commit is contained in:
1 parent
13398aa76e
commit
d4e82c74b1
26 files changed
+2341
-10
No files matched your search
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { type Action, type Game } from '../game/engine';
|
||||
import type { TruthQuestion } from '../game/truthtrance';
|
||||
import {
|
||||
actTruthBid, advanceTruthBid, createTruthBidFixture, truthBidClean, truthBidInventory,
|
||||
truthBidPlayer, type TruthBidStep,
|
||||
} from './fixture-truth-bid';
|
||||
import {
|
||||
settleTruthTravel, truthTravelGroup, truthTravelMove, truthTravelShip,
|
||||
type TruthTravelExpression,
|
||||
} from './fixture-truth-travel';
|
||||
|
||||
export type TruthTravelOrderStep = TruthBidStep;
|
||||
export const actTruthTravelOrder = actTruthBid;
|
||||
export const truthTravelOrderClean = truthBidClean;
|
||||
export const truthTravelOrderInventory = truthBidInventory;
|
||||
export const truthTravelOrderPlayer = truthBidPlayer;
|
||||
export const settleTruthTravelOrder = settleTruthTravel;
|
||||
|
||||
/** The existing public H/A/E base33 start genuinely deals both printed
|
||||
* Truthtrances to Harkonnen. Continue its actual auction/Revival and Harkonnen
|
||||
* movement ending until Atreides' first unused native turn. Only the inherited
|
||||
* unopened deal/ordinary lots/Spice selections and scalar lotteries are used.
|
||||
* No hand, card custody, wallet, army, played clock, promise or receipt is set. */
|
||||
export function createTruthTravelOrderFixture() {
|
||||
const base = createTruthBidFixture({ window: 'waiting' });
|
||||
const actions = structuredClone(base.sourceActions), suffixFrom = actions.length;
|
||||
const game = advanceTruthBid(structuredClone(base.game), state => state.turn === 1 && state.phase === 5 &&
|
||||
state.active === base.opponent && truthBidClean(state), actions);
|
||||
const asker = base.asker, target = base.opponent, observer = base.target;
|
||||
assert.equal(game.advanced, false); assert.deepEqual(game.expansions, []);
|
||||
assert.deepEqual(truthBidPlayer(game, asker).hand.map(card => card.id), base.truths);
|
||||
assert.equal(base.truths.length, 2); assert.equal(new Set(base.truths).size, 2);
|
||||
assert.equal(truthBidPlayer(game, target).shipped, false); assert.equal(truthBidPlayer(game, target).moved, 0);
|
||||
assert.deepEqual(truthBidPlayer(game, target).forces, { 'arrakeen:10': 10 });
|
||||
assert.equal(truthBidPlayer(game, target).reserves, 10);
|
||||
assert.ok(!game.movementRemaining?.includes(asker)); assert.ok(game.movementRemaining?.includes(target));
|
||||
assert.equal(game.travelPromises?.length ?? 0, 0); assert.equal(game.shipmentPromises?.length ?? 0, 0);
|
||||
assert.equal(game.movementPromises?.length ?? 0, 0); assert.equal(game.movementExpressionPromises?.length ?? 0, 0);
|
||||
assert.deepEqual(truthBidInventory(game), base.originalCards);
|
||||
const mixed = truthTravelGroup('or', truthTravelShip(), truthTravelMove());
|
||||
return {
|
||||
game, source: structuredClone(game), base, asker, target, observer, truths: structuredClone(base.truths),
|
||||
setup: base.setup, afterSetup: base.afterSetup, beforeStartingDeal: base.beforeStartingDeal,
|
||||
originalCards: base.originalCards, originalSpice: base.originalSpice,
|
||||
dealOrder: base.dealOrder, spiceOrder: base.spiceOrder,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length, sourceCount: actions.length,
|
||||
continuation: structuredClone(actions.slice(suffixFrom)),
|
||||
sourceCounts: { inheritedBidding: suffixFrom, nativeContinuation: actions.length - suffixFrom, selected: actions.length },
|
||||
mixed,
|
||||
travelQuestion: { kind: 'travel', target, turn: game.turn, mixed } satisfies TruthQuestion,
|
||||
shipmentQuestion: { kind: 'shipment', target, territory: 'polar_sink', minimum: 1 } satisfies TruthQuestion,
|
||||
movementQuestion: { kind: 'movement', target, territory: 'imperial_basin', minimum: 1 } satisfies TruthQuestion,
|
||||
shipment: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
|
||||
alternativeShipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
|
||||
move: { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 1 } satisfies Action,
|
||||
alternativeMove: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1 } satisfies Action,
|
||||
end: { type: 'endMovement' } satisfies Action,
|
||||
sourceDisclosure: `${base.sourceDisclosure} This adapter continues the recorded original auction, normal Revival and ` +
|
||||
'Harkonnen endMovement to clean phase5 Atreides. Both native opening Truthtrances remain physically in Harkonnen custody; ' +
|
||||
'each later declaration uses a distinct genuine card. All selected action counts come from actual recorded arrays. ' +
|
||||
'No native stopping card is present to demonstrate an opponent-caused loss of the final route, so this source proves ' +
|
||||
'actual accepted-order binding and native compatibility, without claiming an opponent-release path.',
|
||||
};
|
||||
}
|
||||
export type TruthTravelOrderFixture = ReturnType<typeof createTruthTravelOrderFixture>;
|
||||
|
||||
export function declareTruthTravelOrder(f: TruthTravelOrderFixture, source = f.game, copy: 0 | 1 = 0,
|
||||
actions: TruthTravelOrderStep[] = []) {
|
||||
const before = structuredClone(source), from = actions.length;
|
||||
let game = actTruthTravelOrder(source, f.asker, { type: 'card', card: f.truths[copy] }, actions);
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actTruthTravelOrder(game, actor.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return { game, before, actions, steps: structuredClone(actions.slice(from)), actionCount: actions.length - from };
|
||||
}
|
||||
export function askTruthTravelOrder(game: Game, asker: string, question: TruthQuestion,
|
||||
actions: TruthTravelOrderStep[] = []): Game {
|
||||
return actTruthTravelOrder(game, asker, { type: 'truthAsk', question: structuredClone(question) }, actions);
|
||||
}
|
||||
export function answerTruthTravelOrder(game: Game, target: string, answer: 'yes' | 'no',
|
||||
actions: TruthTravelOrderStep[] = []): Game {
|
||||
return actTruthTravelOrder(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
export function bindTruthTravelOrder(f: TruthTravelOrderFixture, source: Game, copy: 0 | 1,
|
||||
question: TruthQuestion, answer: 'yes' | 'no' = 'yes', actions: TruthTravelOrderStep[] = []) {
|
||||
const declared = declareTruthTravelOrder(f, source, copy, actions);
|
||||
const asked = askTruthTravelOrder(declared.game, f.asker, question, actions);
|
||||
const game = answerTruthTravelOrder(asked, f.target, answer, actions);
|
||||
return { game, declared, asked, actions };
|
||||
}
|
||||
export function truthTravelOrderQuestion(f: TruthTravelOrderFixture, mixed: TruthTravelExpression): TruthQuestion {
|
||||
return { kind: 'travel', target: f.target, turn: f.game.turn, mixed: structuredClone(mixed) };
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { TravelActionClaim } from '../game/travel-expression-promises';
|
||||
import type { TruthFact } from '../game/truthtrance';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import {
|
||||
actTruthMixedMovement, createTruthMixedMovementFixture, truthMixedMovementClean,
|
||||
truthMixedMovementInventory, truthMixedMovementPlayer, type TruthMixedMovementStep,
|
||||
} from './fixture-truth-mixed-movement';
|
||||
import { createTruthMovementWaitingFixture } from './fixture-truth-movement-waiting';
|
||||
|
||||
export type TruthTravelExpression = MixedActionExpression<TravelActionClaim>;
|
||||
export type TruthTravelStep = TruthMixedMovementStep;
|
||||
export const actTruthTravel = actTruthMixedMovement;
|
||||
export const truthTravelClean = truthMixedMovementClean;
|
||||
export const truthTravelInventory = truthMixedMovementInventory;
|
||||
export const truthTravelPlayer = truthMixedMovementPlayer;
|
||||
export const truthTravelFact = (fact: TruthFact): TruthTravelExpression => ({ kind: 'fact', fact });
|
||||
export const truthTravelShip = (territory = 'polar_sink', minimum = 1): TruthTravelExpression =>
|
||||
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
|
||||
export const truthTravelMove = (territory = 'imperial_basin', minimum = 1): TruthTravelExpression =>
|
||||
({ kind: 'action', action: { kind: 'move', territory, minimum } });
|
||||
export const truthTravelGroup = (kind: 'and' | 'or', ...terms: TruthTravelExpression[]): TruthTravelExpression =>
|
||||
({ kind, terms });
|
||||
|
||||
/** One original public Basic base33 start. Active stops immediately before the
|
||||
* source's paid Polar Sink shipment; shipped retains that one actual action.
|
||||
* Waiting replays the original unopened deal reversal and native phase prefix.
|
||||
* Charity is only an explicitly unsupported-window source for a boundary test.
|
||||
* Every selected count comes from the corresponding recorded action array. */
|
||||
export function createTruthTravelFixture(options: {
|
||||
hajr?: boolean; window?: 'active' | 'waiting' | 'shipped' | 'charity'; mixed?: TruthTravelExpression;
|
||||
} = {}) {
|
||||
const window = options.window ?? 'active';
|
||||
const activeSource = window === 'waiting' ? null : createTruthMixedMovementFixture({
|
||||
hajr: options.hajr, window: window === 'charity' ? 'charity' : 'active',
|
||||
});
|
||||
const waitingSource = window === 'waiting' ? createTruthMovementWaitingFixture({ hajr: options.hajr }) : null;
|
||||
const original = activeSource ?? waitingSource!;
|
||||
let game = structuredClone(original.game), actions = structuredClone(original.actions);
|
||||
let shipmentIndex: number | null = null;
|
||||
if (window === 'active') {
|
||||
assert.ok(activeSource);
|
||||
shipmentIndex = activeSource.actions.findIndex(step => step.actor === activeSource.shipmentStep.actor &&
|
||||
JSON.stringify(step.action) === JSON.stringify(activeSource.shipmentStep.action));
|
||||
assert.ok(shipmentIndex >= 0, 'The source records its original paid shipment explicitly');
|
||||
game = structuredClone(activeSource.beforeShipment);
|
||||
actions = structuredClone(activeSource.actions.slice(0, shipmentIndex));
|
||||
}
|
||||
const asker = original.asker, target = original.target;
|
||||
const shipmentAction: Action = waitingSource ? structuredClone(waitingSource.shipment) :
|
||||
structuredClone(activeSource!.shipmentStep.action);
|
||||
const firstMove: Action = waitingSource ? structuredClone(waitingSource.move) : structuredClone(activeSource!.firstMove);
|
||||
const secondMove: Action = waitingSource
|
||||
? { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 1 }
|
||||
: structuredClone(activeSource!.secondMove);
|
||||
const shipmentTerritory = String(shipmentAction.territory), movementTerritory = String(firstMove.territory);
|
||||
const mixed = options.mixed ?? truthTravelGroup('and', truthTravelShip(shipmentTerritory), truthTravelMove(movementTerritory));
|
||||
const question = { kind: 'travel' as const, target, turn: game.turn, mixed };
|
||||
const hajrCard = original.setup.deck.find(card => card.effect === 'hajr'); assert.ok(hajrCard);
|
||||
const projectile = original.setup.deck.find(card => card.kind === 'projectile'); assert.ok(projectile);
|
||||
const hajrFact: TruthFact = { kind: 'hand', name: hajrCard.name };
|
||||
const projectileFact: TruthFact = { kind: 'hand', name: projectile.name };
|
||||
const spiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: truthTravelPlayer(game, target).spice };
|
||||
assert.equal(game.advanced, false); assert.deepEqual(game.expansions, []);
|
||||
assert.ok(truthTravelClean(game)); assert.equal(game.pendingShipment ?? null, null);
|
||||
assert.ok(truthTravelPlayer(game, asker).hand.some(card => card.id === original.truth));
|
||||
assert.deepEqual(truthTravelInventory(game), original.originalCards);
|
||||
assert.equal(game.travelPromises?.length ?? 0, 0);
|
||||
return {
|
||||
game, source: structuredClone(game), base: original, activeSource, waitingSource, window,
|
||||
asker, target, truth: original.truth, card: original.truth, hajr: original.hajr,
|
||||
setup: original.setup, lobby: original.lobby, beforeStartingDeal: original.beforeStartingDeal,
|
||||
afterSetup: original.afterSetup, beforeShipment: activeSource?.beforeShipment ?? null,
|
||||
originalCards: original.originalCards, originalSpice: original.originalSpice,
|
||||
dealOrder: original.dealOrder, spiceOrder: original.spiceOrder, startingPositions: original.startingPositions,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length, sourceCount: actions.length,
|
||||
sourceCounts: { selected: actions.length, inherited: original.sourceActionCount, shipment: shipmentIndex },
|
||||
mixed, question, hajrFact, projectileFact, spiceFact,
|
||||
shipmentAction, shipmentStep: { actor: target, action: shipmentAction },
|
||||
shipmentTerritory, movementTerritory, firstMove, secondMove, sameDestinationMove: { ...firstMove },
|
||||
shipmentCost: waitingSource ? waitingSource.shipmentCost : 2,
|
||||
hajrAction: original.hajr ? { type: 'card', card: original.hajr } satisfies Action : null,
|
||||
declaration: { type: 'card', card: original.truth } satisfies Action,
|
||||
questionAction: { type: 'truthAsk', question } satisfies Action,
|
||||
currentMove: waitingSource?.currentMove ?? null,
|
||||
currentEnd: { type: 'endMovement' } satisfies Action,
|
||||
sourceDisclosure: `${'sourceDisclosure' in original ? original.sourceDisclosure : original.staging.join(' ')} ` +
|
||||
`This travel adapter selects only the recorded ${window} native prefix and original raw structured question. ` +
|
||||
'Its shipment leaves all refer to one future original reserve shipment after answering, and ground leaves refer to actual native ground groups. ' +
|
||||
'The source has one actually dealt Truthtrance, so simultaneous prior scalar-plus-travel answers are outside this fixture. ' +
|
||||
'No additional physical card, dealt hand, purse, force, played clock, promise, receipt or compiled fact is installed.',
|
||||
};
|
||||
}
|
||||
export type TruthTravelFixture = ReturnType<typeof createTruthTravelFixture>;
|
||||
|
||||
export function declareTruthTravel(f: TruthTravelFixture, source = f.game) {
|
||||
const before = structuredClone(source), actions: TruthTravelStep[] = [];
|
||||
let game = actTruthTravel(source, f.asker, f.declaration, actions);
|
||||
const opened = structuredClone(game);
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actTruthTravel(game, actor.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return { game, before, opened, actions, actionCount: actions.length };
|
||||
}
|
||||
export function askTruthTravel(game: Game, asker: string, target: string, mixed: TruthTravelExpression,
|
||||
actions: TruthTravelStep[] = []): Game {
|
||||
return actTruthTravel(game, asker, { type: 'truthAsk', question: {
|
||||
kind: 'travel', target, turn: game.turn, mixed: structuredClone(mixed),
|
||||
} }, actions);
|
||||
}
|
||||
export function answerTruthTravel(game: Game, target: string, answer: 'yes' | 'no', actions: TruthTravelStep[] = []): Game {
|
||||
return actTruthTravel(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
export function bindTruthTravel(f: TruthTravelFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed, source = f.game) {
|
||||
const declared = declareTruthTravel(f, source), actions = [...declared.actions];
|
||||
const asked = askTruthTravel(declared.game, f.asker, f.target, mixed, actions);
|
||||
const game = answerTruthTravel(asked, f.target, answer, actions);
|
||||
return { game, declared, asked, actions, actionCount: actions.length };
|
||||
}
|
||||
|
||||
/** The private aggregate exposes the original question and its status only. */
|
||||
export function truthTravelPromise(game: Game, target = game.travelPromises?.[0]?.player ?? game.players[0].id) {
|
||||
const row = viewGame(game, target).travelPromises.find(promise => promise.player === target);
|
||||
assert.ok(row, 'The respondent retains the raw travel question and aggregate answer');
|
||||
return row;
|
||||
}
|
||||
export function nextTruthTravel(game: Game): TruthTravelStep | 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 };
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceTruthTravel(game: Game, until: (state: Game) => boolean, actions: TruthTravelStep[] = []): Game {
|
||||
for (let count = 0; count < 800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original travel source ended before its requested native boundary');
|
||||
const next = nextTruthTravel(game); assert.ok(next, 'Supply the original Truthtrance question or answer');
|
||||
game = actTruthTravel(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original travel source did not reach its finite boundary');
|
||||
}
|
||||
export function settleTruthTravel(game: Game, actions: TruthTravelStep[] = []): Game {
|
||||
return advanceTruthTravel(game, state => truthTravelClean(state) && !state.pendingShipment, actions);
|
||||
}
|
||||
export function completeTruthTravel(game: Game, target = game.travelPromises?.[0]?.player ?? game.players[0].id,
|
||||
actions: TruthTravelStep[] = []): Game {
|
||||
for (let count = 0; count < 16; count++) {
|
||||
const promise = truthTravelPromise(game, target);
|
||||
if (promise.fulfilled || promise.released) return game;
|
||||
game = settleTruthTravel(game, actions);
|
||||
const guide = viewGame(game, target).travelPromiseGuide; assert.ok(guide, 'The accepted travel answer has a native completion');
|
||||
game = settleTruthTravel(actTruthTravel(game, target, guide.action, actions), actions);
|
||||
}
|
||||
throw Error('The exact original native travel guide did not settle the accepted aggregate');
|
||||
}
|
||||
export function finishTruthTravel(game: Game, target = game.travelPromises?.[0]?.player ?? game.players[0].id,
|
||||
actions: TruthTravelStep[] = []): Game {
|
||||
game = settleTruthTravel(game, actions);
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, target);
|
||||
game = settleTruthTravel(actTruthTravel(game, target, { type: 'endMovement' }, actions), actions);
|
||||
assert.ok(!game.movementRemaining?.includes(target), 'Only actual owner endMovement closes this native interval');
|
||||
return game;
|
||||
}
|
||||
@@ -69,6 +69,21 @@ void test('mixed action parsing requires both families and preserves raw nested
|
||||
assert.throws(() => decode(input), MixedActionQuestionError);
|
||||
});
|
||||
|
||||
void test('optional current facts permit all-action travel questions without weakening other defaults or budgets', () => {
|
||||
for (const original of [action(), group('or', action(), action('arrakeen')),
|
||||
group('and', fact(), action())])
|
||||
assert.deepEqual(decode(original, { requireFact: false }), original);
|
||||
for (const original of [fact(), group('and', fact(), fact(10))])
|
||||
assert.throws(() => decode(original, { requireFact: false }), MixedActionQuestionError);
|
||||
assert.throws(() => decode(action(), { requireFact: true }), MixedActionQuestionError);
|
||||
assert.throws(() => decode(group('and', action(), action()), { requireFact: false, maxLeaves: 1 }),
|
||||
MixedActionQuestionError);
|
||||
assert.throws(() => decode(group('and', action(), action()), { requireFact: false, maxDepth: 0 }),
|
||||
MixedActionQuestionError);
|
||||
for (const requireFact of [null, 0, 'false'])
|
||||
assert.throws(() => decode(action(), { requireFact } as unknown as MixedActionExpressionLimits), MixedActionQuestionError);
|
||||
});
|
||||
|
||||
void test('mixed action public parsing rejects frozen constants, metadata and malformed groups everywhere', () => {
|
||||
const invalid: unknown[] = [null, [], {}, { kind: 'and', terms: [] },
|
||||
{ kind: 'or', terms: [fact()] }, { kind: 'xor', terms: [fact(), action()] },
|
||||
|
||||
@@ -26,6 +26,22 @@ void test('spent shipment and paid Box decisions cannot start another preparatio
|
||||
assert.deepEqual([...movementShipmentPreparations(game, 'owner', () => assert.fail('Finish the paid decision'))], []);
|
||||
});
|
||||
|
||||
void test('Fremen reserve preparation retains Guild transport variants only with a reciprocal native ally', () => {
|
||||
const game = source(), owner = game.players[0]; owner.faction = 'fremen';
|
||||
const guild: Game['players'][number] = { ...owner, id: 'ally', faction: 'guild', ally: owner.id };
|
||||
game.players.push(guild);
|
||||
const matching = (action: Record<string, unknown>) => action.type === 'guildShip' && action.from === 'reserves' &&
|
||||
action.territory === 'carthag' && action.sector === 11 && action.amount === 2 && action.allyPayment === 1;
|
||||
const choices = [...movementShipmentPreparations(game, owner.id, (state, _actor, action) =>
|
||||
matching(action) ? structuredClone(state) : null)];
|
||||
assert.equal(choices.length, 2);
|
||||
assert.deepEqual(choices.map(choice => choice.actions[0].elite), [0, 1]);
|
||||
guild.ally = null;
|
||||
assert.deepEqual([...movementShipmentPreparations(game, owner.id, (_state, _actor, action) => {
|
||||
assert.notEqual(action.type, 'guildShip'); return null;
|
||||
})], []);
|
||||
});
|
||||
|
||||
void test('owned No-Field shipment candidates preserve marker identity, one-force tariff shares and last-use restrictions', () => {
|
||||
const game = source(), owner = game.players[0]; owner.faction = 'richese';
|
||||
owner.noField = { tokens: [{ id: 'zero', value: 0 }, { id: 'three', value: 3 }, { id: 'five', value: 5 }],
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { compileMixedActionExpression, type MixedActionExpression } from '../game/mixed-action-question';
|
||||
import {
|
||||
finishTravelTurn,
|
||||
liveTravelPromises,
|
||||
parseTravelActionClaim,
|
||||
recordTravelMovement,
|
||||
recordTravelShipment,
|
||||
travelBranches,
|
||||
travelPromiseValue,
|
||||
TravelPromiseError,
|
||||
validateTravelPromises,
|
||||
type TravelActionClaim,
|
||||
type TravelBranchLeaf,
|
||||
type TravelPromise,
|
||||
} from '../game/travel-expression-promises';
|
||||
|
||||
type Expression = MixedActionExpression<TravelActionClaim>;
|
||||
const action = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3): Expression => ({
|
||||
kind: 'action', action: { kind, territory, minimum },
|
||||
});
|
||||
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
|
||||
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
|
||||
const leaf = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3, answer = true): TravelBranchLeaf =>
|
||||
({ kind, territory, minimum, answer });
|
||||
function promise(mixed: Expression, answer = true, event = 'travel:1'): TravelPromise {
|
||||
return {
|
||||
turn: 1, mixed, event, player: 'respondent', asker: 'asker', answer,
|
||||
compiled: compileMixedActionExpression(mixed, () => 'yes', answer),
|
||||
shipment: { pending: true, event: null }, progress: [], movementClosed: false,
|
||||
fulfilled: false, released: false,
|
||||
};
|
||||
}
|
||||
const event = (territory = 'carthag', amount = 3) => ({ territory, amount });
|
||||
|
||||
void test('travel action leaves use native destination/minimum validation and reject every extra field', () => {
|
||||
for (const kind of ['ship', 'move'] as const) {
|
||||
const original = { kind, territory: 'carthag', minimum: 3 };
|
||||
assert.deepEqual(parseTravelActionClaim(original), original);
|
||||
assert.notEqual(parseTravelActionClaim(original), original);
|
||||
for (const invalid of [null, [], {}, { ...original, kind: 'revive' },
|
||||
{ ...original, minimum: 0 }, { ...original, minimum: 21 }, { ...original, minimum: 1.5 },
|
||||
{ ...original, minimum: '3' }, { ...original, territory: 'not-a-territory' },
|
||||
{ ...original, turn: 1 }, { ...original, answer: true }, { ...original, maximum: 3 },
|
||||
{ ...original, claim: { territory: 'carthag', minimum: 3 } }])
|
||||
assert.throws(() => parseTravelActionClaim(invalid), TravelPromiseError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('whole Yes AND requires both native events while OR keeps either alternative available', () => {
|
||||
const and = promise(group('and', action('ship'), action('move', 'arrakeen', 2)));
|
||||
const or = promise(group('or', action('ship'), action('move', 'arrakeen', 2)));
|
||||
assert.deepEqual([...travelBranches(and)], [[leaf('ship'), leaf('move', 'arrakeen', 2)]]);
|
||||
assert.deepEqual([...travelBranches(or)], [[leaf('ship')], [leaf('move', 'arrakeen', 2)]]);
|
||||
const shipped = recordTravelShipment([and, or], 'respondent', 1, event());
|
||||
assert.equal(travelPromiseValue(shipped[0]), false);
|
||||
assert.equal(shipped[0].fulfilled, false);
|
||||
assert.equal(shipped[1].fulfilled, true);
|
||||
assert.deepEqual([...travelBranches(shipped[0])], [[leaf('move', 'arrakeen', 2)]]);
|
||||
const moved = recordTravelMovement(shipped, 'respondent', 1, event('arrakeen', 2));
|
||||
assert.equal(moved[0].fulfilled, true);
|
||||
assert.deepEqual([...travelBranches(moved[0])], [[]]);
|
||||
assert.equal(liveTravelPromises(moved, 'respondent', 1).length, 0);
|
||||
assert.equal(and.shipment.pending, true);
|
||||
assert.deepEqual(and.progress, []);
|
||||
});
|
||||
|
||||
void test('whole No applies De Morgan and permits changing branches after a real action', () => {
|
||||
const andNo = promise(group('and', action('ship'), action('move', 'arrakeen', 2)), false);
|
||||
const orNo = promise(group('or', action('ship'), action('move', 'arrakeen', 2)), false);
|
||||
assert.deepEqual([...travelBranches(andNo)], [[leaf('ship', 'carthag', 3, false)],
|
||||
[leaf('move', 'arrakeen', 2, false)]]);
|
||||
assert.deepEqual([...travelBranches(orNo)], [[leaf('ship', 'carthag', 3, false),
|
||||
leaf('move', 'arrakeen', 2, false)]]);
|
||||
const shipped = recordTravelShipment([andNo], 'respondent', 1, event());
|
||||
assert.equal(shipped[0].fulfilled, false);
|
||||
assert.deepEqual([...travelBranches(shipped[0])], [[leaf('move', 'arrakeen', 2, false)]]);
|
||||
const before = structuredClone(shipped);
|
||||
assert.throws(() => recordTravelMovement(shipped, 'respondent', 1, event('arrakeen', 2)), TravelPromiseError);
|
||||
assert.deepEqual(shipped, before);
|
||||
assert.throws(() => recordTravelShipment([orNo], 'respondent', 1, event()), TravelPromiseError);
|
||||
const skipped = recordTravelShipment([andNo], 'respondent', 1, null);
|
||||
assert.ok([...travelBranches(skipped[0])].some(branch => branch.length === 0),
|
||||
'Skipping the shipment leaves an unrestricted completion, regardless of redundant narrower DNF branches');
|
||||
assert.doesNotThrow(() => recordTravelMovement(skipped, 'respondent', 1, event('arrakeen', 2)));
|
||||
});
|
||||
|
||||
void test('nested branches keep all signed alternatives and remove impossible positive shipment conjunctions', () => {
|
||||
const asked = group('and', group('or', action('ship'), action('move')),
|
||||
group('or', action('ship', 'arrakeen', 2), action('move', 'arrakeen', 4)));
|
||||
const branches = [...travelBranches(promise(asked))];
|
||||
assert.deepEqual(branches, [
|
||||
[leaf('ship'), leaf('move', 'arrakeen', 4)],
|
||||
[leaf('move'), leaf('ship', 'arrakeen', 2)],
|
||||
[leaf('move'), leaf('move', 'arrakeen', 4)],
|
||||
]);
|
||||
assert.deepEqual([...travelBranches(promise(group('and', action('ship'), action('ship', 'arrakeen'))))], []);
|
||||
const no = [...travelBranches(promise(asked, false))];
|
||||
assert.deepEqual(no, [
|
||||
[leaf('ship', 'carthag', 3, false), leaf('move', 'carthag', 3, false)],
|
||||
[leaf('ship', 'arrakeen', 2, false), leaf('move', 'arrakeen', 4, false)],
|
||||
]);
|
||||
});
|
||||
|
||||
void test('same-destination signed bounds keep strongest minimum or lowest negative cap', () => {
|
||||
const asked = group('and', action('move', 'carthag', 2), action('move', 'carthag', 5));
|
||||
assert.deepEqual([...travelBranches(promise(asked))], [[leaf('move', 'carthag', 5)]]);
|
||||
const negative = promise(group('or', action('move', 'carthag', 2), action('move', 'carthag', 5)), false);
|
||||
assert.deepEqual([...travelBranches(negative)], [[leaf('move', 'carthag', 2, false)]]);
|
||||
const atCap = recordTravelMovement([negative], 'respondent', 1, event('carthag', 1));
|
||||
assert.equal(atCap[0].progress[0].maximum, 1);
|
||||
assert.deepEqual([...travelBranches(atCap[0])], [[leaf('move', 'carthag', 2, false)]]);
|
||||
assert.throws(() => recordTravelMovement(atCap, 'respondent', 1, event('carthag', 2)), TravelPromiseError);
|
||||
});
|
||||
|
||||
void test('shipment leaves share one event and later answers receive no earlier shipment or movement', () => {
|
||||
const expression = group('or', action('ship', 'carthag', 5), action('move', 'arrakeen', 5));
|
||||
const old = promise(expression);
|
||||
const shipped = recordTravelShipment([old], 'respondent', 1, event('carthag', 4));
|
||||
const moved = recordTravelMovement(shipped, 'respondent', 1, event('arrakeen', 4));
|
||||
const fresh = promise(expression, true, 'travel:2');
|
||||
fresh.shipment.pending = false;
|
||||
const otherPlayer = { ...promise(expression, true, 'travel:3'), player: 'other' };
|
||||
const otherTurn = { ...promise(expression, true, 'travel:4'), turn: 2 };
|
||||
const list = [moved[0], fresh, otherPlayer, otherTurn];
|
||||
const next = recordTravelMovement(list, 'respondent', 1, event('arrakeen', 3));
|
||||
assert.deepEqual(next[0].progress, [{ territory: 'arrakeen', maximum: 4 }]);
|
||||
assert.deepEqual(next[1].progress, [{ territory: 'arrakeen', maximum: 3 }]);
|
||||
assert.equal(next[2], otherPlayer);
|
||||
assert.equal(next[3], otherTurn);
|
||||
const repeatedShipment = recordTravelShipment(next, 'respondent', 1, event('carthag', 20));
|
||||
assert.equal(repeatedShipment[0].shipment.event?.amount, 4);
|
||||
assert.equal(repeatedShipment[1].shipment.event, null);
|
||||
assert.deepEqual([...travelBranches(repeatedShipment[1])], [[leaf('move', 'arrakeen', 5)]]);
|
||||
});
|
||||
|
||||
void test('only the largest physical movement group counts and event updates are immutable', () => {
|
||||
const original = promise(action('move', 'carthag', 5));
|
||||
const once = recordTravelMovement([original], 'respondent', 1, event('carthag', 3));
|
||||
const twice = recordTravelMovement(once, 'respondent', 1, event('carthag', 3));
|
||||
assert.equal(travelPromiseValue(twice[0]), false);
|
||||
assert.equal(twice[0], once[0]);
|
||||
assert.equal(twice[0].progress[0].maximum, 3);
|
||||
assert.deepEqual(recordTravelMovement(twice, 'respondent', 1, event('carthag', 0)), twice);
|
||||
assert.equal(recordTravelMovement(twice, 'respondent', 1, event('arrakeen', 20))[0], twice[0]);
|
||||
const complete = recordTravelMovement(twice, 'respondent', 1, event('carthag', 5));
|
||||
assert.equal(complete[0].fulfilled, true);
|
||||
assert.deepEqual(original.progress, []);
|
||||
assert.equal(once[0].progress[0].maximum, 3);
|
||||
});
|
||||
|
||||
void test('frozen known and unknown facts constrain only the whole selected answer', () => {
|
||||
for (const answer of [true, false]) {
|
||||
for (const factAnswer of ['yes', 'no', 'unknown'] as const) {
|
||||
for (const kind of ['and', 'or'] as const) {
|
||||
const mixed = group(kind, fact(), action('move'));
|
||||
const record = promise(mixed, answer);
|
||||
record.compiled = compileMixedActionExpression(mixed, () => factAnswer, answer);
|
||||
assert.deepEqual(record.mixed, mixed);
|
||||
if (record.compiled.kind === 'constant') {
|
||||
assert.equal(travelPromiseValue(record), record.compiled.value);
|
||||
assert.deepEqual([...travelBranches(record)], record.compiled.value === answer ? [[]] : []);
|
||||
if (record.compiled.value === answer) {
|
||||
record.fulfilled = true;
|
||||
validateTravelPromises([record], ['respondent', 'asker']);
|
||||
}
|
||||
} else {
|
||||
assert.deepEqual([...travelBranches(record)], [[leaf('move', 'carthag', 3, answer)]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('turn closure rejects unmet Yes, fulfills compatible No and folds every closed opportunity', () => {
|
||||
const yes = promise(group('or', action('ship'), action('move')));
|
||||
const before = structuredClone(yes);
|
||||
assert.throws(() => finishTravelTurn([yes], 'respondent', 1), TravelPromiseError);
|
||||
assert.deepEqual(yes, before);
|
||||
const no = promise(group('and', action('ship'), action('move')), false);
|
||||
const closed = finishTravelTurn([no], 'respondent', 1);
|
||||
assert.equal(closed[0].fulfilled, true);
|
||||
assert.equal(closed[0].movementClosed, true);
|
||||
assert.equal(closed[0].shipment.pending, false);
|
||||
validateTravelPromises(closed, ['respondent', 'asker']);
|
||||
assert.equal(recordTravelMovement(closed, 'respondent', 1, event())[0], closed[0]);
|
||||
const projectedClosed = { ...yes, movementClosed: true, shipment: { pending: false, event: null } };
|
||||
assert.deepEqual([...travelBranches(projectedClosed)], []);
|
||||
assert.deepEqual([...travelBranches({ ...projectedClosed, answer: false })], [[]]);
|
||||
const released = { ...yes, released: true };
|
||||
const finishedRelease = finishTravelTurn([released], 'respondent', 1)[0];
|
||||
assert.equal(finishedRelease.released, true);
|
||||
assert.equal(finishedRelease.fulfilled, false);
|
||||
assert.deepEqual([...travelBranches(finishedRelease)], [[]]);
|
||||
});
|
||||
|
||||
void test('stored structure, whole-answer progress and strict native events reject malformed input', () => {
|
||||
const original = promise(group('and', action('ship'), action('move')));
|
||||
validateTravelPromises([original], ['respondent', 'asker']);
|
||||
const corruptions: unknown[] = [
|
||||
{ ...original, turn: 0 }, { ...original, answer: 'yes' }, { ...original, fulfilled: undefined },
|
||||
{ ...original, fulfilled: true, released: true },
|
||||
{ ...original, shipment: { pending: true, event: event() } },
|
||||
{ ...original, shipment: { pending: false, event: { ...event(), elite: 1 } } },
|
||||
{ ...original, progress: [{ territory: 'carthag', maximum: 21 }] },
|
||||
{ ...original, progress: [{ territory: 'arrakeen', maximum: 3 }] },
|
||||
{ ...original, progress: [{ territory: 'carthag', maximum: 1 }, { territory: 'carthag', maximum: 2 }] },
|
||||
{ ...original, compiled: { kind: 'constant', value: 'unknown' } },
|
||||
{ ...original, compiled: { kind: 'action', action: { kind: 'move', territory: 'arrakeen', minimum: 3 } } },
|
||||
{ ...original, compiled: { kind: 'and', terms: [] } },
|
||||
{ ...original, mixed: { kind: 'constant', value: true } },
|
||||
{ ...original, mixed: fact() },
|
||||
];
|
||||
for (const corrupted of corruptions)
|
||||
assert.throws(() => travelPromiseValue(corrupted as TravelPromise));
|
||||
for (const record of [{ ...original, event: '' }, { ...original, asker: 'respondent' },
|
||||
{ ...original, player: 'missing' }, { ...original, fulfilled: true }])
|
||||
assert.throws(() => validateTravelPromises([record], ['respondent', 'asker']), TravelPromiseError);
|
||||
assert.throws(() => validateTravelPromises([original, original], ['respondent', 'asker']), TravelPromiseError);
|
||||
for (const invalid of [{ ...event(), amount: 21 }, { ...event(), amount: -1 },
|
||||
{ ...event(), amount: 2.5 }, { ...event(), amount: '3' },
|
||||
{ ...event(), territory: 'missing' }, { ...event(), elite: 1 }]) {
|
||||
assert.throws(() => recordTravelMovement([original], 'respondent', 1, invalid as never), TravelPromiseError);
|
||||
assert.throws(() => recordTravelShipment([original], 'respondent', 1, invalid as never), TravelPromiseError);
|
||||
}
|
||||
const violatedNo = { ...original, answer: false,
|
||||
shipment: { pending: false, event: event() }, progress: [{ territory: 'carthag', maximum: 3 }] };
|
||||
assert.throws(() => validateTravelPromises([violatedNo], ['respondent', 'asker']), TravelPromiseError);
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
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 { TravelClaimFields, travelClaimInputError } from '../components/travel-claim-fields';
|
||||
import { TravelAnswers, TravelPromiseGuideButton, TravelPromises } from '../components/travel-promises';
|
||||
import { TruthFactFields } from '../components/mixed-shipment-fields';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { TravelActionClaim } from '../game/travel-expression-promises';
|
||||
import type { TruthFact } from '../game/truthtrance';
|
||||
|
||||
type Expression = MixedActionExpression<TravelActionClaim>;
|
||||
type ElementProps = { children?: ReactNode; [key: string]: unknown };
|
||||
type Element = ReactElement<ElementProps>;
|
||||
const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression => ({ kind: 'fact', fact: value });
|
||||
const action = (kind: 'ship' | 'move' = 'ship', minimum = 1, territory = 'carthag'): Expression =>
|
||||
({ kind: 'action', action: { kind, territory, minimum } });
|
||||
const claim = (): Expression => ({ kind: 'or', terms: [action('ship', 3), { kind: 'and', terms: [fact(), action('move', 4, 'arrakeen')] }] });
|
||||
|
||||
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 find(tree: ReactNode, key: string, value: string): Element {
|
||||
const found = elements(tree).find(element => element.props[key] === value);
|
||||
assert.ok(found, `${key}=${value}`); return found;
|
||||
}
|
||||
const control = (tree: ReactNode, key: string) => find(tree, 'data-travel-control', key);
|
||||
const click = (button: Element) => (button.props.onClick as () => void)();
|
||||
function select(tree: ReactNode, path: string, field: string, value: string) {
|
||||
(find(tree, 'id', `truth-travel-${path}-${field}`).props.onChange as
|
||||
(event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
|
||||
}
|
||||
function minimum(tree: ReactNode, path: string, valueAsNumber: number) {
|
||||
(find(tree, 'id', `truth-travel-${path}-minimum`).props.onChange as
|
||||
(event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } });
|
||||
}
|
||||
function editFact(tree: ReactNode, path: string, value: TruthFact) {
|
||||
const scope = find(tree, 'data-travel-fact-path', path);
|
||||
const fields = elements(scope).find(element => element.type === TruthFactFields);
|
||||
assert.ok(fields);
|
||||
(fields.props.onChange as (next: TruthFact) => void)(value);
|
||||
}
|
||||
|
||||
/** Explicitly controlled projections verify UI callbacks. They do not claim
|
||||
* native acquisition, force movement or promise fulfillment evidence. */
|
||||
function projected(next: Action = { type: 'move', forces: { 'arrakeen:10': 3, 'arrakeen:11': 1 },
|
||||
eliteForces: { 'arrakeen:10': 1 }, territory: 'carthag', sector: 11 }): GameView {
|
||||
const source = createGame('TRUTHTRAVELCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
|
||||
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
|
||||
const game = viewGame(source, 'p');
|
||||
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.truthTravelAnswers = null;
|
||||
return game;
|
||||
}
|
||||
function editor(initial: Expression, disabled = false, game = projected()) {
|
||||
let value = initial, changes = 0;
|
||||
return {
|
||||
get value() { return value; }, get changes() { return changes; },
|
||||
tree: () => TravelClaimFields({ value, game, target: 'p', disabled, onChange(next) { value = next; changes++; } }),
|
||||
html: () => renderToStaticMarkup(createElement(TravelClaimFields, { value, game, target: 'p', disabled, onChange() {} })),
|
||||
error: () => travelClaimInputError(value, game, 'p'),
|
||||
};
|
||||
}
|
||||
function guide(props: Parameters<typeof TravelPromiseGuideButton>[0]) {
|
||||
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 answering(): GameView {
|
||||
const game = projected();
|
||||
game.truthtrance = { stage: 'answer', queue: [{ player: 'a', card: 'controlled-truth-card' }], passed: [],
|
||||
question: { kind: 'travel', target: 'p', turn: 2, mixed: claim() } };
|
||||
game.truthTravelAnswers = ['yes', 'no', 'unknown'];
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('travel editor accepts action-only expressions and preserves shipment and movement alternatives through edits', () => {
|
||||
const e = editor(action('ship', 3)); assert.equal(e.error(), null);
|
||||
select(e.tree(), '1', 'action', 'move'); select(e.tree(), '1', 'territory', 'arrakeen'); minimum(e.tree(), '1', 5);
|
||||
assert.deepEqual(e.value, action('move', 5, 'arrakeen'));
|
||||
select(e.tree(), '1', 'action', 'ship');
|
||||
assert.deepEqual(e.value, action('ship', 5, 'arrakeen'));
|
||||
click(control(e.tree(), 'or-1')); select(e.tree(), '1.2', 'timing', 'action');
|
||||
select(e.tree(), '1.2', 'action', 'move'); minimum(e.tree(), '1.2', 4);
|
||||
assert.deepEqual(e.value, { kind: 'or', terms: [action('ship', 5, 'arrakeen'), action('move', 4)] });
|
||||
assert.equal(e.error(), null);
|
||||
const html = e.html();
|
||||
assert.match(html, /Ship from reserves/); assert.match(html, /Make ground movement/);
|
||||
assert.match(html, /single future original reserve shipment/); assert.match(html, /smaller moves never add together/);
|
||||
assert.match(html, /No makes the whole expression false/); assert.match(html, /Original costs, movement rights/);
|
||||
click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, action('move', 4));
|
||||
select(e.tree(), '1', 'timing', 'fact'); assert.ok(e.error(), 'A pure-fact tree still needs a future action');
|
||||
});
|
||||
|
||||
void test('travel editor retains sixteen optional-fact clauses and four grouping levels without truncation', () => {
|
||||
const e = editor({ kind: 'or', terms: [action('ship', 3), action('move', 4)] });
|
||||
for (let count = 3; count <= 16; count++) {
|
||||
click(control(e.tree(), 'add-action-1'));
|
||||
minimum(e.tree(), `1.${count}`, count);
|
||||
if (count % 2) select(e.tree(), `1.${count}`, 'action', 'move');
|
||||
}
|
||||
assert.equal(e.error(), null); assert.match(e.html(), /16 \/ 16 conditions/);
|
||||
const full = structuredClone(e.value), changes = e.changes;
|
||||
for (const key of ['add-action-1', 'add-fact-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'or-1']) {
|
||||
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
|
||||
}
|
||||
assert.deepEqual(e.value, full); assert.equal(e.changes, changes);
|
||||
const deep = editor({ kind: 'and', terms: [fact(), action('move')] });
|
||||
click(control(deep.tree(), 'or-1.2')); select(deep.tree(), '1.2', 'meaning', 'and');
|
||||
click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
|
||||
assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||||
assert.equal(control(deep.tree(), 'and-1.2.1.1.1').props.disabled, true);
|
||||
editFact(deep.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 3 }); assert.equal(deep.error(), null);
|
||||
click(control(deep.tree(), 'remove-1.2.1.1.2'));
|
||||
assert.match(deep.html(), /3 \/ 4 levels of grouping/);
|
||||
});
|
||||
|
||||
void test('travel fields preserve invalid numeric drafts and reject disabled callbacks', () => {
|
||||
for (const invalid of [NaN, 0, -1, 1.5, 21, Infinity]) {
|
||||
const e = editor(action()); minimum(e.tree(), '1', invalid);
|
||||
assert.ok(e.error());
|
||||
if (Number.isNaN(invalid)) assert.match(e.html(), /value=""/);
|
||||
minimum(e.tree(), '1', 20); assert.equal(e.error(), null);
|
||||
}
|
||||
const malformed = editor(action()); select(malformed.tree(), '1', 'territory', 'invented-place');
|
||||
assert.ok(malformed.error()); assert.match(malformed.html(), /aria-invalid="true"/);
|
||||
const frozen = editor(claim(), true), original = structuredClone(frozen.value);
|
||||
select(frozen.tree(), '1', 'meaning', 'and'); minimum(frozen.tree(), '1.1', 7);
|
||||
select(frozen.tree(), '1.1', 'action', 'move'); select(frozen.tree(), '1.1', 'territory', 'arrakeen');
|
||||
editFact(frozen.tree(), '1.2.1', { kind: 'spice', compare: 'gte', value: 9 });
|
||||
for (const key of ['and-1', 'add-action-1', 'remove-1.1', 'nested-or-1']) click(control(frozen.tree(), key));
|
||||
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, original);
|
||||
});
|
||||
|
||||
void test('travel inputs use printed destinations and current-fact schema without private eligibility or custody reads', () => {
|
||||
const game = projected();
|
||||
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves'])
|
||||
Object.defineProperty(player, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true });
|
||||
for (const expression of [action('ship', 20, 'arrakeen'), { kind: 'and', terms: [fact({ kind: 'stormForecast', compare: 'gte', value: 3 }), action('move')] } as Expression]) {
|
||||
const e = editor(expression, false, game);
|
||||
assert.equal(e.error(), null); assert.match(e.html(), /Carthag/); assert.match(e.html(), /Arrakeen/);
|
||||
assert.match(e.html(), /Only the aggregate answer is shared/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('travel history retains own original whole questions and terminal statuses without compiled branches or foreign history', () => {
|
||||
const game = projected(); game.travelPromiseGuide = null;
|
||||
game.travelPromises.push(
|
||||
{ ...game.travelPromises[0], event: 'controlled-no', answer: false },
|
||||
{ ...game.travelPromises[0], event: 'controlled-complete', turn: 1, fulfilled: true },
|
||||
{ ...game.travelPromises[0], event: 'controlled-release', released: true },
|
||||
{ ...game.travelPromises[0], event: 'controlled-foreign', player: 'a', mixed: { kind: 'and', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), action('ship', 19, 'sietch_tabr')] } },
|
||||
);
|
||||
for (const promise of game.travelPromises) for (const key of ['compiled', 'binding', 'factAnswers', 'residual', 'route'])
|
||||
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||||
const html = markup(game);
|
||||
assert.match(html, /Turn 1/); assert.match(html, /You answered Yes/); assert.match(html, /You answered No/);
|
||||
assert.match(html, /you hold Shield/); assert.match(html, / OR /); assert.match(html, / AND /);
|
||||
assert.match(html, /Fulfilled: this whole commitment is complete/); assert.match(html, /Released: this whole commitment is no longer possible/);
|
||||
assert.match(html, /No negates the whole expression, not each condition separately/);
|
||||
assert.match(html, /maximum physical forces in one actual future move, never the sum/);
|
||||
assert.match(html, /Individual fact answers and any reduced future branch remain private/);
|
||||
assert.doesNotMatch(html, /Poison Blade|19 physical|controlled-foreign|controlled-complete|factAnswers/);
|
||||
assert.equal(TravelPromises({ game: { ...game, me: 'missing' }, busy: false, act() {} }), null);
|
||||
game.travelPromises = game.travelPromises.filter(promise => promise.fulfilled || promise.released);
|
||||
assert.doesNotMatch(markup(game), /<button|Movement turn is still ahead/);
|
||||
});
|
||||
|
||||
void test('travel guide forwards exact native shipment, movement and preparation packets without rewriting nested forces', () => {
|
||||
const actions: Action[] = [
|
||||
projected().travelPromiseGuide!.action,
|
||||
{ type: 'ship', territory: 'arrakeen', sector: 10, amount: 4, elite: 1, allyPayment: 0 },
|
||||
{ type: 'guildShip', from: 'reserves', territory: 'carthag', sector: 11, amount: 6, elite: 0, allyPayment: 3 },
|
||||
{ type: 'move', territory: 'carthag', sector: 11, forces: { 'arrakeen:10': 4 }, eliteForces: {},
|
||||
movementCard: 'controlled-ornithopter', ornithopter: 'twoGroups', ornithopterEvent: 'controlled-flight' },
|
||||
{ type: 'card', card: 'controlled-hajr' }, { type: 'prepareAdvisors', territory: 'arrakeen' },
|
||||
{ type: 'revealNoField', territory: 'arrakeen', sector: 10 }, { type: 'endMovement' },
|
||||
];
|
||||
for (const action of actions) {
|
||||
const game = projected(action), sent: Action[] = [];
|
||||
const button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, false); press(button);
|
||||
assert.deepEqual(sent, [game.travelPromiseGuide!.action]); assert.equal(sent[0], game.travelPromiseGuide!.action);
|
||||
assert.match(markup(game), /data-testid="travel-promise-guide"/);
|
||||
if (action.type === 'guildShip') assert.match(markup(game), /using Guild transport/);
|
||||
}
|
||||
const game = projected({ type: 'decision', event: 'controlled-paid-search', card: 'controlled-recovered-card' });
|
||||
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-card', 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);
|
||||
game.nullentropy.search!.event = 'changed-search'; press(button); assert.equal(sent.length, 1);
|
||||
});
|
||||
|
||||
void test('travel guide rejects stale view, actor, source, turn, phase, native ownership and quiet-window changes', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 4; }, game => { game.status = 'setup'; },
|
||||
game => { game.active = 'a'; }, game => { game.travelPromiseGuide = null; },
|
||||
game => { game.travelPromiseGuide = { ...game.travelPromiseGuide!, action: { ...game.travelPromiseGuide!.action } }; },
|
||||
game => { game.travelPromiseGuide!.event = 'changed'; },
|
||||
game => { game.travelPromiseGuide!.action.travelPromiseEvent = 'changed'; },
|
||||
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'; },
|
||||
game => { game.travelPromises[0].turn = 1; }, game => { game.travelPromises[0].fulfilled = true; },
|
||||
game => { game.travelPromises[0].released = true; }, game => { game.travelPromises = []; },
|
||||
game => { game.phaseOpening = { passed: [] }; }, game => { game.automaticContinuationPending = true; },
|
||||
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||||
game => { game.truthtrance = answering().truthtrance; },
|
||||
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||||
game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
|
||||
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||||
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
change(game); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
const busy = { game: projected(), busy: false, act() { assert.fail('Busy guide cannot send'); } };
|
||||
const busyButton = guide(busy); busy.busy = true; press(busyButton);
|
||||
const replaced = { game: projected(), busy: false, act() { assert.fail('Replaced view cannot send'); } };
|
||||
const replacedButton = guide(replaced); replaced.game = { ...replaced.game }; press(replacedButton);
|
||||
});
|
||||
|
||||
void test('whole travel answers offer only native choices and forward guarded original answer packets', () => {
|
||||
const game = answering(), sent: Action[] = [], props = { game, busy: false, act(value: Action) { sent.push(value); } };
|
||||
for (const answer of game.truthTravelAnswers!) {
|
||||
const tree = TravelAnswers(props); assert.ok(tree);
|
||||
click(find(tree, 'data-travel-answer', answer));
|
||||
}
|
||||
assert.deepEqual(sent, ['yes', 'no', 'unknown'].map(answer => ({ type: 'truthAnswer', answer })));
|
||||
const html = renderToStaticMarkup(createElement(TravelAnswers, props));
|
||||
assert.match(html, /Answer Yes/); assert.match(html, /Answer No/); assert.match(html, /I don’t know yet/);
|
||||
game.truthTravelAnswers = ['no'];
|
||||
const restricted = renderToStaticMarkup(createElement(TravelAnswers, { game, busy: false, act() {} }));
|
||||
assert.match(restricted, /Answer No/); assert.doesNotMatch(restricted, /Answer Yes|I don’t know yet/);
|
||||
assert.equal(TravelAnswers({ game: { ...game, me: 'a' }, busy: false, act() {} }), null);
|
||||
assert.equal(TravelAnswers({ game: { ...game, truthTravelAnswers: null }, busy: false, act() {} }), null);
|
||||
});
|
||||
|
||||
void test('travel answer callbacks reject stale source, offered choices, view identity, turn and modal ownership', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 4; }, game => { game.status = 'setup'; },
|
||||
game => { game.truthtrance = null; }, game => { game.truthtrance!.stage = 'ask'; },
|
||||
game => { game.truthtrance!.question = { ...game.truthtrance!.question! }; },
|
||||
game => { game.truthtrance!.question!.target = 'a'; },
|
||||
game => { game.truthtrance!.queue[0] = { ...game.truthtrance!.queue[0] }; },
|
||||
game => { game.truthtrance!.queue[0].card = 'another-card'; },
|
||||
game => { game.truthTravelAnswers = ['yes']; }, game => { game.truthTravelAnswers!.splice(0); },
|
||||
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||||
game => { game.phaseOpening = { passed: [] }; }, game => { game.automaticContinuationPending = true; },
|
||||
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||||
game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
|
||||
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||||
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = answering(), sent: Action[] = [], tree = TravelAnswers({ game, busy: false, act(value) { sent.push(value); } });
|
||||
const button = find(tree, 'data-travel-answer', 'yes'); change(game); click(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
const busy = { game: answering(), busy: false, act() { assert.fail('Busy answer cannot send'); } };
|
||||
const button = find(TravelAnswers(busy), 'data-travel-answer', 'yes'); busy.busy = true; click(button);
|
||||
const replaced = { game: answering(), busy: false, act() { assert.fail('Replaced view cannot send'); } };
|
||||
const original = find(TravelAnswers(replaced), 'data-travel-answer', 'yes'); replaced.game = { ...replaced.game }; click(original);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
|
||||
/** Controlled native state, conserving the original two physical Truths. This
|
||||
* isolates the printed Fremen/Guild transport rule; no setup/earned-alliance
|
||||
* history is claimed by this fixture. */
|
||||
function controlled(paidByAlly = false) {
|
||||
const game = createGame('TRAVELGUILDFREMEN', newPlayer('a', 'Asker', 'harkonnen'), false);
|
||||
game.players.push(newPlayer('p', 'Fremen', 'fremen'), newPlayer('g', 'Guild', 'guild'));
|
||||
Object.assign(game, { status: 'playing', phase: 5, turn: 2, storm: 18, active: 'p',
|
||||
order: ['p', 'a', 'g'], movementRemaining: ['p', 'a', 'g'], deck: baseDeck(), discard: [] });
|
||||
for (const player of game.players) {
|
||||
player.hand = []; player.forces = {}; player.reserves = 20; player.spice = 10; player.traitorChoices = [];
|
||||
}
|
||||
game.players[1].ally = 'g'; game.players[2].ally = 'p'; game.players[1].spice = paidByAlly ? 0 : 3;
|
||||
const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
|
||||
game.deck = game.deck.filter(card => card.effect !== 'truthtrance'); game.players[0].hand = truths;
|
||||
const inventory = (g: Game) => [...g.deck, ...g.discard, ...g.players.flatMap(player => player.hand)].map(card => card.id).sort();
|
||||
const original = inventory(game);
|
||||
return { game: paidByAlly ? applyAction(game, 'g', { type: 'pledgeAid', amount: 3 }) : game,
|
||||
truths: truths.map(card => card.id), inventory, original };
|
||||
}
|
||||
const ship = (minimum: number) => ({ kind: 'action' as const, action: { kind: 'ship' as const, territory: 'carthag', minimum } });
|
||||
const move = { kind: 'action' as const, action: { kind: 'move' as const, territory: 'imperial_basin', minimum: 6 } };
|
||||
function asked(game: Game, card: string, question: unknown) {
|
||||
game = applyAction(game, 'a', { 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, 'a', { type: 'truthAsk', question });
|
||||
}
|
||||
void test('controlled Guild-allied Fremen travel uses the paid reserve transport outside ordinary reinforcement radius and then moves the physical group', () => {
|
||||
for (const paidByAlly of [false, true]) {
|
||||
const f = controlled(paidByAlly); let game = asked(f.game, f.truths[0], {
|
||||
kind: 'travel', target: 'p', turn: 2, mixed: { kind: 'and', terms: [ship(6), move] },
|
||||
});
|
||||
assert.ok(viewGame(game, 'p').truthTravelAnswers?.includes('yes'));
|
||||
game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' });
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, 'p', { type: 'ship', territory: 'carthag', sector: 11, amount: 6 }));
|
||||
assert.deepEqual(game, before);
|
||||
const guide = viewGame(game, 'p').travelPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'guildShip'); assert.equal(guide.action.from, 'reserves');
|
||||
assert.equal(guide.action.territory, 'carthag'); assert.equal(guide.action.amount, 6);
|
||||
assert.equal(guide.action.allyPayment, paidByAlly ? 3 : 0);
|
||||
game = applyAction(game, 'p', guide.action);
|
||||
assert.equal(game.players[1].spice, 0); assert.equal(game.players[1].reserves, 14);
|
||||
if (paidByAlly) assert.equal(game.aid.g.amount, 0);
|
||||
assert.equal(viewGame(game, 'p').travelPromises[0].fulfilled, false);
|
||||
assert.deepEqual(game.travelPromises![0].progress, []);
|
||||
const moving = viewGame(game, 'p').travelPromiseGuide; assert.ok(moving); assert.equal(moving.action.type, 'move');
|
||||
game = applyAction(game, 'p', moving.action);
|
||||
assert.equal(viewGame(game, 'p').travelPromises[0].fulfilled, true);
|
||||
assert.equal(Object.entries(game.players[1].forces).filter(([key]) => key.startsWith('imperial_basin:')).reduce((sum, [, count]) => sum + count, 0), 6);
|
||||
assert.deepEqual(f.inventory(game), f.original);
|
||||
}
|
||||
});
|
||||
void test('controlled earlier travel No retains the legal Guild-allied Fremen route for a later scalar shipment Yes', () => {
|
||||
const f = controlled(); let game = asked(f.game, f.truths[0], { kind: 'travel', target: 'p', turn: 2, mixed: ship(7) });
|
||||
game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'no' });
|
||||
game = asked(game, f.truths[1], { kind: 'shipment', target: 'p', territory: 'carthag', minimum: 6 });
|
||||
assert.ok(viewGame(game, 'p').truthShipmentAnswers?.includes('yes'));
|
||||
game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' });
|
||||
const action: Action = { type: 'guildShip', from: 'reserves', territory: 'carthag', sector: 11, amount: 6 };
|
||||
game = applyAction(game, 'p', action); assert.equal(game.shipmentPromises?.[0].fulfilled, true);
|
||||
assert.equal(viewGame(game, 'p').travelPromises[0].fulfilled, false);
|
||||
game = applyAction(game, 'p', { type: 'endMovement' });
|
||||
assert.equal(viewGame(game, 'p').travelPromises[0].fulfilled, true); assert.deepEqual(f.inventory(game), f.original);
|
||||
});
|
||||
void test('controlled Fremen cannot use a free ordinary price to fund paid Guild transport', () => {
|
||||
const f = controlled(); f.game.players[1].spice = 2;
|
||||
const game = asked(f.game, f.truths[0], { kind: 'travel', target: 'p', turn: 2, mixed: ship(6) });
|
||||
assert.deepEqual(viewGame(game, 'p').truthTravelAnswers, ['no']);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { TruthQuestion } from '../game/truthtrance';
|
||||
import { truthTravelGroup as group, truthTravelMove as move, truthTravelShip as ship } from './fixture-truth-travel';
|
||||
import {
|
||||
actTruthTravelOrder as act, answerTruthTravelOrder as answer, askTruthTravelOrder as ask,
|
||||
bindTruthTravelOrder as bind, createTruthTravelOrderFixture as fixture, declareTruthTravelOrder as declare,
|
||||
settleTruthTravelOrder as settle, truthTravelOrderInventory as inventory, truthTravelOrderPlayer as player,
|
||||
truthTravelOrderQuestion as travelQuestion, type TruthTravelOrderFixture, type TruthTravelOrderStep,
|
||||
} from './fixture-truth-travel-order';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function physicalTruthsOnce(game: Game, f: TruthTravelOrderFixture) {
|
||||
for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
|
||||
assert.equal(player(game, f.asker).hand.some(card => f.truths.includes(card.id)), false);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
function scalarQuestion(f: TruthTravelOrderFixture, kind: 'shipment' | 'movement' | 'movementExpression'): TruthQuestion {
|
||||
if (kind === 'shipment') return f.shipmentQuestion;
|
||||
if (kind === 'movement') return f.movementQuestion;
|
||||
return { kind: 'movementExpression', target: f.target, turn: f.game.turn, claim: { kind: 'or', terms: [
|
||||
{ kind: 'move', territory: 'imperial_basin', minimum: 1 },
|
||||
{ kind: 'move', territory: 'carthag', minimum: 1 },
|
||||
] } };
|
||||
}
|
||||
function nativeRow(game: Game, kind: 'shipment' | 'movement' | 'movementExpression') {
|
||||
const row = kind === 'shipment' ? game.shipmentPromises?.[0]
|
||||
: kind === 'movement' ? game.movementPromises?.[0] : game.movementExpressionPromises?.[0];
|
||||
assert.ok(row); return row;
|
||||
}
|
||||
|
||||
void test('native travel-order source genuinely owns both original opening Truthtrances before any question', () => {
|
||||
const f = fixture();
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.deck.length, 33);
|
||||
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
||||
assert.equal(f.setup.deck.filter(card => card.effect === 'truthtrance').length, 2);
|
||||
assert.deepEqual(player(f.afterSetup, f.asker).hand.map(card => card.id), f.truths);
|
||||
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
|
||||
assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.target); assert.equal(f.target, 'atreides');
|
||||
assert.equal(f.sourceCount, f.actions.length); assert.equal(f.sourceActionCount, f.sourceActions.length);
|
||||
assert.equal(f.sourceCounts.inheritedBidding + f.sourceCounts.nativeContinuation, f.sourceCount);
|
||||
assert.deepEqual(f.sourceActions.slice(0, f.sourceCounts.inheritedBidding), f.base.sourceActions);
|
||||
assert.equal(f.continuation.length, f.sourceCounts.nativeContinuation);
|
||||
assert.ok(f.continuation.some(step => step.action.type === 'passBid'));
|
||||
assert.ok(f.continuation.some(step => step.action.type === 'ready'));
|
||||
assert.ok(f.continuation.some(step => step.actor === f.asker && step.action.type === 'endMovement'));
|
||||
assert.equal(player(f.game, f.target).spice, 10); assert.equal(player(f.game, f.target).reserves, 10);
|
||||
assert.deepEqual(player(f.game, f.target).forces, { 'arrakeen:10': 10 });
|
||||
assert.equal(f.game.truthHistory?.length ?? 0, 0); assert.equal(f.game.travelPromiseOrder, undefined);
|
||||
assert.deepEqual(inventory(f.game), f.originalCards); assert.equal(f.originalCards.length, 33);
|
||||
});
|
||||
|
||||
for (const kind of ['shipment', 'movement', 'movementExpression'] as const)
|
||||
for (const first of ['native', 'travel'] as const)
|
||||
void test(`two native opening Truths retain ${kind} No and travel OR Yes in actual ${first}-first acceptance order`, () => {
|
||||
const f = fixture(), actions: TruthTravelOrderStep[] = [];
|
||||
const native = scalarQuestion(f, kind), travel = f.travelQuestion;
|
||||
let game = f.game;
|
||||
game = bind(f, game, 0, first === 'native' ? native : travel, first === 'native' ? 'no' : 'yes', actions).game;
|
||||
const firstHistory = structuredClone(game.truthHistory!.at(-1));
|
||||
game = bind(f, game, 1, first === 'native' ? travel : native, first === 'native' ? 'yes' : 'no', actions).game;
|
||||
assert.deepEqual(game.travelPromiseOrder, first === 'native'
|
||||
? [{ kind, index: 0 }, { kind: 'travel', index: 0 }]
|
||||
: [{ kind: 'travel', index: 0 }, { kind, index: 0 }]);
|
||||
assert.deepEqual(game.truthHistory!.at(-2), firstHistory);
|
||||
assert.equal(nativeRow(game, kind).answer, false); assert.ok(!nativeRow(game, kind).released);
|
||||
assert.equal(game.travelPromises![0].answer, true); assert.ok(!game.travelPromises![0].released);
|
||||
assert.deepEqual(viewGame(game, f.observer).travelPromises, []);
|
||||
if (kind === 'shipment') {
|
||||
rejected(game, f.target, f.shipment);
|
||||
const beforeSpice = player(game, f.target).spice;
|
||||
game = settle(act(game, f.target, f.alternativeShipment, actions), actions);
|
||||
assert.equal(player(game, f.target).spice, beforeSpice - 1);
|
||||
assert.ok(!game.travelPromises![0].fulfilled);
|
||||
game = settle(act(game, f.target, f.move, actions), actions);
|
||||
assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
|
||||
} else {
|
||||
rejected(game, f.target, f.move);
|
||||
const beforeSpice = player(game, f.target).spice;
|
||||
game = settle(act(game, f.target, f.shipment, actions), actions);
|
||||
assert.equal(player(game, f.target).spice, beforeSpice - 2);
|
||||
assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
|
||||
if (kind === 'movement') game = settle(act(game, f.target, f.alternativeMove, actions), actions);
|
||||
}
|
||||
assert.equal(game.travelPromises![0].fulfilled, true); assert.ok(!nativeRow(game, kind).released);
|
||||
game = settle(act(game, f.target, f.end, actions), actions);
|
||||
assert.equal(nativeRow(game, kind).fulfilled, true); assert.ok(!nativeRow(game, kind).released);
|
||||
assert.equal(game.travelPromises![0].fulfilled, true); assert.ok(!game.travelPromises![0].released);
|
||||
assert.equal(game.truthHistory!.length, 2); assert.equal(actions.filter(step => step.action.type === 'truthAsk').length, 2);
|
||||
physicalTruthsOnce(game, f);
|
||||
});
|
||||
|
||||
void test('two native opening Truths keep whole travel No with an earlier or later compatible scalar shipment Yes', () => {
|
||||
const f = fixture(), travel = travelQuestion(f, group('and', ship(), move()));
|
||||
for (const first of ['native', 'travel'] as const) {
|
||||
const actions: TruthTravelOrderStep[] = [];
|
||||
let game = bind(f, f.game, 0, first === 'native' ? f.shipmentQuestion : travel,
|
||||
first === 'native' ? 'yes' : 'no', actions).game;
|
||||
const earlier = structuredClone(game.truthHistory!.at(-1));
|
||||
game = bind(f, game, 1, first === 'native' ? travel : f.shipmentQuestion,
|
||||
first === 'native' ? 'no' : 'yes', actions).game;
|
||||
assert.deepEqual(game.travelPromiseOrder, first === 'native'
|
||||
? [{ kind: 'shipment', index: 0 }, { kind: 'travel', index: 0 }]
|
||||
: [{ kind: 'travel', index: 0 }, { kind: 'shipment', index: 0 }]);
|
||||
rejected(game, f.target, f.end);
|
||||
game = settle(act(game, f.target, f.shipment, actions), actions);
|
||||
assert.equal(game.shipmentPromises![0].fulfilled, true);
|
||||
assert.ok(!game.travelPromises![0].fulfilled); rejected(game, f.target, f.move);
|
||||
game = settle(act(game, f.target, f.end, actions), actions);
|
||||
assert.equal(game.travelPromises![0].fulfilled, true); assert.ok(!game.travelPromises![0].released);
|
||||
assert.equal(game.shipmentPromises![0].fulfilled, true); assert.ok(!game.shipmentPromises![0].released);
|
||||
assert.equal(player(game, f.target).moved, 0); assert.deepEqual(game.truthHistory!.at(-2), earlier);
|
||||
physicalTruthsOnce(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('an incompatible second definite answer cannot erase the first native or travel receipt', () => {
|
||||
const f = fixture();
|
||||
// Earlier original shipment Yes forces the later incompatible-destination
|
||||
// travel AND to No. The second native card remains present after rejection.
|
||||
let game = bind(f, f.game, 0, f.shipmentQuestion, 'yes').game;
|
||||
const earlierShipment = structuredClone(game.shipmentPromises![0]), earlierHistory = structuredClone(game.truthHistory![0]);
|
||||
const incompatibleTravel = travelQuestion(f, group('and', ship('arrakeen'), move()));
|
||||
game = ask(declare(f, game, 1).game, f.asker, incompatibleTravel);
|
||||
assert.deepEqual(viewGame(game, f.target).truthTravelAnswers, ['no']);
|
||||
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.deepEqual(game.shipmentPromises![0], earlierShipment); assert.deepEqual(game.truthHistory![0], earlierHistory);
|
||||
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1]));
|
||||
game = answer(game, f.target, 'no'); game = settle(act(game, f.target, f.shipment));
|
||||
game = settle(act(game, f.target, f.end)); physicalTruthsOnce(game, f);
|
||||
assert.equal(game.shipmentPromises![0].fulfilled, true); assert.equal(game.travelPromises![0].fulfilled, true);
|
||||
|
||||
// Reverse actual acceptance: the original travel AND Yes requires Polar
|
||||
// Sink. A later scalar cannot answer No and erase that earlier raw receipt.
|
||||
game = bind(f, f.game, 0, travelQuestion(f, group('and', ship(), move())), 'yes').game;
|
||||
const earlierTravel = structuredClone(game.travelPromises![0]);
|
||||
game = ask(declare(f, game, 1).game, f.asker, f.shipmentQuestion);
|
||||
assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['yes']);
|
||||
rejected(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
assert.deepEqual(game.travelPromises![0], earlierTravel);
|
||||
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1]));
|
||||
game = answer(game, f.target, 'yes');
|
||||
assert.deepEqual(game.travelPromiseOrder, [{ kind: 'travel', index: 0 }, { kind: 'shipment', index: 0 }]);
|
||||
game = settle(act(game, f.target, f.shipment)); game = settle(act(game, f.target, f.move));
|
||||
game = settle(act(game, f.target, f.end));
|
||||
assert.equal(game.travelPromises![0].fulfilled, true); assert.ok(!game.travelPromises![0].released);
|
||||
assert.equal(game.shipmentPromises![0].fulfilled, true); assert.ok(!game.shipmentPromises![0].released);
|
||||
physicalTruthsOnce(game, f);
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
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 } from '../game/truthtrance';
|
||||
import {
|
||||
actTruthTravel as act, answerTruthTravel as answer, askTruthTravel as ask, bindTruthTravel as bind,
|
||||
completeTruthTravel as complete, createTruthTravelFixture as fixture, declareTruthTravel as declare,
|
||||
finishTruthTravel as finish, settleTruthTravel as settle, truthTravelClean as clean,
|
||||
truthTravelFact as fact, truthTravelGroup as group, truthTravelInventory as inventory,
|
||||
truthTravelMove as move, truthTravelPlayer as player, truthTravelPromise as promise,
|
||||
truthTravelShip as ship, type TruthTravelExpression, type TruthTravelStep,
|
||||
} from './fixture-truth-travel';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function physicalTruthOnce(game: Game, truth: string) {
|
||||
assert.equal(game.discard.filter(card => card.id === truth).length, 1);
|
||||
}
|
||||
function nativeControls(game: Game) {
|
||||
return { turn: game.turn, phase: game.phase, active: game.active, order: game.order,
|
||||
movementRemaining: game.movementRemaining, storm: game.storm,
|
||||
response: game.response ?? null, decision: game.decision ?? null,
|
||||
phaseOpening: game.phaseOpening ?? null, pendingShipment: game.pendingShipment ?? null };
|
||||
}
|
||||
const progress = (game: Game, target: string) => game.travelPromises!.find(row => row.player === target)!.progress;
|
||||
|
||||
void test('native travel source freezes only the original Basic prefix before its one paid reserve shipment', () => {
|
||||
const f = fixture();
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.deck.length, 33);
|
||||
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
||||
assert.equal(f.game.advanced, false); assert.deepEqual(f.game.expansions, []);
|
||||
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.target);
|
||||
assert.equal(f.sourceCount, f.sourceActions.length); assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.deepEqual(f.actions, f.activeSource!.actions.slice(0, f.sourceCounts.shipment!));
|
||||
assert.deepEqual(f.activeSource!.actions[f.sourceActionCount], f.shipmentStep);
|
||||
assert.equal(f.sourceCounts.inherited, f.sourceCount + 1);
|
||||
assert.deepEqual(player(f.game, f.target).forces, { 'arrakeen:10': 10 });
|
||||
assert.equal(player(f.game, f.target).reserves, 10); assert.equal(player(f.game, f.target).shipped, false);
|
||||
assert.equal(player(f.game, f.target).moved, 0); assert.equal(f.game.truthHistory?.length ?? 0, 0);
|
||||
assert.equal(f.game.travelPromises?.length ?? 0, 0); assert.equal(f.game.truthtrance ?? null, null);
|
||||
assert.deepEqual(inventory(f.game), f.originalCards); assert.equal(f.originalCards.length, 33);
|
||||
assert.equal(f.setup.deck.filter(card => card.effect === 'truthtrance').length, 2);
|
||||
assert.equal(f.game.players.flatMap(player => player.hand).filter(card => card.effect === 'truthtrance').length, 1,
|
||||
'Only one original Truthtrance has actually been dealt in this source; an undrawn copy is not a second owned answer');
|
||||
});
|
||||
|
||||
void test('native AND Yes requires the original shipment and a distinct actual ground move before own end', () => {
|
||||
const f = fixture(), source = structuredClone(f.game), beforeSpice = player(f.game, f.target).spice;
|
||||
const bound = bind(f); let game = bound.game;
|
||||
assert.deepEqual(f.game, source); assert.deepEqual(nativeControls(game), nativeControls(source));
|
||||
assert.deepEqual(promise(game, f.target).mixed, f.mixed); assert.deepEqual(progress(game, f.target), []);
|
||||
physicalTruthOnce(game, f.truth); rejected(game, f.target, f.currentEnd);
|
||||
game = settle(act(game, f.target, f.shipmentAction));
|
||||
assert.equal(player(game, f.target).spice, beforeSpice - f.shipmentCost);
|
||||
assert.equal(player(game, f.target).reserves, 9); assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
|
||||
assert.deepEqual(progress(game, f.target), []); assert.ok(!promise(game, f.target).fulfilled);
|
||||
rejected(game, f.target, f.currentEnd);
|
||||
game = settle(act(game, f.target, f.firstMove));
|
||||
assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
|
||||
assert.equal(player(game, f.target).forces['arrakeen:10'], 9);
|
||||
assert.deepEqual(progress(game, f.target), [{ territory: 'imperial_basin', maximum: 1 }]);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.ok(!promise(game, f.target).released);
|
||||
game = finish(game, f.target); physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native OR Yes retains both legal manual routes without selecting a fixed branch', () => {
|
||||
const f = fixture(), mixed = group('or', ship(), move()), bound = bind(f, 'yes', mixed).game;
|
||||
for (const action of [f.shipmentAction, f.firstMove]) {
|
||||
let game = settle(act(bound, f.target, action));
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.deepEqual(promise(game, f.target).mixed, mixed);
|
||||
assert.equal(player(game, f.target).moved, action.type === 'move' ? 1 : 0);
|
||||
if (action.type === 'move') assert.equal(player(game, f.target).reserves, 10);
|
||||
game = finish(game, f.target); physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native No to AND retains either true component and settles only at the respondent actual end', () => {
|
||||
const f = fixture(), bound = bind(f, 'no').game;
|
||||
for (const action of [f.shipmentAction, f.firstMove]) {
|
||||
let game = settle(act(bound, f.target, action));
|
||||
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released);
|
||||
if (action.type === 'ship') rejected(game, f.target, f.firstMove);
|
||||
else rejected(game, f.target, f.shipmentAction);
|
||||
game = finish(game, f.target);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.ok(!promise(game, f.target).released);
|
||||
physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native whole OR No forbids either matching action while preserving nonmatching shipment and movement', () => {
|
||||
const f = fixture(), mixed = group('or', ship(), move()); let game = bind(f, 'no', mixed).game;
|
||||
rejected(game, f.target, f.shipmentAction); rejected(game, f.target, f.firstMove);
|
||||
game = settle(act(game, f.target, { ...f.shipmentAction, territory: 'carthag', sector: 11 }));
|
||||
assert.equal(player(game, f.target).forces['carthag:11'], 1); assert.deepEqual(progress(game, f.target), []);
|
||||
game = settle(act(game, f.target, f.secondMove));
|
||||
assert.equal(player(game, f.target).forces['carthag:11'], 2); assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = finish(game, f.target); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native shipment leaves describe the same one future original shipment rather than independent events', () => {
|
||||
const f = fixture(), mixed = group('and', ship(), ship('arrakeen'));
|
||||
const asked = ask(declare(f).game, f.asker, f.target, mixed);
|
||||
assert.deepEqual(viewGame(asked, f.target).truthTravelAnswers, ['no']);
|
||||
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
let game = answer(asked, f.target, 'no');
|
||||
game = settle(act(game, f.target, f.shipmentAction));
|
||||
assert.equal(player(game, f.target).shipped, true); assert.equal(player(game, f.target).reserves, 9);
|
||||
game = finish(game, f.target); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native shipment into a named destination contributes no physical ground movement progress', () => {
|
||||
const f = fixture(), mixed = group('and', ship('imperial_basin'), move());
|
||||
let game = bind(f, 'yes', mixed).game;
|
||||
game = settle(act(game, f.target, { ...f.shipmentAction, territory: 'imperial_basin', sector: 9 }));
|
||||
assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
|
||||
assert.deepEqual(progress(game, f.target), []); assert.ok(!promise(game, f.target).fulfilled);
|
||||
rejected(game, f.target, f.currentEnd); game = settle(act(game, f.target, f.firstMove));
|
||||
assert.equal(player(game, f.target).forces['imperial_basin:9'], 2);
|
||||
assert.deepEqual(progress(game, f.target), [{ territory: 'imperial_basin', maximum: 1 }]);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native future shipment excludes the original shipment already completed before the answer', () => {
|
||||
const f = fixture({ window: 'shipped' }); assert.equal(player(f.game, f.target).shipped, true);
|
||||
assert.equal(f.sourceCount, f.sourceCounts.inherited);
|
||||
const impossible = group('and', ship(), ship('polar_sink', 1));
|
||||
const asked = ask(declare(f).game, f.asker, f.target, impossible);
|
||||
assert.deepEqual(viewGame(asked, f.target).truthTravelAnswers, ['no']);
|
||||
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const mixed = group('or', ship(), move()); let game = bind(f, 'yes', mixed).game;
|
||||
assert.ok(!promise(game, f.target).fulfilled); assert.deepEqual(progress(game, f.target), []);
|
||||
rejected(game, f.target, f.currentEnd); game = settle(act(game, f.target, f.firstMove));
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native smaller repeated Hajr groups retain the maximum move rather than their accumulated force total', () => {
|
||||
const f = fixture(), mixed = group('and', ship(), move('imperial_basin', 2));
|
||||
let game = bind(f, 'no', mixed).game;
|
||||
game = settle(act(game, f.target, f.shipmentAction));
|
||||
game = settle(act(game, f.target, f.firstMove)); game = settle(act(game, f.target, f.hajrAction!));
|
||||
game = settle(act(game, f.target, f.sameDestinationMove));
|
||||
assert.equal(player(game, f.target).forces['imperial_basin:9'], 2);
|
||||
assert.equal(player(game, f.target).moved, 2);
|
||||
assert.deepEqual(progress(game, f.target), [{ territory: 'imperial_basin', maximum: 1 }]);
|
||||
assert.ok(!promise(game, f.target).fulfilled); game = finish(game, f.target);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native voluntary spending or consuming the last move cannot erase an accepted travel Yes', () => {
|
||||
const f = fixture({ hajr: false }); let game = bind(f).game;
|
||||
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: player(game, f.target).spice - 1 });
|
||||
game = settle(act(game, f.target, f.shipmentAction));
|
||||
rejected(game, f.target, f.secondMove); rejected(game, f.target, f.currentEnd);
|
||||
assert.ok(!promise(game, f.target).released); game = settle(act(game, f.target, f.firstMove));
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native nonqualifying move remains legal when the genuinely held Hajr preserves the missing route', () => {
|
||||
const f = fixture(); let game = bind(f).game;
|
||||
game = settle(act(game, f.target, f.shipmentAction)); game = settle(act(game, f.target, f.secondMove));
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
const guide = viewGame(game, f.target).travelPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.hajr);
|
||||
game = complete(game, f.target); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.equal(player(game, f.target).moved, 2); assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
|
||||
assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native mixed travel freezes the actual current-spice fact before an ordinary paid bribe', () => {
|
||||
const f = fixture(), mixed = group('and', fact(f.spiceFact), ship(), move());
|
||||
const beforeSpice = player(f.game, f.target).spice, beforeBribes = player(f.game, f.asker).bribes;
|
||||
assert.ok(beforeSpice >= 5); assert.equal(truthFactAnswer(player(f.game, f.target), f.spiceFact), 'yes');
|
||||
let game = bind(f, 'yes', mixed).game;
|
||||
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
|
||||
assert.equal(player(game, f.target).spice, beforeSpice - 3);
|
||||
assert.equal(player(game, f.asker).bribes, beforeBribes + 3);
|
||||
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
|
||||
assert.ok(!promise(game, f.target).fulfilled); rejected(game, f.target, f.currentEnd);
|
||||
game = settle(act(game, f.target, f.shipmentAction)); game = settle(act(game, f.target, f.firstMove));
|
||||
assert.equal(player(game, f.target).spice, beforeSpice - 3 - f.shipmentCost);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(promise(game, f.target).mixed, mixed);
|
||||
assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native true held-card OR travel and false held-card AND travel settle without unnecessary actions', () => {
|
||||
const f = fixture();
|
||||
for (const [mixed, answerValue] of [
|
||||
[group('or', fact(f.hajrFact), ship(), move()), 'yes'],
|
||||
[group('and', fact(f.projectileFact), ship(), move()), 'no'],
|
||||
] as const) {
|
||||
const asked = ask(declare(f).game, f.asker, f.target, mixed);
|
||||
assert.deepEqual(viewGame(asked, f.target).truthTravelAnswers, [answerValue]);
|
||||
const game = answer(asked, f.target, answerValue);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(viewGame(game, f.target).travelPromiseGuide, null);
|
||||
assert.equal(player(game, f.target).shipped, false); assert.equal(player(game, f.target).moved, 0);
|
||||
assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native waiting travel answer preserves the current owner queue until the respondent real shipment and move', () => {
|
||||
const f = fixture({ window: 'waiting' }); assert.equal(f.game.active, f.asker);
|
||||
assert.equal(f.sourceCount, f.actions.length); assert.equal(player(f.game, f.target).reserves, 20);
|
||||
assert.deepEqual(player(f.game, f.target).forces, {});
|
||||
const before = nativeControls(f.game); let game = bind(f).game;
|
||||
assert.deepEqual(nativeControls(game), before); assert.equal(viewGame(game, f.target).travelPromiseGuide, null);
|
||||
rejected(game, f.target, f.shipmentAction); rejected(game, f.target, f.firstMove);
|
||||
rejected(game, f.target, f.currentEnd);
|
||||
game = settle(act(game, f.asker, f.currentEnd));
|
||||
assert.equal(game.active, f.target); assert.ok(!promise(game, f.target).fulfilled);
|
||||
assert.deepEqual(progress(game, f.target), []);
|
||||
const beforeSpice = player(game, f.target).spice;
|
||||
game = settle(act(game, f.target, f.shipmentAction));
|
||||
assert.equal(player(game, f.target).spice, beforeSpice - f.shipmentCost);
|
||||
assert.equal(player(game, f.target).reserves, 19); assert.equal(player(game, f.target).forces['arrakeen:10'], 1);
|
||||
assert.deepEqual(progress(game, f.target), []); assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = settle(act(game, f.target, f.firstMove)); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.equal(player(game, f.target).forces['carthag:11'], 1);
|
||||
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', () => {
|
||||
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.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));
|
||||
game = declare(f, game).game;
|
||||
assert.ok(!viewGame(game, f.asker).truthTravelTargets.includes(f.target)); rejected(game, f.asker, f.questionAction);
|
||||
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truth)); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native travel projection keeps only the owner raw aggregate while exact next-step guides retain event authority', () => {
|
||||
const f = fixture(), declared = declare(f);
|
||||
assert.ok(viewGame(declared.game, f.asker).truthTravelTargets.includes(f.target));
|
||||
rejected(declared.game, f.target, f.questionAction);
|
||||
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
|
||||
const asked = ask(declared.game, f.asker, f.target, f.mixed);
|
||||
assert.equal(viewGame(asked, f.asker).truthTravelAnswers, null);
|
||||
assert.ok(viewGame(asked, f.target).truthTravelAnswers?.includes('yes'));
|
||||
rejected(asked, f.asker, { type: 'truthAnswer', answer: 'yes' });
|
||||
let game = answer(asked, f.target, 'yes');
|
||||
const own = viewGame(game, f.target), other = viewGame(game, f.asker), row = promise(game, f.target);
|
||||
const required = ['event', 'player', 'asker', 'turn', 'mixed', 'answer'];
|
||||
assert.ok(required.every(key => Object.keys(row).includes(key)));
|
||||
assert.ok(Object.keys(row).every(key => [...required, 'fulfilled', 'released'].includes(key)));
|
||||
assert.deepEqual(other.travelPromises, []); assert.equal(other.travelPromiseGuide, null);
|
||||
assert.deepEqual(own.truthHistory.at(-1)?.question, f.question);
|
||||
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);
|
||||
rejected(game, f.asker, guide.action);
|
||||
rejected(game, f.target, { ...guide.action, travelPromiseEvent: 'stale-native-travel-event' });
|
||||
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);
|
||||
game = complete(game, f.target); rejected(game, f.target, guide.action);
|
||||
physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles answer the raw travel question and perform its exact native completion', () => {
|
||||
const f = fixture(), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(asked); const actions: TruthTravelStep[] = [];
|
||||
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const before = structuredClone(answering), answerAction = botActions(answering).find(action => action.type === 'truthAnswer');
|
||||
assert.ok(answerAction, difficulty); assert.deepEqual(answering, before); assert.equal(answerAction.answer, 'yes', difficulty);
|
||||
assert.ok(answering.truthTravelAnswers?.includes('yes')); game = act(game, f.target, answerAction, actions);
|
||||
for (let count = 0; count < 12 && !promise(game, f.target).fulfilled; count++) {
|
||||
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const guide = view.travelPromiseGuide; assert.ok(guide, difficulty);
|
||||
const action = botActions(view).find(action => action.travelPromiseEvent === guide.event);
|
||||
assert.ok(action, difficulty); assert.deepEqual(action, guide.action);
|
||||
game = settle(act(game, f.target, action, actions), actions);
|
||||
}
|
||||
assert.equal(promise(game, f.target).fulfilled, true, difficulty);
|
||||
assert.ok(actions.some(step => step.action.type === 'ship'), difficulty);
|
||||
assert.ok(actions.some(step => step.action.type === 'move'), difficulty);
|
||||
assert.equal(player(game, f.target).moved, 1); assert.equal(player(game, f.target).reserves, 9);
|
||||
physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(finish(game, f.target, actions)), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native raw travel validates every leaf without consuming the physical card at a rejected Ask', () => {
|
||||
const f = fixture(), declared = declare(f), before = structuredClone(declared.game);
|
||||
const invalid: TruthTravelExpression[] = [
|
||||
group('or', fact(f.hajrFact), ship('not-a-territory'), move()),
|
||||
group('and', ship(), move('imperial_basin', 0)),
|
||||
group('and', ship('polar_sink', 21), move()),
|
||||
];
|
||||
for (const mixed of invalid) rejected(declared.game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'travel', target: f.target, turn: f.game.turn, mixed,
|
||||
} });
|
||||
assert.deepEqual(declared.game, before); assert.ok(clean(f.game));
|
||||
assert.ok(player(declared.game, f.asker).hand.some(card => card.id === f.truth));
|
||||
assert.deepEqual(inventory(declared.game), f.originalCards);
|
||||
});
|
||||
Reference in new issue
Block a user