162 lines
10 KiB
TypeScript
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);
|
|
});
|