Support whole AND/OR future ground moves
This commit is contained in:
1 parent
0e3c155876
commit
d524642b65
20 files changed
+1803
-63
No files matched your search
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { parseMovementExpression, type MovementExpression } from '../game/movement-expression-promises';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import {
|
||||
actTruthMovement, createTruthMovementFixture, truthMovementClean,
|
||||
truthMovementInventory, truthMovementPlayer, type TruthMovementStep,
|
||||
} from './fixture-truth-movement';
|
||||
import { createTruthMovementAdvancedFixture } from './fixture-truth-movement-advanced';
|
||||
|
||||
export type TruthMovementExpressionStep = TruthMovementStep;
|
||||
export const actTruthMovementExpression = actTruthMovement;
|
||||
export const truthMovementExpressionClean = truthMovementClean;
|
||||
export const truthMovementExpressionInventory = truthMovementInventory;
|
||||
export const truthMovementExpressionPlayer = truthMovementPlayer;
|
||||
|
||||
/** Every continuation uses the original native response/readiness controls.
|
||||
* A live grouped obligation uses the same exact action the player sees. */
|
||||
export function nextTruthMovementExpression(game: Game): TruthMovementExpressionStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
for (const player of game.players) {
|
||||
const guide = viewGame(game, player.id).movementExpressionPromiseGuide;
|
||||
if (guide?.phase === game.phase) return { actor: player.id, action: guide.action };
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceTruthMovementExpression(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthMovementExpressionStep[] = []): Game {
|
||||
for (let count = 0; count < 1200; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original grouped movement source ended before its requested control');
|
||||
const next = nextTruthMovementExpression(game);
|
||||
assert.ok(next, 'Supply the original grouped Truthtrance question or answer');
|
||||
game = actTruthMovementExpression(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original grouped movement source did not reach its finite boundary');
|
||||
}
|
||||
export function settleTruthMovementExpression(game: Game, actions: TruthMovementExpressionStep[] = []): Game {
|
||||
return advanceTruthMovementExpression(game, truthMovementExpressionClean, actions);
|
||||
}
|
||||
export function finishTruthMovementExpression(game: Game, target = game.players[0].id,
|
||||
actions: TruthMovementExpressionStep[] = []): Game {
|
||||
game = settleTruthMovementExpression(game, actions);
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, target);
|
||||
game = actTruthMovementExpression(game, target, { type: 'endMovement' }, actions);
|
||||
game = settleTruthMovementExpression(game, actions);
|
||||
assert.ok(!game.movementRemaining?.includes(target), 'Only actual owner endMovement closes its interval');
|
||||
return game;
|
||||
}
|
||||
export function completeTruthMovementExpression(game: Game, target = game.players[0].id,
|
||||
actions: TruthMovementExpressionStep[] = []): Game {
|
||||
const live = () => game.movementExpressionPromises?.some(promise => promise.player === target &&
|
||||
promise.turn === game.turn && promise.answer && !promise.fulfilled && !promise.released);
|
||||
for (let count = 0; count < 12 && live(); count++) {
|
||||
game = settleTruthMovementExpression(game, actions);
|
||||
const guide = viewGame(game, target).movementExpressionPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.phase, game.phase);
|
||||
game = settleTruthMovementExpression(actTruthMovementExpression(game, target, guide.action, actions), actions);
|
||||
}
|
||||
assert.ok(!live(), 'The exact native movement guide fulfills the accepted whole expression');
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Thin adapter over genuine public Basic/Advanced base33 starts. The optional
|
||||
* Charity source replays only the original setup prefix from its unopened deal,
|
||||
* selects the same still-unopened Spice cards, and stops at real phase2. */
|
||||
export function createTruthMovementExpressionFixture(options: {
|
||||
advanced?: boolean; hajr?: boolean; window?: 'active' | 'charity'; claim?: MovementExpression;
|
||||
} = {}) {
|
||||
const base = options.advanced
|
||||
? createTruthMovementAdvancedFixture({ hajr: options.hajr })
|
||||
: createTruthMovementFixture({ hajr: options.hajr });
|
||||
const window = options.window ?? 'active';
|
||||
let game = structuredClone(base.game), actions = structuredClone(base.actions);
|
||||
let afterSetup = structuredClone(base.afterSetup), afterSetupActionCount: number | null = null;
|
||||
let spiceOrder = structuredClone(base.spiceOrder);
|
||||
if (window === 'charity') {
|
||||
const start = base.actions.findIndex(step => step.action.type === 'start'); assert.ok(start >= 0);
|
||||
actions = structuredClone(base.actions.slice(0, start + 1));
|
||||
game = advanceTruthMovementExpression(structuredClone(base.beforeStartingDeal),
|
||||
state => state.status === 'playing', actions);
|
||||
afterSetup = structuredClone(game); afterSetupActionCount = actions.length;
|
||||
spiceOrder = base.spiceOrder.map(({ position, card }) => {
|
||||
const from = game.spiceDeck.findIndex((candidate, index) => index >= position &&
|
||||
JSON.stringify(candidate) === JSON.stringify(card));
|
||||
assert.ok(from >= position, 'Choose the same original unopened ordinary Spice card');
|
||||
const selected = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, selected);
|
||||
return { position, from, card: structuredClone(selected) };
|
||||
});
|
||||
game = advanceTruthMovementExpression(game,
|
||||
state => state.turn === 1 && state.phase === 2 && truthMovementExpressionClean(state), actions);
|
||||
assert.equal(truthMovementExpressionPlayer(game, base.target).shipped, false);
|
||||
assert.equal(truthMovementExpressionPlayer(game, base.target).moved, 0);
|
||||
assert.deepEqual(truthMovementExpressionPlayer(game, base.target).forces, { 'arrakeen:10': 10 });
|
||||
}
|
||||
assert.equal(game.advanced, options.advanced ?? false); assert.deepEqual(game.expansions, []);
|
||||
assert.deepEqual(truthMovementExpressionInventory(game), base.originalCards);
|
||||
assert.ok(truthMovementExpressionPlayer(game, base.asker).hand.some(card => card.id === base.truth));
|
||||
assert.equal(game.movementExpressionPromises, undefined); assert.equal(game.truthtrance ?? null, null);
|
||||
const firstMove = { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 1 } satisfies Action;
|
||||
const secondMove = { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1 } satisfies Action;
|
||||
const claim = parseMovementExpression(options.claim ?? { kind: 'and', terms: [
|
||||
{ kind: 'move', territory: 'imperial_basin', minimum: 1 },
|
||||
{ kind: 'move', territory: 'carthag', minimum: 1 },
|
||||
] });
|
||||
const question = { kind: 'movementExpression' as const, target: base.target, turn: game.turn, claim };
|
||||
return { ...base, base, game, source: structuredClone(game), window, claim, question,
|
||||
afterSetup, afterSetupActionCount, spiceOrder,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length, sourceCount: actions.length,
|
||||
sourceCounts: { inheritedActive: base.sourceActionCount, setup: afterSetupActionCount, selected: actions.length },
|
||||
firstMove, secondMove, sameDestinationMove: { ...firstMove },
|
||||
hajrAction: base.hajr ? { type: 'card', card: base.hajr } satisfies Action : null,
|
||||
declaration: { type: 'card', card: base.truth } satisfies Action,
|
||||
questionAction: { type: 'truthAsk', question } satisfies Action,
|
||||
sourceDisclosure: `${base.staging.join(' ')} This grouped adapter changes only the structured question. ${window === 'charity'
|
||||
? 'Its recorded source excludes the original active-window suffix: the unopened deal is replayed, the same unopened Spice selection is preserved, and actual Storm/Spice controls stop at clean Charity.'
|
||||
: 'Its recorded source retains the exact original public setup, phase controls and paid one-force Polar Sink shipment.'} The future Imperial Basin9 and Carthag11 ground groups use the original Arrakeen10 army; Hajr remains the actually dealt physical card. No played clock, purse, hand, army, movement allowance, answer or progress is installed.` };
|
||||
}
|
||||
export type TruthMovementExpressionFixture = ReturnType<typeof createTruthMovementExpressionFixture>;
|
||||
|
||||
export function declareTruthMovementExpression(f: TruthMovementExpressionFixture, source = f.game) {
|
||||
const before = structuredClone(source), actions: TruthMovementExpressionStep[] = [];
|
||||
let game = actTruthMovementExpression(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 = actTruthMovementExpression(game, actor.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return { game, before, opened, actions, actionCount: actions.length };
|
||||
}
|
||||
export function askTruthMovementExpression(game: Game, asker: string, target: string, claim: MovementExpression,
|
||||
actions: TruthMovementExpressionStep[] = []): Game {
|
||||
return actTruthMovementExpression(game, asker, { type: 'truthAsk', question: {
|
||||
kind: 'movementExpression', target, turn: game.turn, claim: parseMovementExpression(claim),
|
||||
} }, actions);
|
||||
}
|
||||
export function answerTruthMovementExpression(game: Game, target: string, answer: 'yes' | 'no',
|
||||
actions: TruthMovementExpressionStep[] = []): Game {
|
||||
return actTruthMovementExpression(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
export function bindTruthMovementExpression(f: TruthMovementExpressionFixture, answer: 'yes' | 'no' = 'yes',
|
||||
claim = f.claim, source = f.game) {
|
||||
const declared = declareTruthMovementExpression(f, source), actions = [...declared.actions];
|
||||
const asked = askTruthMovementExpression(declared.game, f.asker, f.target, claim, actions);
|
||||
const game = answerTruthMovementExpression(asked, f.target, answer, actions);
|
||||
return { game, declared, asked, actions, actionCount: actions.length };
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { territory } from '../game/board';
|
||||
import type { Action, Game } from '../game/engine';
|
||||
import {
|
||||
MOVEMENT_EXPRESSION_MAX_DEPTH, MOVEMENT_EXPRESSION_MAX_LEAVES, MovementExpressionPromiseError,
|
||||
evaluateMovementExpression, liveMovementExpressionPromises, movementExpressionTerritories, movementExpressionText,
|
||||
parseMovementExpression, parseMovementExpressionClaim, recordMovementExpressionEvent, validateMovementExpressionPromises,
|
||||
type MovementExpression, type MovementExpressionProgress, type MovementExpressionPromise,
|
||||
} from '../game/movement-expression-promises';
|
||||
import { findMovementCompletion, type MovementPromiseAdapter } from '../game/movement-promise-search';
|
||||
import type { MovementEvent, MovementPromise } from '../game/movement-promises';
|
||||
|
||||
const move = (destination = 'carthag', minimum = 3): MovementExpression => ({ kind: 'move', territory: destination, minimum });
|
||||
const group = (kind: 'and' | 'or', ...terms: MovementExpression[]): MovementExpression => ({ kind, terms });
|
||||
const progress = (destination: string, maximum: number): MovementExpressionProgress => [{ territory: destination, maximum }];
|
||||
const promise = (event = 'event', claim: MovementExpression = move(), extra: Partial<MovementExpressionPromise> = {}): MovementExpressionPromise =>
|
||||
({ event, turn: 2, player: 'mover', asker: 'asker', answer: true, progress: [], claim, ...extra });
|
||||
const atomic = (destination = 'carthag', minimum = 3, extra: Partial<MovementPromise> = {}): MovementPromise =>
|
||||
({ turn: 2, player: 'mover', asker: 'asker', answer: true, territory: destination, minimum, ...extra });
|
||||
const seats = ['mover', 'asker', 'other'];
|
||||
|
||||
void test('movement expressions parse all sixteen leaves and four nested groups through scalar printed-board rules', () => {
|
||||
assert.equal(MOVEMENT_EXPRESSION_MAX_DEPTH, 4); assert.equal(MOVEMENT_EXPRESSION_MAX_LEAVES, 16);
|
||||
const sixteen = group('and', ...Array.from({ length: 16 }, (_, index) => move('carthag', index + 1)));
|
||||
const parsed = parseMovementExpression(sixteen);
|
||||
assert.deepEqual(parsed, sixteen); assert.notEqual(parsed, sixteen);
|
||||
if ('terms' in parsed && 'terms' in sixteen) {
|
||||
assert.notEqual(parsed.terms, sixteen.terms); assert.notEqual(parsed.terms[0], sixteen.terms[0]);
|
||||
}
|
||||
const nested = (depth: number): MovementExpression => depth === 0 ? move() : group('or', move('arrakeen'), nested(depth - 1));
|
||||
assert.deepEqual(parseMovementExpression(nested(4)), nested(4));
|
||||
assert.throws(() => parseMovementExpression(nested(5)), MovementExpressionPromiseError);
|
||||
assert.throws(() => parseMovementExpression(group('or', ...Array.from({ length: 17 }, () => move()))), MovementExpressionPromiseError);
|
||||
assert.throws(() => parseMovementExpression(group('and',
|
||||
group('or', ...Array.from({ length: 9 }, () => move())),
|
||||
group('or', ...Array.from({ length: 8 }, () => move())))), MovementExpressionPromiseError);
|
||||
assert.deepEqual(parseMovementExpressionClaim({ turn: Number.MAX_SAFE_INTEGER, claim: move('polar_sink', 20) }),
|
||||
{ turn: Number.MAX_SAFE_INTEGER, claim: move('polar_sink', 20) });
|
||||
});
|
||||
|
||||
void test('strict movement parsing checks decisive later subtrees, exact fields and safe one-to-twenty minima', () => {
|
||||
const invalid: unknown[] = [null, [], {}, { kind: 'and', terms: [] }, { kind: 'or', terms: [move()] },
|
||||
{ kind: 'not', terms: [move(), move()] }, { kind: 'move', territory: 'carthag', minimum: 1, progress: 0 },
|
||||
{ kind: 'and', terms: [move(), move('arrakeen')], branch: 0 },
|
||||
...['', ' carthag', 'homeworld:caladan', 'hidden_mobile_stronghold', 'missing'].map(destination => move(destination)),
|
||||
...[0, -1, 0.5, 21, Infinity, NaN, Number.MAX_SAFE_INTEGER + 1, '3'].map(minimum =>
|
||||
({ kind: 'move', territory: 'carthag', minimum }))];
|
||||
for (const value of invalid) assert.throws(() => parseMovementExpression(value), MovementExpressionPromiseError);
|
||||
const decisive = group('or', move('carthag', 1), move('arrakeen', 0));
|
||||
assert.throws(() => evaluateMovementExpression(decisive, progress('carthag', 1)), MovementExpressionPromiseError);
|
||||
assert.throws(() => evaluateMovementExpression(group('and', move(), decisive), []), MovementExpressionPromiseError);
|
||||
for (const value of [{ turn: 0, claim: move() }, { turn: '2', claim: move() }, { turn: 2, claim: move(), branch: 0 },
|
||||
{ turn: 2 }, { claim: move() }])
|
||||
assert.throws(() => parseMovementExpressionClaim(value), MovementExpressionPromiseError);
|
||||
});
|
||||
|
||||
void test('nested movement text and unique destinations preserve the whole expression', () => {
|
||||
const claim = group('and', move('carthag', 3), group('or', move('arrakeen', 2), move('carthag', 6)));
|
||||
assert.deepEqual(movementExpressionTerritories(claim), ['carthag', 'arrakeen']);
|
||||
assert.equal(evaluateMovementExpression(claim, progress('carthag', 3)), false);
|
||||
assert.equal(evaluateMovementExpression(claim, progress('carthag', 6)), true);
|
||||
assert.equal(evaluateMovementExpression(claim, [...progress('carthag', 3), ...progress('arrakeen', 2)]), true);
|
||||
assert.equal(movementExpressionText(claim),
|
||||
'(make a ground movement of at least 3 physical forces to Carthag AND (make a ground movement of at least 2 physical forces to Arrakeen OR make a ground movement of at least 6 physical forces to Carthag))');
|
||||
});
|
||||
|
||||
void test('progress records one actual move maximum and never sums smaller arrivals or marker pieces', () => {
|
||||
const original = [promise('largest', move('carthag', 6))];
|
||||
let ledger = original;
|
||||
for (const amount of [0, 2, 4, 3, 5]) ledger = recordMovementExpressionEvent(ledger, 'mover', 2, { territory: 'carthag', amount });
|
||||
assert.deepEqual(ledger[0].progress, progress('carthag', 5)); assert.equal(ledger[0].fulfilled, undefined);
|
||||
const bySix = recordMovementExpressionEvent(ledger, 'mover', 2, { territory: 'carthag', amount: 6 });
|
||||
assert.equal(bySix[0].fulfilled, true); assert.deepEqual(bySix[0].progress, progress('carthag', 6));
|
||||
assert.deepEqual(original[0].progress, []); assert.deepEqual(ledger[0].progress, progress('carthag', 5));
|
||||
assert.equal(recordMovementExpressionEvent(original, 'mover', 2, { territory: 'carthag', amount: 0 })[0], original[0]);
|
||||
assert.equal(recordMovementExpressionEvent(original, 'mover', 2, { territory: 'hidden_mobile_stronghold', amount: 10 })[0], original[0]);
|
||||
});
|
||||
|
||||
void test('whole Yes keeps alternate OR routes after partial movement and shares a same-destination event across leaves', () => {
|
||||
const original = promise('alternate', group('or', move('carthag', 5), move('arrakeen', 3)));
|
||||
const partial = recordMovementExpressionEvent([original], 'mover', 2, { territory: 'carthag', amount: 4 });
|
||||
assert.equal(partial[0].fulfilled, undefined);
|
||||
const alternate = recordMovementExpressionEvent(partial, 'mover', 2, { territory: 'arrakeen', amount: 3 });
|
||||
assert.equal(alternate[0].fulfilled, true); assert.equal('branch' in alternate[0], false);
|
||||
assert.deepEqual(alternate[0].progress, [...progress('carthag', 4), ...progress('arrakeen', 3)]);
|
||||
const same = recordMovementExpressionEvent([promise('shared', group('and', move('carthag', 3), move('carthag', 5)))],
|
||||
'mover', 2, { territory: 'carthag', amount: 5 });
|
||||
assert.equal(same[0].fulfilled, true); assert.deepEqual(same[0].progress, progress('carthag', 5));
|
||||
assert.deepEqual(original.progress, []);
|
||||
});
|
||||
|
||||
void test('whole No to nested AND allows one conjunct and rejects the actual move completing the whole statement', () => {
|
||||
const original = promise('not-all', group('and', move('carthag', 2), group('or', move('arrakeen', 3), move('polar_sink', 4))),
|
||||
{ answer: false });
|
||||
const first = recordMovementExpressionEvent([original], 'mover', 2, { territory: 'carthag', amount: 2 });
|
||||
assert.equal(first[0].fulfilled, undefined);
|
||||
for (const event of [{ territory: 'arrakeen', amount: 3 }, { territory: 'polar_sink', amount: 4 }])
|
||||
assert.throws(() => recordMovementExpressionEvent(first, 'mover', 2, event), MovementExpressionPromiseError);
|
||||
const reversed = recordMovementExpressionEvent([original], 'mover', 2, { territory: 'arrakeen', amount: 3 });
|
||||
assert.throws(() => recordMovementExpressionEvent(reversed, 'mover', 2, { territory: 'carthag', amount: 2 }), MovementExpressionPromiseError);
|
||||
assert.deepEqual(first[0].progress, progress('carthag', 2));
|
||||
});
|
||||
|
||||
void test('whole No to nested OR forbids either completed branch while permitting an incomplete AND branch', () => {
|
||||
const original = promise('not-either', group('or', move('carthag', 2), group('and', move('arrakeen', 3), move('polar_sink', 4))),
|
||||
{ answer: false });
|
||||
assert.throws(() => recordMovementExpressionEvent([original], 'mover', 2, { territory: 'carthag', amount: 2 }), MovementExpressionPromiseError);
|
||||
const partial = recordMovementExpressionEvent([original], 'mover', 2, { territory: 'arrakeen', amount: 3 });
|
||||
assert.doesNotThrow(() => recordMovementExpressionEvent(partial, 'mover', 2, { territory: 'carthag', amount: 1 }));
|
||||
assert.throws(() => recordMovementExpressionEvent(partial, 'mover', 2, { territory: 'polar_sink', amount: 4 }), MovementExpressionPromiseError);
|
||||
});
|
||||
|
||||
void test('answer intervals exclude prior movements and retain other-player, old-turn, terminal and nonmatching records', () => {
|
||||
const earlier = recordMovementExpressionEvent([promise('earlier')], 'mover', 2, { territory: 'carthag', amount: 3 })[0];
|
||||
const later = promise('later');
|
||||
const records = [earlier, promise('released', move(), { released: true }), promise('old-turn', move(), { turn: 1 }),
|
||||
promise('other', move(), { player: 'other' }), promise('elsewhere', move('arrakeen')), later];
|
||||
assert.deepEqual(liveMovementExpressionPromises(records, 'mover', 2), [records[4], later]);
|
||||
const next = recordMovementExpressionEvent(records, 'mover', 2, { territory: 'carthag', amount: 2 });
|
||||
for (let index = 0; index < 5; index++) assert.equal(next[index], records[index]);
|
||||
assert.deepEqual(next[5].progress, progress('carthag', 2)); assert.equal(next[5].fulfilled, undefined);
|
||||
assert.deepEqual(later.progress, []);
|
||||
});
|
||||
|
||||
void test('rejected completion cannot partly update another answer or frozen input progress', () => {
|
||||
const records = [promise('yes'), promise('no', group('or', move(), move('arrakeen')), { answer: false })];
|
||||
const partial = recordMovementExpressionEvent(records, 'mover', 2, { territory: 'carthag', amount: 2 });
|
||||
const before = structuredClone(partial);
|
||||
partial.forEach(entry => { entry.progress.forEach(Object.freeze); Object.freeze(entry.progress); Object.freeze(entry); });
|
||||
Object.freeze(partial);
|
||||
assert.throws(() => recordMovementExpressionEvent(partial, 'mover', 2, { territory: 'carthag', amount: 3 }), MovementExpressionPromiseError);
|
||||
assert.deepEqual(partial, before); assert.equal(partial[0].fulfilled, undefined);
|
||||
});
|
||||
|
||||
void test('movement progress and events reject unsafe, overflowing, duplicate or extra-key entries', () => {
|
||||
const invalid: unknown[] = [null, {}, progress('carthag', -1), progress('carthag', 0.5), progress('carthag', 21),
|
||||
progress('carthag', Number.MAX_SAFE_INTEGER + 1), [...progress('carthag', 1), ...progress('carthag', 2)],
|
||||
progress('missing', 1), [{ territory: 'carthag', maximum: 1, sum: 1 }]];
|
||||
for (const value of invalid)
|
||||
assert.throws(() => evaluateMovementExpression(move(), value as MovementExpressionProgress), MovementExpressionPromiseError);
|
||||
for (const event of [{ territory: '', amount: 1 }, { territory: ' carthag', amount: 1 },
|
||||
{ territory: 'carthag', amount: -1 }, { territory: 'carthag', amount: 0.5 }, { territory: 'carthag', amount: 21 },
|
||||
{ territory: 'carthag', amount: Infinity }, { territory: 'carthag', amount: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ territory: 'carthag', amount: 1, marker: 1 }])
|
||||
assert.throws(() => recordMovementExpressionEvent([], 'mover', 2, event), MovementExpressionPromiseError);
|
||||
assert.throws(() => recordMovementExpressionEvent([promise('foreign-progress', move(), { progress: progress('arrakeen', 1) })],
|
||||
'mover', 2, { territory: 'carthag', amount: 1 }), MovementExpressionPromiseError);
|
||||
});
|
||||
|
||||
void test('movement ledger validation binds events, participants, matching maxima and whole-answer terminal flags', () => {
|
||||
const valid = [promise('live', move(), { progress: progress('carthag', 2) }),
|
||||
promise('fulfilled', move(), { progress: progress('carthag', 3), fulfilled: true }),
|
||||
promise('closed-no', move(), { answer: false, fulfilled: true }), promise('released', move(), { released: true })];
|
||||
const before = structuredClone(valid);
|
||||
assert.doesNotThrow(() => validateMovementExpressionPromises(valid, seats));
|
||||
assert.doesNotThrow(() => validateMovementExpressionPromises(JSON.parse(JSON.stringify(valid)) as MovementExpressionPromise[], seats));
|
||||
const base = promise();
|
||||
const invalid: unknown[] = [null, {}, [null], [base, { ...base }],
|
||||
...[{ event: '' }, { player: 'absent' }, { asker: 'absent' }, { asker: 'mover' }, { answer: 'yes' },
|
||||
{ fulfilled: 1 }, { released: 1 }, { fulfilled: true }, { fulfilled: true, released: true },
|
||||
{ progress: progress('carthag', 3) }, { progress: progress('arrakeen', 1) },
|
||||
{ answer: false, progress: progress('carthag', 3) }, { answer: false, progress: progress('carthag', 3), released: true },
|
||||
{ turn: 0 }].map(extra => [{ ...base, ...extra }])];
|
||||
for (const value of invalid)
|
||||
assert.throws(() => validateMovementExpressionPromises(value as MovementExpressionPromise[], seats), MovementExpressionPromiseError);
|
||||
assert.deepEqual(valid, before);
|
||||
});
|
||||
|
||||
/** Structural adapter seam only: native engine entitlement and physical-route
|
||||
* proof belongs to its runtime tests. This fixture consumes one real Hajr ID. */
|
||||
function searchSource(hajr = false): Game {
|
||||
return { turn: 2, advanced: false, players: [{ id: 'mover', faction: 'atreides',
|
||||
forces: { 'imperial_basin:9': 6 }, moved: 0, shipped: true, reserves: 0, tanks: 0, spice: 0,
|
||||
hand: hajr ? [{ id: 'owned-hajr', effect: 'hajr' }] : [] }], discard: [], hajr: [] } as unknown as Game;
|
||||
}
|
||||
function searchAdapter(projected: MovementEvent[] = []): MovementPromiseAdapter {
|
||||
return {
|
||||
allowance: game => game.hajr.includes('mover') ? 2 : 1,
|
||||
canFinish: () => true,
|
||||
*preparations(game) {
|
||||
const player = game.players[0], card = player.hand.find(card => card.effect === 'hajr');
|
||||
if (!card || game.hajr.includes(player.id)) return;
|
||||
const next = structuredClone(game);
|
||||
next.players[0].hand = next.players[0].hand.filter(held => held.id !== card.id);
|
||||
next.discard.push(card); next.hajr.push(player.id);
|
||||
yield { game: next, actions: [{ type: 'card', card: card.id }] };
|
||||
},
|
||||
reachable: (_game, _player, origin, destination, sector) => origin !== destination &&
|
||||
['carthag', 'arrakeen'].includes(destination) && sector === territory(destination).sectors[0],
|
||||
project(game, _actor, action: Action) {
|
||||
const next = structuredClone(game), player = next.players[0];
|
||||
const forces = action.forces as Record<string, number>;
|
||||
const amount = Object.values(forces).reduce((sum, value) => sum + value, 0);
|
||||
for (const [key, count] of Object.entries(forces)) {
|
||||
if (count > player.forces[key]) return null;
|
||||
player.forces[key] -= count;
|
||||
if (player.forces[key] === 0) delete player.forces[key];
|
||||
}
|
||||
const destination = `${String(action.territory)}:${String(action.sector)}`;
|
||||
player.forces[destination] = (player.forces[destination] ?? 0) + amount; player.moved++;
|
||||
const event = { territory: String(action.territory), amount }; projected.push(event);
|
||||
return { game: next, event };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void test('joint search uses an owned Hajr for two distinct AND moves and leaves the original state detached', () => {
|
||||
const source = searchSource(true), before = structuredClone(source);
|
||||
source.movementExpressionPromises = [promise('two-moves', group('and', move('arrakeen', 3), move('carthag', 3)))];
|
||||
const result = findMovementCompletion(source, 'mover', [], searchAdapter());
|
||||
assert.ok(result);
|
||||
assert.equal(result.actions.filter(action => action.type === 'move').length, 2);
|
||||
assert.equal(result.actions.filter(action => action.type === 'card' && action.card === 'owned-hajr').length, 1);
|
||||
assert.deepEqual(source.players, before.players); assert.deepEqual(source.discard, before.discard);
|
||||
assert.deepEqual(source.movementExpressionPromises[0].progress, []);
|
||||
assert.equal(findMovementCompletion(searchSource(), 'mover', [], searchAdapter(), source.movementExpressionPromises), null);
|
||||
});
|
||||
|
||||
void test('joint search retains alternate expression routes under scalar No and shares one move across positive answers', () => {
|
||||
const source = searchSource();
|
||||
const choice = promise('either', group('or', move('arrakeen', 3), move('carthag', 3)));
|
||||
const result = findMovementCompletion(source, 'mover', [atomic('arrakeen', 1, { answer: false }), atomic('carthag', 3)],
|
||||
searchAdapter(), [choice]);
|
||||
assert.ok(result); assert.equal(result.actions.length, 1); assert.equal(result.actions[0].territory, 'carthag');
|
||||
assert.equal(Object.values(result.actions[0].forces as Record<string, number>).reduce((sum, value) => sum + value, 0), 3);
|
||||
});
|
||||
|
||||
void test('joint search rejects whole-No completion before projecting physical pieces and respects earlier progress', () => {
|
||||
const projected: MovementEvent[] = [];
|
||||
const notBoth = promise('not-both', group('and', move('arrakeen', 3), move('carthag', 3)),
|
||||
{ answer: false, progress: progress('arrakeen', 3) });
|
||||
assert.equal(findMovementCompletion(searchSource(), 'mover', [atomic('carthag', 3)], searchAdapter(projected), [notBoth]), null);
|
||||
assert.deepEqual(projected, []);
|
||||
const permitted = findMovementCompletion(searchSource(), 'mover', [atomic('carthag', 2)], searchAdapter(), [notBoth]);
|
||||
assert.ok(permitted); assert.equal(permitted.actions[0].territory, 'carthag');
|
||||
assert.equal(findMovementCompletion(searchSource(), 'mover', [atomic('carthag', 3)], searchAdapter(),
|
||||
[promise('neither', group('or', move('arrakeen', 3), move('carthag', 3)), { answer: false })]), null);
|
||||
});
|
||||
|
||||
void test('whole-expression physical bounds retain reachable OR alternatives and previously satisfied larger leaves', () => {
|
||||
const source = searchSource();
|
||||
const choice = promise('smaller-alternate', group('or', move('arrakeen', 20), move('carthag', 3)));
|
||||
const byAlternate = findMovementCompletion(source, 'mover', [], searchAdapter(), [choice]);
|
||||
assert.ok(byAlternate); assert.equal(byAlternate.actions[0].territory, 'carthag');
|
||||
const partlyDone = promise('larger-earlier', group('and', move('arrakeen', 20), move('carthag', 3)),
|
||||
{ progress: progress('arrakeen', 20) });
|
||||
const remaining = findMovementCompletion(source, 'mover', [], searchAdapter(), [partlyDone]);
|
||||
assert.ok(remaining); assert.equal(remaining.actions.length, 1); assert.equal(remaining.actions[0].territory, 'carthag');
|
||||
assert.equal(findMovementCompletion(source, 'mover', [], searchAdapter(),
|
||||
[promise('impossible', group('or', move('arrakeen', 20), move('carthag', 7)))]), null);
|
||||
});
|
||||
|
||||
void test('explicit empty expression subsets preserve the legacy search contract even when the source has a pending expression', () => {
|
||||
const source = searchSource(); source.movementExpressionPromises = [promise('needs-both', group('and', move('arrakeen'), move('carthag')))];
|
||||
const result = findMovementCompletion(source, 'mover', [atomic()], {
|
||||
...searchAdapter(),
|
||||
project(game, actor, action) {
|
||||
assert.deepEqual(game.movementExpressionPromises, []);
|
||||
return searchAdapter().project(game, actor, action);
|
||||
},
|
||||
}, []);
|
||||
assert.ok(result); assert.equal(result.actions.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
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 { MovementExpressionFields, movementExpressionInputError } from '../components/movement-expression-fields';
|
||||
import { MovementExpressionPromises, MovementExpressionPromiseGuideButton } from '../components/movement-expression-promises';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { parseMovementExpression, type MovementExpression } from '../game/movement-expression-promises';
|
||||
import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
|
||||
type ElementProps = { children?: ReactNode; [key: string]: unknown };
|
||||
type Element = ReactElement<ElementProps>;
|
||||
const territories = [{ id: 'carthag', name: 'Carthag' }, { id: 'arrakeen', name: 'Arrakeen' }, { id: 'sietch_tabr', name: 'Sietch Tabr' }];
|
||||
const move = (minimum = 1, territory = 'carthag'): MovementExpression => ({ kind: 'move', territory, minimum });
|
||||
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-movement-expression-control', key);
|
||||
const click = (button: Element) => (button.props.onClick as () => void)();
|
||||
function select(tree: ReactNode, path: string, field: string, value: string) {
|
||||
const input = find(tree, 'id', `truth-movement-expression-${path}-${field}`);
|
||||
(input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
|
||||
}
|
||||
function minimum(tree: ReactNode, path: string, valueAsNumber: number) {
|
||||
const input = find(tree, 'id', `truth-movement-expression-${path}-minimum`);
|
||||
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } });
|
||||
}
|
||||
function editor(initial: MovementExpression, disabled = false) {
|
||||
let value = initial, changes = 0;
|
||||
return {
|
||||
get value() { return value; }, get changes() { return changes; },
|
||||
tree: () => MovementExpressionFields({ value, territories, disabled, onChange(next) { value = next; changes++; } }),
|
||||
html: () => renderToStaticMarkup(createElement(MovementExpressionFields, { value, territories, disabled, onChange() {} })),
|
||||
};
|
||||
}
|
||||
function nested(depth: number): MovementExpression {
|
||||
let value = move();
|
||||
for (let i = 0; i < depth; i++) value = { kind: 'and', terms: [value, move(2, 'arrakeen')] };
|
||||
return value;
|
||||
}
|
||||
function guide(props: Parameters<typeof MovementExpressionPromiseGuideButton>[0]) {
|
||||
const button = MovementExpressionPromiseGuideButton(props); assert.ok(button); return button;
|
||||
}
|
||||
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
|
||||
const markup = (game: GameView) => renderToStaticMarkup(createElement(MovementExpressionPromises, { game, busy: false, send() {} }));
|
||||
const event = 'controlled-movement-expression-event';
|
||||
|
||||
/** These controlled personalized projections test callbacks and labels only.
|
||||
* They do not claim source-earned cards, physical movement or feasibility. */
|
||||
function projected(phase = 5, action: Action = { type: 'move', forces: { 'arrakeen:10': 3, 'arrakeen:11': 1 },
|
||||
eliteForces: { 'arrakeen:10': 1 }, territory: 'carthag', sector: 11 }): GameView {
|
||||
const source = createGame('TRUTHMOVEMENTEXPRESSIONCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
|
||||
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
|
||||
const game = viewGame(source, 'p');
|
||||
game.status = 'playing'; game.turn = 2; game.phase = phase; game.ready = []; game.active = phase === 5 ? 'p' : null;
|
||||
game.movementExpressionPromises = [{ event, player: 'p', asker: 'a', turn: 2,
|
||||
claim: { kind: 'or', terms: [move(4), { kind: 'and', terms: [move(2, 'arrakeen'), move(3)] }] },
|
||||
answer: true, progress: [{ territory: 'carthag', maximum: 2 }, { territory: 'arrakeen', maximum: 1 }] }];
|
||||
game.movementExpressionPromiseGuide = { phase, action: { ...action, movementExpressionPromiseEvent: event } };
|
||||
game.movementPromises = [];
|
||||
game.auction = phase === 3 ? { bid: 4, bidder: 'a', active: 'p', remaining: 2, inspectionBlocked: null, card: null } : null;
|
||||
return game;
|
||||
}
|
||||
function nativeProjected(method: 'silent' | 'onceAround') {
|
||||
const game = projected(3, { type: 'richeseBid' });
|
||||
const native = createRicheseAuction({ event: 'controlled-native-auction', cardId: 'controlled-card', owner: 'a',
|
||||
method, source: 'cache', eligible: ['p', 'a'], order: ['p', 'a'], tieOrder: ['p', 'a'] });
|
||||
game.richeseAuction = { ...projectRicheseAuction(native, 'p'), card: null, claim: null,
|
||||
ownAllyPayment: 0, ownAvailable: 4, allyAvailable: 0, peekKnown: false, inspectionBlocked: null };
|
||||
game.movementExpressionPromiseGuide!.action = { type: 'richeseBid', event: native.event,
|
||||
amount: method === 'silent' ? 0 : null, allyPayment: 0, movementExpressionPromiseEvent: event };
|
||||
return game;
|
||||
}
|
||||
function paidBox(phase = 5) {
|
||||
const game = projected(phase, { type: 'decision', event: 'controlled-paid-box', card: 'controlled-hajr' });
|
||||
game.decision = { kind: 'nullentropy', player: 'p' };
|
||||
game.nullentropy = { card: { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special', effect: 'nullentropyBox' },
|
||||
blocked: null, search: { event: 'controlled-paid-box', cards: [{ id: 'controlled-hajr', name: 'Hajr', kind: 'special', effect: 'hajr' }] } };
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('controlled movement editor retains sixteen exact leaves, arbitrary nested groups and singleton collapse', () => {
|
||||
const e = editor(move(2)); click(control(e.tree(), 'or-1'));
|
||||
for (let count = 3; count <= 16; count++) {
|
||||
click(control(e.tree(), 'add-1')); minimum(e.tree(), `1.${count}`, count);
|
||||
if (count % 3 === 0) select(e.tree(), `1.${count}`, 'territory', 'arrakeen');
|
||||
}
|
||||
const full = e.value; assert.ok(full.kind !== 'move'); assert.equal(full.terms.length, 16);
|
||||
assert.deepEqual(parseMovementExpression(full), full); assert.match(e.html(), /16 \/ 16 conditions/);
|
||||
assert.equal((e.html().match(/Ground-movement destination/g) ?? []).length, 16);
|
||||
const before = structuredClone(full), changes = e.changes;
|
||||
for (const key of ['add-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.equal(e.changes, changes); assert.deepEqual(e.value, before);
|
||||
const grouped = editor({ kind: 'and', terms: [move(3), move(2, 'arrakeen')] });
|
||||
click(control(grouped.tree(), 'or-1.2')); select(grouped.tree(), '1.2', 'meaning', 'and');
|
||||
click(control(grouped.tree(), 'nested-or-1')); click(control(grouped.tree(), 'nested-and-1'));
|
||||
const groupedValue = grouped.value; assert.ok(groupedValue.kind !== 'move'); assert.equal(groupedValue.terms.length, 4);
|
||||
click(control(grouped.tree(), 'remove-1.2.2')); assert.deepEqual((grouped.value as typeof groupedValue).terms[1], move(2, 'arrakeen'));
|
||||
click(control(grouped.tree(), 'remove-1.4')); click(control(grouped.tree(), 'remove-1.3'));
|
||||
click(control(grouped.tree(), 'remove-1.1')); assert.deepEqual(grouped.value, move(2, 'arrakeen'));
|
||||
});
|
||||
|
||||
void test('depth-four limits and busy controls preserve structure while numeric and destination drafts stay repairable', () => {
|
||||
const deep = editor(nested(4)); assert.equal(movementExpressionInputError(deep.value), null);
|
||||
assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||||
for (const key of ['and-1', 'or-1.1.1.1.1', 'nested-and-1.1.1.1']) {
|
||||
assert.equal(control(deep.tree(), key).props.disabled, true); click(control(deep.tree(), key));
|
||||
}
|
||||
assert.equal(deep.changes, 0); minimum(deep.tree(), '1.1.1.1.1', 20); assert.equal(deep.changes, 1);
|
||||
for (const invalid of [NaN, -1, 0, 0.5, 21, Infinity]) {
|
||||
const e = editor(move()); minimum(e.tree(), '1', invalid);
|
||||
const draft = e.value; assert.ok(draft.kind === 'move'); assert.ok(Object.is(draft.minimum, invalid));
|
||||
assert.ok(movementExpressionInputError(e.value)); assert.match(e.html(), /role="alert"/);
|
||||
select(e.tree(), '1', 'territory', ''); click(control(e.tree(), 'or-1'));
|
||||
const wrapped = e.value; assert.ok(wrapped.kind !== 'move'); assert.deepEqual(wrapped.terms[0], move(invalid, ''));
|
||||
minimum(e.tree(), '1.1', 2); assert.ok(movementExpressionInputError(e.value));
|
||||
select(e.tree(), '1.1', 'territory', 'arrakeen'); assert.equal(movementExpressionInputError(e.value), null);
|
||||
}
|
||||
for (const invalid of [nested(5), { kind: 'and', terms: [move()] }, { kind: 'or', terms: Array.from({ length: 17 }, () => move()) },
|
||||
move(1, 'homeworld:emperor'), move(1, 'hidden_mobile_stronghold')]) assert.ok(movementExpressionInputError(invalid));
|
||||
const frozen = editor({ kind: 'and', terms: [move(), move(2, 'arrakeen')] }, true), before = structuredClone(frozen.value);
|
||||
for (const key of ['add-1', 'nested-and-1', 'or-1', 'remove-1.2']) click(control(frozen.tree(), key));
|
||||
minimum(frozen.tree(), '1.1', 4); select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'territory', 'carthag');
|
||||
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before);
|
||||
});
|
||||
|
||||
void test('persistent whole-No panel shows the full unpinned tree and destination maxima without summing smaller moves', () => {
|
||||
const game = projected(); game.movementExpressionPromiseGuide = null;
|
||||
game.movementExpressionPromises.push(
|
||||
{ ...game.movementExpressionPromises[0], event: 'no-expression', answer: false, claim: { kind: 'and', terms: [move(4), move(2, 'arrakeen')] } },
|
||||
{ ...game.movementExpressionPromises[0], event: 'fulfilled-expression', fulfilled: true },
|
||||
{ ...game.movementExpressionPromises[0], event: 'released-expression', released: true },
|
||||
{ ...game.movementExpressionPromises[0], event: 'foreign-expression', player: 'a', claim: move(19, 'sietch_tabr') },
|
||||
);
|
||||
const before = structuredClone(game), html = markup(game);
|
||||
assert.match(html, /Your movement expression promises/); assert.match(html, / OR /); assert.match(html, / AND /);
|
||||
assert.match(html, /Carthag: 2 physical forces; Arrakeen: 1 physical forces/);
|
||||
assert.match(html, /No negates the whole expression, not each condition separately/);
|
||||
assert.match(html, /Legal alternatives remain available; no fixed branch is selected for you/);
|
||||
assert.match(html, /Smaller moves never add together; progress records the maximum in one move/);
|
||||
assert.match(html, /Prior moves and shipments do not count/); assert.match(html, /before your Movement turn ends this round/);
|
||||
assert.match(html, /No remains binding through every later Hajr or Ornithopter move/);
|
||||
assert.match(html, /Fulfilled: this commitment is complete/); assert.match(html, /Released: this commitment is no longer possible/);
|
||||
assert.match(html, /href="\/rules#truthtrance-movement-expression"/);
|
||||
assert.doesNotMatch(html, /19 physical|controlled-movement-expression-event/); assert.deepEqual(game, before);
|
||||
for (const phase of [0, 1, 2, 3, 4]) { game.phase = phase; assert.match(markup(game), /Your Movement turn is still ahead/); }
|
||||
game.phase = 5; game.active = 'a'; assert.match(markup(game), /Your Movement turn is still ahead/);
|
||||
assert.equal(MovementExpressionPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
|
||||
});
|
||||
|
||||
void test('native movement, shipment, reveal, Hajr and Ornithopter guides forward every quoted field exactly', () => {
|
||||
const actions: Action[] = [
|
||||
projected().movementExpressionPromiseGuide!.action,
|
||||
{ type: 'move', forces: { 'arrakeen:10': 4 }, eliteForces: {}, territory: 'carthag', sector: 11,
|
||||
movementCard: 'controlled-ornithopter', ornithopter: 'twoGroups', ornithopterEvent: 'original-flight' },
|
||||
{ type: 'move', forces: {}, eliteForces: {}, noField: 'controlled-token', event: 'original-token-event', territory: 'carthag', sector: 11 },
|
||||
{ type: 'ship', territory: 'arrakeen', sector: 10, amount: 4, elite: 1, allyPayment: 0, noField: 'controlled-token' },
|
||||
{ type: 'revealNoField', event: 'original-token-event' }, { type: 'prepareAdvisors', territory: 'arrakeen', event: 'original-advisor-event' },
|
||||
{ type: 'card', card: 'controlled-hajr' }, { type: 'endMovement' },
|
||||
];
|
||||
for (const action of actions) {
|
||||
const game = projected(5, action), sent: Action[] = [], props = { game, busy: false, send(value: Action) { sent.push(value); } };
|
||||
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
|
||||
assert.equal(sent[0], game.movementExpressionPromiseGuide!.action); assert.deepEqual(sent, [game.movementExpressionPromiseGuide!.action]);
|
||||
press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
|
||||
}
|
||||
assert.match(markup(projected()), /Move 4 forces to Carthag, sector 11/);
|
||||
assert.match(markup(projected(5, actions[1])), /Use Ornithopter and move 4 forces/);
|
||||
assert.match(markup(projected(5, actions[2])), /Move your concealed No-Field/);
|
||||
assert.match(markup(projected(5, actions[3])), /Ship a concealed No-Field/);
|
||||
assert.match(markup(projected(5, actions[4])), /Reveal your No-Field/);
|
||||
assert.match(markup(projected(5, actions[5])), /Prepare advisors in Arrakeen/);
|
||||
assert.match(markup(projected(5, actions[6])), /Play Hajr/);
|
||||
});
|
||||
|
||||
void test('owned current preparation guides preserve Charity, Revival, native auction and paid Box packets', () => {
|
||||
for (const [phase, action] of [[2, { type: 'charity' }], [2, { type: 'ready' }], [4, { type: 'revive', amount: 3, elite: 1 }],
|
||||
[4, { type: 'ready' }], [3, { type: 'passBid' }], [3, { type: 'decision', karama: false }]] satisfies [number, Action][]) {
|
||||
const game = projected(phase, action), sent: Action[] = [];
|
||||
if (action.type === 'decision') game.decision = { kind: 'auctionPayment', player: 'p' };
|
||||
const button = guide({ game, busy: false, send(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.movementExpressionPromiseGuide!.action);
|
||||
if (action.type === 'decision') game.decision!.player = 'a';
|
||||
else if (phase === 3) game.auction!.active = 'a'; else game.ready.push('p');
|
||||
press(button); assert.equal(sent.length, 1);
|
||||
}
|
||||
for (const method of ['silent', 'onceAround'] as const) {
|
||||
const game = nativeProjected(method), sent: Action[] = [];
|
||||
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.movementExpressionPromiseGuide!.action);
|
||||
assert.match(markup(game), method === 'silent' ? /Submit zero in this Silent auction/ : /Pass this Richese bid/);
|
||||
game.richeseAuction!.event = 'later-event'; press(button); assert.equal(sent.length, 1);
|
||||
}
|
||||
for (const phase of [0, 2, 3, 4, 5]) {
|
||||
const game = paidBox(phase), sent: Action[] = []; game.active = 'a';
|
||||
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.movementExpressionPromiseGuide!.action);
|
||||
assert.match(markup(game), /Choose Hajr in your paid Box search/); assert.match(markup(game), /fee is paid only once/);
|
||||
game.nullentropy!.search!.event = 'later-paid-search'; press(button); assert.equal(sent.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('stale callbacks reject changed phase, own event, busy windows and nested physical move mutation', () => {
|
||||
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.movementExpressionPromiseGuide = null; },
|
||||
game => { game.movementExpressionPromiseGuide = { phase: 5, action: { ...game.movementExpressionPromiseGuide!.action } }; },
|
||||
game => { game.movementExpressionPromiseGuide!.phase = 4; }, game => { game.movementExpressionPromiseGuide!.action.territory = 'arrakeen'; },
|
||||
game => { (game.movementExpressionPromiseGuide!.action.forces as Record<string, number>)['arrakeen:10'] = 2; },
|
||||
game => { (game.movementExpressionPromiseGuide!.action.eliteForces as Record<string, number>)['arrakeen:10'] = 0; },
|
||||
game => { game.movementExpressionPromises[0].event = 'expired'; }, game => { game.movementExpressionPromises[0].player = 'a'; },
|
||||
game => { game.movementExpressionPromises[0].turn = 1; }, game => { game.movementExpressionPromises[0].fulfilled = true; },
|
||||
game => { game.movementExpressionPromises[0].released = true; }, game => { game.movementExpressionPromises = []; },
|
||||
game => { game.phaseOpening = { passed: [] }; }, game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||||
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||||
game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
|
||||
game => { game.automaticContinuationPending = true; }, game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
|
||||
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||||
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||||
game => { game.techTokens = createTechTokens(); },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(), sent: Action[] = [], button = guide({ game, busy: false, send(action) { sent.push(action); } });
|
||||
change(game); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
const props = { game: projected(), busy: false, send() { assert.fail('A now-busy guide cannot dispatch'); } };
|
||||
const button = guide(props); props.busy = true; press(button);
|
||||
const replaced = { game: projected(), busy: false, send() { assert.fail('A replaced view cannot dispatch'); } };
|
||||
const original = guide(replaced); replaced.game = { ...replaced.game }; press(original);
|
||||
});
|
||||
|
||||
void test('waiting and wrong-phase quotes cannot expose premature movement and scalar promises suppress a duplicate visible guide', () => {
|
||||
for (const [phase, action] of [[0, { type: 'ready' }], [1, { type: 'move' }], [2, { type: 'revive' }], [3, { type: 'ready' }],
|
||||
[4, { type: 'charity' }], [4, { type: 'ship' }], [5, { type: 'revive' }], [6, { type: 'move' }]] satisfies [number, Action][]) {
|
||||
const game = projected(phase, action), sent: Action[] = [];
|
||||
const button = guide({ game, busy: false, send(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
const game = projected(); game.active = 'a'; game.movementExpressionPromiseGuide = null;
|
||||
assert.match(markup(game), /Your Movement turn is still ahead/); assert.doesNotMatch(markup(game), /<button/);
|
||||
game.active = 'p'; game.movementExpressionPromiseGuide = projected().movementExpressionPromiseGuide;
|
||||
assert.match(markup(game), /data-testid="movement-expression-promise-guide"/);
|
||||
game.movementPromises.push({ player: 'p', asker: 'a', turn: 2, territory: 'carthag', minimum: 4, answer: true });
|
||||
assert.doesNotMatch(markup(game), /data-testid="movement-expression-promise-guide"/);
|
||||
game.movementPromises[0].fulfilled = true;
|
||||
assert.match(markup(game), /data-testid="movement-expression-promise-guide"/);
|
||||
});
|
||||
|
||||
void test('projected own card names label preparation while eligibility never examines unrelated secret or physical resources', () => {
|
||||
const cards = [
|
||||
{ id: 'controlled-ghola', name: 'Tleilaxu Ghola', kind: 'special' as const, effect: 'ghola' },
|
||||
{ id: 'controlled-box', name: 'Nullentropy Box', kind: 'special' as const, effect: 'nullentropyBox' },
|
||||
{ id: 'controlled-karama', name: 'Karama', kind: 'special' as const, effect: 'karama' },
|
||||
];
|
||||
const ghola = projected(5, { type: 'card', card: cards[0].id, amount: 2 });
|
||||
ghola.players.find(player => player.id === ghola.me)!.hand = cards;
|
||||
assert.match(markup(ghola), /Play Ghola to return 2 forces to reserves/);
|
||||
const box = projected(5, { type: 'card', card: cards[1].id }); box.players.find(player => player.id === box.me)!.hand = cards;
|
||||
assert.match(markup(box), /Pay 2 spice and search with Nullentropy Box/);
|
||||
const payment = projected(3, { type: 'decision', karama: true, card: cards[2].id });
|
||||
payment.decision = { kind: 'auctionPayment', player: 'p' }; payment.players.find(player => player.id === payment.me)!.hand = cards;
|
||||
assert.match(markup(payment), /Use Karama for the current auction payment/);
|
||||
for (const game of [projected(), ghola, box, payment, paidBox()]) {
|
||||
const sent: Action[] = [];
|
||||
for (const seat of game.players) for (const key of ['traitors', 'faceDancers', 'leaders', 'kwisatz', 'spice', 'reserves', 'tanks', 'elites', 'forces',
|
||||
...(seat.id !== game.me ? ['hand'] : [])]) Object.defineProperty(seat, key, {
|
||||
get() { throw Error(`No unrelated ${key} inspection`); }, configurable: true,
|
||||
});
|
||||
if (game.auction) Object.defineProperty(game.auction, 'card', { get() { throw Error('No unrevealed lot inspection'); } });
|
||||
for (const key of ['spiceDeck', 'deck', 'discard']) Object.defineProperty(game, key, {
|
||||
get() { throw Error(`No private ${key} inspection`); }, configurable: true,
|
||||
});
|
||||
assert.match(markup(game), /Your movement expression promises/);
|
||||
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
|
||||
assert.equal(sent[0], game.movementExpressionPromiseGuide!.action);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
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 type { MovementExpression } from '../game/movement-expression-promises';
|
||||
import { createTruthMovementAdvancedFixture } from './fixture-truth-movement-advanced';
|
||||
import {
|
||||
actTruthMovementExpression as act, advanceTruthMovementExpression as advance,
|
||||
answerTruthMovementExpression as answer, askTruthMovementExpression as ask,
|
||||
bindTruthMovementExpression as bind, completeTruthMovementExpression as complete,
|
||||
createTruthMovementExpressionFixture as fixture, declareTruthMovementExpression as declare,
|
||||
finishTruthMovementExpression as finish, settleTruthMovementExpression as settle,
|
||||
truthMovementExpressionClean as clean, truthMovementExpressionInventory as inventory,
|
||||
truthMovementExpressionPlayer as player,
|
||||
} from './fixture-truth-movement-expression';
|
||||
|
||||
const move = (territory = 'imperial_basin', minimum = 1): MovementExpression => ({ kind: 'move', territory, minimum });
|
||||
const both = (): MovementExpression => ({ kind: 'and', terms: [move(), move('carthag')] });
|
||||
const either = (): MovementExpression => ({ kind: 'or', terms: [move(), move('carthag')] });
|
||||
const promise = (game: Game, target = 'atreides') => game.movementExpressionPromises!.find(row => row.player === target)!;
|
||||
const maximum = (game: Game, territory: string, target = 'atreides') =>
|
||||
promise(game, target).progress.find(entry => entry.territory === territory)?.maximum ?? 0;
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} movement AND uses two actual groups and the original Hajr`, () => {
|
||||
const f = fixture({ advanced }); assert.equal(f.setup.status, 'setup'); assert.equal(f.originalCards.length, 33);
|
||||
assert.deepEqual(f.game.expansions, []); assert.equal(f.sourceActionCount, f.sourceActions.length);
|
||||
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 2).map(card => card.id), f.deal);
|
||||
assert.equal(player(f.shipment.before, f.target).spice - player(f.shipment.after, f.target).spice, 2);
|
||||
assert.deepEqual(player(f.game, f.target).forces, { 'arrakeen:10': 10, 'polar_sink:0': 1 });
|
||||
const source = structuredClone(f.game), bound = bind(f); assert.deepEqual(f.game, source);
|
||||
let game = bound.game; assert.deepEqual(promise(game).progress, []);
|
||||
rejected(game, f.target, { type: 'endMovement' });
|
||||
if (advanced) rejected(game, f.target, f.hajrAction!);
|
||||
game = settle(act(game, f.target, f.firstMove));
|
||||
assert.equal(player(game, f.target).moved, 1); assert.equal(maximum(game, 'imperial_basin'), 1);
|
||||
assert.equal(maximum(game, 'carthag'), 0); assert.ok(!promise(game).fulfilled && !promise(game).released);
|
||||
rejected(game, f.target, { type: 'endMovement' });
|
||||
const guide = viewGame(game, f.target).movementExpressionPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.phase, 5); assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.hajr);
|
||||
const progress = structuredClone(promise(game).progress);
|
||||
game = settle(act(game, f.target, guide.action));
|
||||
assert.deepEqual(promise(game).progress, progress, 'An actual Hajr disposal grants movement without moving a force');
|
||||
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
|
||||
game = settle(act(game, f.target, f.secondMove));
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(maximum(game, 'carthag'), 1);
|
||||
assert.equal(player(game, f.target).forces['arrakeen:10'], 8);
|
||||
assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
|
||||
assert.equal(player(game, f.target).forces['carthag:11'], 1);
|
||||
assert.equal(player(game, f.target).moved, 2);
|
||||
game = finish(game, f.target); assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native movement OR Yes accepts the manually chosen alternate actual destination', () => {
|
||||
const f = fixture(); let game = bind(f, 'yes', either()).game;
|
||||
const guide = viewGame(game, f.target).movementExpressionPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'move');
|
||||
const alternate = guide.action.territory === 'imperial_basin' ? f.secondMove : f.firstMove;
|
||||
assert.notEqual(alternate.territory, guide.action.territory);
|
||||
game = settle(act(game, f.target, alternate));
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(maximum(game, String(alternate.territory)), 1);
|
||||
assert.ok(player(game, f.target).hand.some(card => card.id === f.hajr));
|
||||
assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
|
||||
});
|
||||
|
||||
for (const first of ['imperial_basin', 'carthag']) void test(`native movement AND No permits ${first} first and blocks only whole completion`, () => {
|
||||
const f = fixture(); let game = bind(f, 'no').game;
|
||||
const firstMove = first === 'imperial_basin' ? f.firstMove : f.secondMove;
|
||||
const secondMove = first === 'imperial_basin' ? f.secondMove : f.firstMove;
|
||||
game = settle(act(game, f.target, firstMove));
|
||||
assert.equal(maximum(game, first), 1); assert.ok(!promise(game).fulfilled);
|
||||
game = settle(act(game, f.target, f.hajrAction!)); rejected(game, f.target, secondMove);
|
||||
assert.equal(maximum(game, String(secondMove.territory)), 0);
|
||||
game = finish(game, f.target); assert.equal(promise(game).fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native movement OR No rejects either actual ground group until owner finish', () => {
|
||||
const f = fixture(); let game = bind(f, 'no', either()).game;
|
||||
rejected(game, f.target, f.firstMove); rejected(game, f.target, f.secondMove);
|
||||
assert.deepEqual(promise(game).progress, []); assert.ok(!promise(game).fulfilled);
|
||||
game = finish(game, f.target); assert.equal(promise(game).fulfilled, true);
|
||||
assert.equal(player(game, f.target).moved, 0); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native repeated destination thresholds share the actual largest group and never sum two smaller Hajr moves', () => {
|
||||
const f = fixture(), claim: MovementExpression = { kind: 'and', terms: [move(), move('imperial_basin', 2)] };
|
||||
let game = bind(f, 'no', claim).game;
|
||||
game = settle(act(game, f.target, f.firstMove));
|
||||
assert.deepEqual(promise(game).progress, [{ territory: 'imperial_basin', maximum: 1 }]);
|
||||
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.deepEqual(promise(game).progress, [{ territory: 'imperial_basin', maximum: 1 }]);
|
||||
assert.ok(!promise(game).fulfilled); game = finish(game, f.target);
|
||||
assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
|
||||
let yes = bind(f, 'yes', claim).game;
|
||||
yes = settle(act(yes, f.target, { ...f.firstMove, amount: 2 }));
|
||||
assert.equal(promise(yes).fulfilled, true);
|
||||
assert.deepEqual(promise(yes).progress, [{ territory: 'imperial_basin', maximum: 2 }]);
|
||||
assert.ok(player(yes, f.target).hand.some(card => card.id === f.hajr));
|
||||
assert.deepEqual(inventory(finish(yes, f.target)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native prior ground movement is excluded from a newly answered whole expression', () => {
|
||||
const f = fixture(); let game = settle(act(f.game, f.target, f.firstMove));
|
||||
assert.equal(player(game, f.target).moved, 1); assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
|
||||
game = bind(f, 'yes', either(), game).game;
|
||||
assert.deepEqual(promise(game).progress, []); assert.ok(!promise(game).fulfilled);
|
||||
rejected(game, f.target, { type: 'endMovement' });
|
||||
const guide = viewGame(game, f.target).movementExpressionPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.hajr);
|
||||
game = settle(act(game, f.target, guide.action)); assert.deepEqual(promise(game).progress, []);
|
||||
game = settle(act(game, f.target, f.secondMove));
|
||||
assert.deepEqual(promise(game).progress, [{ territory: 'carthag', maximum: 1 }]);
|
||||
assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native no-Hajr feasibility offers No for distinct-destination AND while OR retains either Yes route', () => {
|
||||
const f = fixture({ hajr: false }), declared = declare(f);
|
||||
let game = ask(declared.game, f.asker, f.target, both());
|
||||
assert.deepEqual(viewGame(game, f.target).truthMovementExpressionAnswers, ['no']);
|
||||
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = answer(game, f.target, 'no'); game = settle(act(game, f.target, f.firstMove));
|
||||
assert.ok(!promise(game).fulfilled); assert.equal(promise(finish(game, f.target)).fulfilled, true);
|
||||
const orQuestion = ask(declared.game, f.asker, f.target, either());
|
||||
assert.deepEqual(viewGame(orQuestion, f.target).truthMovementExpressionAnswers, ['yes', 'no']);
|
||||
const yes = complete(answer(orQuestion, f.target, 'yes'), f.target);
|
||||
assert.equal(promise(yes).fulfilled, true); assert.deepEqual(inventory(finish(yes, f.target)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native exact nested movement guide accepts wire objects and rejects stale foreign or modified packets', () => {
|
||||
const f = fixture(), declared = declare(f);
|
||||
assert.ok(viewGame(declared.game, f.asker).truthMovementExpressionTargets.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.claim);
|
||||
assert.equal(viewGame(asked, f.asker).truthMovementExpressionAnswers, null);
|
||||
rejected(asked, f.asker, { type: 'truthAnswer', answer: 'yes' });
|
||||
let game = answer(asked, f.target, 'yes');
|
||||
const guide = viewGame(game, f.target).movementExpressionPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'move'); assert.ok(guide.action.forces);
|
||||
assert.ok(guide.action.movementExpressionPromiseEvent);
|
||||
assert.deepEqual(viewGame(game, f.target).movementExpressionPromises, [promise(game)]);
|
||||
rejected(game, f.asker, guide.action);
|
||||
rejected(game, f.target, { ...guide.action, movementExpressionPromiseEvent: 'stale-whole-movement-event' });
|
||||
const wire: Action = JSON.parse(JSON.stringify(guide.action));
|
||||
const modified = structuredClone(wire), forces = modified.forces as Record<string, number>;
|
||||
const origin = Object.keys(forces)[0]; assert.ok(origin); forces[origin] += 1;
|
||||
rejected(game, f.target, modified);
|
||||
const event = promise(game).event; game = settle(act(game, f.target, wire));
|
||||
assert.equal(promise(game).event, event); assert.ok(promise(game).progress.length > 0);
|
||||
rejected(game, f.target, wire);
|
||||
game = complete(game, f.target); rejected(game, f.target, guide.action);
|
||||
assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('all four minimal policies take the canonical native movement guide through real Hajr and complete AND', () => {
|
||||
const f = fixture(), bound = bind(f).game;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(bound);
|
||||
for (let count = 0; count < 8 && !promise(game).fulfilled; count++) {
|
||||
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const guide = view.movementExpressionPromiseGuide; assert.ok(guide, difficulty);
|
||||
const action = botActions(view).find(action => action.movementExpressionPromiseEvent === guide.action.movementExpressionPromiseEvent);
|
||||
assert.ok(action, difficulty); assert.deepEqual(action, guide.action);
|
||||
game = settle(act(game, f.target, action));
|
||||
}
|
||||
assert.equal(promise(game).fulfilled, true, difficulty); 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 Charity whole movement promise follows actual Bidding Revival shipment and owner movement', () => {
|
||||
const f = fixture({ window: 'charity' }); assert.equal(f.game.phase, 2); assert.equal(f.game.turn, 1);
|
||||
assert.equal(f.sourceCount, f.sourceActions.length); assert.ok(f.afterSetupActionCount! < f.sourceCount);
|
||||
assert.ok(f.sourceActionCount < f.sourceCounts.inheritedActive);
|
||||
assert.equal(player(f.game, f.target).shipped, false); assert.equal(player(f.game, f.target).moved, 0);
|
||||
let game = bind(f).game; assert.deepEqual(promise(game).progress, []);
|
||||
rejected(game, f.target, f.firstMove); rejected(game, f.target, f.hajrAction!);
|
||||
const actions: { actor: string; action: Action }[] = [];
|
||||
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), actions);
|
||||
assert.ok(actions.some(step => step.action.type === 'passBid'));
|
||||
assert.ok(actions.some(step => step.action.type === 'ready'));
|
||||
assert.deepEqual(promise(game).progress, []); assert.equal(player(game, f.target).shipped, false);
|
||||
game = settle(act(game, f.target, f.shipmentStep.action, actions), actions);
|
||||
assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
|
||||
assert.deepEqual(promise(game).progress, [], 'The native reserve shipment contributes no ground-movement progress');
|
||||
game = complete(game, f.target, actions); assert.equal(promise(game).fulfilled, true);
|
||||
assert.equal(maximum(game, 'imperial_basin'), 1); assert.equal(maximum(game, 'carthag'), 1);
|
||||
assert.deepEqual(inventory(finish(game, f.target, actions)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native original Advanced Fremen grouped progress waits for the actual range response and physical commitment', () => {
|
||||
const f = createTruthMovementAdvancedFixture({ targetFaction: 'fremen', hajr: false });
|
||||
assert.ok(f.twoStepMove && f.twoStepDestination);
|
||||
let game = act(f.game, f.asker, f.declaration);
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
|
||||
game = act(game, actor.id, { type: 'truthPass' });
|
||||
}
|
||||
const claim: MovementExpression = { kind: 'or', terms: [
|
||||
move(f.twoStepDestination), move(f.twoStepDestination, 2),
|
||||
] };
|
||||
game = answer(ask(game, f.asker, f.target, claim), f.target, 'yes');
|
||||
const before = structuredClone(player(game, f.target));
|
||||
game = act(game, f.target, f.twoStepMove);
|
||||
assert.equal(game.response?.kind, 'fremenMovement');
|
||||
assert.deepEqual(promise(game, f.target).progress, []);
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
assert.deepEqual(player(game, f.target).forces, before.forces);
|
||||
assert.deepEqual(player(game, f.target).elites, before.elites);
|
||||
game = settle(game);
|
||||
assert.deepEqual(promise(game, f.target).progress, [{ territory: f.twoStepDestination, maximum: 1 }]);
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
const destination = `${f.twoStepDestination}:${String(f.twoStepMove.sector)}`;
|
||||
assert.equal(player(game, f.target).forces[destination], 1);
|
||||
assert.equal(player(game, f.target).elites!.forces[destination], 1);
|
||||
assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
|
||||
});
|
||||
Reference in new issue
Block a user