Support sector-specific Truthtrance movement
This commit is contained in:
1 parent
536d334543
commit
1059cc1682
24 files changed
+1016
-100
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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 } });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user