Files
Dune/tests/movement-promises.test.ts
T

162 lines
10 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
MovementClaimError, parseMovementClaim, validMovementClaim, movementClaimText,
movementMatches, liveMovementPromises, movementEventAllowed,
settleMovementEvent, finishMovementPromises, movementPromiseTargetAvailable,
type MovementPromise,
} from '../game/movement-promises';
import { createGame, joinGame, newPlayer, type Game } from '../game/engine';
const claim = { territory: 'carthag', minimum: 6 };
const promise = (overrides: Partial<MovementPromise> = {}): MovementPromise => ({
...claim, turn: 2, player: 'target', asker: 'asker', answer: true, ...overrides,
});
void test('movement claims validate printed destinations and safe physical minima while extracting only claim fields', () => {
assert.deepEqual(parseMovementClaim({ kind: 'movement', target: 'target', ...claim }), claim);
assert.equal(validMovementClaim(promise()), true);
assert.equal(movementClaimText(claim), 'make a ground movement of at least 6 physical forces to Carthag');
for (const minimum of [0, -1, 1.5, 21, Infinity, NaN, '6', null, undefined])
assert.throws(() => parseMovementClaim({ ...claim, minimum }), MovementClaimError);
for (const territory of ['', 'homeworld:caladan', 'mobile_stronghold', 'missing', null])
assert.equal(validMovementClaim({ ...claim, territory }), false);
for (const value of [null, [], 'carthag', 6])
assert.throws(() => parseMovementClaim(value), MovementClaimError);
assert.deepEqual(parseMovementClaim({ territory: 'polar_sink', minimum: 1 }), { territory: 'polar_sink', minimum: 1 });
assert.equal(validMovementClaim({ ...claim, minimum: 20 }), true);
});
void test('a movement promise needs one matching event rather than an aggregate of smaller arrivals', () => {
let ledger = [promise()];
const original = structuredClone(ledger);
for (const amount of [2, 4, 5]) {
ledger = settleMovementEvent(ledger, 'target', 2, { territory: 'carthag', amount });
assert.equal(ledger[0].fulfilled, undefined);
}
assert.deepEqual(ledger, original);
assert.equal(movementMatches(claim, null), false);
assert.equal(movementMatches(claim, { territory: 'arrakeen', amount: 20 }), false);
assert.equal(movementMatches(claim, { territory: 'carthag', amount: Infinity }), false);
assert.equal(movementMatches(claim, { territory: 'carthag', amount: 6 }), true);
const fulfilled = settleMovementEvent(ledger, 'target', 2, { territory: 'carthag', amount: 7 });
assert.equal(fulfilled[0].fulfilled, true);
assert.deepEqual(ledger, original);
});
void test('No survives a nonmatching first move and still rejects a later Hajr or Ornithopter matching event', () => {
const original = [promise({ answer: false })];
const afterFirst = settleMovementEvent(original, 'target', 2, { territory: 'arrakeen', amount: 8 });
assert.equal(afterFirst[0].fulfilled, undefined);
assert.equal(movementEventAllowed(afterFirst, { territory: 'carthag', amount: 6 }), false);
assert.throws(() => settleMovementEvent(afterFirst, 'target', 2, { territory: 'carthag', amount: 6 }),
/accepted No/);
assert.deepEqual(afterFirst, original);
assert.equal(movementEventAllowed(afterFirst, { territory: 'carthag', amount: 5 }), true);
const finished = finishMovementPromises(afterFirst, 'target', 2);
assert.equal(finished[0].fulfilled, true); assert.equal(original[0].fulfilled, undefined);
});
void test('a fulfilled Yes remains satisfied through later moves and actual turn completion', () => {
const original = [promise()];
assert.throws(() => finishMovementPromises(original, 'target', 2), /before ending movement/);
const fulfilled = settleMovementEvent(original, 'target', 2, { territory: 'carthag', amount: 6 });
const afterLater = settleMovementEvent(fulfilled, 'target', 2, { territory: 'arrakeen', amount: 1 });
assert.equal(afterLater[0].fulfilled, true);
assert.deepEqual(finishMovementPromises(afterLater, 'target', 2), afterLater);
assert.equal(original[0].fulfilled, undefined);
});
void test('settlement and closing affect only live current-player promises and preserve their metadata', () => {
const ledger = [
{ ...promise(), note: { source: 'original' } },
{ ...promise({ player: 'other', answer: false }), note: { source: 'other' } },
{ ...promise({ turn: 1, answer: false }), note: { source: 'old' } },
{ ...promise({ released: true }), note: { source: 'released' } },
{ ...promise({ fulfilled: true }), note: { source: 'fulfilled' } },
];
const before = structuredClone(ledger);
assert.deepEqual(liveMovementPromises(ledger, 'target', 2), [ledger[0]]);
const result = settleMovementEvent(ledger, 'target', 2, { territory: 'carthag', amount: 6 });
assert.equal(result[0].fulfilled, true); assert.deepEqual(result[0].note, ledger[0].note);
assert.deepEqual(result.slice(1), ledger.slice(1)); assert.deepEqual(ledger, before);
assert.deepEqual(finishMovementPromises(result, 'target', 2), result);
assert.deepEqual(settleMovementEvent(ledger, 'target', 3, { territory: 'carthag', amount: 6 }), ledger);
assert.equal(movementEventAllowed([promise({ answer: false, fulfilled: true }), promise({ answer: false, released: true })],
{ territory: 'carthag', amount: 6 }), true);
});
function movementBoundary(): Game {
const g = createGame('MOVEMENTPROMISEBOUNDARY', newPlayer('target', 'Target', 'atreides'), false);
joinGame(g, newPlayer('asker', 'Asker', 'emperor'));
g.status = 'playing'; g.phase = 5; g.turn = 2; g.active = 'target';
g.movementRemaining = ['target', 'asker']; g.players[0].shipped = true;
return g;
}
void test('the Basic profile permits an active respondent or a still-unused waiting turn with Truthtrance or ordinary flight open', () => {
const g = movementBoundary(); assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
g.truthtrance = { stage: 'ask', queue: [], passed: [], question: null };
assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
g.ornithopter = { event: 'flight', player: 'target', turn: 2, mode: 'twoGroups', startingMove: 0, completed: 0,
card: { id: 'richese-ornithopter', name: 'Ornithopter', kind: 'special', effect: 'ornithopter' } };
assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
g.players[0].shipped = false; g.players[0].moved = 1;
assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
assert.equal(movementPromiseTargetAvailable(g, 'asker'), true);
g.players[1].shipped = true;
assert.equal(movementPromiseTargetAvailable(g, 'asker'), false);
g.players[1].shipped = false; g.players[1].moved = 1;
assert.equal(movementPromiseTargetAvailable(g, 'asker'), false);
});
void test('movement question profile permits an unspent shipment but rejects an exhausted turn and active opposing response', () => {
const unused = movementBoundary(); unused.players[0].shipped = false;
assert.equal(movementPromiseTargetAvailable(unused, 'target'), true);
const ended = movementBoundary(); ended.movementRemaining = ['asker'];
assert.equal(movementPromiseTargetAvailable(ended, 'target'), false);
const response = movementBoundary(); response.response = { kind: 'fremenMovement', owner: 'target', location: 'carthag:11', amount: 1, passed: [] };
assert.equal(movementPromiseTargetAvailable(response, 'target'), false);
const advanced = movementBoundary(); advanced.advanced = true;
assert.equal(movementPromiseTargetAvailable(advanced, 'target'), true);
});
void test('Basic native selected families qualify, while optional modules and undeclared native families do not', () => {
const native = movementBoundary(); native.players[0].faction = 'ixians'; native.expansions = ['ix'];
assert.equal(movementPromiseTargetAvailable(native, 'target'), true);
const missingFamily = structuredClone(native); missingFamily.expansions = [];
assert.equal(movementPromiseTargetAvailable(missingFamily, 'target'), false);
for (const key of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery'] as const) {
const configured = { ...native, [key]: true } as unknown as Game;
assert.equal(movementPromiseTargetAvailable(configured, 'target'), false, key);
}
});
void test('earlier current-round movement question profiles include declared Basic native families', () => {
for (const phase of [0, 1, 2, 3, 4]) {
const classic = movementBoundary(); classic.phase = phase; classic.movementRemaining = [];
assert.equal(movementPromiseTargetAvailable(classic, 'target'), true);
const native = structuredClone(classic); native.players[0].faction = 'ixians'; native.expansions = ['ix'];
assert.equal(movementPromiseTargetAvailable(native, 'target'), true);
classic.advanced = true; assert.equal(movementPromiseTargetAvailable(classic, 'target'), false);
}
for (const phase of [-1, 6, 8]) {
const g = movementBoundary(); g.phase = phase;
assert.equal(movementPromiseTargetAvailable(g, 'target'), false);
}
});
void test('Advanced Movement admits active and unused waiting seats with declared native families while retaining module boundaries', () => {
const advanced = movementBoundary(); advanced.advanced = true;
assert.equal(movementPromiseTargetAvailable(advanced, 'target'), true);
assert.equal(movementPromiseTargetAvailable(advanced, 'asker'), true);
for (const field of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery'] as const) {
assert.equal(movementPromiseTargetAvailable({ ...advanced, [field]: true } as unknown as Game, 'target'), false, field);
}
const native = structuredClone(advanced); native.players[0].faction = 'ixians'; native.expansions = ['ix'];
assert.equal(movementPromiseTargetAvailable(native, 'target'), true);
native.expansions = [];
assert.equal(movementPromiseTargetAvailable(native, 'target'), false);
advanced.players[1].shipped = true;
assert.equal(movementPromiseTargetAvailable(advanced, 'asker'), false);
});