Support sector-specific Truthtrance movement

This commit is contained in:
Quintine committed 2026-10-27 21:27:07 +11:00
1 parent 536d334543
commit 1059cc1682
24 files changed
+1016 -100

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import type { Action, Game } from '../game/engine';
import type { MovementExpression } from '../game/movement-expression-promises';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TurnActionLeaf } from '../game/turn-action-promises';
import {
actTruthMovementExpression, createTruthMovementExpressionFixture,
declareTruthMovementExpression, settleTruthMovementExpression,
truthMovementExpressionInventory, truthMovementExpressionPlayer,
} from './fixture-truth-movement-expression';
export type MovementSectorQuestionKind = 'movement' | 'movementExpression' | 'mixedMovement' | 'travel' | 'turnActions';
export const actMovementSector = actTruthMovementExpression;
export const settleMovementSector = settleTruthMovementExpression;
export const movementSectorInventory = truthMovementExpressionInventory;
export const movementSectorPlayer = truthMovementExpressionPlayer;
/** Genuine Basic Atreides/Emperor setup and the original one-force Polar Sink
* shipment. The starting Hajr and Truthtrance are original unopened selected
* deal positions. This adapter chooses two unstormed printed Imperial Basin
* sectors adjacent to the original Arrakeen army; it installs no hand, force,
* wallet, timer, question, promise, progress or saved state. */
export function createTruthMovementSectorFixture() {
const base = createTruthMovementExpressionFixture(), game = structuredClone(base.game);
assert.equal(game.advanced, false); assert.equal(game.phase, 5); assert.equal(game.active, base.target);
assert.ok(base.hajr && movementSectorPlayer(game, base.target).hand.some(card => card.id === base.hajr));
const sectors = [9, 10, 11].filter(sector => sector !== game.storm);
assert.ok(sectors.length >= 2);
const [sector, alternative] = sectors;
const move = (destination: number, amount = 1): Action => ({ type: 'move', from: 'arrakeen:10',
territory: 'imperial_basin', sector: destination, amount });
const leaf = (destination: number, minimum = 1): MovementExpression => ({ kind: 'move',
territory: 'imperial_basin', sector: destination, minimum });
const action = (destination: number, minimum = 1): MixedActionExpression<TurnActionLeaf> => ({
kind: 'action', action: { kind: 'move', territory: 'imperial_basin', sector: destination, minimum },
});
const forceTotal = (state: Game) => {
const player = movementSectorPlayer(state, base.target);
return player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
};
assert.deepEqual(movementSectorInventory(game), base.originalCards);
return { base, game, target: base.target, asker: base.asker, truth: base.truth, hajr: base.hajr,
sector, alternative, move, leaf, action, originalCards: base.originalCards, forceTotal,
originalForces: forceTotal(game), disclosure: base.sourceDisclosure };
}
export type TruthMovementSectorFixture = ReturnType<typeof createTruthMovementSectorFixture>;
export function askMovementSector(game: Game, f: TruthMovementSectorFixture, kind: MovementSectorQuestionKind,
grouped = false): Game {
const exact = f.leaf(f.sector, grouped ? 2 : 1), other = f.leaf(f.alternative, 1);
let question: Record<string, unknown>;
if (kind === 'movement') {
assert.equal(grouped, false);
question = { kind, target: f.target, territory: 'imperial_basin', sector: f.sector, minimum: 1 };
} else if (kind === 'movementExpression') {
question = { kind, target: f.target, turn: game.turn,
claim: grouped ? { kind: 'and', terms: [exact, other] } : exact };
} else if (kind === 'mixedMovement') {
const physicalHajr = movementSectorPlayer(game, f.target).hand.find(card => card.id === f.hajr);
assert.ok(physicalHajr);
const knownFact = { kind: 'fact', fact: { kind: 'hand', name: physicalHajr.name } };
const selected = { kind: 'action', action: { territory: 'imperial_basin', sector: f.sector, minimum: grouped ? 2 : 1 } };
const second = { kind: 'action', action: { territory: 'imperial_basin', sector: f.alternative, minimum: 1 } };
question = { kind, target: f.target, turn: game.turn,
mixed: { kind: 'and', terms: grouped ? [knownFact, selected, second] : [knownFact, selected] } };
} else {
question = { kind, target: f.target, turn: game.turn, mixed: grouped
? { kind: 'and', terms: [f.action(f.sector, 2), f.action(f.alternative)] }
: f.action(f.sector) };
}
return actMovementSector(game, f.asker, { type: 'truthAsk', question } as Action);
}
export function bindMovementSector(f: TruthMovementSectorFixture, kind: MovementSectorQuestionKind,
answer: 'yes' | 'no', grouped = false) {
const declared = declareTruthMovementExpression(f.base, f.game).game;
const asked = askMovementSector(declared, f, kind, grouped);
const game = actMovementSector(asked, f.target, { type: 'truthAnswer', answer });
return { declared, asked, game };
}
+91
View File
@@ -0,0 +1,91 @@
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 { MovementClaimFields } from '../components/movement-claim-fields';
import { MovementExpressionFields } from '../components/movement-expression-fields';
import { MixedMovementFields } from '../components/mixed-movement-fields';
import { TravelClaimFields } from '../components/travel-claim-fields';
import { TurnActionFields } from '../components/turn-action-fields';
import { TERRITORIES } from '../game/board';
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
import type { MovementClaim } from '../game/movement-promises';
import type { MovementExpression } from '../game/movement-expression-promises';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TravelActionClaim } from '../game/travel-expression-promises';
import type { TurnActionLeaf } from '../game/turn-action-promises';
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
const destination = TERRITORIES.find(item => item.id === 'imperial_basin')!;
assert.ok(destination.sectors.includes(9));
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
return [node, ...elements(node.props.children)];
}
function select(tree: ReactNode, id: string, chosen: string) {
const field = elements(tree).find(item => item.props.id === id); assert.ok(field, id);
(field.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value: chosen } });
}
/** Controlled projection exercises editing, not actual movement legality. */
function projection(): GameView {
const game = createGame('MOVEMENTSECTOR', newPlayer('p', 'Atreides', 'atreides'));
joinGame(game, newPlayer('a', 'Emperor', 'emperor'));
const view = viewGame(game, 'p'); view.status = 'playing'; view.turn = 1; view.phase = 5;
view.active = 'p'; view.movementRemaining = ['p']; return view;
}
void test('standalone movement editor selects or clears an exact printed sector and resets on territory change', () => {
let value: MovementClaim = { territory: destination.id, minimum: 2 };
const props = () => ({ id: 'plain', value, onChange(next: MovementClaim) { value = next; } });
const tree = () => MovementClaimFields(props());
assert.match(renderToStaticMarkup(createElement(MovementClaimFields, props())), /Any sector in this territory/);
select(tree(), 'plain-sector', '9'); assert.equal(value.sector, 9);
select(tree(), 'plain-sector', ''); assert.deepEqual(value, { territory: destination.id, minimum: 2 });
select(tree(), 'plain-sector', '9'); select(tree(), 'plain-territory', 'arrakeen');
assert.deepEqual(value, { territory: 'arrakeen', minimum: 2 });
});
void test('movement-expression editor keeps exact-sector values through grouping and clears changed destination', () => {
let value: MovementExpression = { kind: 'move', territory: destination.id, minimum: 2 };
const tree = () => MovementExpressionFields({ value, territories: TERRITORIES, disabled: false, onChange(next) { value = next; } });
select(tree(), 'truth-movement-expression-1-sector', '9');
assert.deepEqual(value, { kind: 'move', territory: destination.id, minimum: 2, sector: 9 });
const wrap = elements(tree()).find(item => item.props['data-movement-expression-control'] === 'and-1'); assert.ok(wrap);
(wrap.props.onClick as () => void)();
const current = (): MovementExpression => value;
const grouped = current(); assert.equal(grouped.kind, 'and');
if (grouped.kind !== 'and') throw Error('Expected the new expression group');
assert.deepEqual(grouped.terms[0], { kind: 'move', territory: destination.id, minimum: 2, sector: 9 });
select(tree(), 'truth-movement-expression-1.1-territory', 'arrakeen');
const updated = current();
if (updated.kind !== 'and') throw Error('Expected the same expression group');
assert.deepEqual(updated.terms[0], { kind: 'move', territory: 'arrakeen', minimum: 2 });
});
void test('mixed movement editor selects the target sector and clears it when the territory changes', () => {
const game = projection(); let value: MixedActionExpression<MovementClaim> = { kind: 'action', action: { territory: destination.id, minimum: 1 } };
const tree = () => MixedMovementFields({ game, value, target: 'p', disabled: false, onChange(next) { value = next; } });
select(tree(), 'truth-mixed-movement-1-sector', '9');
assert.deepEqual(value, { kind: 'action', action: { territory: destination.id, minimum: 1, sector: 9 } });
select(tree(), 'truth-mixed-movement-1-territory', 'arrakeen');
assert.deepEqual(value, { kind: 'action', action: { territory: 'arrakeen', minimum: 1 } });
});
void test('travel and whole-turn action editors choose movement sectors and clear sectors on action changes', () => {
const game = projection();
let travel: MixedActionExpression<TravelActionClaim> = { kind: 'action', action: { kind: 'move', territory: destination.id, minimum: 1 } };
const travelTree = () => TravelClaimFields({ game, value: travel, target: 'p', disabled: false, onChange(next) { travel = next; } });
select(travelTree(), 'truth-travel-1-sector', '9');
assert.deepEqual(travel, { kind: 'action', action: { kind: 'move', territory: destination.id, minimum: 1, sector: 9 } });
select(travelTree(), 'truth-travel-1-action', 'ship');
assert.deepEqual(travel, { kind: 'action', action: { kind: 'ship', territory: destination.id, minimum: 1 } });
select(travelTree(), 'truth-travel-1-sector', '9'); select(travelTree(), 'truth-travel-1-action', 'move');
assert.deepEqual(travel, { kind: 'action', action: { kind: 'move', territory: destination.id, minimum: 1 } });
let whole: MixedActionExpression<TurnActionLeaf> = { kind: 'action', action: { kind: 'move', territory: destination.id, minimum: 1 } };
const wholeTree = () => TurnActionFields({ game, value: whole, target: 'p', disabled: false, onChange(next) { whole = next; } });
select(wholeTree(), 'truth-turn-action-1-sector', '9');
assert.deepEqual(whole, { kind: 'action', action: { kind: 'move', territory: destination.id, minimum: 1, sector: 9 } });
select(wholeTree(), 'truth-turn-action-1-territory', 'arrakeen');
assert.deepEqual(whole, { kind: 'action', action: { kind: 'move', territory: 'arrakeen', minimum: 1 } });
});
+126
View File
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MOBILE_STRONGHOLD } from '../game/board';
import {
MovementExpressionPromiseError,
evaluateMovementExpression,
movementExpressionSectors,
movementExpressionText,
parseMovementExpression,
recordMovementExpressionEvent,
validateMovementExpressionPromises,
type MovementExpression,
type MovementExpressionPromise,
} from '../game/movement-expression-promises';
const move = (sector?: number, minimum = 3): MovementExpression => ({
kind: 'move', territory: 'wind_pass', minimum, ...(sector === undefined ? {} : { sector }),
});
const group = (kind: 'and' | 'or', ...terms: MovementExpression[]): MovementExpression => ({ kind, terms });
const promise = (claim: MovementExpression, answer = true): MovementExpressionPromise => ({
event: 'move-expression', turn: 2, player: 'p', asker: 'a', answer, claim, progress: [],
...(movementExpressionSectors(claim).length ? { sectorProgress: [] } : {}),
});
void test('sector-aware movement expressions parse printed sectors and preserve legacy leaves', () => {
assert.deepEqual(parseMovementExpression(move(15)), move(15));
assert.deepEqual(parseMovementExpression(move()), move());
assert.deepEqual(movementExpressionSectors(group('or', move(15), move(14), move(15), move())), [
{ territory: 'wind_pass', sector: 15 }, { territory: 'wind_pass', sector: 14 },
]);
assert.match(movementExpressionText(move(15)), /Wind Pass.*sector 15/i);
for (const invalid of [
{ kind: 'move', territory: 'wind_pass', minimum: 1, sector: 19 },
{ kind: 'move', territory: 'wind_pass', minimum: 1, sector: 15.5 },
{ kind: 'move', territory: 'wind_pass', minimum: 1, sector: null },
{ kind: 'move', territory: 'wind_pass', minimum: 1, sector: 15, extra: true },
]) assert.throws(() => parseMovementExpression(invalid), MovementExpressionPromiseError);
});
void test('territory-wide progress never invents an exact sector for an older record', () => {
const territoryProgress = [{ territory: 'wind_pass', maximum: 8 }];
assert.equal(evaluateMovementExpression(move(), territoryProgress), true);
assert.equal(evaluateMovementExpression(move(15), territoryProgress), false);
assert.equal(evaluateMovementExpression(move(15), territoryProgress,
[{ territory: 'wind_pass', sector: 14, maximum: 8 }]), false);
assert.equal(evaluateMovementExpression(move(15), territoryProgress,
[{ territory: 'wind_pass', sector: 15, maximum: 3 }]), true);
const record = promise(move(15), false);
const [result] = recordMovementExpressionEvent([record], 'p', 2, { territory: 'wind_pass', amount: 8 });
assert.deepEqual(result.progress, territoryProgress);
assert.deepEqual(result.sectorProgress, []);
validateMovementExpressionPromises([result], ['p', 'a']);
});
void test('separate actual sectors each keep a per-group maximum, never a sum', () => {
const record = promise(group('and', move(14, 4), move(15, 3)));
const afterFirst = recordMovementExpressionEvent([record], 'p', 2,
{ territory: 'wind_pass', sector: 14, amount: 4 });
const afterSmall = recordMovementExpressionEvent(afterFirst, 'p', 2,
{ territory: 'wind_pass', sector: 15, amount: 2 });
const afterRepeat = recordMovementExpressionEvent(afterSmall, 'p', 2,
{ territory: 'wind_pass', sector: 15, amount: 2 });
assert.equal(afterRepeat[0].fulfilled, undefined);
assert.deepEqual(afterRepeat[0].progress, [{ territory: 'wind_pass', maximum: 4 }]);
assert.deepEqual(afterRepeat[0].sectorProgress, [
{ territory: 'wind_pass', sector: 14, maximum: 4 },
{ territory: 'wind_pass', sector: 15, maximum: 2 },
]);
const [done] = recordMovementExpressionEvent(afterRepeat, 'p', 2,
{ territory: 'wind_pass', sector: 15, amount: 3 });
assert.equal(done.fulfilled, true);
assert.deepEqual(done.progress, [{ territory: 'wind_pass', maximum: 4 }]);
assert.deepEqual(done.sectorProgress, [
{ territory: 'wind_pass', sector: 14, maximum: 4 },
{ territory: 'wind_pass', sector: 15, maximum: 3 },
]);
validateMovementExpressionPromises([done], ['p', 'a']);
});
void test('No blocks an actual named sector but permits an unrelated sector', () => {
const record = promise(group('or', move(14, 3), move(15, 4)), false);
const [other] = recordMovementExpressionEvent([record], 'p', 2,
{ territory: 'wind_pass', sector: 16, amount: 8 });
assert.deepEqual(other.progress, [{ territory: 'wind_pass', maximum: 8 }]);
assert.deepEqual(other.sectorProgress, []);
assert.throws(() => recordMovementExpressionEvent([other], 'p', 2,
{ territory: 'wind_pass', sector: 14, amount: 3 }), MovementExpressionPromiseError);
assert.throws(() => recordMovementExpressionEvent([other], 'p', 2,
{ territory: 'wind_pass', sector: 15, amount: 4 }), MovementExpressionPromiseError);
validateMovementExpressionPromises([other], ['p', 'a']);
});
void test('legacy records keep their existing shape and malformed sector progress is rejected', () => {
const [legacy] = recordMovementExpressionEvent([promise(move())], 'p', 2,
{ territory: 'wind_pass', sector: 14, amount: 3 });
assert.deepEqual(legacy.progress, [{ territory: 'wind_pass', maximum: 3 }]);
assert.equal(Object.hasOwn(legacy, 'sectorProgress'), false);
assert.equal(legacy.fulfilled, true);
validateMovementExpressionPromises(JSON.parse(JSON.stringify([legacy])), ['p', 'a']);
const missing = promise(move(14)); delete missing.sectorProgress;
const invalid = [
missing,
{ ...promise(move()), sectorProgress: [] },
{ ...promise(move(14)), progress: [{ territory: 'wind_pass', maximum: 3 }],
sectorProgress: [{ territory: 'wind_pass', sector: 15, maximum: 3 }] },
{ ...promise(move(14)), progress: [{ territory: 'wind_pass', maximum: 2 }],
sectorProgress: [{ territory: 'wind_pass', sector: 14, maximum: 3 }] },
{ ...promise(move(14)), progress: [{ territory: 'wind_pass', maximum: 3 }],
sectorProgress: [{ territory: 'wind_pass', sector: 14, maximum: 1 },
{ territory: 'wind_pass', sector: 14, maximum: 2 }] },
];
for (const row of invalid) assert.throws(() => validateMovementExpressionPromises([row], ['p', 'a']), MovementExpressionPromiseError);
assert.throws(() => recordMovementExpressionEvent([promise(move(14))], 'p', 2,
{ territory: 'wind_pass', sector: 19, amount: 1 }), MovementExpressionPromiseError);
});
void test('unrelated dynamic stronghold entry events retain native structural validity', () => {
const row = promise(move(14));
const untouched = recordMovementExpressionEvent([row], 'p', 2,
{ territory: MOBILE_STRONGHOLD, sector: 0, amount: 1 });
assert.equal(untouched[0], row);
assert.deepEqual(untouched[0].progress, []);
assert.deepEqual(untouched[0].sectorProgress, []);
assert.throws(() => recordMovementExpressionEvent([], 'p', 2,
{ territory: MOBILE_STRONGHOLD, sector: 1, amount: 1 }), MovementExpressionPromiseError);
});
+78
View File
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { TERRITORIES } from '../game/board';
import {
finishMovementPromises, movementClaimText, movementEventAllowed, movementMatches,
parseMovementClaim, settleMovementEvent, validMovementClaim, type MovementPromise,
} from '../game/movement-promises';
const destination = TERRITORIES.find(row => row.sectors.length >= 2)!;
assert.ok(destination);
const [first, second] = destination.sectors;
const promise = (sector: number | undefined, answer: boolean, minimum = 2): MovementPromise => ({
territory: destination.id, minimum, ...(sector === undefined ? {} : { sector }),
event: 'promise', turn: 1, player: 'owner', asker: 'asker', answer,
});
const movement = (sector: number | undefined, amount = 2) => ({
territory: destination.id, amount, ...(sector === undefined ? {} : { sector }),
});
void test('movement claim accepts one actual printed sector and preserves original territory-only data', () => {
const claim = { territory: destination.id, minimum: 2, sector: first };
assert.deepEqual(parseMovementClaim(claim), claim);
assert.match(movementClaimText(claim), new RegExp(`sector ${first}`));
assert.deepEqual(parseMovementClaim({ territory: destination.id, minimum: 2 }),
{ territory: destination.id, minimum: 2 });
assert.equal(JSON.stringify(parseMovementClaim({ territory: destination.id, minimum: 2 })),
JSON.stringify({ territory: destination.id, minimum: 2 }));
assert.deepEqual(parseMovementClaim({ ...claim, turn: 1, player: 'owner' }), claim);
for (const invalid of [undefined, null, -1, 1.1, '1', Math.max(...destination.sectors) + 100]) {
assert.equal(validMovementClaim({ ...claim, sector: invalid }), false);
assert.throws(() => parseMovementClaim({ ...claim, sector: invalid }));
}
});
void test('sector match requires the actual movement destination, not a missing or different sector', () => {
const exact = promise(first, true), generic = promise(undefined, true);
assert.equal(movementMatches(exact, movement(first)), true);
assert.equal(movementMatches(exact, movement(second)), false);
assert.equal(movementMatches(exact, movement(undefined)), false);
assert.equal(movementMatches(exact, movement(first, 1)), false);
assert.equal(movementMatches(exact, movement(first, 21)), false);
assert.equal(movementMatches(exact, null), false);
assert.equal(movementMatches(generic, movement(first)), true);
assert.equal(movementMatches(generic, movement(second)), true);
assert.equal(movementMatches(generic, movement(undefined)), true);
});
void test('Yes remains live through other sectors and smaller groups; one actual group fulfills it', () => {
const original = [promise(first, true, 3)];
const other = settleMovementEvent(original, 'owner', 1, movement(second, 4));
assert.equal(other[0].fulfilled, undefined);
const small = settleMovementEvent(other, 'owner', 1, movement(first, 2));
assert.equal(small[0].fulfilled, undefined);
assert.throws(() => finishMovementPromises(small, 'owner', 1));
const matching = settleMovementEvent(small, 'owner', 1, movement(first, 3));
assert.equal(matching[0].fulfilled, true);
assert.equal(original[0].fulfilled, undefined);
assert.equal(finishMovementPromises(matching, 'owner', 1)[0].fulfilled, true);
});
void test('No blocks only its exact sector and retains the restriction across other movements', () => {
const original = [promise(first, false)];
assert.equal(movementEventAllowed(original, movement(first)), false);
assert.throws(() => settleMovementEvent(original, 'owner', 1, movement(first)));
const allowed = settleMovementEvent(original, 'owner', 1, movement(second));
assert.equal(allowed[0].fulfilled, undefined);
assert.equal(movementEventAllowed(allowed, movement(first)), false);
assert.equal(finishMovementPromises(allowed, 'owner', 1)[0].fulfilled, true);
});
void test('distinct exact-sector Yes claims can be fulfilled by two distinct physical moves', () => {
const rows = [promise(first, true), { ...promise(second, true), event: 'second-promise' }];
const firstMove = settleMovementEvent(rows, 'owner', 1, movement(first));
assert.equal(firstMove[0].fulfilled, true); assert.equal(firstMove[1].fulfilled, undefined);
assert.throws(() => finishMovementPromises(firstMove, 'owner', 1));
const secondMove = settleMovementEvent(firstMove, 'owner', 1, movement(second));
assert.ok(secondMove.every(row => row.fulfilled));
});
+82
View File
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { compileMixedActionExpression, type MixedActionExpression } from '../game/mixed-action-question';
import {
needsMovementSectorProgress, parseTravelActionClaim, recordTravelMovement, travelBranches,
travelPromiseValue, TravelPromiseError, validateTravelPromises,
type TravelActionClaim, type TravelPromise,
} from '../game/travel-expression-promises';
type Expression = MixedActionExpression<TravelActionClaim>;
const move = (minimum: number, sector?: number): Expression => ({ kind: 'action', action: {
kind: 'move', territory: 'imperial_basin', minimum, ...(sector === undefined ? {} : { sector }),
} });
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
function promise(mixed: Expression, answer = true): TravelPromise {
return { turn: 1, mixed, event: 'travel-sector', player: 'p', asker: 'a', answer,
compiled: compileMixedActionExpression(mixed, () => 'yes', answer), shipment: { pending: true, event: null },
progress: [], ...(needsMovementSectorProgress(mixed) ? { sectorProgress: [] } : {}),
movementClosed: false, fulfilled: false, released: false };
}
void test('movement-sector leaves validate only printed sectors and preserve sectorless bytes', () => {
const leaf = { kind: 'move', territory: 'imperial_basin', minimum: 1, sector: 9 };
assert.deepEqual(parseTravelActionClaim(leaf), leaf);
for (const invalid of [{ ...leaf, sector: 0 }, { ...leaf, sector: 9.5 }, { ...leaf, sector: '9' },
{ ...leaf, territory: 'nowhere' }, { ...leaf, extra: true }]) assert.throws(() => parseTravelActionClaim(invalid));
const original = promise(move(1)); assert.ok(!Object.hasOwn(original, 'sectorProgress'));
const [recorded] = recordTravelMovement([original], 'p', 1, { territory: 'imperial_basin', amount: 1, sector: 9 });
assert.deepEqual(recorded.progress, [{ territory: 'imperial_basin', maximum: 1 }]);
assert.ok(!Object.hasOwn(recorded, 'sectorProgress'));
});
void test('exact sector movement uses separate real maxima and never infers an unrecorded sector', () => {
const p = promise(move(2, 9));
let [next] = recordTravelMovement([p], 'p', 1, { territory: 'imperial_basin', amount: 3 });
assert.equal(travelPromiseValue(next), false); assert.deepEqual(next.sectorProgress, []);
[next] = recordTravelMovement([next], 'p', 1, { territory: 'imperial_basin', sector: 10, amount: 2 });
assert.equal(travelPromiseValue(next), false); assert.deepEqual(next.sectorProgress, []);
[next] = recordTravelMovement([next], 'p', 1, { territory: 'imperial_basin', sector: 9, amount: 1 });
assert.deepEqual(next.progress, [{ territory: 'imperial_basin', maximum: 3 }]);
assert.deepEqual(next.sectorProgress, [{ territory: 'imperial_basin', sector: 9, maximum: 1 }]);
[next] = recordTravelMovement([next], 'p', 1, { territory: 'imperial_basin', sector: 9, amount: 2 });
assert.equal(next.fulfilled, true); assert.equal(travelPromiseValue(next), true);
validateTravelPromises([next], ['p', 'a']);
});
void test('two named sectors may be satisfied by distinct actual movements without summing their groups', () => {
const p = promise(group('and', move(3, 9), move(2, 10)));
assert.deepEqual([...travelBranches(p)], [[{ kind: 'move', territory: 'imperial_basin', minimum: 3, sector: 9, answer: true },
{ kind: 'move', territory: 'imperial_basin', minimum: 2, sector: 10, answer: true }]]);
let [next] = recordTravelMovement([p], 'p', 1, { territory: 'imperial_basin', sector: 9, amount: 3 });
assert.equal(next.fulfilled, false);
[next] = recordTravelMovement([next], 'p', 1, { territory: 'imperial_basin', sector: 10, amount: 1 });
assert.equal(next.fulfilled, false);
[next] = recordTravelMovement([next], 'p', 1, { territory: 'imperial_basin', sector: 10, amount: 2 });
assert.equal(next.fulfilled, true);
assert.deepEqual(next.sectorProgress, [{ territory: 'imperial_basin', sector: 9, maximum: 3 },
{ territory: 'imperial_basin', sector: 10, maximum: 2 }]);
});
void test('whole No rejects an exact matching movement but permits another sector', () => {
const p = promise(move(2, 9), false);
const [other] = recordTravelMovement([p], 'p', 1, { territory: 'imperial_basin', sector: 10, amount: 2 });
assert.throws(() => recordTravelMovement([other], 'p', 1, { territory: 'imperial_basin', sector: 9, amount: 2 }), TravelPromiseError);
assert.equal(other.fulfilled, false);
});
void test('sector metadata is required only for an originally named exact movement and cannot be forged', () => {
const p = promise(move(2, 9));
assert.throws(() => { const incomplete = structuredClone(p); delete incomplete.sectorProgress; validateTravelPromises([incomplete], ['p', 'a']); });
const forged = { ...p, progress: [{ territory: 'imperial_basin', maximum: 1 }],
sectorProgress: [{ territory: 'imperial_basin', sector: 10, maximum: 1 }] };
assert.throws(() => validateTravelPromises([forged], ['p', 'a']));
const folded = promise(group('or', { kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 1 } }, move(2, 9)));
assert.deepEqual(folded.sectorProgress, []);
});
void test('unrelated physical moves into recognized dynamic locations do not break a printed sector question', () => {
const p = promise(move(2, 9));
for (const territory of ['hidden_mobile_stronghold', 'cistern']) {
const [next] = recordTravelMovement([p], 'p', 1, { territory, sector: 0, amount: 1 });
assert.deepEqual(next, p);
const [legacy] = recordTravelMovement([p], 'p', 1, { territory, amount: 1 });
assert.deepEqual(legacy, p);
assert.throws(() => recordTravelMovement([p], 'p', 1, { territory, sector: 1, amount: 1 }), TravelPromiseError);
assert.throws(() => parseTravelActionClaim({ kind: 'move', territory, minimum: 1, sector: 0 }), TravelPromiseError);
}
});
+121
View File
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MovementPromises } from '../components/movement-promises';
import { MovementExpressionPromiseGuideButton } from '../components/movement-expression-promises';
import { TravelPromiseGuideButton } from '../components/travel-promises';
import { TurnActionGuideButton } from '../components/turn-action-promises';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game, type GameView } from '../game/engine';
import {
actMovementSector as act, bindMovementSector as bind,
createTruthMovementSectorFixture as fixture, movementSectorInventory as inventory,
movementSectorPlayer as player, settleMovementSector as settle,
type MovementSectorQuestionKind,
} from './fixture-truth-movement-sector';
const kinds: MovementSectorQuestionKind[] = ['movement', 'movementExpression', 'mixedMovement', 'travel', 'turnActions'];
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function row(game: Game, kind: MovementSectorQuestionKind, target: string): {
fulfilled?: boolean; released?: boolean; progress?: { territory: string; maximum: number }[];
sectorProgress?: { territory: string; sector: number; maximum: number }[];
} {
const rows = kind === 'movement' ? game.movementPromises : kind === 'movementExpression' || kind === 'mixedMovement'
? game.movementExpressionPromises : kind === 'travel' ? game.travelPromises : game.turnActionPromises;
const found = rows?.find(item => item.player === target); assert.ok(found); return found;
}
function answers(view: GameView, kind: MovementSectorQuestionKind) {
return kind === 'movement' ? view.truthMovementAnswers : kind === 'movementExpression'
? view.truthMovementExpressionAnswers : kind === 'mixedMovement' ? view.truthMixedMovementAnswers
: kind === 'travel' ? view.truthTravelAnswers : view.truthTurnActionAnswers;
}
function guide(view: GameView, kind: MovementSectorQuestionKind): Action | undefined {
return kind === 'movement' ? view.movementPromisePolicy?.[0] : kind === 'movementExpression' || kind === 'mixedMovement'
? view.movementExpressionPromiseGuide?.action : kind === 'travel'
? view.travelPromiseGuide?.action : view.turnActionGuide?.action;
}
function guideMarkup(view: GameView, kind: MovementSectorQuestionKind): string {
const noop = () => {};
return kind === 'movement' ? renderToStaticMarkup(createElement(MovementPromises, { game: view, busy: false, act: noop }))
: kind === 'movementExpression' || kind === 'mixedMovement' ? renderToStaticMarkup(createElement(MovementExpressionPromiseGuideButton, { game: view, busy: false, send: noop }))
: kind === 'travel' ? renderToStaticMarkup(createElement(TravelPromiseGuideButton, { game: view, busy: false, act: noop }))
: renderToStaticMarkup(createElement(TurnActionGuideButton, { game: view, busy: false, act: noop }));
}
for (const kind of kinds) void test(`${kind}: native Yes binds one exact ground sector and exposes a legal human/bot guide`, () => {
const f = fixture(), bound = bind(f, kind, 'yes'); let game = bound.game;
assert.ok(answers(viewGame(bound.asked, f.target), kind)?.includes('yes'));
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
rejected(game, f.target, { type: 'endMovement' });
const view = viewGame(game, f.target), action = guide(view, kind); assert.ok(action);
assert.equal(action.type, 'move'); assert.equal(action.territory, 'imperial_basin'); assert.equal(action.sector, f.sector);
assert.match(guideMarkup(view, kind), new RegExp(`sector ${f.sector}`, 'i'));
for (const difficulty of DIFFICULTIES) {
const bot = viewGame(game, f.target);
bot.players.find(entry => entry.id === f.target)!.bot = difficulty;
assert.deepEqual(botActions(bot)[0], action);
}
game = settle(act(game, f.target, action));
assert.equal(row(game, kind, f.target).fulfilled, true);
assert.ok((player(game, f.target).forces[`imperial_basin:${f.sector}`] ?? 0) > 0);
assert.equal(f.forceTotal(game), f.originalForces);
assert.deepEqual(inventory(game), f.originalCards);
});
for (const kind of kinds) void test(`${kind}: native No blocks only the named sector`, () => {
const f = fixture(); let game = bind(f, kind, 'no').game;
rejected(game, f.target, f.move(f.sector));
game = settle(act(game, f.target, f.move(f.alternative)));
assert.equal(player(game, f.target).forces[`imperial_basin:${f.alternative}`], 1);
assert.notEqual(row(game, kind, f.target).fulfilled, true);
game = settle(act(game, f.target, { type: 'endMovement' }));
assert.equal(row(game, kind, f.target).fulfilled, true);
assert.equal(f.forceTotal(game), f.originalForces);
assert.deepEqual(inventory(game), f.originalCards);
});
for (const kind of kinds) void test(`${kind}: a different first sector preserves Yes only while actual Hajr remains available`, () => {
const f = fixture(); assert.ok(f.hajr); let game = bind(f, kind, 'yes').game;
game = settle(act(game, f.target, f.move(f.alternative)));
assert.notEqual(row(game, kind, f.target).fulfilled, true);
rejected(game, f.target, { type: 'endMovement' });
game = settle(act(game, f.target, { type: 'card', card: f.hajr }));
game = settle(act(game, f.target, f.move(f.sector)));
assert.equal(row(game, kind, f.target).fulfilled, true);
assert.equal(player(game, f.target).forces[`imperial_basin:${f.sector}`], 1);
assert.equal(player(game, f.target).forces[`imperial_basin:${f.alternative}`], 1);
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
assert.equal(f.forceTotal(game), f.originalForces);
assert.deepEqual(inventory(game), f.originalCards);
});
for (const kind of ['movementExpression', 'mixedMovement', 'travel', 'turnActions'] as const)
void test(`${kind}: two exact-sector conditions use separate native group maxima`, () => {
const f = fixture(); assert.ok(f.hajr); let game = bind(f, kind, 'yes', true).game;
game = settle(act(game, f.target, f.move(f.sector, 2)));
assert.notEqual(row(game, kind, f.target).fulfilled, true);
assert.deepEqual(row(game, kind, f.target).progress, [{ territory: 'imperial_basin', maximum: 2 }]);
game = settle(act(game, f.target, { type: 'card', card: f.hajr }));
game = settle(act(game, f.target, f.move(f.alternative)));
assert.equal(row(game, kind, f.target).fulfilled, true);
assert.deepEqual(row(game, kind, f.target).progress, [{ territory: 'imperial_basin', maximum: 2 }]);
assert.deepEqual(row(game, kind, f.target).sectorProgress, [
{ territory: 'imperial_basin', sector: f.sector, maximum: 2 },
{ territory: 'imperial_basin', sector: f.alternative, maximum: 1 },
]);
assert.equal(f.forceTotal(game), f.originalForces);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('invalid movement sector cannot consume the declared physical Truthtrance', () => {
const f = fixture(), declared = bind(f, 'movement', 'yes');
// Reopen only the actual original declaration returned by the fixture; no state is installed.
rejected(declared.declared, f.asker, { type: 'truthAsk', question: {
kind: 'movement', target: f.target, territory: 'imperial_basin', sector: 0, minimum: 1,
} });
assert.ok(player(declared.declared, f.asker).hand.some(card => card.id === f.truth));
});
+84
View File
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { compileMixedActionExpression, type MixedActionExpression } from '../game/mixed-action-question';
import { needsMovementSectorProgress } from '../game/travel-expression-promises';
import {
parseTurnActionLeaf, recordTurnActionMovement, turnActionBranches, turnActionValue, TurnActionError,
validateTurnActionPromises, type TurnActionLeaf, type TurnActionPromise,
} from '../game/turn-action-promises';
type Expression = MixedActionExpression<TurnActionLeaf>;
const move = (minimum: number, sector?: number): Expression => ({ kind: 'action', action: {
kind: 'move', territory: 'imperial_basin', minimum, ...(sector === undefined ? {} : { sector }),
} });
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
function promise(mixed: Expression, answer = true): TurnActionPromise {
return { turn: 1, mixed, event: 'turn-movement-sector', player: 'p', asker: 'a', answer,
compiled: compileMixedActionExpression(mixed, () => 'yes', answer), shipment: { pending: true, event: null },
progress: [], ...(needsMovementSectorProgress(mixed) ? { sectorProgress: [] } : {}), payments: [],
transportClosed: false, paymentClosed: false, fulfilled: false, released: false };
}
void test('turn-action movement parser accepts exact printed sectors and rejects unprinted choices', () => {
const leaf = { kind: 'move', territory: 'imperial_basin', minimum: 1, sector: 9 };
assert.deepEqual(parseTurnActionLeaf(leaf), leaf);
assert.throws(() => parseTurnActionLeaf({ ...leaf, sector: 0 }));
assert.throws(() => parseTurnActionLeaf({ ...leaf, sector: 9.5 }));
assert.throws(() => parseTurnActionLeaf({ ...leaf, extra: true }));
});
void test('whole turn-action distinguishes original sector from general maxima without guessing old events', () => {
const p = promise(move(2, 9));
let [next] = recordTurnActionMovement([p], 'p', 1, { territory: 'imperial_basin', amount: 3 });
assert.equal(turnActionValue(next), false); assert.deepEqual(next.sectorProgress, []);
[next] = recordTurnActionMovement([next], 'p', 1, { territory: 'imperial_basin', sector: 10, amount: 2 });
assert.equal(turnActionValue(next), false); assert.deepEqual(next.sectorProgress, []);
[next] = recordTurnActionMovement([next], 'p', 1, { territory: 'imperial_basin', sector: 9, amount: 2 });
assert.deepEqual(next.progress, [{ territory: 'imperial_basin', maximum: 3 }]);
assert.deepEqual(next.sectorProgress, [{ territory: 'imperial_basin', sector: 9, maximum: 2 }]);
assert.equal(next.fulfilled, true); validateTurnActionPromises([next], ['p', 'a']);
});
void test('distinct positive sector movements stay separate branches and can occur as separate groups', () => {
const p = promise(group('and', move(3, 9), move(2, 10)));
assert.deepEqual([...turnActionBranches(p)], [[{ kind: 'move', territory: 'imperial_basin', minimum: 3, sector: 9, answer: true },
{ kind: 'move', territory: 'imperial_basin', minimum: 2, sector: 10, answer: true }]]);
let [next] = recordTurnActionMovement([p], 'p', 1, { territory: 'imperial_basin', sector: 9, amount: 3 });
assert.equal(next.fulfilled, false);
[next] = recordTurnActionMovement([next], 'p', 1, { territory: 'imperial_basin', sector: 10, amount: 2 });
assert.equal(next.fulfilled, true);
assert.deepEqual(next.sectorProgress, [{ territory: 'imperial_basin', sector: 9, maximum: 3 },
{ territory: 'imperial_basin', sector: 10, maximum: 2 }]);
});
void test('broad and exact movement conditions retain separate thresholds and actual destinations', () => {
const p = promise(group('and', move(3), move(2, 9)));
assert.equal([...turnActionBranches(p)][0].length, 2);
let [next] = recordTurnActionMovement([p], 'p', 1, { territory: 'imperial_basin', sector: 9, amount: 2 });
assert.equal(next.fulfilled, false);
[next] = recordTurnActionMovement([next], 'p', 1, { territory: 'imperial_basin', sector: 10, amount: 3 });
assert.equal(next.fulfilled, true);
});
void test('whole No forbids only its actual named sector and sectorless records keep their original shape', () => {
const p = promise(move(2, 9), false);
const [other] = recordTurnActionMovement([p], 'p', 1, { territory: 'imperial_basin', sector: 10, amount: 2 });
assert.throws(() => recordTurnActionMovement([other], 'p', 1, { territory: 'imperial_basin', sector: 9, amount: 2 }), TurnActionError);
const generic = promise(move(2));
const [accepted] = recordTurnActionMovement([generic], 'p', 1, { territory: 'imperial_basin', sector: 10, amount: 2 });
assert.ok(!Object.hasOwn(accepted, 'sectorProgress'));
assert.deepEqual(accepted.progress, [{ territory: 'imperial_basin', maximum: 2 }]);
});
void test('exact-sector records require their own ledger and cannot invent another sector maximum', () => {
const p = promise(move(2, 9));
assert.throws(() => { const missing = structuredClone(p); delete missing.sectorProgress; validateTurnActionPromises([missing], ['p', 'a']); });
const forged = { ...p, progress: [{ territory: 'imperial_basin', maximum: 1 }],
sectorProgress: [{ territory: 'imperial_basin', sector: 10, maximum: 1 }] };
assert.throws(() => validateTurnActionPromises([forged], ['p', 'a']));
});
void test('unrelated native dynamic-location movement leaves printed sector conditions intact', () => {
const p = promise(move(2, 9));
for (const territory of ['hidden_mobile_stronghold', 'cistern']) {
const [next] = recordTurnActionMovement([p], 'p', 1, { territory, sector: 0, amount: 1 });
assert.deepEqual(next, p);
const [legacy] = recordTurnActionMovement([p], 'p', 1, { territory, amount: 1 });
assert.deepEqual(legacy, p);
assert.throws(() => recordTurnActionMovement([p], 'p', 1, { territory, sector: 1, amount: 1 }), TurnActionError);
assert.throws(() => parseTurnActionLeaf({ kind: 'move', territory, minimum: 1, sector: 0 }), TurnActionError);
}
});